feat: forms, analytics, footer, LocationsSlider fix
Some checks are pending
CI / Type Check (push) Waiting to run
CI / Lint (push) Waiting to run
CI / Unit Tests (push) Waiting to run
Deploy / Build & Push Image (push) Waiting to run
Deploy / Deploy to VPS (push) Blocked by required conditions

- GroupRequestForm: full form with group type/size/date, POST /api/leads
- BirthdayBookingForm: birthday booking with package preselect from ?package=, POST /api/leads
- Leads collection: added message, groupSize, preferredDate, packageSlug fields
- GoogleAnalytics + BinotelWidget: Script-based, loaded from SiteSettings global
- layout.tsx: generateMetadata from SiteSettings, GA4 + Binotel wired
- Footer: full render — logo, nav links, contacts, socials from CMS
- LocationsSlider: BtnGradient href fixed to /kvytky?category=slug
- utm.ts: added getUtmParams() client helper

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Vadym Samoilenko 2026-05-10 21:50:06 +01:00
parent d5f880d631
commit 557bf5a1b5
13 changed files with 676 additions and 81 deletions

View file

@ -1,6 +1,6 @@
import type { Metadata } from 'next'
import Link from 'next/link'
import { PageHero } from '@/components/ui/PageHero'
import { BirthdayBookingForm } from '@/components/forms/BirthdayBookingForm'
export const metadata: Metadata = {
title: 'Дні народження — Шуміленд',
@ -44,7 +44,13 @@ const PACKAGES = [
},
]
export default function BirthdayPage() {
export default async function BirthdayPage({
searchParams,
}: {
searchParams: Promise<{ package?: string }>
}) {
const params = await searchParams
const defaultPackage = params.package
return (
<div className="min-h-screen bg-[#fdf2e8]">
<PageHero
@ -100,26 +106,20 @@ export default function BirthdayPage() {
))}
</div>
<div id="order-form" className="rounded-[24px] bg-[#223e0d] p-10 text-center">
<div id="order-form" className="rounded-[24px] bg-[#223e0d] p-10">
<h2
className="mb-4 text-[28px] font-bold text-white"
className="mb-2 text-[28px] font-bold text-white"
style={{ fontFamily: 'var(--font-montserrat, Montserrat), sans-serif' }}
>
Замовити святкування
</h2>
<p
className="mx-auto mb-8 max-w-[500px] text-[16px] text-white/70"
className="mb-8 text-[15px] text-white/70"
style={{ fontFamily: 'var(--font-montserrat, Montserrat), sans-serif' }}
>
Залиште заявку і наш менеджер зв&apos;яжеться з вами протягом 30 хвилин
</p>
<Link
href="#order-form"
className="inline-flex items-center rounded-[64px] bg-[#f28b4a] px-10 py-4 text-[16px] font-bold text-white transition-shadow hover:shadow-[0_0_20px_0_#f28b4a]"
style={{ fontFamily: 'var(--font-montserrat, Montserrat), sans-serif' }}
>
Залишити заявку
</Link>
<BirthdayBookingForm defaultPackage={defaultPackage} />
</div>
</div>
</div>

View file

@ -1,6 +1,6 @@
import type { Metadata } from 'next'
import Link from 'next/link'
import { PageHero } from '@/components/ui/PageHero'
import { GroupRequestForm } from '@/components/forms/GroupRequestForm'
export const metadata: Metadata = {
title: 'Групові відвідування — Шуміленд',
@ -77,28 +77,20 @@ export default function GroupVisitsPage() {
))}
</div>
<div id="order-form" className="flex flex-col items-center gap-8 rounded-[24px] bg-[#223e0d] p-10 md:flex-row">
<div className="flex-1">
<h2
className="mb-3 text-[28px] font-bold text-white"
style={{ fontFamily: 'var(--font-montserrat, Montserrat), sans-serif' }}
>
Подати заявку на групове відвідування
</h2>
<p
className="text-[15px] text-white/70"
style={{ fontFamily: 'var(--font-montserrat, Montserrat), sans-serif' }}
>
Вкажіть кількість учасників та бажану дату менеджер зателефонує і погодить деталі.
</p>
</div>
<Link
href="#order-form"
className="inline-flex shrink-0 items-center rounded-[64px] bg-[#f28b4a] px-10 py-4 text-[16px] font-bold text-white transition-shadow hover:shadow-[0_0_20px_0_#f28b4a]"
<div id="order-form" className="rounded-[24px] bg-[#223e0d] p-10">
<h2
className="mb-2 text-[28px] font-bold text-white"
style={{ fontFamily: 'var(--font-montserrat, Montserrat), sans-serif' }}
>
Написати нам
</Link>
Подати заявку на групове відвідування
</h2>
<p
className="mb-8 text-[15px] text-white/70"
style={{ fontFamily: 'var(--font-montserrat, Montserrat), sans-serif' }}
>
Вкажіть кількість учасників та бажану дату менеджер зателефонує і погодить деталі.
</p>
<GroupRequestForm />
</div>
</div>
</div>

