feat: forms, analytics, footer, LocationsSlider fix
- 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:
parent
d5f880d631
commit
557bf5a1b5
13 changed files with 676 additions and 81 deletions
|
|
@ -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' }}
|
||||
>
|
||||
Залиште заявку і наш менеджер зв'яжеться з вами протягом 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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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 />
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
10
src/components/analytics/BinotelWidget.tsx
Normal file
10
src/components/analytics/BinotelWidget.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
16
src/components/analytics/GoogleAnalytics.tsx
Normal file
16
src/components/analytics/GoogleAnalytics.tsx
Normal 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>
|
||||
</>
|
||||
)
|
||||
}
|
||||
232
src/components/forms/BirthdayBookingForm.tsx
Normal file
232
src/components/forms/BirthdayBookingForm.tsx
Normal 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 }}>
|
||||
Менеджер зв'яжеться з вами протягом 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'
|
||||
210
src/components/forms/GroupRequestForm.tsx
Normal file
210
src/components/forms/GroupRequestForm.tsx
Normal 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'
|
||||
|
|
@ -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>
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
20
src/lib/getSiteSettings.ts
Normal file
20
src/lib/getSiteSettings.ts
Normal 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 {}
|
||||
}
|
||||
})
|
||||
|
|
@ -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) {
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue