feat(pages): redesign all 6 pages to match Figma designs with full CMS coverage

- Birthday: new pricing structure (component breakdown instead of packages),
  packageItems with image upload per card, why/accordion CMS fields
- GroupVisits: all text/prices/images CMS-editable (heroDescription, amenities,
  featureImages, workingHours, price, bottomImages, etc.)
- DyvoLis: fixed 404 (seed location record), combo tickets now show (600/1500/1800/2000)
- ThankYou: DyvoLis topiary background, 'Купити квиток' button text
- Tariffs: updated to match Figma (300/150/300/50 dyno + 4 combo variants)
- Seed: add DyvoLis location, correct hero text and section titles for home

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Vadym Samoilenko 2026-05-28 16:27:24 +01:00
parent 095beb0303
commit 6863f5022d
7 changed files with 958 additions and 392 deletions

View file

@ -5,6 +5,7 @@ import { BirthdayBookingForm } from '@/components/forms/BirthdayBookingForm'
import { FormBlock, type FormData as FormBlockData } from '@/components/forms/FormBlock'
import { RefreshRouteOnSave } from '@/components/cms/RefreshRouteOnSave'
import { DyvoLisWhyVisit } from '@/components/sections/DyvoLisWhyVisit'
import type { Media } from '@/payload-types'
export const revalidate = 60
@ -14,28 +15,15 @@ const FONT_POPPINS = { fontFamily: 'var(--font-poppins, Poppins), sans-serif' }
async function getBirthdayPageData() {
try {
const payload = await getPayload({ config: configPromise })
return await payload.findGlobal({ slug: 'birthday-page', depth: 1 })
return await payload.findGlobal({ slug: 'birthday-page', depth: 2 })
} catch {
return null
}
}
async function getPackages() {
try {
const payload = await getPayload({ config: configPromise })
const result = await payload.find({
collection: 'birthday-packages',
sort: 'sort',
limit: 20,
})
return result.docs
} catch {
return []
}
}
function formatPrice(price: number): string {
return price.toLocaleString('uk-UA').replace(/,/g, ' ')
function mediaUrl(m: number | Media | null | undefined): string | null {
if (!m || typeof m === 'number') return null
return (m as Media).url ?? null
}
export async function generateMetadata(): Promise<Metadata> {
@ -48,45 +36,146 @@ export async function generateMetadata(): Promise<Metadata> {
}
}
const PACKAGE_LOCATIONS = [
{ name: 'ДинопаркArk', description: 'Справжні динозаври в натуральну величину' },
{ name: 'ДивоЛіс', description: 'Казкові топіарні фігури улюблених персонажів' },
{ name: 'Дзеркальний Лабіринт', description: 'Весела гра для дітей та дорослих' },
{ name: 'Костюмованих ведучих', description: 'Аніматори в яскравих костюмах проведуть свято' },
{ name: 'Аніматорів', description: 'Конкурси, ігри та розваги для всіх гостей' },
{ name: 'Затишну альтанку', description: 'Власна зона відпочинку для вашої родини' },
]
export default async function BirthdayPage() {
const pageData = await getBirthdayPageData()
const d = pageData as any
const WHY_ITEMS = [
{
title: 'Свято під ключ',
description:
'Ми беремо на себе всі деталі: аніматорів, конкурси, прикраси та окрему зону для вашої родини. Вам залишається лише насолоджуватись.',
},
{
title: 'Простір для дітей і дорослих',
description:
'Шуміленд — це 7 локацій, де кожен знайде щось для себе: від динозаврів до казкових лісів, від лабіринтів до фотозон.',
},
{
title: 'Незабутні фото та спогади',
description:
'Унікальні декорації, яскраві персонажі та щира радість дітей — ідеальний фон для фотографій, які хочеться переглядати знову і знову.',
},
]
const heroTitle = d?.heroTitle ?? 'ДЕНЬ НАРОДЖЕННЯ У ШУМІЛЕНДІ ПІД КЛЮЧ'
const heroSubtitle =
d?.heroSubtitle ??
'Будьте повноцінними гостями на дні народження вашої дитини. Залиште нам усі турботи про організацію. Ваш єдиний обовʼязок — відпочивати, святкувати, фотографуватися та насолоджуватися моментами.'
const heroCta = d?.heroCta ?? 'Забронювати пригоду'
export default async function BirthdayPage({
searchParams,
}: {
searchParams: Promise<{ package?: string }>
}) {
const params = await searchParams
const defaultPackage = params.package
const [pageData, packages] = await Promise.all([getBirthdayPageData(), getPackages()])
const packageSectionTitle = d?.packageSectionTitle ?? 'ЩО ВХОДИТЬ У ПАКЕТ СВЯТА'
const packageSectionSubtitle =
d?.packageSectionSubtitle ?? 'Єдиний квиток для іменинника та 15-ти гостей'
const packageItems: {
title: string
description: string
imageUrl: string | null
ctaLabel: string
ctaHref?: string
}[] = (
d?.packageItems ?? [
{
title: 'ДинопаркArk',
description: 'Справжні динозаври в натуральну величину',
ctaLabel: 'Замовити',
},
{
title: 'ДивоЛіс',
description: 'Казкові топіарні фігури улюблених персонажів',
ctaLabel: 'Замовити',
},
{
title: 'Дзеркальний Лабіринт',
description: 'Весела гра для дітей та дорослих',
ctaLabel: 'Замовити',
},
{
title: 'Костюмованих ведучих',
description: 'Аніматори в яскравих костюмах проведуть свято',
ctaLabel: 'Замовити',
},
{
title: 'Аквагрим',
description: 'Конкурси, ігри та розваги для всіх гостей',
ctaLabel: 'Замовити',
},
{
title: 'Затишну альтанку',
description: 'Власна зона відпочинку для вашої родини',
ctaLabel: 'Замовити',
},
]
).map((item: any) => ({
title: item.title,
description: item.description,
imageUrl: mediaUrl(item.image),
ctaLabel: item.ctaLabel ?? 'Замовити',
ctaHref: item.ctaHref,
}))
const whyTitle = d?.whyTitle ?? 'Чому варто відвідати ДивоЛіс'
const whyItems = d?.whyItems ?? [
{
title: 'Свято під ключ',
description:
'Ми беремо на себе всі деталі: аніматорів, конкурси, прикраси та окрему зону для вашої родини.',
},
{
title: 'Простір для дітей і дорослих',
description: 'Шуміленд — це 7 локацій, де кожен знайде щось для себе.',
},
{
title: 'Незабутні фото та спогади',
description: 'Унікальні декорації та щира радість дітей — ідеальний фон для фотографій.',
},
]
const whyVideos = d?.whyVideos ?? []
const workingHours = d?.workingHours ?? "п'ятниця-субота-неділя з 11:00 до 20:00"
const pricingSectionTitle = d?.pricingSectionTitle ?? 'ВАРТІСТЬ КВИТКІВ:'
const pricingPackages = d?.pricingPackages ?? [
{ label: 'Стандарт', price: '1 500 грн', ctaLabel: 'Купити квиток', ctaHref: '/kvytky' },
{ label: '+ 4 дитини', price: '1 800 грн', ctaLabel: 'Купити квиток', ctaHref: '/kvytky' },
{ label: '+ 4 дорослих', price: '2 000 грн', ctaLabel: 'Купити квиток', ctaHref: '/kvytky' },
{
label: 'Додатково',
price: '400 грн',
note: 'особа',
ctaLabel: 'Купити квиток',
ctaHref: '/kvytky',
},
]
const entranceSectionTitle = d?.entranceSectionTitle ?? 'Вхід на локації (для інших дітей):'
const entrancePrices = d?.entrancePrices ?? [
{
label: 'Вхід на локації для інших дітей',
price: '600 грн',
ctaLabel: 'Забронювати пригоду',
ctaHref: '#order-form',
},
{
label: 'ДиноПарк',
price: '300 грн',
ctaLabel: 'Забронювати пригоду',
ctaHref: '#order-form',
},
{ label: 'Диволіс', price: '250 грн', ctaLabel: 'Забронювати пригоду', ctaHref: '#order-form' },
{
label: 'Дзеркальний Лабіринт',
price: '160 грн',
ctaLabel: 'Забронювати пригоду',
ctaHref: '#order-form',
},
]
const freeInclusions =
d?.freeInclusions ??
'Діти до 3 років, Діти з іменинником до 18 років, VIP (за наявності запрошення), Діти-сироти'
const entertainmentSectionTitle = d?.entertainmentSectionTitle ?? 'Розважальна програма:'
const entertainmentPackages = d?.entertainmentPackages ?? [
{ label: 'Тривалість 1 год', price: '3 000 грн', ctaLabel: 'Замовити', ctaHref: '#order-form' },
{
label: 'Тривалість 1.5 год',
price: '4 500 грн',
ctaLabel: 'Замовити',
ctaHref: '#order-form',
},
{ label: 'Тривалість 2 год', price: '6 000 грн', ctaLabel: 'Замовити', ctaHref: '#order-form' },
]
const formTitle = d?.formTitle ?? 'Замовити святкування'
const formSubtitle =
d?.formSubtitle ?? "Залиште заявку і наш менеджер зв'яжеться з вами протягом 30 хвилин"
return (
<div className="min-h-screen">
{/* ── 1. HERO ────────────────────────────────────────────────────── */}
{/* ── 1. HERO ────────────────────────────────────────────────────── */}
<section
className="relative flex min-h-[520px] flex-col items-center justify-end overflow-hidden"
style={{
@ -95,42 +184,34 @@ export default async function BirthdayPage({
backgroundPosition: 'center',
}}
>
{/* dark green overlay */}
<div
className="absolute inset-0"
style={{ background: 'rgba(30, 60, 10, 0.72)' }}
style={{ background: 'rgba(30,60,10,0.55)' }}
aria-hidden="true"
/>
{/* orange banner box */}
<div className="relative z-10 mx-auto mt-20 w-full max-w-[900px] px-6">
<div
className="rounded-[20px] px-8 py-7 text-center shadow-[0_4px_30px_rgba(242,139,74,0.4)]"
style={{
background: 'linear-gradient(90deg, #f28b4a 0%, #fdcf54 55%, #f28b4a 100%)',
}}
style={{ background: 'linear-gradient(90deg, #f28b4a 0%, #fdcf54 55%, #f28b4a 100%)' }}
>
<h1
className="text-[24px] leading-tight font-black text-[#1a1a1a] uppercase sm:text-[32px] lg:text-[40px]"
style={FONT_MONT}
>
{pageData?.heroTitle ?? 'ДЕНЬ НАРОДЖЕННЯ У ШУМІЛЕНДІ ПІД КЛЮЧ'}
{heroTitle}
</h1>
</div>
</div>
{/* dark green subtitle band */}
<div
className="relative z-10 mt-6 w-full px-6 py-6"
style={{ background: 'rgba(30, 60, 10, 0.85)' }}
style={{ background: 'rgba(30,60,10,0.85)' }}
>
<div className="mx-auto flex max-w-[900px] flex-col items-center gap-4 sm:flex-row sm:justify-between">
<p
className="text-center text-[16px] leading-relaxed text-white/90 sm:text-left sm:text-[18px]"
className="text-center text-[15px] leading-relaxed text-white/90 sm:text-left sm:text-[17px]"
style={FONT_POPPINS}
>
{pageData?.heroSubtitle ??
"Зробіть свято незабутнім! Оберіть пакет і наші менеджери зв'яжуться з вами для уточнення деталей."}
{heroSubtitle}
</p>
<a
href="#order-form"
@ -141,52 +222,58 @@ export default async function BirthdayPage({
whiteSpace: 'nowrap',
}}
>
Забронювати пригоду
{heroCta}
</a>
</div>
</div>
</section>
{/* ── 2. ЩО ВХОДИТЬ У ПАКЕТ СВЯТА ──────────────────────────────── */}
{/* ── 2. ЩО ВХОДИТЬ У ПАКЕТ СВЯТА ──────────────────────────────── */}
<section className="py-16" style={{ background: '#f1fbeb' }}>
<div className="mx-auto max-w-[1204px] px-6 lg:px-8">
<h2
className="mb-2 text-[24px] font-black text-[#272727] uppercase lg:text-[32px]"
style={FONT_MONT}
>
ЩО ВХОДИТЬ У ПАКЕТ СВЯТА
{packageSectionTitle}
</h2>
<p className="mb-10 text-[16px] text-[#396817]" style={FONT_POPPINS}>
Єдиний квиток для іменинника та 15-ти гостей
{packageSectionSubtitle}
</p>
{/* Desktop: 2 rows × 3 cols. Mobile: horizontal scroll carousel */}
<div className="hidden gap-5 sm:grid sm:grid-cols-3">
{PACKAGE_LOCATIONS.map((loc) => (
{packageItems.map((item) => (
<div
key={loc.name}
key={item.title}
className="flex flex-col gap-3 rounded-[20px] p-5 shadow-[0_4px_30px_rgba(242,139,74,0.25)]"
style={{ background: '#fff' }}
>
{/* image placeholder */}
<div
className="h-[160px] w-full rounded-[14px]"
style={{ background: '#e8f5dc' }}
aria-hidden="true"
/>
{item.imageUrl ? (
<img
src={item.imageUrl}
alt={item.title}
className="h-[160px] w-full rounded-[14px] object-cover"
/>
) : (
<div
className="h-[160px] w-full rounded-[14px]"
style={{ background: '#e8f5dc' }}
aria-hidden="true"
/>
)}
<div className="flex items-start justify-between gap-3">
<div>
<h3 className="text-[16px] font-bold text-[#272727]" style={FONT_MONT}>
{loc.name}
{item.title}
</h3>
<p className="mt-1 text-[13px] text-[#555]" style={FONT_POPPINS}>
{loc.description}
{item.description}
</p>
</div>
<button
<a
href={item.ctaHref ?? '#order-form'}
className="flex h-9 w-9 flex-none items-center justify-center rounded-full text-white transition-opacity hover:opacity-80"
style={{ background: '#f28b4a' }}
aria-label={`Детальніше про ${loc.name}`}
aria-label={item.ctaLabel}
>
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" aria-hidden="true">
<path
@ -197,41 +284,49 @@ export default async function BirthdayPage({
strokeLinejoin="round"
/>
</svg>
</button>
</a>
</div>
</div>
))}
</div>
{/* Mobile: scroll carousel */}
{/* Mobile scroll */}
<div
className="flex gap-4 overflow-x-auto pb-4 sm:hidden"
style={{ scrollSnapType: 'x mandatory' }}
>
{PACKAGE_LOCATIONS.map((loc) => (
{packageItems.map((item) => (
<div
key={loc.name}
key={item.title}
className="flex w-[260px] flex-none flex-col gap-3 rounded-[20px] p-5 shadow-[0_4px_30px_rgba(242,139,74,0.25)]"
style={{ background: '#fff', scrollSnapAlign: 'start' }}
>
<div
className="h-[130px] w-full rounded-[14px]"
style={{ background: '#e8f5dc' }}
aria-hidden="true"
/>
{item.imageUrl ? (
<img
src={item.imageUrl}
alt={item.title}
className="h-[130px] w-full rounded-[14px] object-cover"
/>
) : (
<div
className="h-[130px] w-full rounded-[14px]"
style={{ background: '#e8f5dc' }}
aria-hidden="true"
/>
)}
<div className="flex items-start justify-between gap-3">
<div>
<h3 className="text-[15px] font-bold text-[#272727]" style={FONT_MONT}>
{loc.name}
{item.title}
</h3>
<p className="mt-1 text-[12px] text-[#555]" style={FONT_POPPINS}>
{loc.description}
{item.description}
</p>
</div>
<button
<a
href={item.ctaHref ?? '#order-form'}
className="flex h-8 w-8 flex-none items-center justify-center rounded-full text-white"
style={{ background: '#f28b4a' }}
aria-label={`Детальніше про ${loc.name}`}
aria-label={item.ctaLabel}
>
<svg width="14" height="14" viewBox="0 0 16 16" fill="none" aria-hidden="true">
<path
@ -242,7 +337,7 @@ export default async function BirthdayPage({
strokeLinejoin="round"
/>
</svg>
</button>
</a>
</div>
</div>
))}
@ -250,15 +345,25 @@ export default async function BirthdayPage({
</div>
</section>
{/* ── 3. ЧОМУ ВАРТО ВІДВІДАТИ ───────────────────────────────────── */}
<DyvoLisWhyVisit title="Чому варто обрати Шуміленд" items={WHY_ITEMS} />
{/* ── 3. ЧОМУ ВАРТО ─────────────────────────────────────────────── */}
<DyvoLisWhyVisit
title={whyTitle}
items={whyItems.map((i: any) => ({ title: i.title, description: i.description }))}
reviewVideos={
whyVideos.length > 0
? whyVideos.map((v: any) => ({
src: v.src,
poster: v.poster ?? null,
label: v.label ?? null,
}))
: undefined
}
/>
{/* ── 4. WORKING HOURS BANNER ───────────────────────────────────── */}
{/* ── 4. WORKING HOURS ──────────────────────────────────────────── */}
<section
className="py-10"
style={{
background: 'linear-gradient(90deg, #f28b4a 0%, #fdcf54 55%, #f28b4a 100%)',
}}
style={{ background: 'linear-gradient(90deg, #f28b4a 0%, #fdcf54 55%, #f28b4a 100%)' }}
>
<div className="mx-auto flex max-w-[1204px] flex-col items-center gap-2 px-6 text-center lg:px-8">
<p
@ -271,112 +376,131 @@ export default async function BirthdayPage({
className="text-[20px] font-black text-[#1a1a1a] uppercase lg:text-[26px]"
style={FONT_MONT}
>
п&#8217;ятниця&thinsp;&mdash;&thinsp;субота&thinsp;&mdash;&thinsp;неділя з 11:00 до
20:00
{workingHours}
</p>
</div>
</section>
{/* ── 5. PRICING SECTION ────────────────────────────────────────── */}
{/* ── 5. PRICING ────────────────────────────────────────────────── */}
<section className="rounded-t-[40px] py-16" style={{ background: '#396817' }}>
<div className="mx-auto max-w-[1204px] px-6 lg:px-8">
<h2
className="mb-10 text-[24px] font-black text-white uppercase lg:text-[32px]"
style={FONT_MONT}
>
ВАРТІСТЬ КВИТКІВ:
{pricingSectionTitle}
</h2>
<div className="grid grid-cols-1 gap-5 sm:grid-cols-2 lg:grid-cols-3">
{packages.length > 0
? packages.map((pkg) => (
<div
key={pkg.id}
className="flex flex-col gap-5 rounded-[20px] p-7 shadow-[0_4px_30px_rgba(242,139,74,0.25)]"
style={{ background: '#fdf2e8' }}
>
{pkg.featured && pkg.badge && (
<span
className="self-start rounded-full bg-[#f28b4a] px-3 py-1 text-[11px] font-bold text-white uppercase"
style={FONT_MONT}
>
{pkg.badge}
</span>
)}
<div>
<p
className="text-[42px] leading-none font-black text-[#272727]"
style={FONT_MONT}
>
{pkg.priceLabel ?? formatPrice(pkg.price)}{' '}
<span className="text-[22px]">{pkg.currency ?? '₴'}</span>
</p>
<h3 className="mt-1 text-[18px] font-bold text-[#396817]" style={FONT_MONT}>
{pkg.name}
</h3>
</div>
{pkg.features && pkg.features.length > 0 && (
<ul className="flex flex-col gap-2">
{pkg.features.map((f: { id?: string | null; text: string }) => (
<li
key={f.id}
className="flex items-center gap-2 text-[13px] text-[#555]"
style={FONT_POPPINS}
>
<span className="text-[#f28b4a]"></span> {f.text}
</li>
))}
</ul>
)}
<a
href="#order-form"
className="mt-auto flex items-center justify-center rounded-[56px] py-3 text-[15px] font-bold text-[#1a1a1a] transition-opacity hover:opacity-90"
style={{
...FONT_MONT,
background: 'linear-gradient(90deg, #f28b4a 0%, #fdcf54 55%, #f28b4a 100%)',
}}
>
Купити квиток
</a>
</div>
))
: /* placeholder cards when CMS is empty */
[1, 2, 3].map((n) => (
<div
key={n}
className="flex flex-col gap-5 rounded-[20px] p-7 shadow-[0_4px_30px_rgba(242,139,74,0.25)]"
style={{ background: '#fdf2e8' }}
>
<div>
<p
className="text-[42px] leading-none font-black text-[#272727]"
style={FONT_MONT}
>
</p>
<h3 className="mt-1 text-[18px] font-bold text-[#396817]" style={FONT_MONT}>
Пакет {n}
</h3>
</div>
<a
href="#order-form"
className="mt-auto flex items-center justify-center rounded-[56px] py-3 text-[15px] font-bold text-[#1a1a1a] transition-opacity hover:opacity-90"
style={{
...FONT_MONT,
background: 'linear-gradient(90deg, #f28b4a 0%, #fdcf54 55%, #f28b4a 100%)',
}}
>
Купити квиток
</a>
</div>
))}
{/* Main packages grid */}
<div className="grid grid-cols-1 gap-5 sm:grid-cols-2">
{pricingPackages.map((pkg: any) => (
<div
key={pkg.label}
className="flex flex-col gap-4 rounded-[20px] p-7"
style={{ background: '#fdf2e8' }}
>
<p
className="text-[13px] font-bold tracking-widest text-[#396817] uppercase"
style={FONT_MONT}
>
{pkg.label}
</p>
<p className="text-[48px] leading-none font-black text-[#1a1a1a]" style={FONT_MONT}>
{pkg.price}
</p>
{pkg.note && (
<p className="text-[14px] text-[#555]" style={FONT_POPPINS}>
{pkg.note}
</p>
)}
<a
href={pkg.ctaHref ?? '/kvytky'}
className="mt-auto flex items-center justify-center rounded-[56px] py-3 text-[15px] font-bold text-[#1a1a1a] transition-opacity hover:opacity-90"
style={{
...FONT_MONT,
background: 'linear-gradient(90deg, #f28b4a 0%, #fdcf54 55%, #f28b4a 100%)',
}}
>
{pkg.ctaLabel ?? 'Купити квиток'}
</a>
</div>
))}
</div>
{/* Free inclusions note */}
<p className="mt-8 text-[14px] leading-relaxed text-white/80" style={FONT_POPPINS}>
<span className="font-bold text-white">Безкоштовно:</span> До 3 дорослих (батьки та інші
супроводжуючі), Діти до 3 років, Вхід по запрошеннях для іменинника
</p>
{/* Entrance prices */}
<h3 className="mt-10 mb-6 text-[18px] font-black text-white" style={FONT_MONT}>
{entranceSectionTitle}
</h3>
<div className="grid grid-cols-1 gap-5 sm:grid-cols-2">
{entrancePrices.map((item: any) => (
<div
key={item.label}
className="flex flex-col gap-4 rounded-[20px] p-7"
style={{ background: '#fdf2e8' }}
>
<p
className="text-[13px] font-bold tracking-widest text-[#396817] uppercase"
style={FONT_MONT}
>
{item.label}
</p>
<p className="text-[48px] leading-none font-black text-[#1a1a1a]" style={FONT_MONT}>
{item.price}
</p>
<a
href={item.ctaHref ?? '#order-form'}
className="mt-auto flex items-center justify-center rounded-[56px] py-3 text-[15px] font-bold text-[#1a1a1a] transition-opacity hover:opacity-90"
style={{
...FONT_MONT,
background: 'linear-gradient(90deg, #f28b4a 0%, #fdcf54 55%, #f28b4a 100%)',
}}
>
{item.ctaLabel ?? 'Забронювати пригоду'}
</a>
</div>
))}
</div>
{/* Free inclusions */}
{freeInclusions && (
<p className="mt-8 text-[14px] leading-relaxed text-white/80" style={FONT_POPPINS}>
<span className="font-bold text-white">Безкоштовно:</span> {freeInclusions}
</p>
)}
{/* Entertainment packages */}
<h3 className="mt-10 mb-6 text-[18px] font-black text-white" style={FONT_MONT}>
{entertainmentSectionTitle}
</h3>
<div className="grid grid-cols-1 gap-5 sm:grid-cols-2 lg:grid-cols-3">
{entertainmentPackages.map((pkg: any) => (
<div
key={pkg.label}
className="flex flex-col gap-4 rounded-[20px] p-7"
style={{ background: '#fdf2e8' }}
>
<p
className="text-[13px] font-bold tracking-widest text-[#396817] uppercase"
style={FONT_MONT}
>
{pkg.label}
</p>
<p className="text-[48px] leading-none font-black text-[#1a1a1a]" style={FONT_MONT}>
{pkg.price}
</p>
<a
href={pkg.ctaHref ?? '#order-form'}
className="mt-auto flex items-center justify-center rounded-[56px] py-3 text-[15px] font-bold text-[#1a1a1a] transition-opacity hover:opacity-90"
style={{
...FONT_MONT,
background: 'linear-gradient(90deg, #f28b4a 0%, #fdcf54 55%, #f28b4a 100%)',
}}
>
{pkg.ctaLabel ?? 'Замовити'}
</a>
</div>
))}
</div>
</div>
</section>
@ -385,11 +509,10 @@ export default async function BirthdayPage({
<div className="mx-auto max-w-[1204px] px-6 lg:px-8">
<div className="rounded-[24px] bg-[#2d5414] p-8 lg:p-10">
<h2 className="mb-2 text-[28px] font-bold text-white" style={FONT_MONT}>
{pageData?.formTitle ?? 'Замовити святкування'}
{formTitle}
</h2>
<p className="mb-8 text-[15px] text-white/70" style={FONT_POPPINS}>
{pageData?.formSubtitle ??
"Залиште заявку і наш менеджер зв'яжеться з вами протягом 30 хвилин"}
{formSubtitle}
</p>
{pageData?.form && typeof pageData.form === 'object' ? (
<FormBlock
@ -397,7 +520,7 @@ export default async function BirthdayPage({
submitLabel="Замовити святкування"
/>
) : (
<BirthdayBookingForm defaultPackage={defaultPackage} />
<BirthdayBookingForm />
)}
</div>
</div>

View file

@ -4,13 +4,15 @@ import configPromise from '@payload-config'
import { GroupRequestForm } from '@/components/forms/GroupRequestForm'
import { FormBlock, type FormData as FormBlockData } from '@/components/forms/FormBlock'
import { RefreshRouteOnSave } from '@/components/cms/RefreshRouteOnSave'
import { PayloadImage } from '@/components/ui/PayloadImage'
import type { Media } from '@/payload-types'
export const revalidate = 60
async function getGroupVisitsData() {
try {
const payload = await getPayload({ config: configPromise })
return await payload.findGlobal({ slug: 'group-visits-page', depth: 1 })
return await payload.findGlobal({ slug: 'group-visits-page', depth: 2 })
} catch {
return null
}
@ -26,14 +28,65 @@ export async function generateMetadata(): Promise<Metadata> {
}
}
function mediaUrl(m: number | Media | null | undefined): string | null {
if (!m || typeof m === 'number') return null
return (m as Media).url ?? null
}
export default async function GroupVisitsPage() {
const data = await getGroupVisitsData()
const d = data as any
const formTitle = data?.formTitle ?? 'Подати заявку на групове відвідування'
const heroTitle = d?.heroTitle ?? 'Групові відвідування'
const heroSubtitle =
d?.heroSubtitle ??
'Спеціальні умови для організованих груп. Мінімум 10 осіб — максимум вражень.'
const heroDescription =
d?.heroDescription ??
'Шукаєте ідеальне місце для групового виїзду класу чи садочка? Або яскраву локацію для фотосесії? Хочете, щоб дитячий випускний альбом був дійсно унікальним? Запрошуємо провести цей захопливий і незабутній день на казковій локації.'
const heroCta = d?.heroCta ?? 'Забронювати пригоду'
const featureText =
d?.featureText ??
'На дітлах чекає подорож ДинопарКом та ДивоЛісом. Це активне дозвілля на свіжому повітрі та справжні казкові пригоди, де кожен стане героєм власної історії.'
const featureImages: string[] = (d?.featureImages ?? [])
.map((i: any) => mediaUrl(i.image))
.filter(Boolean)
const amenitiesTitle = d?.amenitiesTitle ?? 'Ми подбали про затишок і комфорт'
const amenities: { label: string; imageUrl: string | null }[] = (
d?.amenities ?? [
{ label: '2 локації без обмежень у часі' },
{ label: 'Вбиральні та кафе на території' },
{ label: 'Укриття поруч' },
{ label: 'Огороджено забором, є охорона' },
]
).map((a: any) => ({ label: a.label, imageUrl: mediaUrl(a.image) }))
const workingHours = d?.workingHours ?? "п'ятниця-субота-неділя з 11:00 до 20:00"
const price = d?.price ?? '350 грн'
const priceLabel = d?.priceLabel ?? 'особа'
const priceNote = d?.priceNote ?? 'Вхід для двох дорослих, що супроводжують дітей, безкоштовний.'
const priceMinPeople = d?.priceMinPeople ?? 'Пропозиція для груп від 10 людей'
const priceCta = d?.priceCta ?? 'Купити квиток'
const priceDescription =
d?.priceDescription ??
'У вартість входить відвідування Динопарку та ДивоЛісу.\nЧас перебування на локаціях необмежений.'
const bottomText =
d?.bottomText ??
'Хочете перетворити візит на справжню маленьку експедицію з розповідями або замовити екскурсію з розповідями про динозаврів? Ми залюбки це організуємо! Телефонуйте нам — і ми все підготуємо та розрахуємо індивідуально для вашої групи.'
const bottomImages: string[] = (d?.bottomImages ?? [])
.map((i: any) => mediaUrl(i.image))
.filter(Boolean)
const formTitle = d?.formTitle ?? 'Подати заявку на групове відвідування'
const formSubtitle =
data?.formSubtitle ??
d?.formSubtitle ??
'Вкажіть кількість учасників та бажану дату — менеджер зателефонує і погодить деталі.'
const FONT_MONT = { fontFamily: 'var(--font-montserrat, Montserrat), sans-serif' }
return (
<div className="min-h-screen bg-[#f1fbeb]">
{/* 1. Hero */}
@ -49,21 +102,19 @@ export default async function GroupVisitsPage() {
<div className="relative z-10 px-4 text-center">
<div
className="inline-block rounded-[12px] px-8 py-5"
style={{
background: 'linear-gradient(90deg, #f28b4a, #fdcf54, #f28b4a)',
}}
style={{ background: 'linear-gradient(90deg, #f28b4a, #fdcf54, #f28b4a)' }}
>
<h1
className="text-[32px] leading-tight font-black tracking-widest text-[#1a1a1a] uppercase md:text-[48px]"
style={{ fontFamily: 'var(--font-montserrat, Montserrat), sans-serif' }}
style={FONT_MONT}
>
ГРУПОВІ ВІЗИТИ
{heroTitle}
</h1>
<p
className="mt-2 text-[15px] font-medium text-[#1a1a1a] md:text-[18px]"
style={{ fontFamily: 'var(--font-montserrat, Montserrat), sans-serif' }}
style={FONT_MONT}
>
{data?.heroSubtitle ?? 'Спеціальна пропозиція для садочків та шкіл'}
{heroSubtitle}
</p>
</div>
</div>
@ -74,21 +125,19 @@ export default async function GroupVisitsPage() {
<div className="mx-auto max-w-[860px] text-center">
<p
className="mb-8 text-[16px] leading-relaxed text-white md:text-[18px]"
style={{ fontFamily: 'var(--font-montserrat, Montserrat), sans-serif' }}
style={FONT_MONT}
>
{}
{(data as any)?.heroDescription ??
'Шукаєте ідеальне місце для групового виїзду класу чи садочка? Або яскраву локацію для фотосесії? Хочете, щоб дитячий випускний альбом був дійсно унікальним? Запрошуємо провести цей захопливий і незабутній день на казковій локації.'}
{heroDescription}
</p>
<a
href="#order-form"
className="inline-block w-full rounded-[12px] px-10 py-4 text-[17px] font-bold text-[#1a1a1a] transition-opacity hover:opacity-90 md:w-auto"
style={{
background: 'linear-gradient(90deg, #f28b4a, #fdcf54, #f28b4a)',
fontFamily: 'var(--font-montserrat, Montserrat), sans-serif',
...FONT_MONT,
}}
>
Забронювати пригоду
{heroCta}
</a>
</div>
</section>
@ -98,15 +147,30 @@ export default async function GroupVisitsPage() {
<div className="mx-auto grid max-w-[1100px] grid-cols-1 items-center gap-10 md:grid-cols-2">
<p
className="text-[16px] leading-relaxed text-[#1a1a1a] md:text-[18px]"
style={{ fontFamily: 'var(--font-montserrat, Montserrat), sans-serif' }}
style={FONT_MONT}
>
На дітлах чекає подорож ДинопарКом та ДивоЛісом. Це активне дозвілля на свіжому повітрі
та справжні казкові пригоди, де кожен стане героєм власної історії.
{featureText}
</p>
{/* Tilted image placeholders */}
<div className="relative flex h-[280px] items-center justify-center">
<div className="absolute top-[5%] left-[5%] h-[200px] w-[60%] rotate-[-2deg] rounded-[16px] bg-[#c8e6a0] shadow-md" />
<div className="absolute right-[5%] bottom-[5%] h-[200px] w-[60%] rotate-3 rounded-[16px] bg-[#c8e6a0] shadow-md" />
{featureImages.length >= 2 ? (
<>
<img
src={featureImages[0]}
alt=""
className="absolute top-[5%] left-[5%] h-[200px] w-[60%] rotate-[-2deg] rounded-[16px] object-cover shadow-md"
/>
<img
src={featureImages[1]}
alt=""
className="absolute right-[5%] bottom-[5%] h-[200px] w-[60%] rotate-3 rounded-[16px] object-cover shadow-md"
/>
</>
) : (
<>
<div className="absolute top-[5%] left-[5%] h-[200px] w-[60%] rotate-[-2deg] rounded-[16px] bg-[#c8e6a0] shadow-md" />
<div className="absolute right-[5%] bottom-[5%] h-[200px] w-[60%] rotate-3 rounded-[16px] bg-[#c8e6a0] shadow-md" />
</>
)}
</div>
</div>
</section>
@ -116,22 +180,25 @@ export default async function GroupVisitsPage() {
<div className="mx-auto max-w-[1100px]">
<h2
className="mb-8 text-center text-[22px] font-black tracking-wide text-[#396817] uppercase md:text-[28px]"
style={{ fontFamily: 'var(--font-montserrat, Montserrat), sans-serif' }}
style={FONT_MONT}
>
МИ ПОДБАЛИ ПРО ЗАТИШОК І КОМФОРТ
{amenitiesTitle}
</h2>
<div className="grid grid-cols-2 gap-4 md:grid-cols-2 lg:grid-cols-4">
{[
{ label: '2 локації без обмежень у часі', hasPhoto: true },
{ label: 'Вбиральні та кафе на території', hasPhoto: true },
{ label: 'Укриття поруч', hasPhoto: false },
{ label: 'Огороджено забором, є охорона', hasPhoto: true },
].map((item) => (
<div className="grid grid-cols-2 gap-4 lg:grid-cols-4">
{amenities.map((item) => (
<div key={item.label} className="overflow-hidden rounded-[16px] bg-white shadow-md">
{item.hasPhoto && <div className="h-[120px] bg-[#c8e6a0]" />}
{item.imageUrl ? (
<img
src={item.imageUrl}
alt={item.label}
className="h-[120px] w-full object-cover"
/>
) : (
<div className="h-[120px] bg-[#c8e6a0]" />
)}
<p
className="p-4 text-[14px] leading-snug font-semibold text-[#1a1a1a]"
style={{ fontFamily: 'var(--font-montserrat, Montserrat), sans-serif' }}
style={FONT_MONT}
>
{item.label}
</p>
@ -148,15 +215,12 @@ export default async function GroupVisitsPage() {
>
<p
className="text-[13px] font-bold tracking-widest text-[#1a1a1a] uppercase"
style={{ fontFamily: 'var(--font-montserrat, Montserrat), sans-serif' }}
style={FONT_MONT}
>
ЧАС РОБОТИ
</p>
<p
className="mt-1 text-[18px] font-black text-[#1a1a1a] md:text-[22px]"
style={{ fontFamily: 'var(--font-montserrat, Montserrat), sans-serif' }}
>
п&apos;ятниця-субота-неділя з 11:00 до 20:00
<p className="mt-1 text-[18px] font-black text-[#1a1a1a] md:text-[22px]" style={FONT_MONT}>
{workingHours}
</p>
</section>
@ -165,59 +229,53 @@ export default async function GroupVisitsPage() {
<div className="mx-auto max-w-[700px]">
<h2
className="mb-8 text-center text-[22px] font-black tracking-wide text-white uppercase md:text-[28px]"
style={{ fontFamily: 'var(--font-montserrat, Montserrat), sans-serif' }}
style={FONT_MONT}
>
ВАРТІСТЬ ГРУПОВОГО ВІЗИТУ:
</h2>
<div className="mb-8 rounded-[20px] bg-[#fdf2e8] px-8 py-10 text-center shadow-lg">
<p
className="mb-3 text-[12px] font-bold tracking-widest text-[#396817] uppercase"
style={{ fontFamily: 'var(--font-montserrat, Montserrat), sans-serif' }}
style={FONT_MONT}
>
СПЕЦІАЛЬНА ЦІНА ДЛЯ ГРУП
</p>
<p
className="text-[72px] leading-none font-black text-[#1a1a1a] md:text-[96px]"
style={{ fontFamily: 'var(--font-montserrat, Montserrat), sans-serif' }}
style={FONT_MONT}
>
350 грн
{price}
</p>
<p
className="mt-1 text-[16px] font-medium text-[#396817]"
style={{ fontFamily: 'var(--font-montserrat, Montserrat), sans-serif' }}
>
особа
<p className="mt-1 text-[16px] font-medium text-[#396817]" style={FONT_MONT}>
{priceLabel}
</p>
<p
className="mx-auto mt-4 max-w-[420px] text-[14px] leading-relaxed text-[#1a1a1a]"
style={{ fontFamily: 'var(--font-montserrat, Montserrat), sans-serif' }}
style={FONT_MONT}
>
Вхід для двох дорослих, що супроводжують дітей, безкоштовний.
{priceNote}
</p>
<p
className="mt-3 text-[13px] font-semibold text-[#f28b4a]"
style={{ fontFamily: 'var(--font-montserrat, Montserrat), sans-serif' }}
>
Пропозиція для груп від 10 людей
<p className="mt-3 text-[13px] font-semibold text-[#f28b4a]" style={FONT_MONT}>
{priceMinPeople}
</p>
<a
href="#order-form"
className="mt-6 inline-block rounded-[12px] px-10 py-4 text-[16px] font-bold text-[#1a1a1a] transition-opacity hover:opacity-90"
style={{
background: 'linear-gradient(90deg, #f28b4a, #fdcf54, #f28b4a)',
fontFamily: 'var(--font-montserrat, Montserrat), sans-serif',
...FONT_MONT,
}}
>
Купити квиток
{priceCta}
</a>
</div>
<p
className="text-center text-[15px] leading-relaxed text-white/80"
style={{ fontFamily: 'var(--font-montserrat, Montserrat), sans-serif' }}
>
У вартість входить відвідування ДинопаркаTM та ДивоЛісу.
<br />
Час перебування на локаціях необмежений.
<p className="text-center text-[15px] leading-relaxed text-white/80" style={FONT_MONT}>
{priceDescription.split('\n').map((line, i) => (
<span key={i}>
{line}
{i < priceDescription.split('\n').length - 1 && <br />}
</span>
))}
</p>
</div>
</section>
@ -227,16 +285,30 @@ export default async function GroupVisitsPage() {
<div className="mx-auto grid max-w-[1100px] grid-cols-1 items-center gap-10 md:grid-cols-2">
<p
className="text-[16px] leading-relaxed text-[#1a1a1a] md:text-[18px]"
style={{ fontFamily: 'var(--font-montserrat, Montserrat), sans-serif' }}
style={FONT_MONT}
>
Хочете перетворити візит на справжню маленьку експедицію з розповідями або замовити
екскурсію з розповідями про динозаврів? Ми залюбки це організуємо! Телефонуйте нам і
ми все підготуємо та розрахуємо індивідуально для вашої групи.
{bottomText}
</p>
{/* Tilted image placeholders */}
<div className="relative flex h-[280px] items-center justify-center">
<div className="absolute top-[5%] left-[5%] h-[200px] w-[60%] rotate-[-2deg] rounded-[16px] bg-[#c8e6a0] shadow-md" />
<div className="absolute right-[5%] bottom-[5%] h-[200px] w-[60%] rotate-3 rounded-[16px] bg-[#c8e6a0] shadow-md" />
{bottomImages.length >= 2 ? (
<>
<img
src={bottomImages[0]}
alt=""
className="absolute top-[5%] left-[5%] h-[200px] w-[60%] rotate-[-2deg] rounded-[16px] object-cover shadow-md"
/>
<img
src={bottomImages[1]}
alt=""
className="absolute right-[5%] bottom-[5%] h-[200px] w-[60%] rotate-3 rounded-[16px] object-cover shadow-md"
/>
</>
) : (
<>
<div className="absolute top-[5%] left-[5%] h-[200px] w-[60%] rotate-[-2deg] rounded-[16px] bg-[#c8e6a0] shadow-md" />
<div className="absolute right-[5%] bottom-[5%] h-[200px] w-[60%] rotate-3 rounded-[16px] bg-[#c8e6a0] shadow-md" />
</>
)}
</div>
</div>
</section>
@ -244,16 +316,10 @@ export default async function GroupVisitsPage() {
{/* 8. Order form */}
<section id="order-form" className="bg-[#f1fbeb] px-4 pb-16 md:px-8">
<div className="mx-auto max-w-[860px] rounded-[24px] bg-[#396817] p-8 md:p-12">
<h2
className="mb-2 text-[24px] font-bold text-white md:text-[28px]"
style={{ fontFamily: 'var(--font-montserrat, Montserrat), sans-serif' }}
>
<h2 className="mb-2 text-[24px] font-bold text-white md:text-[28px]" style={FONT_MONT}>
{formTitle}
</h2>
<p
className="mb-8 text-[15px] text-white/70"
style={{ fontFamily: 'var(--font-montserrat, Montserrat), sans-serif' }}
>
<p className="mb-8 text-[15px] text-white/70" style={FONT_MONT}>
{formSubtitle}
</p>
{data?.form && typeof data.form === 'object' ? (

View file

@ -12,21 +12,19 @@ export default function DyakuiemoPage() {
return (
<main
className="relative flex min-h-screen items-center justify-center overflow-hidden px-4 py-20"
style={{
background: 'linear-gradient(135deg, #1e3610 0%, #2d5414 40%, #396817 70%, #1e3610 100%)',
}}
style={{ background: '#1e3610' }}
>
{/* Background texture */}
{/* Background photo — DyvoLis topiary */}
<div
className="pointer-events-none absolute inset-0 opacity-20"
className="pointer-events-none absolute inset-0"
aria-hidden="true"
style={{
backgroundImage: `url('/images/page-hero-default.webp')`,
backgroundImage: `url('/images/dyvolis/photo-01.jpg')`,
backgroundSize: 'cover',
backgroundPosition: 'center',
mixBlendMode: 'luminosity',
}}
/>
<div className="pointer-events-none absolute inset-0 bg-black/30" aria-hidden="true" />
{/* Ticket card */}
<div
@ -57,7 +55,7 @@ export default function DyakuiemoPage() {
className="inline-flex w-fit items-center gap-2 rounded-full px-7 py-3.5 text-[15px] font-bold text-white transition-opacity hover:opacity-85"
style={{ background: '#3b39b5', ...FONT_MONT }}
>
Купити ще квиток
Купити квиток
</Link>
</div>

View file

@ -124,15 +124,19 @@ export function DyvoLisTickets({
workingHours = 'щодня з 11:00 до 20:00',
comboDescription = 'Динопарк + Диволіс із казковими топіарними фігурами + Дзеркальний лабіринт',
}: DyvoLisTicketsProps) {
const [tariffs, setTariffs] = useState<Tariff[]>([])
const [single, setSingle] = useState<Tariff[]>([])
const [combo, setCombo] = useState<Tariff[]>([])
const [loading, setLoading] = useState(true)
useEffect(() => {
fetch('/api/tickets/tariffs')
.then((r) => r.json())
.then((data: { tariffs?: Tariff[] }) => {
const dyvolis = (data.tariffs ?? []).filter((t) => t.categoryTag === 'dyvolis')
setTariffs(dyvolis)
const all = data.tariffs ?? []
const dyno = all.filter((t) => t.categoryTag === 'dyno')
const fallback = all.filter((t) => t.categoryTag === 'dyvolis')
setSingle(dyno.length > 0 ? dyno : fallback)
setCombo(all.filter((t) => t.categoryTag === 'combo'))
})
.catch(() => {
/* show nothing on error — section still renders */
@ -140,13 +144,6 @@ export function DyvoLisTickets({
.finally(() => setLoading(false))
}, [])
const single = tariffs.filter(
(t) => !t.name.toLowerCase().includes('комбо') && !t.name.toLowerCase().includes('combo')
)
const combo = tariffs.filter(
(t) => t.name.toLowerCase().includes('комбо') || t.name.toLowerCase().includes('combo')
)
return (
<section className="relative overflow-hidden">
{/* Dark green background */}
@ -187,9 +184,7 @@ export function DyvoLisTickets({
<div className="mb-10 grid grid-cols-1 gap-4 sm:grid-cols-2">
{loading
? Array.from({ length: 4 }).map((_, i) => <SkeletonCard key={i} />)
: (single.length > 0 ? single : tariffs).map((t) => (
<TicketCard key={t.id} tariff={t} />
))}
: single.map((t) => <TicketCard key={t.id} tariff={t} />)}
</div>
{/* Combo section — only if we have combo tariffs */}

View file

@ -10,17 +10,263 @@ export const BirthdayPage: GlobalConfig = {
hooks: { afterChange: [revalidateGlobalAfterChange] },
versions: { max: 20, drafts: { autosave: { interval: 2000 } } },
fields: [
// Hero
{
name: 'heroTitle',
type: 'text',
defaultValue: ні народження',
defaultValue: ЕНЬ НАРОДЖЕННЯ У ШУМІЛЕНДІ ПІД КЛЮЧ',
},
{
name: 'heroSubtitle',
type: 'text',
defaultValue:
"Зробіть свято незабутнім! Оберіть пакет і наші менеджери зв'яжуться з вами для уточнення деталей.",
'Будьте повноцінними гостями на дні народження вашої дитини. Залиште нам усі турботи про організацію. Ваш єдиний обовʼязок — відпочивати, святкувати, фотографуватися та насолоджуватися моментами.',
},
{
name: 'heroCta',
type: 'text',
defaultValue: 'Забронювати пригоду',
},
// Package items (ЩО ВХОДИТЬ)
{
name: 'packageSectionTitle',
type: 'text',
defaultValue: 'ЩО ВХОДИТЬ У ПАКЕТ СВЯТА',
},
{
name: 'packageSectionSubtitle',
type: 'text',
defaultValue: 'Єдиний квиток для іменинника та 15-ти гостей',
},
{
name: 'packageItems',
type: 'array',
label: 'Що входить у пакет (картки з фото)',
fields: [
{ name: 'title', type: 'text', required: true },
{ name: 'description', type: 'textarea', required: true },
{
name: 'image',
type: 'upload',
relationTo: 'media',
admin: { description: 'Фото для картки' },
},
{ name: 'ctaLabel', type: 'text', defaultValue: 'Замовити' },
{ name: 'ctaHref', type: 'text', admin: { description: 'Посилання кнопки (опційно)' } },
],
defaultValue: [
{
title: 'ДинопаркArk',
description: 'Справжні динозаври в натуральну величину',
ctaLabel: 'Замовити',
},
{
title: 'ДивоЛіс',
description: 'Казкові топіарні фігури улюблених персонажів',
ctaLabel: 'Замовити',
},
{
title: 'Дзеркальний Лабіринт',
description: 'Весела гра для дітей та дорослих',
ctaLabel: 'Замовити',
},
{
title: 'Костюмованих ведучих',
description: 'Аніматори в яскравих костюмах проведуть свято',
ctaLabel: 'Замовити',
},
{
title: 'Аквагрим',
description: 'Конкурси, ігри та розваги для всіх гостей',
ctaLabel: 'Замовити',
},
{
title: 'Затишну альтанку',
description: 'Власна зона відпочинку для вашої родини',
ctaLabel: 'Замовити',
},
],
},
// Why section
{
name: 'whyTitle',
type: 'text',
defaultValue: 'Чому варто відвідати ДивоЛіс',
},
{
name: 'whyItems',
type: 'array',
label: 'Переваги (акордеон)',
fields: [
{ name: 'title', type: 'text', required: true },
{ name: 'description', type: 'textarea', required: true },
],
defaultValue: [
{
title: 'Свято під ключ',
description:
'Ми беремо на себе всі деталі: аніматорів, конкурси, прикраси та окрему зону для вашої родини. Вам залишається лише насолоджуватись.',
},
{
title: 'Простір для дітей і дорослих',
description:
'Шуміленд — це 7 локацій, де кожен знайде щось для себе: від динозаврів до казкових лісів, від лабіринтів до фотозон.',
},
{
title: 'Незабутні фото та спогади',
description:
'Унікальні декорації, яскраві персонажі та щира радість дітей — ідеальний фон для фотографій, які хочеться переглядати знову і знову.',
},
],
},
{
name: 'whyVideos',
type: 'array',
label: 'Відео-відгуки у секції "Чому"',
fields: [
{ name: 'src', type: 'text', required: true },
{ name: 'poster', type: 'text' },
{ name: 'label', type: 'text' },
],
},
// Working hours
{
name: 'workingHours',
type: 'text',
defaultValue: "п'ятниця-субота-неділя з 11:00 до 20:00",
},
// Pricing section
{
name: 'pricingSectionTitle',
type: 'text',
defaultValue: 'ВАРТІСТЬ КВИТКІВ:',
},
{
name: 'pricingPackages',
type: 'array',
label: 'Пакети (2-колонна сітка)',
fields: [
{ name: 'label', type: 'text', required: true, admin: { description: 'Підпис над ціною' } },
{
name: 'price',
type: 'text',
required: true,
admin: { description: 'Напр. "1 500 грн"' },
},
{ name: 'note', type: 'text', admin: { description: 'Примітка під ціною' } },
{ name: 'ctaLabel', type: 'text', defaultValue: 'Купити квиток' },
{ name: 'ctaHref', type: 'text', defaultValue: '/kvytky' },
],
defaultValue: [
{ label: 'Стандарт', price: '1 500 грн', ctaLabel: 'Купити квиток', ctaHref: '/kvytky' },
{ label: '+ 4 дитини', price: '1 800 грн', ctaLabel: 'Купити квиток', ctaHref: '/kvytky' },
{
label: '+ 4 дорослих',
price: '2 000 грн',
ctaLabel: 'Купити квиток',
ctaHref: '/kvytky',
},
{
label: 'Додатково',
price: '400 грн',
note: 'особа',
ctaLabel: 'Купити квиток',
ctaHref: '/kvytky',
},
],
},
{
name: 'entranceSectionTitle',
type: 'text',
defaultValue: 'Вхід на локації (для інших дітей):',
},
{
name: 'entrancePrices',
type: 'array',
label: 'Ціни на вхід (для інших дітей)',
fields: [
{ name: 'label', type: 'text', required: true },
{ name: 'price', type: 'text', required: true },
{ name: 'note', type: 'text' },
{ name: 'ctaLabel', type: 'text', defaultValue: 'Забронювати пригоду' },
{ name: 'ctaHref', type: 'text', defaultValue: '#order-form' },
],
defaultValue: [
{
label: 'Вхід на локації для інших дітей',
price: '600 грн',
ctaLabel: 'Забронювати пригоду',
ctaHref: '#order-form',
},
{
label: 'ДиноПарк',
price: '300 грн',
ctaLabel: 'Забронювати пригоду',
ctaHref: '#order-form',
},
{
label: 'Диволіс',
price: '250 грн',
ctaLabel: 'Забронювати пригоду',
ctaHref: '#order-form',
},
{
label: 'Дзеркальний Лабіринт',
price: '160 грн',
ctaLabel: 'Забронювати пригоду',
ctaHref: '#order-form',
},
],
},
{
name: 'freeInclusions',
type: 'text',
label: '"Безкоштовно" текст',
defaultValue:
'Діти до 3 років, Діти з іменинником до 18 років, VIP (за наявності запрошення), Діти-сироти',
},
{
name: 'entertainmentSectionTitle',
type: 'text',
defaultValue: 'Розважальна програма:',
},
{
name: 'entertainmentPackages',
type: 'array',
label: 'Пакети розважальної програми',
fields: [
{
name: 'label',
type: 'text',
required: true,
admin: { description: 'Напр. "Тривалість 1 год"' },
},
{ name: 'price', type: 'text', required: true },
{ name: 'ctaLabel', type: 'text', defaultValue: 'Замовити' },
{ name: 'ctaHref', type: 'text', defaultValue: '#order-form' },
],
defaultValue: [
{
label: 'Тривалість 1 год',
price: '3 000 грн',
ctaLabel: 'Замовити',
ctaHref: '#order-form',
},
{
label: 'Тривалість 1.5 год',
price: '4 500 грн',
ctaLabel: 'Замовити',
ctaHref: '#order-form',
},
{
label: 'Тривалість 2 год',
price: '6 000 грн',
ctaLabel: 'Замовити',
ctaHref: '#order-form',
},
],
},
// Form
{
name: 'formTitle',
type: 'text',

View file

@ -10,6 +10,7 @@ export const GroupVisitsPage: GlobalConfig = {
hooks: { afterChange: [revalidateGlobalAfterChange] },
versions: { max: 20, drafts: { autosave: { interval: 2000 } } },
fields: [
// Hero
{
name: 'heroTitle',
type: 'text',
@ -20,6 +21,122 @@ export const GroupVisitsPage: GlobalConfig = {
type: 'text',
defaultValue: 'Спеціальні умови для організованих груп. Мінімум 10 осіб — максимум вражень.',
},
// Green band
{
name: 'heroDescription',
type: 'textarea',
label: 'Текст зеленої смуги під hero',
defaultValue:
'Шукаєте ідеальне місце для групового виїзду класу чи садочка? Або яскраву локацію для фотосесії? Хочете, щоб дитячий випускний альбом був дійсно унікальним? Запрошуємо провести цей захопливий і незабутній день на казковій локації.',
},
{
name: 'heroCta',
type: 'text',
label: 'Текст кнопки в hero',
defaultValue: 'Забронювати пригоду',
},
// Feature two-col section
{
name: 'featureText',
type: 'textarea',
label: 'Текст у секції з фото (ліворуч)',
defaultValue:
'На дітлах чекає подорож ДинопарКом та ДивоЛісом. Це активне дозвілля на свіжому повітрі та справжні казкові пригоди, де кожен стане героєм власної історії.',
},
{
name: 'featureImages',
type: 'array',
label: 'Фото у секції (два перекошені фото)',
admin: { description: 'Завантажте 2 фото — відображаються перекошено side by side' },
fields: [{ name: 'image', type: 'upload', relationTo: 'media', required: true }],
},
// Amenity cards
{
name: 'amenitiesTitle',
type: 'text',
label: 'Заголовок секції зручностей',
defaultValue: 'Ми подбали про затишок і комфорт',
},
{
name: 'amenities',
type: 'array',
label: 'Картки зручностей',
fields: [
{ name: 'label', type: 'text', required: true },
{
name: 'image',
type: 'upload',
relationTo: 'media',
admin: { description: 'Фото для картки (опційно)' },
},
],
defaultValue: [
{ label: '2 локації без обмежень у часі' },
{ label: 'Вбиральні та кафе на території' },
{ label: 'Укриття поруч' },
{ label: 'Огороджено забором, є охорона' },
],
},
// Working hours
{
name: 'workingHours',
type: 'text',
label: 'Час роботи',
defaultValue: "п'ятниця-субота-неділя з 11:00 до 20:00",
},
// Pricing
{
name: 'price',
type: 'text',
label: 'Ціна (напр. "350 грн")',
defaultValue: '350 грн',
},
{
name: 'priceLabel',
type: 'text',
label: 'Підпис до ціни',
defaultValue: 'особа',
},
{
name: 'priceNote',
type: 'text',
label: 'Примітка до ціни',
defaultValue: 'Вхід для двох дорослих, що супроводжують дітей, безкоштовний.',
},
{
name: 'priceMinPeople',
type: 'text',
label: 'Мінімальна кількість (підпис помаранчевий)',
defaultValue: 'Пропозиція для груп від 10 людей',
},
{
name: 'priceCta',
type: 'text',
label: 'Кнопка в блоці ціни',
defaultValue: 'Купити квиток',
},
{
name: 'priceDescription',
type: 'textarea',
label: 'Текст під ціновим блоком',
defaultValue:
'У вартість входить відвідування Динопарку та ДивоЛісу.\nЧас перебування на локаціях необмежений.',
},
// Bottom section
{
name: 'bottomText',
type: 'textarea',
label: 'Текст нижньої секції (ліворуч)',
defaultValue:
'Хочете перетворити візит на справжню маленьку експедицію з розповідями або замовити екскурсію з розповідями про динозаврів? Ми залюбки це організуємо! Телефонуйте нам — і ми все підготуємо та розрахуємо індивідуально для вашої групи.',
},
{
name: 'bottomImages',
type: 'array',
label: 'Фото нижньої секції (два перекошені фото)',
fields: [{ name: 'image', type: 'upload', relationTo: 'media', required: true }],
},
// Form
{
name: 'formTitle',
type: 'text',
@ -31,48 +148,6 @@ export const GroupVisitsPage: GlobalConfig = {
defaultValue:
'Вкажіть кількість учасників та бажану дату — менеджер зателефонує і погодить деталі.',
},
{
name: 'groups',
type: 'array',
fields: [
{ name: 'icon', type: 'text', required: true, admin: { description: 'Emoji іконка' } },
{ name: 'title', type: 'text', required: true },
{ name: 'description', type: 'textarea', required: true },
{
name: 'minPeople',
type: 'text',
required: true,
admin: { description: 'Напр. "15 осіб"' },
},
{ name: 'discount', type: 'text', required: true, admin: { description: 'Напр. "15%"' } },
],
defaultValue: [
{
icon: '🏫',
title: 'Шкільні екскурсії',
description:
'Пізнавальні екскурсії для учнів початкової та середньої школи. Екскурсовод, адаптована програма, безпечний маршрут.',
minPeople: '15 осіб',
discount: '15%',
},
{
icon: '🎒',
title: 'Дитячі садки',
description:
'Програма для наймолодших — безпечний формат, розвивальні активності, відповідальний супровід.',
minPeople: '10 осіб',
discount: '20%',
},
{
icon: '🏢',
title: 'Корпоративи',
description:
'Тімбілдинг та корпоративний відпочинок у форматі парку розваг. Ексклюзивні зони, кейтеринг, програма на замовлення.',
minPeople: '20 осіб',
discount: '10%',
},
],
},
{
name: 'form',
type: 'relationship',

View file

@ -33,12 +33,19 @@ async function seed(): Promise<void> {
slug: 'home-page',
data: {
hero: {
title: 'Шуміленд — світ, де казка оживає',
subtitle:
'Сімейний тематичний парк розваг. ДиноПарк, Диво Ліс, Дзеркальний Лабіринт — незабутні емоції для всієї родини.',
title: 'Започаткуйте традицію:',
subtitle: 'щороку фотографуйтесь біля улюбленого динозавра',
ctaLabel: 'Купити квиток',
ctaHref: '/kvytky',
},
sectionTitles: {
locations: 'ЛАСКАВО ПРОСИМО ДО ШУМІЛЕНДУ',
whyParents: 'ЧОМУ БАТЬКИ ОБИРАЮТЬ ШУМІЛЕНД',
birthday: 'ДЕНЬ НАРОДЖЕННЯ В ШУМІЛЕНДІ',
gallery: 'ФОТОГАЛЕРЕЯ',
reviews: 'ВІДГУКИ',
news: 'НОВИНИ',
},
locations: [
{
name: 'ДиноПарк',
@ -173,93 +180,149 @@ async function seed(): Promise<void> {
console.log('Blog posts already exist, skipping.')
}
// Tariffs (sample — normally synced from ezy API)
const { totalDocs: tariffCount } = await payload.find({
collection: 'tariffs',
limit: 1,
overrideAccess: true,
})
if (tariffCount === 0) {
// Tariffs — update to match Figma designs
{
// Delete old tariffs and re-seed with correct data
const existing = await payload.find({ collection: 'tariffs', limit: 100, overrideAccess: true })
for (const t of existing.docs) {
await payload.delete({ collection: 'tariffs', id: t.id, overrideAccess: true })
}
const tariffs = [
// Individual dino-park tickets (shown on both Dino and DyvoLis pages)
{
ezy_id: 1001,
last_synced_name: 'Дорослий — ДиноПарк',
display_name: 'Дорослий',
last_synced_price: 350,
last_synced_name: 'Вхід до Динопарку',
display_name: 'Вхід до Динопарку',
last_synced_price: 300,
category_tag: 'dyno',
sort: 1,
visible: true,
},
{
ezy_id: 1002,
last_synced_name: 'Дитячий — ДиноПарк',
display_name: 'Дитячий (312 років)',
last_synced_price: 250,
last_synced_name: 'Звичайна екскурсія',
display_name: 'Звичайна екскурсія',
last_synced_price: 150,
category_tag: 'dyno',
sort: 2,
visible: true,
},
{
ezy_id: 1003,
last_synced_name: 'Дитина до 3 років — ДиноПарк',
display_name: 'До 3 років (безкоштовно)',
last_synced_price: 0,
last_synced_name: 'Палеонтологічна екскурсія',
display_name: 'Палеонтологічна екскурсія',
last_synced_price: 300,
category_tag: 'dyno',
sort: 3,
visible: true,
},
{
ezy_id: 2001,
last_synced_name: 'Дорослий — Диво Ліс',
display_name: 'Дорослий',
last_synced_price: 300,
category_tag: 'dyvolis',
sort: 1,
visible: true,
},
{
ezy_id: 2002,
last_synced_name: 'Дитячий — Диво Ліс',
display_name: 'Дитячий (312 років)',
last_synced_price: 200,
category_tag: 'dyvolis',
sort: 2,
ezy_id: 1004,
last_synced_name: 'ДиноРодо',
display_name: 'ДиноРодо',
last_synced_price: 50,
category_tag: 'dyno',
sort: 4,
visible: true,
},
// Combo tickets
{
ezy_id: 3001,
last_synced_name: 'Комбо — ДиноПарк + Диво Ліс',
display_name: 'Комбо дорослий',
last_synced_price: 550,
last_synced_name: 'Комбо на 1 людину',
display_name: 'Комбо на 1 людину',
last_synced_price: 600,
category_tag: 'combo',
sort: 1,
visible: true,
},
{
ezy_id: 3002,
last_synced_name: 'Комбо дитячий — ДиноПарк + Диво Ліс',
display_name: 'Комбо дитячий',
last_synced_price: 400,
last_synced_name: 'Комбо на 3 людини',
display_name: 'Комбо на 3 людини',
last_synced_price: 1500,
category_tag: 'combo',
sort: 2,
visible: true,
},
{
ezy_id: 4001,
last_synced_name: 'Сімейний (2 дор + 2 діт)',
display_name: 'Сімейний квиток',
last_synced_price: 1200,
category_tag: 'family',
sort: 1,
ezy_id: 3003,
last_synced_name: 'Комбо на 4 людини',
display_name: 'Комбо на 4 людини',
last_synced_price: 1800,
category_tag: 'combo',
sort: 3,
visible: true,
},
{
ezy_id: 3004,
last_synced_name: 'Комбо на 5 людин',
display_name: 'Комбо на 5 людин',
last_synced_price: 2000,
category_tag: 'combo',
sort: 4,
visible: true,
},
]
for (const t of tariffs) {
await payload.create({ collection: 'tariffs', data: t as never, overrideAccess: true })
}
console.log('Seeded tariffs')
console.log('Seeded tariffs (updated to match Figma)')
}
// Locations
const { totalDocs: locCount } = await payload.find({
collection: 'locations',
limit: 1,
overrideAccess: true,
})
if (locCount === 0) {
await payload.create({
collection: 'locations',
data: {
name: 'Диво Ліс',
slug: 'dyvolis',
tagline: 'Казковий світ топіарних фігур',
shortDesc: 'Топіарні фігури з живих рослин — 60+ персонажів улюблених казок у живому лісі.',
showInMenu: true,
showOnHome: true,
showDetailPage: true,
sort: 2,
heroStat: '60+',
heroStatLabel: 'експонатів з безпечних для дітей матеріалів',
heroTips: [
{ text: 'Унікальна ландшафтна композиція з місцями для відпочинку' },
{ text: 'Повна свобода переміщення — без заборон' },
],
galleryQuote:
'Це місце — де малеча зустрічає героїв улюблених казок. Простір справжнього дитинства.',
whyVisitTitle: 'Чому варто відвідати ДивоЛіс',
whyVisitItems: [
{
title: 'Простір для спільної фантазії',
description:
'Вигадуйте казки та пригоди разом із дітьми — кожна топіарна фігурка стає новою сторінкою вашої власної чарівної історії.',
},
{
title: 'Казковий ліс у справжньому лісі',
description:
'Ми створили локацію, в якій гармонійно поєднуються казкові фігури та жива природа. Прогулянка лісом ще не була такою захопливою.',
},
{
title: 'Магічні кадри для сімейного альбому',
description:
'Унікальні топіарні декорації та яскраві персонажі — ідеальний фон для незабутніх сімейних фотографій.',
},
],
workingHours: 'щодня з 11:00 до 20:00',
comboDescription:
'Динопарк + Диволіс із казковими топіарними фігурами + Дзеркальний лабіринт',
} as never,
overrideAccess: true,
})
console.log('Created DyvoLis location')
} else {
console.log('Tariffs already exist, skipping.')
console.log('Locations already exist, skipping.')
}
process.exit(0)