View file

@ -4,6 +4,9 @@ import { Montserrat, Inter, Poppins } from 'next/font/google'
import '@/app/globals.css'
import { Header } from '@/components/layout/Header'
import { Footer } from '@/components/layout/Footer'
import { GoogleAnalytics } from '@/components/analytics/GoogleAnalytics'
import { BinotelWidget } from '@/components/analytics/BinotelWidget'
import { getSiteSettings } from '@/lib/getSiteSettings'
const montserrat = Montserrat({
subsets: ['latin', 'cyrillic'],
@ -26,20 +29,28 @@ const poppins = Poppins({
display: 'swap',
})
export const metadata: Metadata = {
title: {
template: '%s | Shumiland',
default: 'Шуміленд — світ, де казка оживає',
},
description:
'Сімейний тематичний парк, де гра допомагає пізнавати світ, а кожна прогулянка перетворюється на незабутню пригоду.',
export async function generateMetadata(): Promise<Metadata> {
const settings = await getSiteSettings()
return {
title: {
template: '%s | Шуміленд',
default: settings.defaultMetaTitle ?? 'Шуміленд — світ, де казка оживає',
},
description:
settings.defaultMetaDescription ??
'Сімейний тематичний парк, де гра допомагає пізнавати світ, а кожна прогулянка перетворюється на незабутню пригоду.',
}
}
export default function FrontendLayout({ children }: { children: React.ReactNode }) {
export default async function FrontendLayout({ children }: { children: React.ReactNode }) {
const settings = await getSiteSettings()
return (
<div
className={`${montserrat.variable} ${inter.variable} ${poppins.variable} bg-background text-foreground flex min-h-screen flex-col`}
>
{settings.ga4Id && <GoogleAnalytics measurementId={settings.ga4Id} />}
{settings.binotelId && <BinotelWidget widgetId={settings.binotelId} />}
<Header />
<main className="flex-1">{children}</main>
<Footer />

View file

@ -22,6 +22,10 @@ const LeadSchema = z.object({
utmContent: z.string().max(200).optional(),
utmTerm: z.string().max(200).optional(),
gclid: z.string().max(200).optional(),
message: z.string().max(2000).optional(),
groupSize: z.number().int().min(1).max(1000).optional(),
preferredDate: z.string().max(30).optional(),
packageSlug: z.string().max(50).optional(),
})
export async function POST(req: NextRequest): Promise<NextResponse> {
@ -64,6 +68,10 @@ export async function POST(req: NextRequest): Promise<NextResponse> {
utmContent: data.utmContent,
utmTerm: data.utmTerm,
gclid: data.gclid,
message: data.message,
groupSize: data.groupSize,
preferredDate: data.preferredDate,
packageSlug: data.packageSlug,
status: 'new',
},
overrideAccess: true,

View file

@ -36,6 +36,10 @@ export const Leads: CollectionConfig = {
],
admin: { position: 'sidebar' },
},
{ name: 'message', type: 'textarea', label: 'Повідомлення' },
{ name: 'groupSize', type: 'number', label: 'Кількість учасників' },
{ name: 'preferredDate', type: 'date', label: 'Бажана дата', admin: { date: { pickerAppearance: 'dayOnly' } } },
{ name: 'packageSlug', type: 'text', label: 'Пакет (slug)' },
{ name: 'notes', type: 'textarea' },
{
name: 'lastCallAt',

View file

@ -0,0 +1,10 @@
import Script from 'next/script'
export function BinotelWidget({ widgetId }: { widgetId: string }) {
if (!widgetId) return null
return (
<Script id="binotel-widget" strategy="afterInteractive">
{`(function(w,d,u,b){w['binotelCallTrackerObject']=b;w[b]=w[b]||function(){(w[b].q=w[b].q||[]).push(arguments)};var s=d.createElement('script');s.async=true;s.src=u;var h=d.getElementsByTagName('script')[0];h.parentNode.insertBefore(s,h);})(window,document,'//app.binotel.com/smart-button/build/smart-button.js','binotelSmartButton');binotelSmartButton('run','${widgetId}');`}
</Script>
)
}

View file

@ -0,0 +1,16 @@
import Script from 'next/script'
export function GoogleAnalytics({ measurementId }: { measurementId: string }) {
if (!measurementId) return null
return (
<>
<Script
src={`https://www.googletagmanager.com/gtag/js?id=${measurementId}`}
strategy="afterInteractive"
/>
<Script id="ga4-init" strategy="afterInteractive">
{`window.dataLayer=window.dataLayer||[];function gtag(){dataLayer.push(arguments);}gtag('js',new Date());gtag('config','${measurementId}');`}
</Script>
</>
)
}

View file

@ -0,0 +1,232 @@
'use client'
import { useState, useTransition } from 'react'
import { getUtmParams } from '@/lib/utm'
const FONT = 'var(--font-montserrat, Montserrat), sans-serif'
const PACKAGES = [
{ value: 'standard', label: 'Стандарт — 4 500 ₴' },
{ value: 'premium', label: 'Преміум — 8 900 ₴' },
{ value: 'vip', label: 'VIP — 15 000 ₴' },
]
interface BirthdayBookingFormProps {
defaultPackage?: string
}
export function BirthdayBookingForm({ defaultPackage }: BirthdayBookingFormProps) {
const [name, setName] = useState('')
const [phone, setPhone] = useState('')
const [email, setEmail] = useState('')
const [childAge, setChildAge] = useState('')
const [packageSlug, setPackageSlug] = useState(defaultPackage ?? '')
const [guestCount, setGuestCount] = useState('')
const [preferredDate, setPreferredDate] = useState('')
const [wishes, setWishes] = useState('')
const [success, setSuccess] = useState(false)
const [error, setError] = useState<string | null>(null)
const [isPending, startTransition] = useTransition()
function handleSubmit(e: React.FormEvent) {
e.preventDefault()
setError(null)
startTransition(async () => {
try {
const utm = getUtmParams()
const res = await fetch('/api/leads', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
name,
phone,
email: email || undefined,
formSource: 'birthday-booking',
packageSlug: packageSlug || undefined,
groupSize: guestCount ? Number(guestCount) : undefined,
preferredDate: preferredDate || undefined,
message: [
childAge ? `Вік іменинника: ${childAge}` : '',
wishes,
].filter(Boolean).join('\n') || undefined,
...utm,
}),
})
const data = (await res.json()) as { ok?: boolean; error?: string }
if (!res.ok) {
setError(data.error ?? 'Щось пішло не так. Спробуйте ще раз.')
return
}
setSuccess(true)
} catch {
setError('Помилка мережі. Перевірте з\'єднання та спробуйте ще раз.')
}
})
}
if (success) {
return (
<div className="flex flex-col items-center gap-4 py-10 text-center">
<div className="text-[48px]">🎂</div>
<h3 className="text-[24px] font-bold text-white" style={{ fontFamily: FONT }}>
Заявку отримано!
</h3>
<p className="text-white/70 text-[16px]" style={{ fontFamily: FONT }}>
Менеджер зв&apos;яжеться з вами протягом 30 хвилин для уточнення деталей свята.
</p>
</div>
)
}
return (
<form onSubmit={handleSubmit} className="flex flex-col gap-5">
<div className="grid grid-cols-1 gap-5 md:grid-cols-2">
<Field label="Ваше ім'я *" htmlFor="bd-name">
<input
id="bd-name"
type="text"
required
value={name}
onChange={(e) => setName(e.target.value)}
placeholder="Іван Іванов"
className={INPUT_CLS}
style={{ fontFamily: FONT }}
/>
</Field>
<Field label="Телефон *" htmlFor="bd-phone">
<input
id="bd-phone"
type="tel"
required
value={phone}
onChange={(e) => setPhone(e.target.value)}
placeholder="+38 (0__) ___-__-__"
className={INPUT_CLS}
style={{ fontFamily: FONT }}
/>
</Field>
<Field label="Email" htmlFor="bd-email">
<input
id="bd-email"
type="email"
value={email}
onChange={(e) => setEmail(e.target.value)}
placeholder="your@email.com"
className={INPUT_CLS}
style={{ fontFamily: FONT }}
/>
</Field>
<Field label="Вік іменинника" htmlFor="bd-age">
<input
id="bd-age"
type="number"
min={1}
max={18}
value={childAge}
onChange={(e) => setChildAge(e.target.value)}
placeholder="7"
className={INPUT_CLS}
style={{ fontFamily: FONT }}
/>
</Field>
<Field label="Пакет" htmlFor="bd-pkg">
<select
id="bd-pkg"
value={packageSlug}
onChange={(e) => setPackageSlug(e.target.value)}
className={INPUT_CLS}
style={{ fontFamily: FONT }}
>
<option value="">Оберіть пакет</option>
{PACKAGES.map((p) => (
<option key={p.value} value={p.value}>
{p.label}
</option>
))}
</select>
</Field>
<Field label="Кількість гостей" htmlFor="bd-guests">
<input
id="bd-guests"
type="number"
min={1}
max={100}
value={guestCount}
onChange={(e) => setGuestCount(e.target.value)}
placeholder="15"
className={INPUT_CLS}
style={{ fontFamily: FONT }}
/>
</Field>
<Field label="Бажана дата *" htmlFor="bd-date">
<input
id="bd-date"
type="date"
required
value={preferredDate}
onChange={(e) => setPreferredDate(e.target.value)}
className={INPUT_CLS + ' md:col-span-2'}
style={{ fontFamily: FONT }}
/>
</Field>
</div>
<Field label="Побажання" htmlFor="bd-wishes">
<textarea
id="bd-wishes"
rows={4}
value={wishes}
onChange={(e) => setWishes(e.target.value)}
placeholder="Тема свята, улюблені герої, особливі побажання..."
className={INPUT_CLS + ' resize-none'}
style={{ fontFamily: FONT }}
/>
</Field>
{error && (
<div className="rounded-xl border border-red-400 bg-red-900/30 px-4 py-3 text-[14px] text-red-300">
{error}
</div>
)}
<button
type="submit"
disabled={isPending}
className="mt-2 inline-flex items-center justify-center rounded-[64px] bg-[#f28b4a] px-10 py-4 text-[16px] font-bold text-white transition-shadow hover:shadow-[0_0_20px_0_#f28b4a] disabled:opacity-60"
style={{ fontFamily: FONT }}
>
{isPending ? 'Надсилаємо...' : 'Замовити святкування'}
</button>
</form>
)
}
function Field({
label,
htmlFor,
children,
}: {
label: string
htmlFor: string
children: React.ReactNode
}) {
return (
<div className="flex flex-col gap-2">
<label htmlFor={htmlFor} className="text-[14px] font-medium text-white/80" style={{ fontFamily: FONT }}>
{label}
</label>
{children}
</div>
)
}
const INPUT_CLS =
'w-full rounded-[12px] border-2 border-white/20 bg-white/10 px-4 py-3 text-[15px] text-white placeholder-white/40 focus:border-[#f28b4a] focus:outline-none'

View file

@ -0,0 +1,210 @@
'use client'
import { useState, useTransition } from 'react'
import { getUtmParams } from '@/lib/utm'
const FONT = 'var(--font-montserrat, Montserrat), sans-serif'
const GROUP_TYPES = [
{ value: 'school', label: 'Шкільна екскурсія' },
{ value: 'kindergarten', label: 'Дитячий садок' },
{ value: 'corporate', label: 'Корпоратив' },
{ value: 'other', label: 'Інше' },
]
export function GroupRequestForm() {
const [name, setName] = useState('')
const [phone, setPhone] = useState('')
const [email, setEmail] = useState('')
const [groupSize, setGroupSize] = useState('')
const [preferredDate, setPreferredDate] = useState('')
const [groupType, setGroupType] = useState('')
const [message, setMessage] = useState('')
const [success, setSuccess] = useState(false)
const [error, setError] = useState<string | null>(null)
const [isPending, startTransition] = useTransition()
function handleSubmit(e: React.FormEvent) {
e.preventDefault()
setError(null)
startTransition(async () => {
try {
const utm = getUtmParams()
const res = await fetch('/api/leads', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
name,
phone,
email: email || undefined,
formSource: 'group-request',
groupSize: groupSize ? Number(groupSize) : undefined,
preferredDate: preferredDate || undefined,
message: [groupType ? `Тип групи: ${groupType}` : '', message].filter(Boolean).join('\n') || undefined,
...utm,
}),
})
const data = (await res.json()) as { ok?: boolean; error?: string }
if (!res.ok) {
setError(data.error ?? 'Щось пішло не так. Спробуйте ще раз.')
return
}
setSuccess(true)
} catch {
setError('Помилка мережі. Перевірте з\'єднання та спробуйте ще раз.')
}
})
}
if (success) {
return (
<div className="flex flex-col items-center gap-4 py-10 text-center">
<div className="text-[48px]"></div>
<h3 className="text-[24px] font-bold text-white" style={{ fontFamily: FONT }}>
Заявку отримано!
</h3>
<p className="text-white/70 text-[16px]" style={{ fontFamily: FONT }}>
Менеджер зателефонує вам протягом 30 хвилин для уточнення деталей.
</p>
</div>
)
}
return (
<form onSubmit={handleSubmit} className="flex flex-col gap-5">
<div className="grid grid-cols-1 gap-5 md:grid-cols-2">
<Field label="Ваше ім'я *" htmlFor="grp-name">
<input
id="grp-name"
type="text"
required
value={name}
onChange={(e) => setName(e.target.value)}
placeholder="Іван Іванов"
className={INPUT_CLS}
style={{ fontFamily: FONT }}
/>
</Field>
<Field label="Телефон *" htmlFor="grp-phone">
<input
id="grp-phone"
type="tel"
required
value={phone}
onChange={(e) => setPhone(e.target.value)}
placeholder="+38 (0__) ___-__-__"
className={INPUT_CLS}
style={{ fontFamily: FONT }}
/>
</Field>
<Field label="Email" htmlFor="grp-email">
<input
id="grp-email"
type="email"
value={email}
onChange={(e) => setEmail(e.target.value)}
placeholder="your@email.com"
className={INPUT_CLS}
style={{ fontFamily: FONT }}
/>
</Field>
<Field label="Кількість учасників *" htmlFor="grp-size">
<input
id="grp-size"
type="number"
required
min={10}
max={500}
value={groupSize}
onChange={(e) => setGroupSize(e.target.value)}
placeholder="30"
className={INPUT_CLS}
style={{ fontFamily: FONT }}
/>
</Field>
<Field label="Бажана дата" htmlFor="grp-date">
<input
id="grp-date"
type="date"
value={preferredDate}
onChange={(e) => setPreferredDate(e.target.value)}
className={INPUT_CLS}
style={{ fontFamily: FONT }}
/>
</Field>
<Field label="Тип групи" htmlFor="grp-type">
<select
id="grp-type"
value={groupType}
onChange={(e) => setGroupType(e.target.value)}
className={INPUT_CLS}
style={{ fontFamily: FONT }}
>
<option value="">Оберіть тип</option>
{GROUP_TYPES.map((t) => (
<option key={t.value} value={t.label}>
{t.label}
</option>
))}
</select>
</Field>
</div>
<Field label="Повідомлення" htmlFor="grp-msg">
<textarea
id="grp-msg"
rows={4}
value={message}
onChange={(e) => setMessage(e.target.value)}
placeholder="Додаткові побажання або запитання..."
className={INPUT_CLS + ' resize-none'}
style={{ fontFamily: FONT }}
/>
</Field>
{error && (
<div className="rounded-xl border border-red-400 bg-red-900/30 px-4 py-3 text-[14px] text-red-300">
{error}
</div>
)}
<button
type="submit"
disabled={isPending}
className="mt-2 inline-flex items-center justify-center rounded-[64px] bg-[#f28b4a] px-10 py-4 text-[16px] font-bold text-white transition-shadow hover:shadow-[0_0_20px_0_#f28b4a] disabled:opacity-60"
style={{ fontFamily: FONT }}
>
{isPending ? 'Надсилаємо...' : 'Надіслати заявку'}
</button>
</form>
)
}
function Field({
label,
htmlFor,
children,
}: {
label: string
htmlFor: string
children: React.ReactNode
}) {
return (
<div className="flex flex-col gap-2">
<label htmlFor={htmlFor} className="text-[14px] font-medium text-white/80" style={{ fontFamily: FONT }}>
{label}
</label>
{children}
</div>
)
}
const INPUT_CLS =
'w-full rounded-[12px] border-2 border-white/20 bg-white/10 px-4 py-3 text-[15px] text-white placeholder-white/40 focus:border-[#f28b4a] focus:outline-none'

View file

@ -1,4 +1,5 @@
/* eslint-disable @next/next/no-img-element */
import Link from 'next/link'
import { getGlobal } from '@/lib/payload'
import type { FooterGlobal } from '@/types/globals'
@ -7,60 +8,146 @@ const LOGO_G1 = '/images/figma/logo-g1-lg.svg'
const LOGO_G2 = '/images/figma/logo-g2-lg.svg'
const LOGO_G3 = '/images/figma/logo-g3-lg.svg'
export async function Footer() {
let copyright = '© Шуміленд 2026'
const FONT = 'var(--font-montserrat, Montserrat), sans-serif'
const STATIC_NAV = [
{ label: 'Головна', href: '/' },
{ label: 'Локації', href: '/lokatsii' },
{ label: 'Квитки', href: '/kvytky' },
{ label: 'Дні народження', href: '/dni-narodzhennia' },
{ label: 'Групові відвідування', href: '/grupovi-vidviduvannia' },
]
const SOCIAL_ICONS: Record<string, string> = {
instagram: 'M12 2.163c3.204 0 3.584.012 4.85.07 3.252.148 4.771 1.691 4.919 4.919.058 1.265.069 1.645.069 4.849 0 3.205-.012 3.584-.069 4.849-.149 3.225-1.664 4.771-4.919 4.919-1.266.058-1.644.07-4.85.07-3.204 0-3.584-.012-4.849-.07-3.26-.149-4.771-1.699-4.919-4.92-.058-1.265-.07-1.644-.07-4.849 0-3.204.013-3.583.07-4.849.149-3.227 1.664-4.771 4.919-4.919 1.266-.057 1.645-.069 4.849-.069zm0-2.163c-3.259 0-3.667.014-4.947.072-4.358.2-6.78 2.618-6.98 6.98-.059 1.281-.073 1.689-.073 4.948 0 3.259.014 3.668.072 4.948.2 4.358 2.618 6.78 6.98 6.98 1.281.058 1.689.072 4.948.072 3.259 0 3.668-.014 4.948-.072 4.354-.2 6.782-2.618 6.979-6.98.059-1.28.073-1.689.073-4.948 0-3.259-.014-3.667-.072-4.947-.196-4.354-2.617-6.78-6.979-6.98-1.281-.059-1.69-.073-4.949-.073zm0 5.838c-3.403 0-6.162 2.759-6.162 6.162s2.759 6.163 6.162 6.163 6.162-2.759 6.162-6.163c0-3.403-2.759-6.162-6.162-6.162zm0 10.162c-2.209 0-4-1.79-4-4 0-2.209 1.791-4 4-4s4 1.791 4 4c0 2.21-1.791 4-4 4zm6.406-11.845c-.796 0-1.441.645-1.441 1.44s.645 1.44 1.441 1.44c.795 0 1.439-.645 1.439-1.44s-.644-1.44-1.439-1.44z',
facebook: 'M24 12.073c0-6.627-5.373-12-12-12s-12 5.373-12 12c0 5.99 4.388 10.954 10.125 11.854v-8.385H7.078v-3.47h3.047V9.43c0-3.007 1.792-4.669 4.533-4.669 1.312 0 2.686.235 2.686.235v2.953H15.83c-1.491 0-1.956.925-1.956 1.874v2.25h3.328l-.532 3.47h-2.796v8.385C19.612 23.027 24 18.062 24 12.073z',
youtube: 'M23.495 6.205a3.007 3.007 0 0 0-2.088-2.088c-1.87-.501-9.396-.501-9.396-.501s-7.507-.01-9.396.501A3.007 3.007 0 0 0 .527 6.205a31.247 31.247 0 0 0-.522 5.805 31.247 31.247 0 0 0 .522 5.783 3.007 3.007 0 0 0 2.088 2.088c1.868.502 9.396.502 9.396.502s7.506 0 9.396-.502a3.007 3.007 0 0 0 2.088-2.088 31.247 31.247 0 0 0 .5-5.783 31.247 31.247 0 0 0-.5-5.805zM9.609 15.601V8.408l6.264 3.602z',
tiktok: 'M12.525.02c1.31-.02 2.61-.01 3.91-.02.08 1.53.63 3.09 1.75 4.17 1.12 1.11 2.7 1.62 4.24 1.79v4.03c-1.44-.05-2.89-.35-4.2-.97-.57-.26-1.1-.59-1.62-.93-.01 2.92.01 5.84-.02 8.75-.08 1.4-.54 2.79-1.35 3.94-1.31 1.92-3.58 3.17-5.91 3.21-1.43.08-2.86-.31-4.08-1.03-2.02-1.19-3.44-3.37-3.65-5.71-.02-.5-.03-1-.01-1.49.18-1.9 1.12-3.72 2.58-4.96 1.66-1.44 3.98-2.13 6.15-1.72.02 1.48-.04 2.96-.04 4.44-.99-.32-2.15-.23-3.02.37-.63.41-1.11 1.04-1.36 1.75-.21.51-.15 1.07-.14 1.61.24 1.64 1.82 3.02 3.5 2.87 1.12-.01 2.19-.66 2.77-1.61.19-.33.4-.67.41-1.06.1-1.79.06-3.57.07-5.36.01-4.03-.01-8.05.02-12.07z',
}
export async function Footer() {
let footer: FooterGlobal | null = null
try {
const footer = await getGlobal<FooterGlobal>('footer')
if (footer?.copyrightText) copyright = footer.copyrightText
footer = await getGlobal<FooterGlobal>('footer')
} catch {
// DB not available — use default
// DB not available
}
const copyright = footer?.copyrightText ?? '© Шуміленд 2026'
const navLinks = footer?.navLinks?.length ? footer.navLinks : STATIC_NAV
const contacts = footer?.contacts
const socials = footer?.socials?.filter((s) => s.url)
return (
<footer className="relative bg-[#223e0d] overflow-hidden">
{/* Background texture */}
<footer className="relative overflow-hidden bg-[#223e0d]">
<img
src={IMG_BG}
alt=""
aria-hidden="true"
className="absolute inset-0 w-full h-full object-cover pointer-events-none opacity-60"
className="pointer-events-none absolute inset-0 h-full w-full object-cover opacity-60"
/>
<div className="relative z-10 flex flex-col items-center gap-[60px] py-[60px] px-8">
{/* Logo */}
<div
className="relative"
style={{ width: '184px', height: '160px' }}
aria-label="Шуміленд"
>
<div
className="absolute"
style={{ top: '91.32%', right: '21.81%', bottom: '0.97%', left: '22.26%' }}
>
<img src={LOGO_G1} alt="" aria-hidden="true" className="block w-full h-full" />
</div>
<div
className="absolute"
style={{ top: '71.76%', right: '2.82%', bottom: '7.3%', left: '1.41%' }}
>
<img src={LOGO_G2} alt="" aria-hidden="true" className="block w-full h-full" />
</div>
<div
className="absolute"
style={{ top: '1.61%', right: '2.82%', bottom: '38.73%', left: '21.27%' }}
>
<img src={LOGO_G3} alt="" aria-hidden="true" className="block w-full h-full" />
</div>
<div className="relative z-10 mx-auto max-w-[1204px] px-8 py-[60px]">
<div className="flex flex-col items-center gap-12 lg:flex-row lg:items-start lg:justify-between">
{/* Logo */}
<Link href="/" aria-label="Шуміленд — на головну" className="shrink-0">
<div className="relative" style={{ width: '120px', height: '104px' }}>
<div className="absolute" style={{ top: '91.32%', right: '21.81%', bottom: '0.97%', left: '22.26%' }}>
<img src={LOGO_G1} alt="" aria-hidden="true" className="block h-full w-full" />
</div>
<div className="absolute" style={{ top: '71.76%', right: '2.82%', bottom: '7.3%', left: '1.41%' }}>
<img src={LOGO_G2} alt="" aria-hidden="true" className="block h-full w-full" />
</div>
<div className="absolute" style={{ top: '1.61%', right: '2.82%', bottom: '38.73%', left: '21.27%' }}>
<img src={LOGO_G3} alt="" aria-hidden="true" className="block h-full w-full" />
</div>
</div>
</Link>
{/* Nav */}
<nav aria-label="Підвал навігація">
<ul className="flex flex-col gap-3 text-center lg:text-left">
{navLinks.map((link) =>
link.label && link.href ? (
<li key={link.href}>
<Link
href={link.href}
className="text-[15px] font-medium text-white/70 transition-colors hover:text-[#f28b4a]"
style={{ fontFamily: FONT }}
>
{link.label}
</Link>
</li>
) : null,
)}
</ul>
</nav>
{/* Contacts */}
{contacts && (
<div className="flex flex-col gap-3 text-center lg:text-left">
{contacts.phone && (
<a
href={`tel:${contacts.phone.replace(/\s/g, '')}`}
className="text-[15px] text-white/70 transition-colors hover:text-[#f28b4a]"
style={{ fontFamily: FONT }}
>
{contacts.phone}
</a>
)}
{contacts.email && (
<a
href={`mailto:${contacts.email}`}
className="text-[15px] text-white/70 transition-colors hover:text-[#f28b4a]"
style={{ fontFamily: FONT }}
>
{contacts.email}
</a>
)}
{contacts.address && (
<p className="text-[15px] text-white/60" style={{ fontFamily: FONT }}>
{contacts.address}
</p>
)}
</div>
)}
{/* Socials */}
{socials && socials.length > 0 && (
<div className="flex gap-4">
{socials.map((s) => {
const icon = SOCIAL_ICONS[s.platform ?? '']
return (
s.url && (
<a
key={s.platform}
href={s.url}
target="_blank"
rel="noopener noreferrer"
aria-label={s.platform ?? 'Social'}
className="flex h-10 w-10 items-center justify-center rounded-full bg-white/10 transition-colors hover:bg-[#f28b4a]"
>
{icon ? (
<svg viewBox="0 0 24 24" className="h-5 w-5 fill-white" aria-hidden="true">
<path d={icon} />
</svg>
) : (
<span className="text-[12px] font-bold text-white">{s.platform?.[0]?.toUpperCase()}</span>
)}
</a>
)
)
})}
</div>
)}
</div>
{/* Copyright */}
<p
className="text-white font-bold text-[20px]"
style={{ fontFamily: 'var(--font-montserrat, Montserrat), sans-serif' }}
>
{copyright}
</p>
<div className="mt-12 border-t border-white/10 pt-6 text-center">
<p className="text-[14px] text-white/40" style={{ fontFamily: FONT }}>
{copyright}
</p>
</div>
</div>
</footer>
)

View file

@ -94,7 +94,7 @@ export function LocationsSlider({ locations }: LocationsSliderProps) {
{loc.description}
</p>
</div>
<BtnGradient href={loc.href}>Купити квиток</BtnGradient>
<BtnGradient href={`/kvytky?category=${loc.slug}`}>Купити квиток</BtnGradient>
</div>
</div>
</article>

View file

@ -0,0 +1,20 @@
import { cache } from 'react'
import { getPayload } from 'payload'
import configPromise from '@payload-config'
export interface SiteSettingsData {
ga4Id?: string | null
binotelId?: string | null
defaultMetaTitle?: string | null
defaultMetaDescription?: string | null
}
export const getSiteSettings = cache(async (): Promise<SiteSettingsData> => {
try {
const payload = await getPayload({ config: configPromise })
const data = await payload.findGlobal({ slug: 'site-settings', overrideAccess: true })
return data as SiteSettingsData
} catch {
return {}
}
})

View file

@ -9,6 +9,11 @@ const UTM_WHITELIST = [
export type UtmParams = Partial<Record<(typeof UTM_WHITELIST)[number], string>>
export function getUtmParams(): UtmParams {
if (typeof window === 'undefined') return {}
return parseQuery(new URLSearchParams(window.location.search))
}
export function parseQuery(searchParams: URLSearchParams): UtmParams {
const result: UtmParams = {}
for (const key of UTM_WHITELIST) {