+ {settings.ga4Id &&
}
+ {settings.binotelId &&
}
{children}
diff --git a/src/app/api/leads/route.ts b/src/app/api/leads/route.ts
index eb59ffa..ad31f62 100644
--- a/src/app/api/leads/route.ts
+++ b/src/app/api/leads/route.ts
@@ -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
{
@@ -64,6 +68,10 @@ export async function POST(req: NextRequest): Promise {
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,
diff --git a/src/collections/Leads.ts b/src/collections/Leads.ts
index 63bd424..0ef2a78 100644
--- a/src/collections/Leads.ts
+++ b/src/collections/Leads.ts
@@ -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',
diff --git a/src/components/analytics/BinotelWidget.tsx b/src/components/analytics/BinotelWidget.tsx
new file mode 100644
index 0000000..aca6ac3
--- /dev/null
+++ b/src/components/analytics/BinotelWidget.tsx
@@ -0,0 +1,10 @@
+import Script from 'next/script'
+
+export function BinotelWidget({ widgetId }: { widgetId: string }) {
+ if (!widgetId) return null
+ return (
+
+ )
+}
diff --git a/src/components/analytics/GoogleAnalytics.tsx b/src/components/analytics/GoogleAnalytics.tsx
new file mode 100644
index 0000000..33cd86b
--- /dev/null
+++ b/src/components/analytics/GoogleAnalytics.tsx
@@ -0,0 +1,16 @@
+import Script from 'next/script'
+
+export function GoogleAnalytics({ measurementId }: { measurementId: string }) {
+ if (!measurementId) return null
+ return (
+ <>
+
+
+ >
+ )
+}
diff --git a/src/components/forms/BirthdayBookingForm.tsx b/src/components/forms/BirthdayBookingForm.tsx
new file mode 100644
index 0000000..2e44bcc
--- /dev/null
+++ b/src/components/forms/BirthdayBookingForm.tsx
@@ -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(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 (
+
+
🎂
+
+ Заявку отримано!
+
+
+ Менеджер зв'яжеться з вами протягом 30 хвилин для уточнення деталей свята.
+
+
+ )
+ }
+
+ return (
+
+ )
+}
+
+function Field({
+ label,
+ htmlFor,
+ children,
+}: {
+ label: string
+ htmlFor: string
+ children: React.ReactNode
+}) {
+ return (
+
+
+ {children}
+
+ )
+}
+
+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'
diff --git a/src/components/forms/GroupRequestForm.tsx b/src/components/forms/GroupRequestForm.tsx
new file mode 100644
index 0000000..47b1e2b
--- /dev/null
+++ b/src/components/forms/GroupRequestForm.tsx
@@ -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(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 (
+
+
✅
+
+ Заявку отримано!
+
+
+ Менеджер зателефонує вам протягом 30 хвилин для уточнення деталей.
+
+
+ )
+ }
+
+ return (
+
+ )
+}
+
+function Field({
+ label,
+ htmlFor,
+ children,
+}: {
+ label: string
+ htmlFor: string
+ children: React.ReactNode
+}) {
+ return (
+
+
+ {children}
+
+ )
+}
+
+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'
diff --git a/src/components/layout/Footer.tsx b/src/components/layout/Footer.tsx
index c568fa0..7cffae9 100644
--- a/src/components/layout/Footer.tsx
+++ b/src/components/layout/Footer.tsx
@@ -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 = {
+ 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('footer')
- if (footer?.copyrightText) copyright = footer.copyrightText
+ footer = await getGlobal('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 (
-
diff --git a/src/lib/getSiteSettings.ts b/src/lib/getSiteSettings.ts
new file mode 100644
index 0000000..bd40c67
--- /dev/null
+++ b/src/lib/getSiteSettings.ts
@@ -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