feat(pages): implement all 6 pages from Figma + fix nav dropdown

Pages implemented:
- grupovi-vidviduvannia: tilted polaroid banners, amenity grid, wave pricing, correct Figma tile pattern
- dni-narodzhennia: hero, package cards, pricing, order form
- kvytky: horizontal ticket rows with photos, tabs, combo, benefits accordion
- kvytky/dyakuiemo: SVG ticket shape with perforations
- dynozavry: DinoWheel 25 dinosaurs with real images + gallery (node 2004:560/568)
- HeroSlider: vertical slider with 6 slides

Components:
- DinoWheel: complete 25-dino dataset from Figma, orange semicircle SVG rotation, gallery below
- KvytkyTicketsClient: rewritten to horizontal row layout matching Figma
- HeaderClient: remove overflow-hidden clipping dropdown; add chevron indicator
- Header: inherit DEFAULT_NAV children when CMS has no children for that item

Fixes:
- Nav dropdown: CMS was overriding DEFAULT_NAV without children — now inherits
- Wave pattern: use correct Figma wave tile (wave-tile-correct.png, 657x868px) for .wave-bg-pricing
- Lokatsii nav links: ДиноПарк → /lokatsii/dynozavry, ДивоЛіс → /lokatsii/dyvolis

Assets: 35+ new images (dino-*, group-*, birthday-*, ticket-*, gallery)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Vadym Samoilenko 2026-05-29 20:02:50 +01:00
parent f317de7b0f
commit 83d274f0ba
72 changed files with 2671 additions and 1011 deletions

Binary file not shown.

After

Width:  |  Height:  |  Size: 524 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.7 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 344 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 10 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 10 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.6 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 10 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 62 KiB

View file

@ -0,0 +1,10 @@
<svg preserveAspectRatio="none" width="100%" height="100%" overflow="visible" style="display: block;" viewBox="0 0 1858.1 1858.1" fill="none" xmlns="http://www.w3.org/2000/svg">
<circle id="Ellipse 91" cx="929.051" cy="929.051" r="929.051" fill="url(#paint0_linear_0_16)"/>
<defs>
<linearGradient id="paint0_linear_0_16" x1="0" y1="929.051" x2="1858.1" y2="929.051" gradientUnits="userSpaceOnUse">
<stop offset="0.0288462" stop-color="#F28B4A"/>
<stop offset="0.466346" stop-color="#FDCF54"/>
<stop offset="1" stop-color="#F28D4B"/>
</linearGradient>
</defs>
</svg>

After

Width:  |  Height:  |  Size: 566 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 99 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 463 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 226 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 11 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 33 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 658 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 71 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 93 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.6 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 551 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 425 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 350 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 17 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 14 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 452 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 105 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 989 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.9 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 746 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 452 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 12 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 25 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.6 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.2 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.4 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.7 MiB

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 251 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 10 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 19 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 344 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.8 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.2 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.6 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.4 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 8 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 453 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 344 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 492 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 264 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.5 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 300 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 426 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 271 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 195 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 191 KiB

View file

@ -1,10 +1,12 @@
/* eslint-disable @next/next/no-img-element */
import type { Metadata } from 'next'
import { getPayload } from 'payload'
import configPromise from '@payload-config'
import { BirthdayPricing } from '@/components/sections/BirthdayPricing'
import { DyvoLisWhyVisit } from '@/components/sections/DyvoLisWhyVisit'
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
@ -12,6 +14,29 @@ export const revalidate = 60
const FONT_MONT = { fontFamily: 'var(--font-montserrat, Montserrat), sans-serif' }
const FONT_POPPINS = { fontFamily: 'var(--font-poppins, Poppins), sans-serif' }
// Static fallback images — downloaded from Figma design (node 3:77)
const IMG_HERO_BG = '/images/figma/birthday-hero-bg.jpg'
const IMG_DINOPARK = '/images/figma/birthday-dinopark.jpg'
const IMG_DYVOLIS = '/images/figma/birthday-dyvolis.jpg'
const IMG_LABYRINTH = '/images/figma/birthday-labyrinth.jpg'
const IMG_ANIMATORS = '/images/figma/birthday-animators.jpg'
const IMG_AQUAGRIM = '/images/figma/birthday-aquagrim.jpg'
const IMG_ALTANKA = '/images/figma/birthday-altanka.jpg'
function ArrowIcon() {
return (
<svg width="18" height="14" viewBox="0 0 18 14" fill="none" aria-hidden="true">
<path
d="M1 7h16M10 1l6 6-6 6"
stroke="white"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
/>
</svg>
)
}
async function getBirthdayPageData() {
try {
const payload = await getPayload({ config: configPromise })
@ -40,89 +65,91 @@ export default async function BirthdayPage() {
const pageData = await getBirthdayPageData()
const d = pageData as any
const heroTitle = d?.heroTitle ?? 'ДЕНЬ НАРОДЖЕННЯ У ШУМІЛЕНДІ ПІД КЛЮЧ'
const heroTitle = d?.heroTitle ?? 'ДЕНЬ НАРОДЖЕННЯ\nУ ШУМІЛЕНДІ ПІД КЛЮЧ'
const heroSubtitle =
d?.heroSubtitle ??
'Будьте повноцінними гостями на дні народження вашої дитини. Залиште нам усі турботи про організацію. Ваш єдиний обовʼязок — відпочивати, святкувати, фотографуватися та насолоджуватися моментами.'
const heroCta = d?.heroCta ?? 'Забронювати пригоду'
"Будьте головними гостями на дні народження вашої дитини. Залиште нам усі турботи про організацію. Ваш єдиний обов'язок — відпочивати, святкувати, фотографуватися та насолоджуватися моментом."
const packageSectionTitle = d?.packageSectionTitle ?? О ВХОДИТЬ У ПАКЕТ СВЯТА'
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,
}))
d?.packageSectionSubtitle ?? 'Одна локація — три унікальні світи для іменинника та його друзів.'
const includedItems: { title: string; description: string; image: string | null }[] = [
{
title: d?.packageItems?.[0]?.title ?? 'ДинопаркАрк',
description:
d?.packageItems?.[0]?.description ??
'справжня археологічна експедиція до світу динозаврів, що пробуджує жагу до відкриттів.',
image: mediaUrl(d?.packageItems?.[0]?.image) ?? IMG_DINOPARK,
},
{
title: d?.packageItems?.[1]?.title ?? 'ДивоЛіс',
description:
d?.packageItems?.[1]?.description ??
'казкова територія з єдинорогами, драконами та магічними звірятами, де гра стає ще більш захопливою',
image: mediaUrl(d?.packageItems?.[1]?.image) ?? IMG_DYVOLIS,
},
{
title: d?.packageItems?.[2]?.title ?? 'Дзеркальний Лабіринт',
description:
d?.packageItems?.[2]?.description ??
'виклик для кмітливості, спостережливості та логіці, справжній атракціон для друзів.',
image: mediaUrl(d?.packageItems?.[2]?.image) ?? IMG_LABYRINTH,
},
]
const addOnItems: { title: string; description: string; image: string | null }[] = [
{
title: d?.packageItems?.[3]?.title ?? 'Костюмованих ведучих',
description:
d?.packageItems?.[3]?.description ??
'професіональні аніматори, які знають, як захопити увагу малечі, аби усі брали участь у розвагах і ніхто не залишився осторонь.',
image: mediaUrl(d?.packageItems?.[3]?.image) ?? IMG_ANIMATORS,
},
{
title: d?.packageItems?.[4]?.title ?? 'Аквагрим',
description:
d?.packageItems?.[4]?.description ??
'унікальні малюнки та візерунки безпечними фарбами, які допоможуть дітлахам перетворитися на улюблених казкових героїв.',
image: mediaUrl(d?.packageItems?.[4]?.image) ?? IMG_AQUAGRIM,
},
{
title: d?.packageItems?.[5]?.title ?? 'Затишну альтанку',
description:
d?.packageItems?.[5]?.description ??
'простір для смачного частування гостей та дорослих розмов, поки діти на повну занурені у квести, розваги та атракціони.',
image: mediaUrl(d?.packageItems?.[5]?.image) ?? IMG_ALTANKA,
},
]
const whyTitle = d?.whyTitle ?? 'Чому варто відвідати ДивоЛіс'
const whyItems = d?.whyItems ?? [
{
title: 'Свято під ключ',
title: 'Для батьків — мінімум турбот',
description:
'Ми беремо на себе всі деталі: аніматорів, конкурси, прикраси та окрему зону для вашої родини.',
},
{
title: 'Простір для дітей і дорослих',
description: 'Шуміленд — це 7 локацій, де кожен знайде щось для себе.',
title: 'Для дитини — максимум задоволення',
description:
'Це свято, яке не буде схожим на інші. Унікальні локації, квести, розваги — друзі дітей будуть у захваті. А задоволені друзі — це високий соціальний статус у дитячому середовищі, що також дуже важливо.',
},
{
title: 'Незабутні фото та спогади',
description: 'Унікальні декорації та щира радість дітей — ідеальний фон для фотографій.',
title: 'Для гостей — зручність та безпека',
description: 'Шуміленд — це 7 локацій, де кожен знайде щось для себе.',
},
]
const whyVideos = d?.whyVideos ?? []
const workingHours = d?.workingHours ?? "п'ятниця-субота-неділя з 11:00 до 20:00"
const pricingSectionTitle = d?.pricingSectionTitle ?? 'ВАРТІСТЬ КВИТКІВ:'
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: '3 дитини', price: '1500 грн', ctaLabel: 'Купити квиток', ctaHref: '/kvytky' },
{ label: '4 дитини', price: '1800 грн', ctaLabel: 'Купити квиток', ctaHref: '/kvytky' },
{ label: '5 дітей', price: '2000 грн', ctaLabel: 'Купити квиток', ctaHref: '/kvytky' },
{
label: 'Додатково',
label: '>5 дітей',
price: '400 грн',
note: 'особа',
ctaLabel: 'Купити квиток',
@ -133,18 +160,23 @@ export default async function BirthdayPage() {
const entranceSectionTitle = d?.entranceSectionTitle ?? 'Вхід на локації (для інших дітей):'
const entrancePrices = d?.entrancePrices ?? [
{
label: 'Вхід на локації для інших дітей',
label: 'Вхід на локації (для інших дітей)',
price: '600 грн',
ctaLabel: 'Забронювати пригоду',
ctaHref: '#order-form',
},
{
label: 'ДиноПарк',
label: 'Дино Парк',
price: '300 грн',
ctaLabel: 'Забронювати пригоду',
ctaHref: '#order-form',
},
{ label: 'Диволіс', price: '250 грн', ctaLabel: 'Забронювати пригоду', ctaHref: '#order-form' },
{
label: 'Диво Ліс',
price: '250 грн',
ctaLabel: 'Забронювати пригоду',
ctaHref: '#order-form',
},
{
label: 'Дзеркальний Лабіринт',
price: '160 грн',
@ -153,20 +185,18 @@ export default async function BirthdayPage() {
},
]
const freeInclusions =
d?.freeInclusions ??
'Діти до 3 років, Діти з іменинником до 18 років, VIP (за наявності запрошення), Діти-сироти'
const freeInclusions: string | null = d?.freeInclusions ?? null
const entertainmentSectionTitle = d?.entertainmentSectionTitle ?? 'Розважальна програма:'
const entertainmentPackages = d?.entertainmentPackages ?? [
{ label: 'Тривалість 1 год', price: '3 000 грн', ctaLabel: 'Замовити', ctaHref: '#order-form' },
{ label: 'Тривалість 1 год', price: '3000 грн', ctaLabel: 'Замовити', ctaHref: '#order-form' },
{
label: 'Тривалість 1.5 год',
price: '4 500 грн',
label: 'Тривалість 1,5 год',
price: '4500 грн',
ctaLabel: 'Замовити',
ctaHref: '#order-form',
},
{ label: 'Тривалість 2 год', price: '6 000 грн', ctaLabel: 'Замовити', ctaHref: '#order-form' },
{ label: 'Тривалість 2 год', price: '6000 грн', ctaLabel: 'Замовити', ctaHref: '#order-form' },
]
const formTitle = d?.formTitle ?? 'Замовити святкування'
@ -174,178 +204,107 @@ export default async function BirthdayPage() {
d?.formSubtitle ?? "Залиште заявку і наш менеджер зв'яжеться з вами протягом 30 хвилин"
return (
<div className="min-h-screen">
{/* ── 1. HERO ─────────────────────────────────────────────────────── */}
<section
className="relative flex min-h-[520px] flex-col items-center justify-end overflow-hidden"
style={{
backgroundImage: "url('/images/page-hero-default.webp')",
backgroundSize: 'cover',
backgroundPosition: 'center',
}}
>
<div
className="absolute inset-0"
style={{ background: 'rgba(30,60,10,0.55)' }}
<div className="min-h-screen" style={{ background: '#f1fbeb' }}>
{/* ── 1. HERO ──────────────────────────────────────────────────────────── */}
<section className="relative overflow-hidden">
{/* Background image */}
<img
src={mediaUrl(d?.heroImage) ?? IMG_HERO_BG}
alt=""
aria-hidden="true"
className="absolute inset-0 h-full w-full object-cover object-center"
style={{ pointerEvents: 'none' }}
/>
<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%)' }}
>
<h1
className="text-[24px] leading-tight font-black text-[#1a1a1a] uppercase sm:text-[32px] lg:text-[40px]"
style={FONT_MONT}
{/* Title badge — centred over image (Figma: gradient orange rounded rect) */}
<div className="relative z-10 flex flex-col items-center justify-end pt-[120px] pb-0 lg:pt-[180px]">
<div className="mx-auto w-full max-w-[1204px] px-4 lg:px-0">
<div
className="rounded-[19px] px-6 py-[18px] text-center lg:px-[30px] lg:py-[10px]"
style={{
background: 'linear-gradient(90deg, #f28b4a 2.885%, #fdcf54 46.635%, #f28b4a 100%)',
}}
>
{heroTitle}
</h1>
<h1
className="text-[22px] leading-tight font-black whitespace-pre-line text-[#272727] uppercase sm:text-[36px] lg:text-[64px] lg:leading-[1.2]"
style={FONT_MONT}
>
{heroTitle}
</h1>
</div>
</div>
</div>
{/* Dark-green subtitle bar — pinned to bottom of hero */}
<div
className="relative z-10 mt-6 w-full px-6 py-6"
style={{ background: 'rgba(30,60,10,0.85)' }}
className="relative z-10 mt-6 w-full overflow-hidden px-4 py-6 lg:rounded-b-[20px] lg:py-[35px]"
style={{ background: '#396817' }}
>
<div className="mx-auto flex max-w-[900px] flex-col items-center gap-4 sm:flex-row sm:justify-between">
<div className="mx-auto max-w-[1204px]">
<p
className="text-center text-[15px] leading-relaxed text-white/90 sm:text-left sm:text-[17px]"
className="text-[15px] leading-[1.5] font-medium text-white lg:text-[24px]"
style={FONT_POPPINS}
>
{heroSubtitle}
</p>
<a
href="#order-form"
className="flex-none rounded-[56px] px-8 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%)',
whiteSpace: 'nowrap',
}}
</div>
</div>
</section>
{/* ── 2. ЩО ВХОДИТЬ У ПАКЕТ СВЯТА ─────────────────────────────────────── */}
<section className="py-[40px] lg:py-[60px]" style={{ background: '#f1fbeb' }}>
<div className="mx-auto max-w-[1204px] px-4 lg:px-8">
{/* Section heading */}
<div className="mb-[20px] flex flex-col gap-[20px] lg:mb-[40px] lg:pr-[400px]">
<h2
className="text-[22px] font-bold text-[#272727] uppercase lg:text-[32px]"
style={FONT_MONT}
>
{heroCta}
</a>
{packageSectionTitle}
</h2>
<p
className="text-[15px] leading-[1.5] text-[#272727] lg:text-[16px]"
style={FONT_POPPINS}
>
{packageSectionSubtitle.includes('—') ? (
<>
<strong style={FONT_POPPINS}>
{packageSectionSubtitle.split('—')[0]?.trim()}
</strong>
{' — '}
{packageSectionSubtitle.split('—').slice(1).join('—').trim()}
</>
) : (
packageSectionSubtitle
)}
</p>
</div>
</div>
</section>
{/* ── 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}
{/* Included 3-card row */}
<div className="grid grid-cols-1 gap-5 sm:grid-cols-2 lg:grid-cols-3">
{includedItems.map((item) => (
<PackageCard key={item.title} item={item} />
))}
</div>
{/* Also can order label */}
<p
className="mt-[40px] mb-[20px] text-[15px] font-bold text-[#272727] lg:mb-[40px] lg:text-[16px]"
style={FONT_POPPINS}
>
{packageSectionTitle}
</h2>
<p className="mb-10 text-[16px] text-[#396817]" style={FONT_POPPINS}>
{packageSectionSubtitle}
Також можна додатково замовити:
</p>
<div className="hidden gap-5 sm:grid sm:grid-cols-3">
{packageItems.map((item) => (
<div
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' }}
>
{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}>
{item.title}
</h3>
<p className="mt-1 text-[13px] text-[#555]" style={FONT_POPPINS}>
{item.description}
</p>
</div>
<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={item.ctaLabel}
>
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" aria-hidden="true">
<path
d="M3 8h10M9 4l4 4-4 4"
stroke="white"
strokeWidth="1.8"
strokeLinecap="round"
strokeLinejoin="round"
/>
</svg>
</a>
</div>
</div>
))}
</div>
{/* Mobile scroll */}
<div
className="flex gap-4 overflow-x-auto pb-4 sm:hidden"
style={{ scrollSnapType: 'x mandatory' }}
>
{packageItems.map((item) => (
<div
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' }}
>
{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}>
{item.title}
</h3>
<p className="mt-1 text-[12px] text-[#555]" style={FONT_POPPINS}>
{item.description}
</p>
</div>
<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={item.ctaLabel}
>
<svg width="14" height="14" viewBox="0 0 16 16" fill="none" aria-hidden="true">
<path
d="M3 8h10M9 4l4 4-4 4"
stroke="white"
strokeWidth="1.8"
strokeLinecap="round"
strokeLinejoin="round"
/>
</svg>
</a>
</div>
</div>
{/* Add-on 3-card row */}
<div className="grid grid-cols-1 gap-5 sm:grid-cols-2 lg:grid-cols-3">
{addOnItems.map((item) => (
<PackageCard key={item.title} item={item} />
))}
</div>
</div>
</section>
{/* ── 3. ЧОМУ ВАРТО ─────────────────────────────────────────────── */}
{/* ── 3. ЧОМУ ВАРТО ────────────────────────────────────────────────────── */}
<DyvoLisWhyVisit
title={whyTitle}
items={whyItems.map((i: any) => ({ title: i.title, description: i.description }))}
@ -360,158 +319,115 @@ export default async function BirthdayPage() {
}
/>
{/* ── 4. WORKING HOURS ──────────────────────────────────────────── */}
{/* ── 4. PRICING (green background section) ────────────────────────────── */}
<section
className="py-10"
style={{ background: 'linear-gradient(90deg, #f28b4a 0%, #fdcf54 55%, #f28b4a 100%)' }}
className="relative overflow-hidden rounded-t-[20px] pt-0"
style={{ background: '#396817' }}
>
<div className="mx-auto flex max-w-[1204px] flex-col items-center gap-2 px-6 text-center lg:px-8">
<p
className="text-[13px] font-bold tracking-widest text-[#1a1a1a]/70 uppercase"
style={FONT_MONT}
>
ЧАС РОБОТИ
</p>
<p
className="text-[20px] font-black text-[#1a1a1a] uppercase lg:text-[26px]"
style={FONT_MONT}
>
{workingHours}
</p>
</div>
</section>
{/* Wave pattern overlay */}
<div className="wave-bg-pricing pointer-events-none absolute inset-0" aria-hidden="true" />
{/* ── 5. PRICING ────────────────────────────────────────────────── */}
<section className="rounded-t-[40px] py-16" style={{ background: '#396817' }}>
<div className="mx-auto max-w-[1204px] px-6 lg:px-8">
<div className="relative z-10 mx-auto max-w-[1204px] px-4 pb-[60px] lg:px-8">
{/* Working hours banner — full-width inside the section */}
<div
className="mb-[60px] flex flex-col items-center justify-center gap-[10px] overflow-hidden px-[30px] py-[10px] text-center lg:-mx-8 lg:rounded-b-[20px]"
style={{
background: 'linear-gradient(90deg, #f28b4a 2.885%, #fdcf54 46.635%, #f28b4a 100%)',
}}
>
<p
className="text-[18px] font-bold text-[#272727] uppercase lg:text-[32px]"
style={FONT_MONT}
>
Час роботи
</p>
<p className="text-[16px] font-bold text-[#272727] lg:text-[32px]" style={FONT_MONT}>
{workingHours}
</p>
</div>
{/* Birthday package pricing (Figma pricingPackages) */}
<h2
className="mb-10 text-[24px] font-black text-white uppercase lg:text-[32px]"
className="mb-[40px] text-[22px] font-black text-white uppercase lg:text-[32px]"
style={FONT_MONT}
>
{pricingSectionTitle}
</h2>
{/* 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>
<PriceCard key={pkg.label} pkg={pkg} />
))}
</div>
{/* Entrance prices */}
<h3 className="mt-10 mb-6 text-[18px] font-black text-white" style={FONT_MONT}>
<h3
className="mt-[60px] mb-[40px] text-[18px] font-bold text-white lg:text-[32px]"
style={FONT_POPPINS}
>
{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>
<PriceCard key={item.label} pkg={item} />
))}
</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}
<div className="mt-[40px]">
<p className="text-[18px] font-bold text-white lg:text-[20px]" style={FONT_MONT}>
Безкоштовно:
</p>
)}
<ul className="mt-3 list-disc pl-6">
{(freeInclusions
? freeInclusions.split(',')
: [
'Діти до 3 років',
'Діти з інвалідністю до 18 років',
'УБД (за наявності посвідчення)',
'Діти-сироти',
]
).map((item: string, i: number) => (
<li
key={i}
className="text-[16px] leading-[1.5] font-medium text-white lg:text-[20px]"
style={FONT_MONT}
>
{item.trim()}
</li>
))}
</ul>
</div>
{/* Entertainment packages */}
<h3 className="mt-10 mb-6 text-[18px] font-black text-white" style={FONT_MONT}>
{/* Entertainment programme */}
<h3
className="mt-[60px] mb-[40px] text-[18px] font-bold text-white lg:text-[32px]"
style={FONT_POPPINS}
>
{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>
<PriceCard key={pkg.label} pkg={pkg} />
))}
</div>
</div>
</section>
{/* ── 6. ORDER FORM ─────────────────────────────────────────────── */}
<section id="order-form" style={{ background: '#396817' }} className="pt-2 pb-20">
<div className="mx-auto max-w-[1204px] px-6 lg:px-8">
{/* ── 5. BIRTHDAY PRICING PACKAGES (BirthdayPricing component) ────────── */}
<BirthdayPricing
packages={d?.birthdayPackages}
title={d?.birthdayPricingTitle}
intro={d?.birthdayPricingIntro}
/>
{/* ── 6. ORDER FORM ────────────────────────────────────────────────────── */}
<section id="order-form" style={{ background: '#396817' }} className="pt-2 pb-[60px]">
<div className="mx-auto max-w-[1204px] px-4 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}>
<h2 className="mb-2 text-[24px] font-bold text-white lg:text-[28px]" style={FONT_MONT}>
{formTitle}
</h2>
<p className="mb-8 text-[15px] text-white/70" style={FONT_POPPINS}>
<p className="mb-8 text-[14px] text-white/70 lg:text-[15px]" style={FONT_POPPINS}>
{formSubtitle}
</p>
{pageData?.form && typeof pageData.form === 'object' ? (
@ -530,3 +446,113 @@ export default async function BirthdayPage() {
</div>
)
}
// ── Sub-components ────────────────────────────────────────────────────────────
function PackageCard({
item,
}: {
item: { title: string; description: string; image: string | null }
}) {
return (
<div
className="relative flex flex-col gap-[24px] overflow-hidden rounded-[20px] p-[20px] shadow-[0_4px_30px_rgba(242,139,74,0.25)]"
style={{ background: '#f1fbeb' }}
>
{/* Card image */}
<div className="relative h-[200px] overflow-hidden rounded-[16px] lg:h-[280px]">
{item.image ? (
<img src={item.image} alt={item.title} className="h-full w-full object-cover" />
) : (
<div className="h-full w-full" style={{ background: '#d6f2c0' }} aria-hidden="true" />
)}
</div>
{/* Text block */}
<div className="flex flex-col gap-[12px]">
<h3
className="text-[20px] leading-[1.5] font-bold text-[#272727] lg:text-[24px]"
style={{ fontFamily: 'var(--font-montserrat, Montserrat), sans-serif' }}
>
{item.title}
</h3>
<p
className="text-[14px] leading-[1.5] text-[#272727] lg:text-[16px]"
style={{ fontFamily: 'var(--font-poppins, Poppins), sans-serif' }}
>
{item.description}
</p>
</div>
{/* CTA row */}
<div className="mt-auto flex items-center justify-end gap-[24px]">
<span
className="text-[14px] font-bold text-[#272727] lg:text-[16px]"
style={{ fontFamily: 'var(--font-montserrat, Montserrat), sans-serif' }}
>
Замовити
</span>
<a
href="#order-form"
className="flex h-[50px] w-[50px] flex-none items-center justify-center rounded-full transition-opacity hover:opacity-80"
style={{ background: '#f28b4a' }}
aria-label="Замовити"
>
<ArrowIcon />
</a>
</div>
</div>
)
}
function PriceCard({
pkg,
}: {
pkg: { label: string; price: string; note?: string; ctaLabel?: string; ctaHref?: string }
}) {
return (
<div
className="relative flex flex-col gap-[12px] overflow-hidden rounded-[20px] p-[20px] shadow-[0_4px_30px_rgba(242,139,74,0.25)] lg:gap-[24px]"
style={{ background: '#f1fbeb' }}
>
{/* Category label */}
<p
className="text-[13px] font-bold tracking-wider text-[#f28b4a] uppercase lg:text-[16px]"
style={{ fontFamily: 'var(--font-montserrat, Montserrat), sans-serif' }}
>
{pkg.label}
</p>
{/* Divider */}
<div className="h-px w-full" style={{ background: '#e0d0c0' }} />
{/* Price */}
<p
className="text-[40px] leading-[1.5] font-black text-[#272727] lg:text-[64px]"
style={{ fontFamily: 'var(--font-montserrat, Montserrat), sans-serif' }}
>
{pkg.price}
</p>
{pkg.note && (
<p
className="text-[14px] font-bold text-[#272727]"
style={{ fontFamily: 'var(--font-montserrat, Montserrat), sans-serif' }}
>
{pkg.note}
</p>
)}
{/* CTA button */}
<a
href={pkg.ctaHref ?? '#order-form'}
className="mt-auto flex w-full items-center justify-center rounded-[64px] py-[10px] text-[16px] font-bold text-white transition-opacity hover:opacity-90 lg:text-[20px]"
style={{
background: '#f28b4a',
fontFamily: 'var(--font-montserrat, Montserrat), sans-serif',
}}
>
{pkg.ctaLabel ?? 'Купити квиток'}
</a>
</div>
)
}

View file

@ -1,14 +1,29 @@
/* eslint-disable @next/next/no-img-element */
import type { Metadata } from 'next'
import { getPayload } from 'payload'
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
const FONT_MONT = { fontFamily: 'var(--font-montserrat, Montserrat), sans-serif' }
// Static fallback images — downloaded from Figma design
const IMG_GROUP_HERO = '/images/figma/group-hero-bg.jpg'
const IMG_AMENITY = [
'/images/figma/group-amenity-1.jpg',
'/images/figma/group-amenity-2.jpg',
'/images/figma/group-amenity-3.jpg',
'/images/figma/group-amenity-4.jpg',
]
const IMG_BANNER_A = '/images/figma/group-banner-1.jpg'
const IMG_BANNER_B = '/images/figma/group-banner-2.jpg'
const IMG_BOTTOM_A = '/images/figma/group-bottom-1.jpg'
const IMG_BOTTOM_B = '/images/figma/group-bottom-2.jpg'
async function getGroupVisitsData() {
try {
const payload = await getPayload({ config: configPromise })
@ -33,14 +48,64 @@ function mediaUrl(m: number | Media | null | undefined): string | null {
return (m as Media).url ?? null
}
function TiltedBanner({ text, imageA, imageB }: { text: string; imageA: string; imageB: string }) {
return (
<section className="bg-[#f1fbeb] py-[40px] lg:py-0">
{/* Mobile */}
<div className="px-4 md:px-8 lg:hidden">
<p
className="mb-6 text-[16px] leading-[1.5] font-medium text-[#272727] md:text-[20px]"
style={FONT_MONT}
>
{text}
</p>
<div className="grid grid-cols-2 gap-4">
{[imageA, imageB].map((src, i) => (
<div
key={i}
className="overflow-hidden rounded-[16px] shadow-[0_4px_30px_rgba(242,139,74,0.25)]"
>
<img src={src} alt="" className="h-[160px] w-full object-cover" />
</div>
))}
</div>
</div>
{/* Desktop — polaroid cards tilted over background */}
<div className="relative mx-auto hidden h-[672px] max-w-[1204px] overflow-hidden lg:block">
<div className="absolute top-1/2 left-0 w-[409px] -translate-y-1/2">
<p className="text-[24px] leading-[1.5] font-medium text-[#272727]" style={FONT_MONT}>
{text}
</p>
</div>
{/* Card A — tilted left */}
<div
className="absolute w-[388px] rounded-[20px] bg-[#f1fbeb] px-[20px] pt-[20px] pb-[60px] shadow-[0_4px_30px_rgba(242,139,74,0.25)]"
style={{ left: 587, top: 29, transform: 'rotate(-5.88deg)' }}
>
<div className="h-[280px] overflow-hidden rounded-[20px]">
<img src={imageA} alt="" className="h-full w-full object-cover" />
</div>
</div>
{/* Card B — tilted right */}
<div
className="absolute w-[388px] rounded-[20px] bg-[#f1fbeb] px-[20px] pt-[20px] pb-[60px] shadow-[0_4px_30px_rgba(242,139,74,0.25)]"
style={{ left: 763, top: 208, transform: 'rotate(9.35deg)' }}
>
<div className="h-[280px] overflow-hidden rounded-[20px]">
<img src={imageB} alt="" className="h-full w-full object-cover" />
</div>
</div>
</div>
</section>
)
}
export default async function GroupVisitsPage() {
const data = await getGroupVisitsData()
const d = data as any
const heroTitle = d?.heroTitle ?? 'Групові відвідування'
const heroSubtitle =
d?.heroSubtitle ??
'Спеціальні умови для організованих груп. Мінімум 10 осіб — максимум вражень.'
const heroTitle = d?.heroTitle ?? 'Групові Візити'
const heroSubtitle = d?.heroSubtitle ?? 'Спеціальна пропозиція для садочків та шкіл'
const heroDescription =
d?.heroDescription ??
'Шукаєте ідеальне місце для групового виїзду класу чи садочка? Або яскраву локацію для фотосесії? Хочете, щоб дитячий випускний альбом був дійсно унікальним? Запрошуємо провести цей захопливий і незабутній день на казковій локації.'
@ -48,7 +113,7 @@ export default async function GroupVisitsPage() {
const featureText =
d?.featureText ??
'На дітлах чекає подорож ДинопарКом та ДивоЛісом. Це активне дозвілля на свіжому повітрі та справжні казкові пригоди, де кожен стане героєм власної історії.'
'На дітлахів чекає подорож ДиноПарком та ДивоЛісом. Це активне дозвілля на свіжому повітрі та справжні казкові пригоди, де кожен стане героєм власної історії.'
const featureImages: string[] = (d?.featureImages ?? [])
.map((i: any) => mediaUrl(i.image))
.filter(Boolean)
@ -56,26 +121,29 @@ export default async function GroupVisitsPage() {
const amenitiesTitle = d?.amenitiesTitle ?? 'Ми подбали про затишок і комфорт'
const amenities: { label: string; imageUrl: string | null }[] = (
d?.amenities ?? [
{ label: '2 локації без обмежень у часі' },
{ label: 'Вбиральні та кафе на території' },
{ label: '2 локації\nбез обмежень у часі' },
{ label: 'Вбиральня та кафе\nна території' },
{ label: 'Укриття поруч' },
{ label: 'Огороджено забором, є охорона' },
{ label: 'Огороджено забором,\nє охорона' },
]
).map((a: any) => ({ label: a.label, imageUrl: mediaUrl(a.image) }))
).map((a: any, i: number) => ({
label: a.label,
imageUrl: mediaUrl(a.image) ?? IMG_AMENITY[i] ?? null,
}))
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 priceMinPeople = d?.priceMinPeople ?? 'Пропозиція діє для груп від 10 людей'
const priceCta = d?.priceCta ?? 'Купити квиток'
const priceDescription =
d?.priceDescription ??
'У вартість входить відвідування Динопарку та ДивоЛісу.\nЧас перебування на локаціях необмежений.'
'У вартість входить відвідування ДиноПарку та ДивоЛісу.\nЧас перебування на локаціях необмежений.'
const bottomText =
d?.bottomText ??
'Хочете перетворити візит на справжню маленьку експедицію з розповідями або замовити екскурсію з розповідями про динозаврів? Ми залюбки це організуємо! Телефонуйте нам — і ми все підготуємо та розрахуємо індивідуально для вашої групи.'
'Хочете перетворити візит на справжню маленьку експедицію з розкопками або замовити екскурсію з розповідями про динозаврів? Ми залюбки це організуємо! Телефонуйте нам — і ми все підготуємо та розрахуємо індивідуально для вашої групи.'
const bottomImages: string[] = (d?.bottomImages ?? [])
.map((i: any) => mediaUrl(i.image))
.filter(Boolean)
@ -85,119 +153,98 @@ export default async function GroupVisitsPage() {
d?.formSubtitle ??
'Вкажіть кількість учасників та бажану дату — менеджер зателефонує і погодить деталі.'
const FONT_MONT = { fontFamily: 'var(--font-montserrat, Montserrat), sans-serif' }
const heroBg = mediaUrl(d?.heroImage) ?? IMG_GROUP_HERO
return (
<div className="min-h-screen bg-[#f1fbeb]">
{/* 1. Hero */}
{/* ── 1. Hero ── */}
<section
className="relative flex min-h-[340px] items-center justify-center overflow-hidden md:min-h-[500px]"
className="relative -mt-[60px] flex min-h-[500px] flex-col items-center justify-end overflow-hidden lg:-mt-[120px] lg:min-h-[800px]"
style={{
backgroundImage: "url('/images/page-hero-default.webp')",
backgroundImage: `url('${heroBg}')`,
backgroundSize: 'cover',
backgroundPosition: 'center',
}}
>
<div className="absolute inset-0" style={{ background: 'rgba(0,0,0,0.45)' }} />
<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)' }}
<div
aria-hidden="true"
className="pointer-events-none absolute inset-0"
style={{
background:
'linear-gradient(to bottom, rgba(0,0,0,0.05) 20%, rgba(0,0,0,0.25) 60%, rgba(0,0,0,0.45) 100%)',
}}
/>
<div
className="relative z-10 mx-[10px] flex w-[calc(100%-20px)] max-w-[1204px] flex-col items-center gap-[10px] overflow-clip rounded-t-[20px] px-[30px] py-[30px] text-center lg:py-[40px]"
style={{
background: 'linear-gradient(90deg, #f28b4a 2.9%, #fdcf54 46.6%, #f28b4a 100%)',
}}
>
<h1
className="text-[32px] leading-[1.2] font-bold text-[#272727] uppercase md:text-[48px] lg:text-[64px]"
style={FONT_MONT}
>
<h1
className="text-[32px] leading-tight font-black tracking-widest text-[#1a1a1a] uppercase md:text-[48px]"
style={FONT_MONT}
>
{heroTitle}
</h1>
<p
className="mt-2 text-[15px] font-medium text-[#1a1a1a] md:text-[18px]"
style={FONT_MONT}
>
{heroSubtitle}
</p>
</div>
{heroTitle}
</h1>
<p
className="text-[16px] leading-[1.5] font-bold text-[#272727] md:text-[24px] lg:text-[32px]"
style={FONT_MONT}
>
{heroSubtitle}
</p>
</div>
</section>
{/* 2. Green description band */}
<section className="bg-[#396817] px-4 py-10 md:px-8">
<div className="mx-auto max-w-[860px] text-center">
{/* ── 2. Green description band ── */}
<section className="rounded-b-[20px] bg-[#396817] px-4 py-10 md:px-8 lg:py-[35px]">
<div className="mx-auto max-w-[1200px]">
<p
className="mb-8 text-[16px] leading-relaxed text-white md:text-[18px]"
className="mb-8 text-[16px] leading-relaxed font-medium text-white md:text-[20px] lg:text-[24px]"
style={FONT_MONT}
>
{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)',
...FONT_MONT,
}}
className="inline-flex w-full items-center justify-center rounded-[64px] px-[30px] py-[10px] text-[17px] font-bold text-white transition-opacity hover:opacity-90 lg:w-auto lg:text-[20px]"
style={{ background: '#f28b4a', ...FONT_MONT }}
>
{heroCta}
</a>
</div>
</section>
{/* 3. Two-column feature block */}
<section className="bg-[#f1fbeb] px-4 py-14 md:px-8">
<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={FONT_MONT}
>
{featureText}
</p>
<div className="relative flex h-[280px] items-center justify-center">
{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>
{/* ── 3. Feature banner — tilted polaroid cards ── */}
<TiltedBanner
text={featureText}
imageA={featureImages[0] ?? IMG_BANNER_A}
imageB={featureImages[1] ?? IMG_BANNER_B}
/>
{/* 4. Amenity cards grid */}
<section className="bg-[#f1fbeb] px-4 pb-14 md:px-8">
<div className="mx-auto max-w-[1100px]">
{/* ── 4. Amenity cards ── */}
<section className="bg-[#f1fbeb] px-4 py-[40px] md:px-8 lg:py-[60px]">
<div className="mx-auto max-w-[1204px]">
<h2
className="mb-8 text-center text-[22px] font-black tracking-wide text-[#396817] uppercase md:text-[28px]"
className="mb-[40px] text-[22px] font-bold text-[#272727] uppercase md:text-[28px] lg:mb-[60px] lg:text-[32px]"
style={FONT_MONT}
>
{amenitiesTitle}
</h2>
<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.imageUrl ? (
<img
src={item.imageUrl}
alt={item.label}
className="h-[120px] w-full object-cover"
/>
) : (
<div className="h-[120px] bg-[#c8e6a0]" />
)}
<div className="grid grid-cols-1 gap-[20px] sm:grid-cols-2">
{amenities.map((item, i) => (
<div
key={i}
className="flex flex-col gap-[24px] rounded-[20px] bg-[#f1fbeb] p-[20px] shadow-[0_4px_30px_rgba(242,139,74,0.25)] lg:h-[416px]"
>
<div className="h-[180px] overflow-hidden rounded-[20px] lg:h-[280px]">
{item.imageUrl ? (
<img src={item.imageUrl} alt="" className="h-full w-full object-cover" />
) : (
<div className="h-full bg-[#c8e6a0]" />
)}
</div>
<p
className="p-4 text-[14px] leading-snug font-semibold text-[#1a1a1a]"
className="text-center text-[18px] leading-[1.5] font-bold whitespace-pre-line text-[#272727] lg:text-[24px]"
style={FONT_MONT}
>
{item.label}
@ -208,112 +255,98 @@ export default async function GroupVisitsPage() {
</div>
</section>
{/* 5. Working hours banner */}
<section
className="px-4 py-8 text-center"
style={{ background: 'linear-gradient(90deg, #f28b4a, #fdcf54, #f28b4a)' }}
>
<p
className="text-[13px] font-bold tracking-widest text-[#1a1a1a] uppercase"
style={FONT_MONT}
>
ЧАС РОБОТИ
</p>
<p className="mt-1 text-[18px] font-black text-[#1a1a1a] md:text-[22px]" style={FONT_MONT}>
{workingHours}
</p>
</section>
{/* 6. Pricing section */}
<section className="rounded-t-[32px] bg-[#396817] px-4 py-14 md:px-8">
<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={FONT_MONT}
{/* ── 5+6. Working hours + Pricing — green section with wave pattern ── */}
<section className="relative overflow-hidden rounded-t-[20px]">
<div
className="wave-bg-pricing absolute inset-0 z-0"
style={{ backgroundColor: '#396817' }}
/>
<div className="relative z-10">
{/* Working hours banner */}
<div
className="flex flex-col items-center justify-center gap-[10px] px-[30px] py-[10px] text-center font-bold text-[#272727]"
style={{
background: 'linear-gradient(90deg, #f28b4a 2.9%, #fdcf54 46.6%, #f28b4a 100%)',
...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={FONT_MONT}
>
СПЕЦІАЛЬНА ЦІНА ДЛЯ ГРУП
</p>
<p
className="text-[72px] leading-none font-black text-[#1a1a1a] md:text-[96px]"
style={FONT_MONT}
>
{price}
</p>
<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={FONT_MONT}
>
{priceNote}
</p>
<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)',
...FONT_MONT,
}}
>
{priceCta}
</a>
<p className="text-[18px] leading-[1.5] uppercase lg:text-[32px]">Час роботи</p>
<p className="text-[15px] leading-[1.5] lg:text-[32px]">{workingHours}</p>
</div>
<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>
{/* 7. Bottom two-column block */}
<section className="bg-[#f1fbeb] px-4 py-14 md:px-8">
<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={FONT_MONT}
>
{bottomText}
</p>
<div className="relative flex h-[280px] items-center justify-center">
{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" />
</>
)}
{/* Pricing content */}
<div className="mx-auto max-w-[1204px] px-4 py-[40px] md:px-8 lg:py-[60px]">
<h2
className="mb-[40px] text-[20px] font-bold text-[#f1fbeb] uppercase lg:mb-[60px] lg:text-[32px]"
style={FONT_MONT}
>
Вартість групового візиту:
</h2>
{/* Pricing card */}
<div
className="mb-[40px] flex flex-col items-center gap-[12px] rounded-[20px] p-[20px] shadow-[0_4px_30px_rgba(242,139,74,0.25)] lg:mb-[60px]"
style={{ background: '#f1fbeb' }}
>
<p
className="text-[13px] font-bold text-[#f28b4a] uppercase lg:text-[16px]"
style={FONT_MONT}
>
Спеціальна ціна для груп
</p>
<div className="h-px w-full bg-[#c8e6a0]" />
<p
className="text-[48px] leading-[1.5] font-black text-[#272727] lg:text-[64px]"
style={FONT_MONT}
>
{price}
</p>
<p className="text-[14px] font-bold text-[#272727] lg:text-[16px]" style={FONT_MONT}>
{priceLabel}
</p>
<p
className="max-w-[500px] text-center text-[13px] leading-[1.5] font-bold text-[#272727] lg:text-[16px]"
style={FONT_MONT}
>
{priceNote}
</p>
<div className="h-px w-full bg-[#c8e6a0]" />
<p className="text-[13px] font-bold text-[#f28b4a] lg:text-[16px]" style={FONT_MONT}>
{priceMinPeople}
</p>
<a
href="#order-form"
className="mt-2 flex w-full items-center justify-center rounded-[64px] px-[30px] py-[10px] text-[17px] font-bold text-white transition-opacity hover:opacity-90 lg:text-[20px]"
style={{ background: '#f28b4a', ...FONT_MONT }}
>
{priceCta}
</a>
</div>
{/* Price description */}
<div className="flex flex-col gap-[20px]">
{priceDescription.split('\n').map((line: string, i: number) => (
<p
key={i}
className="text-[18px] leading-[1.5] font-bold text-[#fdf2e8] lg:text-[32px]"
style={{ fontFamily: 'var(--font-poppins, Poppins), sans-serif' }}
>
{line}
</p>
))}
</div>
</div>
</div>
</section>
{/* 8. Order form */}
{/* ── 7. Bottom banner — tilted polaroid cards ── */}
<TiltedBanner
text={bottomText}
imageA={bottomImages[0] ?? IMG_BOTTOM_A}
imageB={bottomImages[1] ?? IMG_BOTTOM_B}
/>
{/* ── 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={FONT_MONT}>

View file

@ -4,17 +4,82 @@ import Link from 'next/link'
export const metadata: Metadata = {
title: 'Дякуємо за покупку — Шуміленд',
description: 'Ваші квитки відправлені на email.',
robots: 'noindex,nofollow',
}
const FONT_MONT = { fontFamily: 'var(--font-montserrat, Montserrat), sans-serif' }
const TICKET_W = 760
const TICKET_H = 380
// X where the perforation / stub separation lives
const PERF_X = 600
const STUB_W = TICKET_W - PERF_X // 160px stub
const CORNER_R = 22
// Perforation dots
const DOTS = 11
const DOT_R = 5.5
const DOT_AREA_H = TICKET_H - CORNER_R * 2
const DOT_SPACING = DOT_AREA_H / (DOTS - 1)
function TicketSvg() {
// Build scalloped-edge path for one solid filled shape.
// We cut circular notches at the 4 main-body corners and 2 stub corners.
return (
<svg
viewBox={`0 0 ${TICKET_W} ${TICKET_H}`}
preserveAspectRatio="none"
xmlns="http://www.w3.org/2000/svg"
aria-hidden="true"
style={{ position: 'absolute', inset: 0, width: '100%', height: '100%' }}
>
<defs>
<linearGradient id="tg" x1="0%" y1="0%" x2="100%" y2="0%">
<stop offset="0%" stopColor="#f28b4a" />
<stop offset="47%" stopColor="#fdcf54" />
<stop offset="100%" stopColor="#f28d4b" />
</linearGradient>
<mask id="tm">
<rect width={TICKET_W} height={TICKET_H} fill="white" />
{/* Notch top-left */}
<circle cx={0} cy={CORNER_R} r={CORNER_R} fill="black" />
{/* Notch bottom-left */}
<circle cx={0} cy={TICKET_H - CORNER_R} r={CORNER_R} fill="black" />
{/* Notch top at perf line */}
<circle cx={PERF_X} cy={0} r={CORNER_R} fill="black" />
{/* Notch bottom at perf line */}
<circle cx={PERF_X} cy={TICKET_H} r={CORNER_R} fill="black" />
{/* Notch top-right */}
<circle cx={TICKET_W} cy={CORNER_R} r={CORNER_R} fill="black" />
{/* Notch bottom-right */}
<circle cx={TICKET_W} cy={TICKET_H - CORNER_R} r={CORNER_R} fill="black" />
</mask>
</defs>
{/* Main ticket body + stub */}
<rect width={TICKET_W} height={TICKET_H} fill="url(#tg)" mask="url(#tm)" />
{/* Perforation dots */}
{Array.from({ length: DOTS }).map((_, i) => (
<circle
key={i}
cx={PERF_X}
cy={CORNER_R + i * DOT_SPACING}
r={DOT_R}
fill="white"
opacity="0.75"
/>
))}
</svg>
)
}
export default function DyakuiemoPage() {
return (
<main
className="relative flex min-h-screen items-center justify-center overflow-hidden px-4 py-20"
style={{ background: '#1e3610' }}
className="relative flex min-h-screen items-center justify-center overflow-hidden px-4 py-16"
style={{ background: '#f1fbeb' }}
>
{/* Background photo — DyvoLis topiary */}
{/* Background: Shumiland forest photo */}
<div
className="pointer-events-none absolute inset-0"
aria-hidden="true"
@ -22,91 +87,100 @@ export default function DyakuiemoPage() {
backgroundImage: `url('/images/dyvolis/photo-01.jpg')`,
backgroundSize: 'cover',
backgroundPosition: 'center',
filter: 'blur(2px) brightness(0.8)',
transform: 'scale(1.05)',
}}
/>
{/* Subtle green-tinted overlay matching Figma bg */}
<div
className="pointer-events-none absolute inset-0"
aria-hidden="true"
style={{ background: 'rgba(57,104,23,0.15)' }}
/>
{/* Large orange glow behind ticket (rotated ellipse from Figma) */}
<div
className="pointer-events-none absolute"
aria-hidden="true"
style={{
width: '900px',
height: '900px',
right: '-180px',
top: '50%',
transform: 'translateY(-50%) rotate(15deg)',
background:
'radial-gradient(ellipse 55% 55% at 55% 50%, rgba(253,207,84,0.6) 0%, rgba(242,139,74,0.35) 45%, transparent 75%)',
filter: 'blur(70px)',
}}
/>
<div className="pointer-events-none absolute inset-0 bg-black/30" aria-hidden="true" />
{/* Ticket card */}
<div
className="relative z-10 w-full max-w-[760px] overflow-hidden rounded-[24px] shadow-[0_24px_80px_rgba(0,0,0,0.45)]"
style={{ background: '#f5c140' }}
className="relative z-10 w-full"
style={{ maxWidth: `${TICKET_W}px` }}
role="main"
aria-label="Підтвердження замовлення"
>
<div className="flex min-h-[340px] lg:min-h-[400px]">
{/* Left: content */}
<div className="flex flex-1 flex-col justify-center px-8 py-10 lg:px-14 lg:py-12">
{/* Aspect-ratio shell — preserves ticket proportions responsively */}
<div style={{ position: 'relative', paddingBottom: `${(TICKET_H / TICKET_W) * 100}%` }}>
<TicketSvg />
{/* Content: left portion up to perf line */}
<div
style={{
position: 'absolute',
inset: 0,
right: `${(STUB_W / TICKET_W) * 100}%`,
display: 'flex',
flexDirection: 'column',
justifyContent: 'center',
padding: '8% 10%',
}}
>
<h1
className="mb-4 text-[36px] leading-[1.1] font-black text-[#1a1a1a] uppercase lg:text-[52px]"
style={FONT_MONT}
className="leading-[1.15] font-black text-[#272727] uppercase"
style={{
...FONT_MONT,
fontSize: 'clamp(22px, 5.5vw, 64px)',
marginBottom: '0.35em',
}}
>
Дякуємо
<br />
за покупку
</h1>
<p
className="mb-8 text-[16px] leading-[1.5] font-bold text-[#1a1a1a]/80 lg:text-[20px]"
style={FONT_MONT}
className="leading-[1.5] font-bold text-[#272727]"
style={{
...FONT_MONT,
fontSize: 'clamp(12px, 2vw, 28px)',
marginBottom: '1.4em',
opacity: 0.85,
}}
>
Ваші квитки відправлені на email.
</p>
<Link
href="/kvytky"
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 }}
className="inline-flex w-fit items-center justify-center font-bold text-white transition-shadow hover:shadow-[0_0_20px_0_#403997]"
style={{
...FONT_MONT,
background: '#403997',
borderRadius: '64px',
padding: '0.45em 1.4em',
fontSize: 'clamp(11px, 1.6vw, 20px)',
}}
>
Купити квиток
</Link>
</div>
{/* Divider with notches */}
<div className="relative hidden flex-none items-center sm:flex">
{/* Notch top */}
<div
className="absolute top-0 left-1/2 h-10 w-10 -translate-x-1/2 -translate-y-1/2 rounded-full"
style={{ background: 'linear-gradient(135deg, #1e3610 0%, #396817 100%)' }}
aria-hidden="true"
/>
{/* Dashed line */}
<div
className="h-full w-[2px]"
style={{
background:
'repeating-linear-gradient(to bottom, rgba(0,0,0,0.18) 0px, rgba(0,0,0,0.18) 8px, transparent 8px, transparent 16px)',
}}
/>
{/* Notch bottom */}
<div
className="absolute bottom-0 left-1/2 h-10 w-10 -translate-x-1/2 translate-y-1/2 rounded-full"
style={{ background: 'linear-gradient(135deg, #1e3610 0%, #396817 100%)' }}
aria-hidden="true"
/>
</div>
{/* Right: perforation dots */}
<div
className="hidden flex-none flex-col items-center justify-center gap-3 px-6 sm:flex lg:px-8"
aria-hidden="true"
>
{Array.from({ length: 11 }).map((_, i) => (
<div
key={i}
className="rounded-full"
style={{
width: i === 5 ? 16 : 10,
height: i === 5 ? 16 : 10,
background: `rgba(26,26,26,${i === 5 ? 0.35 : 0.2})`,
}}
/>
))}
</div>
</div>
</div>
{/* Back to home link */}
{/* Back to home */}
<Link
href="/"
className="absolute bottom-8 left-1/2 -translate-x-1/2 text-[13px] font-medium text-white/60 underline underline-offset-4 transition-colors hover:text-white/90"
className="absolute bottom-8 left-1/2 -translate-x-1/2 text-[13px] font-medium text-white/70 underline underline-offset-4 transition-colors hover:text-white"
style={FONT_MONT}
>
На головну

View file

@ -1,14 +1,18 @@
/* eslint-disable @next/next/no-img-element */
import type { Metadata } from 'next'
import Link from 'next/link'
import { getPayload } from 'payload'
import configPromise from '@payload-config'
import { PageHero } from '@/components/ui/PageHero'
import { TariffCardClient } from '@/components/ui/TariffCardClient'
import { getSiteSettings } from '@/lib/getSiteSettings'
import { RefreshRouteOnSave } from '@/components/cms/RefreshRouteOnSave'
import { KvytkyTicketsClient } from '@/components/sections/KvytkyTicketsClient'
import { BirthdayPricing } from '@/components/sections/BirthdayPricing'
import type { BirthdayPackageCMS } from '@/types/globals'
export const revalidate = 60
const FONT_MONT = { fontFamily: 'var(--font-montserrat, Montserrat), sans-serif' }
const FONT_INTER = { fontFamily: 'var(--font-inter, Inter), sans-serif' }
interface Tariff {
id: number
name: string
@ -47,18 +51,9 @@ async function getBirthdayPackages() {
sort: 'sort',
limit: 10,
})
return result.docs
return result.docs as unknown as BirthdayPackageCMS[]
} catch {
return []
}
}
async function getGroupVisitsData() {
try {
const payload = await getPayload({ config: configPromise })
return await payload.findGlobal({ slug: 'group-visits-page', depth: 0 })
} catch {
return null
return [] as BirthdayPackageCMS[]
}
}
@ -73,9 +68,12 @@ export async function generateMetadata(): Promise<Metadata> {
}
export default async function TicketsPage() {
const [tariffData, pageData, birthdayPackages, groupVisitsData, siteSettings] = await Promise.all(
[getTariffs(), getPageData(), getBirthdayPackages(), getGroupVisitsData(), getSiteSettings()]
)
const [tariffData, pageData, birthdayPackages, siteSettings] = await Promise.all([
getTariffs(),
getPageData(),
getBirthdayPackages(),
getSiteSettings(),
])
const tariffs = tariffData?.tariffs ?? []
const hasWarning = !!tariffData?.warning
@ -84,166 +82,442 @@ export default async function TicketsPage() {
(siteSettings.tariffCategoryLabels ?? []).map(({ key, label }) => [key, label])
)
const grouped = tariffs.reduce<Record<string, Tariff[]>>((acc, t) => {
const key = t.categoryTag ?? 'other'
acc[key] ??= []
acc[key].push(t)
return acc
}, {})
const FONT_MONT = { fontFamily: 'var(--font-montserrat, Montserrat), sans-serif' }
const heroTitle = pageData?.heroTitle ?? 'Купити квиток'
const heroSubtitle =
pageData?.heroSubtitle ?? 'Оберіть квиток та придбайте онлайн — без черги на касі'
return (
<div className="min-h-screen bg-[#f1fbeb]">
<PageHero
title={pageData?.heroTitle ?? 'Купити квиток'}
subtitle={
pageData?.heroSubtitle ?? 'Оберіть квиток та придбайте онлайн — без черги на касі'
}
/>
{/* ── HERO ── */}
<HeroSection title={heroTitle} subtitle={heroSubtitle} />
<div className="mx-auto max-w-[1204px] px-8 py-16">
{hasWarning && (
<div className="mb-10 rounded-xl border border-amber-200 bg-amber-50 px-6 py-4 text-[14px] text-amber-800">
Ціни можуть бути застарілими сервіс ezy.com.ua тимчасово недоступний.
</div>
)}
<div className="flex flex-col gap-12">
{tariffs.length === 0 ? (
<div className="py-20 text-center">
<p className="mb-6 text-[20px] text-[#272727]" style={FONT_MONT}>
Квитки тимчасово недоступні. Спробуйте пізніше або зателефонуйте нам.
</p>
<a
href="tel:+380671443635"
className="inline-flex rounded-[64px] bg-[#f28b4a] px-8 py-3 font-bold text-white"
style={FONT_MONT}
>
Зателефонувати
</a>
</div>
) : (
Object.entries(grouped).map(([category, items]) => (
<div key={category}>
<h2
className="mb-6 text-[24px] font-bold text-[#272727] uppercase"
style={FONT_MONT}
>
{categoryLabelsMap[category] ?? category}
</h2>
<div className="grid grid-cols-1 gap-5 sm:grid-cols-2 lg:grid-cols-3">
{items.map((tariff) => (
<TariffCardClient
key={tariff.id}
tariffId={String(tariff.id)}
name={tariff.name}
price={tariff.price}
categoryTag={tariff.categoryTag}
icon={tariff.icon}
/>
))}
</div>
</div>
))
)}
{birthdayPackages.length > 0 && (
<div>
<h2 className="mb-6 text-[24px] font-bold text-[#272727] uppercase" style={FONT_MONT}>
{pageData?.sectionTitleBirthday ?? 'Дні народження'}
</h2>
<div className="grid grid-cols-1 gap-5 sm:grid-cols-2 lg:grid-cols-3">
{birthdayPackages.map((pkg) => (
<div
key={pkg.id}
className="flex flex-col gap-4 rounded-[20px] bg-[#396817] p-8 shadow-[0_4px_60px_0_rgba(242,139,74,0.25)]"
>
<div>
<h3
className="text-[20px] leading-tight font-bold text-white"
style={FONT_MONT}
>
{pkg.name}
</h3>
{pkg.features && pkg.features.length > 0 && (
<p
className="mt-2 text-[14px] leading-relaxed text-white/70"
style={FONT_MONT}
>
{pkg.features
.slice(0, 3)
.map((f: { text: string }) => f.text)
.join(' · ')}
</p>
)}
</div>
<Link
href={pkg.ctaHref ?? '/dni-narodzhennia#order-form'}
className="mt-auto flex items-center justify-center rounded-[56px] py-[10px] 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 ?? 'Дізнатися ціну'}
</Link>
</div>
))}
</div>
</div>
)}
{groupVisitsData?.groups && groupVisitsData.groups.length > 0 && (
<div>
<h2 className="mb-6 text-[24px] font-bold text-[#272727] uppercase" style={FONT_MONT}>
{pageData?.sectionTitleGroups ?? 'Групові відвідування'}
</h2>
<div className="grid grid-cols-1 gap-5 sm:grid-cols-2 lg:grid-cols-3">
{groupVisitsData.groups.map(
(grp: {
title: string
description: string
minPeople: string
discount: string
}) => (
<div
key={grp.title}
className="flex flex-col gap-4 rounded-[20px] bg-[#396817] p-8 shadow-[0_4px_60px_0_rgba(242,139,74,0.25)]"
>
<div>
<h3
className="text-[20px] leading-tight font-bold text-white"
style={FONT_MONT}
>
{grp.title}
</h3>
<p
className="mt-2 text-[14px] leading-relaxed text-white/70"
style={FONT_MONT}
>
Від {grp.minPeople} · Знижка {grp.discount}
</p>
</div>
<Link
href="/grupovi-vidviduvannia#order-form"
className="mt-auto flex items-center justify-center rounded-[56px] py-[10px] text-[15px] font-bold text-[#1a1a1a] transition-opacity hover:opacity-90"
style={{
...FONT_MONT,
background: 'linear-gradient(90deg,#f28b4a 0%,#fdcf54 55%,#f28b4a 100%)',
}}
>
Дізнатися ціну
</Link>
</div>
)
)}
</div>
</div>
)}
{/* ── WORKING HOURS BANNER ── */}
<div className="mx-auto max-w-[1204px] px-4 lg:px-8">
<div
className="flex flex-col items-center justify-center gap-2 overflow-hidden rounded-b-[20px] px-8 py-5 text-center lg:flex-row lg:gap-6"
style={{
background: 'linear-gradient(90deg, #f28b4a 0%, #fdcf54 50%, #f28b4a 100%)',
}}
>
<p
className="text-[20px] font-bold text-[#272727] uppercase lg:text-[32px]"
style={FONT_MONT}
>
Час роботи
</p>
<p className="text-[16px] font-bold text-[#272727] lg:text-[32px]" style={FONT_MONT}>
пʼятниця-субота-неділя з 11:00 до 20:00
</p>
</div>
</div>
{/* ── WARNING BANNER ── */}
{hasWarning && (
<div className="mx-auto max-w-[1204px] px-4 py-4 lg:px-8">
<div className="rounded-xl border border-amber-200 bg-amber-50 px-6 py-4 text-[14px] text-amber-800">
Ціни можуть бути застарілими сервіс ezy.com.ua тимчасово недоступний.
</div>
</div>
)}
{/* ── INDIVIDUAL TICKETS (CLIENT component for cart interactivity) ── */}
<KvytkyTicketsClient serverTariffs={tariffs} categoryLabelsMap={categoryLabelsMap} />
{/* ── COMBO TICKETS SECTION ── */}
<ComboTicketsSection tariffs={tariffs} />
{/* ── BIRTHDAY PACKAGES ── */}
<BirthdayPricing
packages={birthdayPackages.length > 0 ? birthdayPackages : undefined}
title={pageData?.sectionTitleBirthday ?? 'День Народження в Шуміленді'}
/>
{/* ── BENEFITS & CONDITIONS ── */}
<BenefitsSection />
<RefreshRouteOnSave />
</div>
)
}
// ─────────────────────────────────────────────
// HERO SECTION
// ─────────────────────────────────────────────
function HeroSection({ title, subtitle }: { title: string; subtitle: string }) {
return (
<div className="relative -mt-[60px] h-[440px] overflow-hidden lg:-mt-[120px] lg:h-[502px]">
<img
src="/images/figma/kvytky-hero-bg.jpg"
alt=""
className="absolute inset-0 h-full w-full object-cover"
/>
<div className="absolute inset-0 bg-[rgba(0,0,0,0.35)]" />
<div className="absolute inset-0 flex flex-col items-center justify-center gap-3 px-4 text-center">
<h1
className="text-[40px] leading-[1.1] font-bold text-white uppercase lg:text-[64px]"
style={FONT_MONT}
>
{title}
</h1>
<p
className="max-w-[780px] text-[18px] leading-[1.5] font-bold text-white lg:text-[32px]"
style={FONT_MONT}
>
{subtitle}
</p>
</div>
</div>
)
}
// ─────────────────────────────────────────────
// COMBO TICKETS SECTION
// ─────────────────────────────────────────────
const COMBO_CARDS_STATIC = [
{
id: 'combo-1',
name: 'Комбо 1',
subtitle: null as string | null,
price: '600 ₴',
description: 'Індивідуальний квиток для повного занурення в атмосферу парку.',
featured: false,
badge: null as string | null,
locations: ['Динопарк', 'Топіарні фігури', 'Дзеркальний лабіринт'],
},
{
id: 'combo-family-3',
name: 'Комбо Сімейний',
subtitle: '(3 ос.)' as string | null,
price: '1500 ₴',
description: 'Оптимальний вибір для батьків та дитини (віком до 14 років).',
featured: true,
badge: 'Найпопулярніший' as string | null,
locations: ['Динопарк', 'Топіарні фігури', 'Дзеркальний лабіринт'],
},
{
id: 'combo-family-4',
name: 'Комбо Сімейний',
subtitle: '(4 ос.)' as string | null,
price: '1800 ₴',
description: 'Універсальний сімейний пакет розваг для компанії з 4 людей.',
featured: false,
badge: null as string | null,
locations: ['Динопарк', 'Топіарні фігури', 'Дзеркальний лабіринт'],
},
{
id: 'combo-family-5',
name: 'Комбо Сімейний',
subtitle: '(5 ос.)' as string | null,
price: '2000 ₴',
description: 'Максимальний та найвигідніший пакет для великої родини.',
featured: false,
badge: null as string | null,
locations: ['Динопарк', 'Топіарні фігури', 'Дзеркальний лабіринт'],
},
]
function ComboTicketsSection({ tariffs }: { tariffs: Tariff[] }) {
const comboFromApi = tariffs.filter((t) => t.categoryTag === 'combo')
const cards =
comboFromApi.length > 0
? comboFromApi.map((t, i) => ({
id: String(t.id),
name: t.name,
subtitle: null as string | null,
price: `${t.price}`,
description: '',
featured: i === 1,
badge: i === 1 ? 'Найпопулярніший' : (null as string | null),
locations: ['Динопарк', 'Топіарні фігури', 'Дзеркальний лабіринт'],
}))
: COMBO_CARDS_STATIC
return (
<section className="py-[60px]">
<div className="mx-auto max-w-[1204px] px-4 lg:px-8">
<h2
className="mb-[60px] text-[24px] font-bold text-[#272727] uppercase lg:text-[32px]"
style={FONT_MONT}
>
Комбо-квитки
</h2>
<div className="flex flex-col gap-10 lg:flex-row lg:items-start lg:gap-[59px]">
{cards.map((card) => (
<ComboCard key={card.id} card={card} />
))}
</div>
</div>
</section>
)
}
interface ComboCardData {
name: string
subtitle: string | null
price: string
description: string
featured: boolean
badge: string | null
locations: string[]
}
function ComboCard({ card }: { card: ComboCardData }) {
const { name, subtitle, price, description, featured, badge, locations } = card
return (
<div
className={`flex w-full flex-col items-center lg:w-[362px]${!featured ? 'lg:pt-[84px]' : ''}`}
>
{/* Badge desktop */}
{featured && badge && (
<div
className="relative z-10 mb-[-14px] hidden h-[30px] items-center justify-center rounded-[24px] px-5 text-white lg:flex"
style={{
backgroundColor: '#396817',
...FONT_INTER,
fontSize: 16,
fontWeight: 500,
boxShadow: '0 0 11.897px 1.190px rgba(242,139,74,0.25)',
}}
>
{badge}
</div>
)}
{/* Badge mobile */}
{featured && badge && (
<div
className="mb-3 inline-flex h-[30px] items-center justify-center rounded-[24px] px-5 text-white lg:hidden"
style={{
backgroundColor: '#396817',
...FONT_INTER,
fontSize: 16,
fontWeight: 500,
}}
>
{badge}
</div>
)}
<div
className="wave-bg-pricing relative flex w-full flex-col overflow-hidden rounded-[23.79px] lg:w-[362px]"
style={{
backgroundColor: featured ? '#f28b4a' : '#396817',
height: '756px',
paddingTop: 60,
paddingLeft: 40,
paddingRight: 40,
paddingBottom: 40,
gap: 40,
boxShadow: '0 4px 60px 0 rgba(242,139,74,0.25)',
display: 'flex',
flexDirection: 'column',
}}
>
{/* Header: name + divider + price */}
<div className="relative flex flex-col items-center gap-6">
<p
className="w-full text-center font-bold uppercase"
style={{
...FONT_MONT,
fontSize: 40,
lineHeight: 1.1,
color: featured ? '#272727' : '#f28b4a',
}}
>
{name}
{subtitle && (
<>
<br />
<span style={{ fontSize: 20, lineHeight: 1.5 }}>{subtitle}</span>
</>
)}
</p>
<div className="h-px w-full bg-white" />
<p
className="w-full text-center text-white"
style={{
...FONT_INTER,
fontSize: 64,
fontWeight: 900,
lineHeight: 1,
}}
>
{price}
</p>
</div>
{/* Description */}
{description && (
<p
className="relative text-[20px] leading-[1.5] font-medium text-white"
style={FONT_MONT}
>
{description}
</p>
)}
{/* Locations list */}
<div className="relative flex flex-col gap-4">
<p className="text-[20px] leading-[1.5] font-bold text-white" style={FONT_MONT}>
Включає 3 локації:
</p>
<ul className="flex flex-col gap-4">
{locations.map((loc) => (
<li key={loc} className="flex items-center gap-[25px]">
<img
src="/images/figma/check-mark.webp"
alt=""
aria-hidden="true"
className="h-[30px] w-[30px] flex-none object-contain"
/>
<span
className="text-[20px] leading-[1.5] font-medium text-white"
style={FONT_MONT}
>
{loc}
</span>
</li>
))}
</ul>
</div>
</div>
</div>
)
}
// ─────────────────────────────────────────────
// BENEFITS & CONDITIONS SECTION
// ─────────────────────────────────────────────
const FREE_ENTRY_ITEMS = [
'Діти військовослужбовців',
'Діти до 3-х років включно',
'Діти з інвалідністю до 18 років',
'Діти-сироти до 14 років',
'Учасники бойових дій (УБД)',
]
const DISCOUNT_ITEMS = [
"Багатодітні сім'ї",
'Люди з інвалідністю І та ІІ групи',
'Пенсіонери',
'Групові візити та школи',
]
function BenefitItem({ text }: { text: string }) {
return (
<div className="flex items-center justify-between border-b border-[#c8e8b0] py-3 last:border-b-0">
<p
className="text-[16px] leading-[1.5] font-medium text-[#272727] lg:text-[20px]"
style={FONT_MONT}
>
{text}
</p>
<svg
width="19"
height="11"
viewBox="0 0 19 11"
fill="none"
className="flex-none text-[#f28b4a]"
aria-hidden="true"
>
<path
d="M1 10L9.5 1L18 10"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
/>
</svg>
</div>
)
}
function BenefitsSection() {
return (
<section className="py-[60px]">
<div className="mx-auto max-w-[1204px] px-4 lg:px-8">
<h2
className="mb-[60px] text-[24px] font-bold text-[#272727] uppercase lg:text-[32px]"
style={FONT_MONT}
>
Пільги та умови
</h2>
<div className="flex flex-col gap-5">
<div className="flex flex-col gap-5 lg:flex-row">
{/* Free entry card */}
<div
className="flex flex-1 flex-col gap-6 rounded-t-[20px] px-5 py-10 shadow-[0_4px_30px_rgba(242,139,74,0.25)]"
style={{ background: '#f1fbeb' }}
>
<div className="flex flex-col gap-3">
<div className="flex items-center justify-between gap-3">
<p
className="text-[20px] leading-[1.5] font-bold text-[#272727] lg:text-[24px]"
style={FONT_MONT}
>
Вхід безкоштовний
</p>
<span
className="flex items-center justify-center rounded-[39px] bg-[#396817] px-5 py-[2px] text-[18px] font-bold text-white lg:text-[20px]"
style={FONT_MONT}
>
0 грн
</span>
</div>
<div className="h-px w-full bg-[#c8e8b0]" />
</div>
<div className="flex flex-col">
{FREE_ENTRY_ITEMS.map((item) => (
<BenefitItem key={item} text={item} />
))}
</div>
</div>
{/* Discounts card */}
<div
className="flex flex-1 flex-col gap-6 rounded-t-[20px] px-5 py-10 shadow-[0_4px_30px_rgba(242,139,74,0.25)]"
style={{ background: '#f1fbeb' }}
>
<div className="flex flex-col gap-3">
<div className="flex items-center justify-between gap-3">
<p
className="text-[20px] leading-[1.5] font-bold text-[#272727] lg:text-[24px]"
style={FONT_MONT}
>
Спеціальні знижки
</p>
<span
className="flex items-center justify-center rounded-[39px] bg-[#f28b4a] px-5 py-[2px] text-[18px] font-bold text-white lg:text-[20px]"
style={FONT_MONT}
>
-30%
</span>
</div>
<div className="h-px w-full bg-[#c8e8b0]" />
</div>
<div className="flex flex-col">
{DISCOUNT_ITEMS.map((item) => (
<BenefitItem key={item} text={item} />
))}
</div>
</div>
</div>
{/* Footnote */}
<div className="rounded-b-[20px] bg-[#d6f2c0] px-8 py-[30px]">
<p
className="text-center text-[16px] leading-[1.5] font-normal text-[#272727] lg:text-[20px]"
style={FONT_MONT}
>
*Знижки та пільги поширюються виключно на індивідуальне відвідування 3 основних
локацій (Динопарк, Зона топіарних фігур, Дзеркальний лабіринт) та не сумуються з
тарифами категорії «КОМБО».
</p>
</div>
</div>
</div>
</section>
)
}

View file

@ -25,6 +25,46 @@
--shadow-brand: 0px 4px 60px rgba(242, 139, 74, 0.25);
}
/* Wave pattern via ::before — uses PNG (362×568px RGBA, lighter green #70b030 on transparent) */
/* No position:relative here — parent divs use Tailwind 'absolute' as containing block */
.wave-bg-green::before {
content: '';
position: absolute;
inset: 0;
background-image: url('/images/figma/card-pattern-light.png');
background-size: 362px auto;
background-repeat: repeat;
opacity: 0.2;
border-radius: inherit;
pointer-events: none;
}
.wave-bg-green-card::before {
content: '';
position: absolute;
inset: 0;
background-image: url('/images/figma/card-pattern-light.png');
background-size: 100% auto;
background-repeat: repeat-y;
opacity: 0.2;
border-radius: inherit;
pointer-events: none;
}
/* Exact Figma wave contour pattern — cropped from Figma render (657×868px) */
/* Used on pricing/green sections that match the Figma wave design exactly */
.wave-bg-pricing::before {
content: '';
position: absolute;
inset: 0;
background-image: url('/images/figma/wave-tile-correct.png');
background-size: 657px 868px;
background-repeat: repeat-x;
background-position: center top;
border-radius: inherit;
pointer-events: none;
}
@keyframes progress-fill {
from {
width: 0%;

View file

@ -20,7 +20,7 @@ const DEFAULT_NAV: NavLinkItem[] = [
label: 'Локації',
href: '/lokatsii',
children: [
{ label: 'ДиноПарк', href: '/lokatsii#dynopark' },
{ label: 'ДиноПарк', href: '/lokatsii/dynozavry' },
{ label: 'Диво Ліс', href: '/lokatsii/dyvolis' },
{ label: 'Дзеркальний Лабіринт', href: '/lokatsii#maze' },
{ label: 'Тир з призами', href: '/lokatsii#tir' },
@ -62,12 +62,18 @@ export async function Header() {
if (l.autoChildrenFrom === 'locations' && menuLocations.length > 0) {
item.children = menuLocations.map((loc) => ({
label: loc.name,
href: loc.href ?? `/lokatsii#${loc.slug}`,
href:
loc.href ??
(loc.showDetailPage ? `/lokatsii/${loc.slug}` : `/lokatsii#${loc.slug}`),
}))
} else if (l.children && l.children.length > 0) {
item.children = l.children
.filter((c) => c.label && c.href)
.map((c) => ({ label: c.label!, href: c.href! }))
} else {
// CMS item has no children — inherit from DEFAULT_NAV by href
const defaultItem = DEFAULT_NAV.find((d) => d.href === item.href)
if (defaultItem?.children) item.children = defaultItem.children
}
return item

View file

@ -5,7 +5,6 @@ import Image from 'next/image'
import Link from 'next/link'
import { useState } from 'react'
import { NavLink } from '@/components/ui/NavLink'
import { BtnPrimary } from '@/components/ui/BtnPrimary'
import { CartIcon } from '@/components/ui/CartIcon'
export interface NavLinkItem {
@ -23,26 +22,20 @@ interface HeaderClientProps {
}
/**
* Liquid-glass header.
* - sits over the hero (page uses `-mt-[120px]` on hero so header overlays it)
* - max width 1204 (Figma frame is 1204 inside 1920 viewport, centered)
* - height 120 desktop / 60 mobile
* - rounded bottom corners 20px
* - real backdrop-filter blur + saturate + a subtle dark-green tint
* so the foliage behind shows through but text stays readable
* Figma header orangeyelloworange gradient pill, dark text, purple CTA button.
* Positioned at top-0, sits over the hero (hero uses -mt-[120px] to slide under).
*/
export function HeaderClient({ navLinks, ctaLabel, ctaHref, logo }: HeaderClientProps) {
const [menuOpen, setMenuOpen] = useState(false)
return (
<header className="sticky top-0 z-50 px-[10px]">
{/* ── Desktop / tablet bar ── */}
{/* overflow-visible so dropdown menus escape the pill container */}
<div
className="relative mx-auto max-w-[1204px] rounded-b-[20px]"
style={{
backgroundColor: 'rgba(34, 62, 13, 0.55)',
backdropFilter: 'blur(18px) saturate(180%) brightness(1.1)',
WebkitBackdropFilter: 'blur(18px) saturate(180%) brightness(1.1)',
boxShadow: 'inset 0 1px 0 rgba(255,255,255,0.15), 0 2px 12px rgba(0,0,0,0.20)',
background: 'linear-gradient(90deg, #f28b4a 2.885%, #fdcf54 46.635%, #f28d4b 100%)',
}}
>
<div className="relative flex h-[60px] items-center justify-between px-[20px] lg:h-[120px] lg:px-[30px]">
@ -52,8 +45,8 @@ export function HeaderClient({ navLinks, ctaLabel, ctaHref, logo }: HeaderClient
<Image
src={logo.url}
alt={logo.alt ?? 'Шуміленд'}
width={140}
height={50}
width={71}
height={62}
className="object-contain"
/>
) : (
@ -70,15 +63,35 @@ export function HeaderClient({ navLinks, ctaLabel, ctaHref, logo }: HeaderClient
<ul className="m-0 flex list-none items-center gap-[24px] p-0">
{navLinks.map((link) => (
<li key={link.href} className="group relative">
<NavLink href={link.href}>{link.label}</NavLink>
<span className="inline-flex items-center gap-1">
<NavLink href={link.href} variant="dark">
{link.label}
</NavLink>
{link.children && (
<svg
width="10"
height="6"
viewBox="0 0 10 6"
fill="none"
aria-hidden="true"
className="text-[#272727] transition-transform duration-150 group-hover:rotate-180"
>
<path
d="M1 1L5 5L9 1"
stroke="currentColor"
strokeWidth="1.5"
strokeLinecap="round"
/>
</svg>
)}
</span>
{link.children && (
/* Outer wrapper: pt-2 bridges the gap so hover is never lost */
<div className="pointer-events-none absolute top-full left-0 z-50 min-w-[220px] pt-2 opacity-0 transition-all duration-150 group-hover:pointer-events-auto group-hover:opacity-100">
<div
className="overflow-hidden rounded-[16px] py-2"
style={{
backgroundColor: 'rgba(34, 62, 13, 0.92)',
backgroundColor: 'rgba(34, 62, 13, 0.95)',
backdropFilter: 'blur(22px) saturate(160%)',
WebkitBackdropFilter: 'blur(22px) saturate(160%)',
boxShadow:
@ -106,13 +119,23 @@ export function HeaderClient({ navLinks, ctaLabel, ctaHref, logo }: HeaderClient
{/* Cart + CTA — desktop */}
<div className="hidden items-center gap-3 lg:flex">
<CartIcon />
<BtnPrimary href={ctaHref}>{ctaLabel}</BtnPrimary>
{/* Figma: CTA button is purple #403997 inside the gradient header */}
<Link
href={ctaHref}
className="inline-flex items-center justify-center rounded-[64px] px-[30px] py-[10px] text-[20px] font-bold whitespace-nowrap text-white transition-opacity hover:opacity-90"
style={{
backgroundColor: '#403997',
fontFamily: 'var(--font-montserrat, Montserrat), sans-serif',
}}
>
{ctaLabel}
</Link>
</div>
{/* Hamburger — mobile */}
<button
onClick={() => setMenuOpen((v) => !v)}
className="-mr-2 p-2 text-white lg:hidden"
className="-mr-2 p-2 text-[#272727] lg:hidden"
aria-label={menuOpen ? 'Закрити меню' : 'Відкрити меню'}
aria-expanded={menuOpen}
>
@ -120,14 +143,14 @@ export function HeaderClient({ navLinks, ctaLabel, ctaHref, logo }: HeaderClient
{menuOpen ? (
<path
d="M6 6L18 18M6 18L18 6"
stroke="white"
stroke="#272727"
strokeWidth="2"
strokeLinecap="round"
/>
) : (
<path
d="M3 6H21M3 12H21M3 18H21"
stroke="white"
stroke="#272727"
strokeWidth="2"
strokeLinecap="round"
/>
@ -177,8 +200,11 @@ export function HeaderClient({ navLinks, ctaLabel, ctaHref, logo }: HeaderClient
<Link
href={ctaHref}
onClick={() => setMenuOpen(false)}
className="inline-flex items-center rounded-[64px] bg-[#f28b4a] px-7 py-[10px] text-[18px] font-bold text-white transition-shadow hover:shadow-[0_0_20px_0_#f28b4a]"
style={{ fontFamily: 'var(--font-montserrat, Montserrat), sans-serif' }}
className="inline-flex items-center rounded-[64px] px-7 py-[10px] text-[18px] font-bold text-white transition-opacity hover:opacity-90"
style={{
backgroundColor: '#403997',
fontFamily: 'var(--font-montserrat, Montserrat), sans-serif',
}}
>
{ctaLabel}
</Link>

View file

@ -4,14 +4,14 @@
import { useState, useEffect, useRef, useCallback } from 'react'
const FONT_MONT = { fontFamily: 'var(--font-montserrat, Montserrat), sans-serif' }
const FONT_POP = { fontFamily: 'var(--font-poppins, Poppins), sans-serif' }
const WAVE_BG = `url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='160' height='160'%3E%3Ccircle cx='80' cy='80' r='60' fill='none' stroke='rgba(255,255,255,0.04)' stroke-width='1.5'/%3E%3Ccircle cx='80' cy='80' r='40' fill='none' stroke='rgba(255,255,255,0.03)' stroke-width='1'/%3E%3Ccircle cx='0' cy='0' r='60' fill='none' stroke='rgba(255,255,255,0.04)' stroke-width='1.5'/%3E%3Ccircle cx='160' cy='0' r='60' fill='none' stroke='rgba(255,255,255,0.04)' stroke-width='1.5'/%3E%3Ccircle cx='0' cy='160' r='60' fill='none' stroke='rgba(255,255,255,0.04)' stroke-width='1.5'/%3E%3Ccircle cx='160' cy='160' r='60' fill='none' stroke='rgba(255,255,255,0.04)' stroke-width='1.5'/%3E%3C/svg%3E")`
// ── Types ──────────────────────────────────────────────────────────────────
interface DinoSpec {
name: string
epoch?: string | null
latinName?: string | null
length?: string | null
weight?: string | null
height?: string | null
imageUrl?: string | null
thumbnailUrl?: string | null
}
@ -20,276 +20,672 @@ interface DinoWheelProps {
dinos?: DinoSpec[]
}
// ── Complete dataset extracted from Figma dino_slider (node 2025:672) ─────
const FALLBACK: DinoSpec[] = [
{ name: 'Тиранозавр Рекс', epoch: 'Крейдяний', length: '12 м', weight: '7 т' },
{ name: 'Карнотавр', epoch: 'Крейдяний', length: '8 м', weight: '1.5 т' },
{ name: 'Трицератопс', epoch: 'Крейдяний', length: '9 м', weight: '12 т' },
{ name: 'Велоцираптор', epoch: 'Крейдяний', length: '2 м', weight: '15 кг' },
{ name: 'Спінозавр', epoch: 'Крейдяний', length: '15 м', weight: '20 т' },
{ name: 'Птеранодон', epoch: 'Юрський', length: '2.5 м', weight: '20 кг' },
{ name: 'Брахіозавр', epoch: 'Юрський', length: '26 м', weight: '56 т' },
{ name: 'Анкілозавр', epoch: 'Крейдяний', length: '8 м', weight: '7 т' },
{
name: 'Тиранозавр Рекс',
latinName: 'Гігантська Версія',
length: '20 м.',
height: '7 м.',
imageUrl: '/images/figma/dino-trex-giant.png',
},
{
name: 'Барионікс',
length: '7 м.',
height: '2,5 м.',
imageUrl: '/images/figma/dino-baryonyx.png',
},
{
name: 'Овіраптор',
length: '3 м.',
imageUrl: '/images/figma/dino-oviraptor.png',
},
{
name: 'Спінозавр',
length: '15 м.',
height: '5,8 м.',
imageUrl: '/images/figma/dino-spinosaurus.png',
},
{
name: 'Карнотавр',
length: '10 м.',
height: '3,2 м.',
imageUrl: '/images/figma/dino-carnotaurus.png',
},
{
name: 'Ділофозавр на скелі',
length: '6 м.',
imageUrl: '/images/figma/dino-dilophosaurus.png',
},
{
name: 'Брахіозавр',
length: '25 м.',
height: '10 м.',
imageUrl: '/images/figma/dino-brachiosaurus.png',
},
{
name: 'Пара Брахіозаврів',
length: '12 м.',
height: '6 м.',
imageUrl: '/images/figma/dino-brachio-pair.png',
},
{
name: 'Ураноза́вр',
length: '6 м.',
height: '2,2 м.',
imageUrl: '/images/figma/dino-ouranosaurus.png',
},
{
name: 'Стиракозавр',
length: '6 м.',
height: '2,2 м.',
imageUrl: '/images/figma/dino-styracosaurus.png',
},
{
name: 'Космоцератопс',
latinName: 'Пара Закоханих',
length: '5 м.',
height: '2,5 м.',
imageUrl: '/images/figma/dino-kosmoceratops.png',
},
{
name: 'Стегозавр',
length: '15 м.',
height: '7 м.',
imageUrl: '/images/figma/dino-stegosaurus.png',
},
{
name: 'Анкілозавр',
length: '8 м.',
height: '2,6 м.',
imageUrl: '/images/figma/dino-ankylosaurus.png',
},
{
name: 'Паразавролоф',
length: '7 м.',
height: '2,2 м.',
imageUrl: '/images/figma/dino-parasaurolophus.png',
},
{
name: 'Паразавролоф',
latinName: 'Сімейство',
length: '6 м.',
height: '2 м.',
imageUrl: '/images/figma/dino-parasaurolophus-family.png',
},
{
name: 'Велоцираптор',
length: '4 м.',
height: '1,7 м.',
imageUrl: '/images/figma/dino-velociraptor.png',
},
{
name: 'Кетцалькоатль',
height: '6,5 м.',
imageUrl: '/images/figma/dino-quetzalcoatl.png',
},
{
name: 'Птеродактиль',
height: '2 м.',
imageUrl: '/images/figma/dino-pterodactyl.png',
},
{
name: 'Яйця Динозаврів',
height: '1,5 м.',
imageUrl: '/images/figma/dino-dino-eggs.png',
},
{
name: 'Трицератопс',
length: '6 м.',
height: '2,45 м.',
imageUrl: '/images/figma/dino-triceratops.png',
},
{
name: 'Тиранозавр',
latinName: 'DinoRodeo',
length: '4 м.',
height: '1,6 м.',
imageUrl: '/images/figma/dino-trex-rodeo.png',
},
{
name: 'Карнотавр',
latinName: 'DinoRodeo',
length: '3,5 м.',
height: '1,5 м.',
imageUrl: '/images/figma/dino-carno-rodeo.png',
},
{
name: 'Тиранозавр',
latinName: 'Версія 2',
length: '4 м.',
height: '1,8 м.',
imageUrl: '/images/figma/dino-trex2.png',
},
{
name: 'Велоцираптор',
latinName: 'Версія 2',
length: '4 м.',
height: '1,8 м.',
imageUrl: '/images/figma/dino-velociraptor2.png',
},
{
name: 'Тиранозавр Рекс',
latinName: 'Рев до Небес',
length: '6 м.',
height: '2,8 м.',
imageUrl: '/images/figma/dino-trex3.png',
},
]
const DINO_EMOJIS = ['🦖', '🦕', '🦖', '🦕', '🦖', '🦕', '🦕', '🦖']
const DINO_EMOJIS = [
'🦖',
'🦕',
'🦖',
'🦕',
'🦖',
'🦕',
'🦕',
'🦖',
'🦖',
'🦕',
'🦖',
'🦕',
'🦕',
'🦖',
'🦕',
'🦖',
'🦕',
'🦅',
'🦅',
'🥚',
'🦖',
'🦖',
'🦖',
'🦖',
'🦖',
]
// ── Orange wheel SVG (matches Figma Group 1966048708) ──────────────────────
// Orange/gold donut with green dividing lines and white border rings
function DinoWheelSVG({ rotation, n, size }: { rotation: number; n: number; size: number }) {
const cx = size / 2
const cy = size / 2
const outerR = size * 0.495 // outer edge of the donut ring
const innerR = size * 0.14 // inner hole of the donut ring
// Generate segment divider lines
const dividers: { x1: number; y1: number; x2: number; y2: number }[] = []
for (let i = 0; i < n; i++) {
const angleDeg = (i / n) * 360
const rad = (angleDeg * Math.PI) / 180
dividers.push({
x1: cx + innerR * Math.cos(rad),
y1: cy + innerR * Math.sin(rad),
x2: cx + outerR * Math.cos(rad),
y2: cy + outerR * Math.sin(rad),
})
}
return (
<svg
viewBox={`0 0 ${size} ${size}`}
fill="none"
xmlns="http://www.w3.org/2000/svg"
style={{
position: 'absolute',
inset: 0,
width: '100%',
height: '100%',
transform: `rotate(${rotation}deg)`,
transformOrigin: '50% 50%',
transition: 'transform 0.72s cubic-bezier(0.4, 0, 0.2, 1)',
willChange: 'transform',
display: 'block',
overflow: 'visible',
}}
aria-hidden="true"
>
<defs>
<linearGradient
id={`wg-${n}`}
x1="0"
y1={cy}
x2={size}
y2={cy}
gradientUnits="userSpaceOnUse"
>
<stop offset="0.029" stopColor="#F28B4A" />
<stop offset="0.466" stopColor="#FDCF54" />
<stop offset="1" stopColor="#F28D4B" />
</linearGradient>
</defs>
{/* Outer ring border */}
<circle cx={cx} cy={cy} r={outerR + 3} stroke="rgba(255,255,255,0.18)" strokeWidth="2" />
{/* Donut body — using clip-path trick for fillRule evenodd */}
<circle cx={cx} cy={cy} r={outerR} fill={`url(#wg-${n})`} />
{/* Inner hole (cover with section background color) */}
<circle cx={cx} cy={cy} r={innerR} fill="#2a4418" />
{/* Segment dividers */}
{dividers.map((d, i) => (
<line key={i} x1={d.x1} y1={d.y1} x2={d.x2} y2={d.y2} stroke="#396817" strokeWidth="3" />
))}
{/* Inner ring border (decorative) */}
<circle cx={cx} cy={cy} r={innerR + 2} stroke="rgba(255,255,255,0.12)" strokeWidth="1.5" />
</svg>
)
}
// ── Main component ─────────────────────────────────────────────────────────
export function DinoWheel({ dinos }: DinoWheelProps) {
const items = dinos && dinos.length > 0 ? dinos : FALLBACK
const n = items.length
const [active, setActive] = useState(0)
const [visible, setVisible] = useState(true)
const timer = useRef<ReturnType<typeof setInterval> | null>(null)
const goTo = useCallback((i: number) => {
setVisible(false)
setTimeout(() => {
setActive(i)
setVisible(true)
}, 220)
}, [])
// Track the wheel's CSS rotation angle continuously to avoid back-spinning
const [wheelAngle, setWheelAngle] = useState<number>(() => {
// Place item 0 at bottom (270°). SVG 0° = right, 90° = down.
// Item 0 starts at angle 0° → need to rotate wheel so 0° aligns to 270° (bottom)
return 270
})
const activeRef = useRef(0)
const timerRef = useRef<ReturnType<typeof setInterval> | null>(null)
const resetTimer = useCallback(
(next: number) => {
if (timer.current) clearInterval(timer.current)
timer.current = setInterval(() => {
setVisible(false)
setTimeout(() => {
setActive((p) => {
const nx = (p + 1) % n
return nx
})
setVisible(true)
}, 220)
}, 4000)
goTo(next)
// ── Compute target wheel rotation to put item i at bottom (270°) ──
const angleForItem = useCallback(
(i: number) => {
// Item i natural angle = (i/n)*360
// We want it at 270°, so wheel rotation needed = 270 - (i/n)*360
return 270 - (i / n) * 360
},
[n, goTo]
[n]
)
useEffect(() => {
timer.current = setInterval(() => {
// ── Rotate to item i, going shortest path from current angle ──
const goTo = useCallback(
(nextIdx: number) => {
setVisible(false)
const target = angleForItem(nextIdx)
// Compute current "base" angle (normalized)
setWheelAngle((current) => {
const currentNorm = ((current % 360) + 360) % 360
const targetNorm = ((target % 360) + 360) % 360
let diff = targetNorm - currentNorm
// Pick shortest arc
if (diff > 180) diff -= 360
if (diff < -180) diff += 360
return current + diff
})
setTimeout(() => {
setActive((p) => (p + 1) % n)
setActive(nextIdx)
activeRef.current = nextIdx
setVisible(true)
}, 220)
}, 4000)
}, 240)
},
[angleForItem]
)
// ── Auto-advance timer ──
const startTimer = useCallback(() => {
if (timerRef.current) clearInterval(timerRef.current)
timerRef.current = setInterval(() => {
const next = (activeRef.current + 1) % n
goTo(next)
}, 4500)
}, [n, goTo])
useEffect(() => {
startTimer()
return () => {
if (timer.current) clearInterval(timer.current)
if (timerRef.current) clearInterval(timerRef.current)
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [n])
function handleSelect(i: number) {
if (timerRef.current) clearInterval(timerRef.current)
goTo(i)
// Resume auto-advance after pause
setTimeout(startTimer, 8000)
}
const current = items[active]!
// Arc positions: center at (50%, 80%) of wheel container, radius ~42%
// Angles from 200° to 340° (CSS coords: 0=right, 90=down)
// Items placed in a "smile" arc below center
const getArcPos = (i: number) => {
const startDeg = 200
const spanDeg = 140
const deg = startDeg + (i / Math.max(n - 1, 1)) * spanDeg
const rad = (deg * Math.PI) / 180
// Percentage offset from center
const cx = 50 // %
const cy = 32 // % from top — center of the circle
const r = 38 // % radius
const x = cx + r * Math.cos(rad)
const y = cy + r * Math.sin(rad)
return { x, y }
}
// ── Layout sizing ──
// Wheel is a circle. We show only the TOP half (semicircle) above the info panel.
// Container height = wheelSize/2 so the bottom half is clipped.
const WHEEL_SIZE = 560 // overall circle diameter in px
const DINO_RADIUS = WHEEL_SIZE * 0.37 // radius at which dino icons sit
const DINO_ACTIVE_SIZE = 88
const DINO_INACTIVE_SIZE = 56
// Each dino's position on the rotating wheel:
// Natural angle of item i = (i/n)*360 deg (measured from SVG 0° = right)
// Since wheel rotates by wheelAngle, the screen angle of item i = (i/n)*360 + wheelAngle
// We want the ACTIVE item at screen angle 270° (bottom) — that's enforced by wheelAngle.
// For rendering the dino overlay images, we place them at their natural SVG angles
// (relative to the wheel) and let the wheel rotation carry them.
// But we counter-rotate each dino image so it always faces upright.
return (
<section
style={{
background: `${WAVE_BG}, #2a4418`,
backgroundSize: '160px 160px, cover',
}}
>
<div className="mx-auto max-w-[1204px] px-6 py-12 lg:py-20">
{/* Desktop: two-column layout */}
<div className="flex flex-col gap-8 lg:flex-row lg:items-center lg:gap-12">
{/* Left: info card */}
<div className="flex-none lg:w-[380px]">
<section className="relative overflow-hidden" style={{ background: '#2a4418' }}>
{/* Topographic wave background */}
<div
aria-hidden="true"
style={{
position: 'absolute',
inset: 0,
backgroundImage: `url('/images/figma/dyno-wave-tile.svg')`,
backgroundSize: '200px auto',
backgroundRepeat: 'repeat',
opacity: 0.35,
pointerEvents: 'none',
}}
/>
<div className="relative mx-auto max-w-[1440px] px-4 py-12 lg:py-20">
{/* ── Section heading ── */}
<h2
className="mb-8 text-center text-[22px] font-bold text-white uppercase lg:text-[32px]"
style={FONT_MONT}
>
Мешканці динопарку
</h2>
{/* ── Wheel + Info layout ── */}
<div className="flex flex-col items-center gap-8 lg:flex-row lg:items-end lg:justify-center lg:gap-0">
{/* ── Rotating wheel (semicircle — top half visible) ── */}
<div
aria-label="Колесо динозаврів"
className="relative flex-none"
style={{
width: WHEEL_SIZE,
maxWidth: '96vw',
// Show only the upper half: container is half the circle
height: WHEEL_SIZE / 2,
overflow: 'hidden',
}}
>
{/* Full-size circle container — positioned so center is at bottom of box */}
<div
className="rounded-[24px] p-8 shadow-[0_8px_40px_rgba(0,0,0,0.3)]"
style={{ background: 'rgba(255,255,255,0.97)' }}
style={{
position: 'absolute',
top: 0,
left: '50%',
transform: 'translateX(-50%)',
width: WHEEL_SIZE,
height: WHEEL_SIZE,
}}
>
<p
className="mb-1 text-[13px] font-semibold tracking-[0.12em] text-[#396817] uppercase"
style={FONT_MONT}
>
Мешканець парку
</p>
<h2
className="mb-6 text-[24px] leading-[1.15] font-black text-[#272727] uppercase lg:text-[30px]"
style={{
...FONT_MONT,
transition: 'opacity 0.22s ease',
opacity: visible ? 1 : 0,
}}
>
{current.name}
</h2>
{/* SVG wheel (rotates to show active dino at bottom / 6-o'clock) */}
<DinoWheelSVG rotation={wheelAngle} n={n} size={WHEEL_SIZE} />
{/* Stats */}
<div
className="flex flex-col gap-3"
style={{ transition: 'opacity 0.22s ease', opacity: visible ? 1 : 0 }}
>
{current.epoch && <StatRow label="Епоха" value={current.epoch} />}
{current.length && <StatRow label="Довжина" value={current.length} />}
{current.weight && <StatRow label="Вага" value={current.weight} />}
</div>
{/* Navigation dots */}
<div className="mt-8 flex flex-wrap gap-2">
{items.map((_, i) => (
<button
key={i}
onClick={() => resetTimer(i)}
className="h-2.5 w-2.5 rounded-full transition-all duration-300"
style={{ background: i === active ? '#396817' : '#c8e6b0' }}
aria-label={items[i]!.name}
aria-current={i === active ? true : undefined}
/>
))}
</div>
</div>
</div>
{/* Right: wheel */}
<div className="relative flex-1">
{/* Circular wheel container */}
<div
className="relative mx-auto"
style={{ width: '100%', maxWidth: 500, aspectRatio: '1' }}
>
{/* Outer ring decoration */}
<div
className="absolute inset-0 rounded-full"
style={{
border: '2px solid rgba(255,255,255,0.1)',
}}
/>
<div
className="absolute rounded-full"
style={{
inset: '12%',
border: '1px solid rgba(255,255,255,0.06)',
}}
/>
{/* Center: active dino image */}
<div
className="absolute flex items-center justify-center"
style={{
inset: '22%',
transition: 'opacity 0.22s ease',
opacity: visible ? 1 : 0,
}}
>
{current.imageUrl ? (
<img
src={current.imageUrl}
alt={current.name}
className="h-full w-full object-contain drop-shadow-2xl"
style={{ filter: 'drop-shadow(0 8px 24px rgba(0,0,0,0.5))' }}
/>
) : (
<span
style={{
fontSize: 'clamp(64px, 12vw, 110px)',
lineHeight: 1,
filter: 'drop-shadow(0 8px 24px rgba(0,0,0,0.4))',
}}
aria-hidden="true"
>
{DINO_EMOJIS[active % DINO_EMOJIS.length]}
</span>
)}
</div>
{/* Arc thumbnails */}
{/* Dino icons placed around the ring, counter-rotated to stay upright */}
{items.map((dino, i) => {
const { x, y } = getArcPos(i)
// Each segment occupies 360/n degrees.
// Item i natural angle (SVG convention) = (i/n)*360
const naturalDeg = (i / n) * 360
// The icon is at radius DINO_RADIUS from center.
// It rotates with the wheel (+wheelAngle) but we apply the natural offset.
// Total rotation of icon on screen: naturalDeg + wheelAngle
// We counter-rotate the inner image element so the dino faces upright.
const isActive = i === active
return (
<button
key={i}
onClick={() => resetTimer(i)}
onClick={() => handleSelect(i)}
aria-label={dino.name}
aria-current={isActive ? true : undefined}
style={{
position: 'absolute',
left: `${x}%`,
top: `${y}%`,
transform: 'translate(-50%, -50%)',
width: isActive ? 'clamp(52px, 8vw, 72px)' : 'clamp(40px, 6vw, 56px)',
height: isActive ? 'clamp(52px, 8vw, 72px)' : 'clamp(40px, 6vw, 56px)',
borderRadius: '50%',
overflow: 'hidden',
border: isActive ? '3px solid #f5c842' : '2px solid rgba(255,255,255,0.25)',
boxShadow: isActive
? '0 0 0 3px rgba(245,200,66,0.4), 0 4px 16px rgba(0,0,0,0.4)'
: '0 2px 8px rgba(0,0,0,0.3)',
transition: 'all 0.3s cubic-bezier(0.4, 0, 0.2, 1)',
background: '#1e3610',
// Positioned at center of circle
left: WHEEL_SIZE / 2,
top: WHEEL_SIZE / 2,
width: 0,
height: 0,
border: 'none',
background: 'none',
padding: 0,
cursor: 'pointer',
zIndex: isActive ? 10 : 5,
zIndex: isActive ? 20 : 10,
// Arm extends to DINO_RADIUS. We rotate by the wheel's natural item angle.
// The SVG is at wheelAngle, so we also add that to keep icon on same segment.
transform: `rotate(${naturalDeg + wheelAngle}deg)`,
transformOrigin: '0 0',
transition: 'transform 0.72s cubic-bezier(0.4, 0, 0.2, 1)',
willChange: 'transform',
}}
>
{dino.thumbnailUrl ? (
<img
src={dino.thumbnailUrl}
alt=""
aria-hidden="true"
className="h-full w-full object-cover"
/>
) : (
<span
style={{
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
height: '100%',
fontSize: isActive ? 28 : 22,
}}
aria-hidden="true"
>
{DINO_EMOJIS[i % DINO_EMOJIS.length]}
</span>
)}
{/* The actual icon: translate up by DINO_RADIUS, then counter-rotate */}
<div
style={{
position: 'absolute',
left: -(isActive ? DINO_ACTIVE_SIZE : DINO_INACTIVE_SIZE) / 2,
top: -(
DINO_RADIUS +
(isActive ? DINO_ACTIVE_SIZE : DINO_INACTIVE_SIZE) / 2
),
width: isActive ? DINO_ACTIVE_SIZE : DINO_INACTIVE_SIZE,
height: isActive ? DINO_ACTIVE_SIZE : DINO_INACTIVE_SIZE,
// Counter-rotate so the dino doesn't spin with the wheel
transform: `rotate(${-(naturalDeg + wheelAngle)}deg)`,
transformOrigin: '50% 50%',
transition: [
'transform 0.72s cubic-bezier(0.4, 0, 0.2, 1)',
'width 0.3s ease',
'height 0.3s ease',
'top 0.3s ease',
'left 0.3s ease',
].join(', '),
willChange: 'transform',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
pointerEvents: 'none',
}}
>
{dino.thumbnailUrl || dino.imageUrl ? (
<img
src={(dino.thumbnailUrl ?? dino.imageUrl)!}
alt=""
aria-hidden="true"
style={{
width: '100%',
height: '100%',
objectFit: 'contain',
filter: isActive
? 'drop-shadow(0 4px 12px rgba(0,0,0,0.7))'
: 'brightness(0.05) saturate(0)',
opacity: isActive ? 1 : 0.85,
transition: 'filter 0.3s ease, opacity 0.3s ease',
}}
/>
) : (
<span
aria-hidden="true"
style={{
fontSize: isActive ? 42 : 26,
lineHeight: 1,
filter: isActive ? 'none' : 'grayscale(1) brightness(0.15)',
transition: 'filter 0.3s ease, font-size 0.3s ease',
}}
>
{DINO_EMOJIS[i % DINO_EMOJIS.length]}
</span>
)}
</div>
</button>
)
})}
</div>
{/* Mobile: dino name below wheel */}
<p
className="mt-4 text-center text-[18px] font-bold text-white uppercase lg:hidden"
style={{ ...FONT_MONT, transition: 'opacity 0.22s ease', opacity: visible ? 1 : 0 }}
>
{current.name}
</p>
</div>
{/* ── Info panel — to the right on desktop, below on mobile ── */}
<div
className="flex w-full max-w-[400px] flex-col items-center gap-5 text-center lg:w-auto lg:min-w-[380px] lg:pb-6"
aria-live="polite"
aria-atomic="true"
>
{/* Dino name & subtitle */}
<div
style={{
transition: 'opacity 0.24s ease',
opacity: visible ? 1 : 0,
minHeight: 80,
display: 'flex',
flexDirection: 'column',
alignItems: 'center',
gap: 4,
}}
>
<p
className="text-[28px] leading-tight font-bold text-white uppercase lg:text-[36px]"
style={FONT_MONT}
>
{current.name}
</p>
{current.latinName && (
<p className="text-[14px] text-white/60 lg:text-[16px]" style={FONT_POP}>
({current.latinName})
</p>
)}
</div>
{/* Stats table */}
<div
className="flex flex-col gap-2 text-[16px] text-white lg:text-[18px]"
style={{
...FONT_POP,
transition: 'opacity 0.24s ease',
opacity: visible ? 1 : 0,
minHeight: 60,
}}
>
{current.length && (
<div className="flex items-center justify-center gap-4">
<span className="w-[80px] text-right text-white/60">Довжина</span>
<span className="font-bold">{current.length}</span>
</div>
)}
{current.height && (
<div className="flex items-center justify-center gap-4">
<span className="w-[80px] text-right text-white/60">Висота</span>
<span className="font-bold">{current.height}</span>
</div>
)}
</div>
{/* Prev / Next buttons */}
<div className="flex items-center gap-4">
<button
onClick={() => handleSelect((active - 1 + n) % n)}
className="flex h-10 w-10 items-center justify-center rounded-full transition-colors"
style={{ border: '1px solid rgba(255,255,255,0.28)', color: '#fff' }}
aria-label="Попередній динозавр"
>
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" aria-hidden="true">
<path
d="M10 3L5 8L10 13"
stroke="white"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
/>
</svg>
</button>
{/* Dot indicators */}
<div className="flex flex-wrap justify-center gap-1.5">
{items.map((_, i) => (
<button
key={i}
onClick={() => handleSelect(i)}
aria-label={items[i]!.name}
aria-current={i === active ? true : undefined}
style={{
width: i === active ? 20 : 8,
height: 8,
borderRadius: 4,
background: i === active ? '#fdcf54' : 'rgba(255,255,255,0.28)',
border: 'none',
padding: 0,
cursor: 'pointer',
transition: 'width 0.3s ease, background 0.3s ease',
}}
/>
))}
</div>
<button
onClick={() => handleSelect((active + 1) % n)}
className="flex h-10 w-10 items-center justify-center rounded-full transition-colors"
style={{ border: '1px solid rgba(255,255,255,0.28)', color: '#fff' }}
aria-label="Наступний динозавр"
>
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" aria-hidden="true">
<path
d="M6 3L11 8L6 13"
stroke="white"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
/>
</svg>
</button>
</div>
</div>
</div>
{/* ── Mobile grid — always shown, compact name list ── */}
<div className="mt-8 grid grid-cols-2 gap-2 sm:grid-cols-3 lg:hidden">
{items.map((dino, i) => (
<button
key={i}
onClick={() => handleSelect(i)}
className="flex items-center gap-2 rounded-[10px] px-3 py-2 text-left text-[12px] font-semibold transition-all"
style={{
background: i === active ? 'rgba(253,207,84,0.18)' : 'rgba(255,255,255,0.06)',
color: i === active ? '#fdcf54' : 'rgba(255,255,255,0.65)',
border: i === active ? '1px solid #fdcf54' : '1px solid transparent',
...FONT_MONT,
}}
aria-current={i === active ? true : undefined}
>
<span aria-hidden="true" style={{ fontSize: 14 }}>
{DINO_EMOJIS[i % DINO_EMOJIS.length]}
</span>
<span className="truncate">{dino.name}</span>
</button>
))}
</div>
</div>
{/* ── Gallery — 4 park photos from Figma node 2004:568 ── */}
<div className="mx-auto max-w-[1440px] px-4 pb-12 lg:pb-20">
<div className="grid grid-cols-2 gap-3 lg:grid-cols-4 lg:gap-4">
{[
{ src: '/images/figma/dyno-gallery-1.jpg', alt: 'Динопарк фото 1' },
{ src: '/images/figma/dyno-gallery-2.jpg', alt: 'Динопарк фото 2' },
{ src: '/images/figma/dyno-gallery-3.jpg', alt: 'Динопарк фото 3' },
{ src: '/images/figma/dyno-gallery-4.jpg', alt: 'Динопарк фото 4' },
].map((photo, i) => (
<div key={i} className="overflow-hidden rounded-[20px]" style={{ aspectRatio: '3/4' }}>
<img
src={photo.src}
alt={photo.alt}
className="h-full w-full object-cover transition-transform duration-500 hover:scale-105"
/>
</div>
))}
</div>
</div>
</section>
)
}
function StatRow({ label, value }: { label: string; value: string }) {
return (
<div className="flex items-center justify-between gap-4 rounded-[10px] bg-[#f1fbeb] px-4 py-2.5">
<span
className="text-[13px] font-semibold tracking-wide text-[#396817] uppercase"
style={FONT_MONT}
>
{label}
</span>
<span className="text-[16px] font-bold text-[#272727]" style={FONT_MONT}>
{value}
</span>
</div>
)
}

View file

@ -0,0 +1,261 @@
'use client'
import { useState, useEffect, useCallback, useRef } from 'react'
import { BtnPrimary } from '@/components/ui/BtnPrimary'
type SlideType = 'brand' | 'location'
interface SlideData {
img: string
type: SlideType
title: string
subtitle: string
ctaLabel: string
ctaHref: string
}
// Odd slides (1,3,5) = brand message; Even slides (2,4,6) = ДивоЛіс location
const SLIDES: SlideData[] = [
{
img: '/images/figma/hero-slide-1.webp',
type: 'brand',
title: 'Започаткуйте\nтрадицію:',
subtitle: 'щороку фотографуйтесь біля улюбленого динозавра',
ctaLabel: 'Купити квиток',
ctaHref: '/kvytky',
},
{
img: '/images/figma/hero-slide-2.webp',
type: 'location',
title: 'ДивоЛіс',
subtitle:
'Це простір, де ви разом із дитиною створюєте власний магічний світ, вчитеся помічати дива у звичайних речах та розвиваєте творчу уяву через спільну гру. Хто знає, можлива саме ця прогулянка спонукає вже дорослу дитину написати казкову історію, яка стане бестселером.',
ctaLabel: 'Купити квиток',
ctaHref: '/kvytky',
},
{
img: '/images/figma/hero-slide-3.webp',
type: 'brand',
title: 'Започаткуйте\nтрадицію:',
subtitle: 'щороку фотографуйтесь біля улюбленого динозавра',
ctaLabel: 'Купити квиток',
ctaHref: '/kvytky',
},
{
img: '/images/figma/hero-slide-4.webp',
type: 'location',
title: 'ДивоЛіс',
subtitle:
'Це простір, де ви разом із дитиною створюєте власний магічний світ, вчитеся помічати дива у звичайних речах та розвиваєте творчу уяву через спільну гру. Хто знає, можлива саме ця прогулянка спонукає вже дорослу дитину написати казкову історію, яка стане бестселером.',
ctaLabel: 'Купити квиток',
ctaHref: '/kvytky',
},
{
img: '/images/figma/hero-slide-5.webp',
type: 'brand',
title: 'Започаткуйте\nтрадицію:',
subtitle: 'щороку фотографуйтесь біля улюбленого динозавра',
ctaLabel: 'Купити квиток',
ctaHref: '/kvytky',
},
{
img: '/images/figma/hero-slide-6.webp',
type: 'location',
title: 'ДивоЛіс',
subtitle:
'Це простір, де ви разом із дитиною створюєте власний магічний світ, вчитеся помічати дива у звичайних речах та розвиваєте творчу уяву через спільну гру. Хто знає, можлива саме ця прогулянка спонукає вже дорослу дитину написати казкову історію, яка стане бестселером.',
ctaLabel: 'Купити квиток',
ctaHref: '/kvytky',
},
]
const INTERVAL = 5000
export function HeroSlider() {
const [current, setCurrent] = useState(0)
const [textVisible, setTextVisible] = useState(true)
const [paused, setPaused] = useState(false)
const timerRef = useRef<ReturnType<typeof setInterval> | null>(null)
const goTo = useCallback((idx: number) => {
setTextVisible(false)
setTimeout(() => {
setCurrent((idx + SLIDES.length) % SLIDES.length)
setTextVisible(true)
}, 280)
}, [])
const next = useCallback(() => goTo(current + 1), [current, goTo])
useEffect(() => {
if (paused) return
timerRef.current = setInterval(next, INTERVAL)
return () => {
if (timerRef.current) clearInterval(timerRef.current)
}
}, [next, paused])
const slide = SLIDES[current]!
const isBrand = slide.type === 'brand'
return (
<section
className="relative mx-[10px] -mt-[60px] overflow-hidden rounded-b-[20px] lg:-mt-[120px]"
style={{ height: 'clamp(480px, calc(56.25vw + 60px), calc(100vh + 60px))' }}
onMouseEnter={() => setPaused(true)}
onMouseLeave={() => setPaused(false)}
>
{/* ── Slides track — vertical ── */}
<div
className="absolute inset-x-0 top-0 will-change-transform"
style={{
height: `${SLIDES.length * 100}%`,
transform: `translateY(-${(current / SLIDES.length) * 100}%)`,
transition: 'transform 0.85s cubic-bezier(0.77,0,0.18,1)',
}}
>
{SLIDES.map((s, i) => (
<div
key={i}
className="absolute inset-x-0"
style={{ top: `${(i / SLIDES.length) * 100}%`, height: `${100 / SLIDES.length}%` }}
>
{/* eslint-disable-next-line @next/next/no-img-element */}
<img
src={s.img}
alt=""
aria-hidden="true"
className="pointer-events-none h-full w-full object-cover object-center"
loading={i === 0 ? 'eager' : 'lazy'}
/>
</div>
))}
</div>
{/* ── Gradient overlay ── */}
<div
aria-hidden="true"
className="pointer-events-none absolute inset-0 hidden lg:block"
style={{
background:
'linear-gradient(to right, rgba(0,8,0,0.72) 0%, rgba(0,8,0,0.55) 35%, rgba(0,8,0,0.15) 65%, transparent 80%)',
}}
/>
<div
aria-hidden="true"
className="pointer-events-none absolute inset-0 lg:hidden"
style={{
background: 'linear-gradient(160deg, rgba(0,8,0,0.75) 35%, rgba(0,8,0,0.15) 90%)',
}}
/>
{/* ── Text content ── */}
<div
className="relative z-20 flex h-full flex-col justify-center px-6 lg:px-0"
style={{
opacity: textVisible ? 1 : 0,
transform: textVisible ? 'translateY(0)' : 'translateY(10px)',
transition: 'opacity 0.3s ease, transform 0.3s ease',
}}
>
<div className="lg:mx-auto lg:w-full lg:max-w-[1504px]">
<div className="flex max-w-[85vw] flex-col lg:max-w-none lg:pl-[154px]">
{/* Title */}
<h1
className="font-bold text-white uppercase"
style={{
fontFamily: 'var(--font-montserrat, Montserrat), sans-serif',
fontSize: 'clamp(28px, 5vw, 96px)',
lineHeight: 1.2,
fontWeight: 700,
whiteSpace: 'pre-line',
marginBottom: isBrand ? '0.3em' : '0.4em',
}}
>
{slide.title}
</h1>
{/* Subtitle */}
{isBrand ? (
/* Brand slide: large bold subtitle, same as original Hero */
<p
className="text-white"
style={{
fontFamily: 'var(--font-montserrat, Montserrat), sans-serif',
fontSize: 'clamp(18px, 2.5vw, 48px)',
fontWeight: 900,
lineHeight: 1.5,
marginBottom: '1em',
}}
>
{slide.subtitle}
</p>
) : (
/* Location slide: paragraph description */
<p
className="text-white"
style={{
fontFamily: 'var(--font-poppins, Poppins), sans-serif',
fontSize: 'clamp(14px, 1.35vw, 20px)',
fontWeight: 300,
lineHeight: 1.65,
maxWidth: '560px',
marginBottom: '1.2em',
}}
>
{slide.subtitle}
</p>
)}
<BtnPrimary href={slide.ctaHref} className="self-start">
{slide.ctaLabel}
</BtnPrimary>
</div>
</div>
</div>
{/* ── Vertical nav dots (desktop) ── */}
<div className="absolute top-1/2 right-5 z-30 hidden -translate-y-1/2 flex-col gap-3 lg:flex">
{SLIDES.map((_, i) => (
<button
key={i}
aria-label={`Слайд ${i + 1}`}
onClick={() => goTo(i)}
className="flex h-8 w-8 items-center justify-center"
>
<span
className="block rounded-full transition-all duration-300"
style={{
width: i === current ? 10 : 6,
height: i === current ? 10 : 6,
backgroundColor: i === current ? '#f28b4a' : 'rgba(255,255,255,0.55)',
boxShadow: i === current ? '0 0 0 2px rgba(242,139,74,0.4)' : 'none',
}}
/>
</button>
))}
</div>
{/* ── Mobile dots (bottom) ── */}
<div className="absolute bottom-4 left-1/2 z-30 flex -translate-x-1/2 gap-2 lg:hidden">
{SLIDES.map((_, i) => (
<button
key={i}
aria-label={`Слайд ${i + 1}`}
onClick={() => goTo(i)}
className="flex h-6 w-6 items-center justify-center"
>
<span
className="block rounded-full transition-all duration-300"
style={{
width: i === current ? 8 : 5,
height: i === current ? 8 : 5,
backgroundColor: i === current ? '#f28b4a' : 'rgba(255,255,255,0.5)',
}}
/>
</button>
))}
</div>
</section>
)
}

View file

@ -0,0 +1,310 @@
'use client'
import { useState } from 'react'
import { useCart } from '@/context/CartContext'
const FONT_MONT = { fontFamily: 'var(--font-montserrat, Montserrat), sans-serif' }
interface Tariff {
id: number
name: string
price: number
categoryTag: string
sort: number
icon?: string | null
}
interface KvytkyTicketsClientProps {
serverTariffs: Tariff[]
categoryLabelsMap: Record<string, string>
}
// Photo mapping: tariff name → image path
const TICKET_PHOTOS: Record<string, string> = {
'Вхід до Динопарку': '/images/figma/ticket-dinopark.jpg',
Динопарк: '/images/figma/ticket-dinopark.jpg',
ДивоЛіс: '/images/figma/ticket-divo-lis.jpg',
'Дзеркальний лабіринт': '/images/figma/ticket-maze.jpg',
ДиноРодо: '/images/figma/ticket-dynarodeo.jpg',
Автомістечко: '/images/figma/ticket-autodrom.jpg',
'XD Кінотеатр': '/images/figma/ticket-cinema.jpg',
'VR (Віртуальна реальність)': '/images/figma/ticket-divo-lis.jpg',
'Тир звичайний': '/images/figma/ticket-tir1.jpg',
'Тир призовий': '/images/figma/ticket-tir2.jpg',
Потяг: '/images/figma/ticket-train.jpg',
'Звичайна екскурсія': '/images/figma/ticket-dinopark.jpg',
'Палеонтологічна екскурсія': '/images/figma/ticket-dinopark.jpg',
}
// Fallback photos by category
const CATEGORY_FALLBACK_PHOTOS: Record<string, string> = {
dyno: '/images/figma/ticket-dinopark.jpg',
zone: '/images/figma/ticket-divo-lis.jpg',
attraction: '/images/figma/ticket-cinema.jpg',
program: '/images/figma/ticket-dinopark.jpg',
dyvolis: '/images/figma/ticket-divo-lis.jpg',
other: '/images/figma/ticket-dinopark.jpg',
}
// Category badge styles from Figma node 4006:847
const CATEGORY_COLORS: Record<string, { bg: string; label: string }> = {
zone: { bg: '#b9e995', label: 'Зона' },
attraction: { bg: '#fce2d2', label: 'Атракціон' },
program: { bg: '#fee194', label: 'Екскурсія' },
dyno: { bg: '#b9e995', label: 'Зона' },
dyvolis: { bg: '#b9e995', label: 'Зона' },
combo: { bg: '#b9e995', label: 'Комбо' },
other: { bg: '#fee194', label: 'Інше' },
}
// Descriptions mapped from Figma design
const TICKET_DESCRIPTIONS: Record<string, string> = {
'Вхід до Динопарку': 'Вхід на локацію, де розташовані 23 живі рухомі фігури динозаврів',
Динопарк: 'Вхід на локацію, де розташовані 23 живі рухомі фігури динозаврів',
ДивоЛіс: 'Зона казкових топіарних фігур. Затишний зелений простір та фотозони',
'Дзеркальний лабіринт': 'Захоплюючий світ оптичних ілюзій, світлових ефектів та відображень',
ДиноРодо: 'Захоплюючий атракціон для юних верхівців — прокатися верхи на дино',
Автомістечко: 'Безпечний міні-трек для найменших водіїв',
'XD Кінотеатр': "Динамічний фільм з об'ємним звуком та спецефектами",
'VR (Віртуальна реальність)': 'Дивовижні цифрові 3D світи та пригоди',
'Тир звичайний': 'Перевірте влучність та відпрацюйте навички стрільби',
'Тир призовий': 'Випробуйте влучність та виграйте фірмовий приз',
Потяг: 'Оглядова спокійна поїздка казковим маршрутом',
Кошики: 'Карусель з плавним обертанням',
'Диво сітки': 'Активний трирівневий простір для стрибків та розваг',
'Дино сітки': 'Активний трирівневий простір для стрибків та розваг',
'Звичайна екскурсія': 'Захоплива екскурсія з провідником по Динопарку',
'Палеонтологічна екскурсія': 'Поглиблена пізнавальна екскурсія про динозаврів та їх епоху',
}
function getTicketPhoto(tariff: Tariff): string {
return (
TICKET_PHOTOS[tariff.name] ??
CATEGORY_FALLBACK_PHOTOS[tariff.categoryTag] ??
'/images/figma/ticket-dinopark.jpg'
)
}
function getTicketDescription(tariff: Tariff): string {
return TICKET_DESCRIPTIONS[tariff.name] ?? ''
}
function getCategoryStyle(tag: string): { bg: string; label: string } {
return CATEGORY_COLORS[tag] ?? { bg: '#b9e995', label: 'Інше' }
}
// Tab configuration — Figma: "Усі розваги" | "Основні зони" | "Атракціони"
const ALL_TAB = '__all__'
const ZONE_TAB = '__zones__'
const ATTRACTION_TAB = '__attractions__'
const ZONE_TAGS = new Set(['zone', 'dyno', 'dyvolis'])
const ATTRACTION_TAGS = new Set(['attraction'])
export function KvytkyTicketsClient({
serverTariffs,
categoryLabelsMap: _categoryLabelsMap,
}: KvytkyTicketsClientProps) {
// Filter out combo tariffs — they have their own section
const singleTariffs = serverTariffs.filter((t) => t.categoryTag !== 'combo')
const [activeTab, setActiveTab] = useState<string>(ALL_TAB)
const visibleTariffs = singleTariffs.filter((t) => {
if (activeTab === ALL_TAB) return true
if (activeTab === ZONE_TAB) return ZONE_TAGS.has(t.categoryTag)
if (activeTab === ATTRACTION_TAB) return ATTRACTION_TAGS.has(t.categoryTag)
return t.categoryTag === activeTab
})
const hasZones = singleTariffs.some((t) => ZONE_TAGS.has(t.categoryTag))
const hasAttractions = singleTariffs.some((t) => ATTRACTION_TAGS.has(t.categoryTag))
if (singleTariffs.length === 0) {
return (
<section className="py-[60px]">
<div className="mx-auto max-w-[1204px] px-4 lg:px-8">
<div className="py-20 text-center">
<p className="mb-6 text-[20px] text-[#272727]" style={FONT_MONT}>
Квитки тимчасово недоступні. Спробуйте пізніше або зателефонуйте нам.
</p>
<a
href="tel:+380671443635"
className="inline-flex rounded-[64px] bg-[#f28b4a] px-8 py-3 font-bold text-white"
style={FONT_MONT}
>
Зателефонувати
</a>
</div>
</div>
</section>
)
}
return (
<section className="py-[60px]">
<div className="mx-auto max-w-[1204px] px-4 lg:px-8">
<h2
className="mb-[40px] text-[24px] font-bold text-[#272727] uppercase lg:text-[32px]"
style={FONT_MONT}
>
Вартість квитків
</h2>
{/* Category tabs — Figma: "Усі розваги" | "Основні зони" | "Атракціони" */}
<div className="mb-[40px] flex flex-wrap gap-3">
{[
{ id: ALL_TAB, label: 'Усі розваги', show: true },
{ id: ZONE_TAB, label: 'Основні зони', show: hasZones },
{ id: ATTRACTION_TAB, label: 'Атракціони', show: hasAttractions },
]
.filter((t) => t.show)
.map((tab) => {
const isActive = activeTab === tab.id
return (
<button
key={tab.id}
onClick={() => setActiveTab(tab.id)}
className="overflow-clip rounded-[20px] border-2 border-[#f28b4a] px-[30px] py-[10px] text-[18px] font-bold text-[#272727] transition-all hover:shadow-[0_0_20px_0_#f28b4a] lg:text-[20px]"
style={{
...FONT_MONT,
background: isActive
? 'linear-gradient(90deg, #fad5bb 0%, #fad5bb 100%)'
: '#f1fbeb',
}}
>
{tab.label}
</button>
)
})}
</div>
{/* Ticket rows — horizontal layout per Figma */}
<div className="flex flex-col divide-y divide-[#c8e8b0]">
{visibleTariffs.map((tariff) => (
<TicketRow key={tariff.id} tariff={tariff} />
))}
</div>
</div>
</section>
)
}
function TicketRow({ tariff }: { tariff: Tariff }) {
const { addItem } = useCart()
const [count, setCount] = useState(1)
const [added, setAdded] = useState(false)
function handleAdd() {
for (let i = 0; i < count; i++) {
addItem({
tariffId: String(tariff.id),
name: tariff.name,
price: tariff.price,
categoryTag: tariff.categoryTag,
icon: tariff.icon ?? undefined,
})
}
setAdded(true)
setCount(1)
setTimeout(() => setAdded(false), 1500)
}
const catStyle = getCategoryStyle(tariff.categoryTag)
const photo = getTicketPhoto(tariff)
const description = getTicketDescription(tariff)
return (
<div className="flex flex-col gap-4 py-6 sm:flex-row sm:items-center sm:gap-[40px] lg:gap-[77px]">
{/* Photo — 300x300 rounded per Figma */}
<div className="h-[200px] w-full flex-none overflow-hidden rounded-[20px] bg-white sm:h-[160px] sm:w-[160px] lg:h-[300px] lg:w-[300px]">
{/* eslint-disable-next-line @next/next/no-img-element */}
<img src={photo} alt={tariff.name} className="h-full w-full object-cover" />
</div>
{/* Description column */}
<div className="flex flex-1 flex-col gap-[16px] lg:gap-[20px]">
{/* Category badge */}
<span
className="inline-flex w-fit items-center justify-center rounded-[39px] px-[24px] py-[5px] text-[14px] font-bold text-[#272727] lg:px-[30px] lg:text-[20px]"
style={{ ...FONT_MONT, backgroundColor: catStyle.bg }}
>
{catStyle.label}
</span>
{/* Name */}
<p
className="text-[18px] leading-[1.3] font-bold text-[#272727] lg:text-[24px]"
style={FONT_MONT}
>
{tariff.name}
</p>
{/* Description */}
{description && (
<p
className="text-[13px] leading-[1.5] font-light text-[#272727] lg:text-[16px]"
style={FONT_MONT}
>
{description}
</p>
)}
</div>
{/* Price + Counter column */}
<div className="flex flex-row items-center justify-between gap-4 sm:flex-col sm:items-center sm:justify-center sm:gap-[30px]">
{/* Price — large number + "грн" */}
<p className="text-center leading-none font-bold text-[#272727]" style={FONT_MONT}>
<span className="text-[36px] lg:text-[48px]">{tariff.price}</span>
<br />
<span className="text-[16px] lg:text-[24px]">грн</span>
</p>
{/* Counter + Add to cart */}
<div className="flex flex-col items-center gap-3">
{/* Counter pill */}
<div className="flex items-center gap-[10px] rounded-[39px] border border-[#c8e8b0] bg-white px-[16px] py-[6px]">
<button
onClick={() => setCount((c) => Math.max(1, c - 1))}
aria-label="Зменшити кількість"
className="flex h-[22px] w-[22px] items-center justify-center rounded-full text-[18px] font-bold text-[#396817] transition-opacity hover:opacity-70 active:opacity-40"
style={FONT_MONT}
>
</button>
<span
className="min-w-[28px] text-center text-[20px] font-bold text-[#272727]"
style={FONT_MONT}
>
{count}
</span>
<button
onClick={() => setCount((c) => Math.min(10, c + 1))}
aria-label="Збільшити кількість"
className="flex h-[22px] w-[22px] items-center justify-center rounded-full text-[18px] font-bold text-[#396817] transition-opacity hover:opacity-70 active:opacity-40"
style={FONT_MONT}
>
+
</button>
</div>
{/* Buy button */}
<button
onClick={handleAdd}
className="rounded-[64px] px-6 py-[8px] text-[14px] font-bold transition-all hover:opacity-90 lg:text-[16px]"
style={{
...FONT_MONT,
background: added
? '#4caf50'
: 'linear-gradient(90deg, #f28b4a 0%, #fdcf54 55%, #f28b4a 100%)',
color: added ? '#fff' : '#1a1a1a',
minWidth: '140px',
}}
>
{added ? '✓ Додано' : 'Купити квиток'}
</button>
</div>
</div>
</div>
)
}

View file

@ -0,0 +1,194 @@
'use client'
/* eslint-disable @next/next/no-img-element */
import { useState, useCallback, useEffect, useRef, type ReactNode } from 'react'
export interface CoverflowSlide {
src: string
alt: string
/** Optional overlay content rendered on top of the image */
overlay?: ReactNode
}
interface CoverflowSliderProps {
slides: CoverflowSlide[]
/** px width of each card (default 420) */
cardWidth?: number
/** px height of each card (default 300) */
cardHeight?: number
/** Border radius of each card (default 20) */
radius?: number
className?: string
/** Auto-advance interval in ms. 0 = disabled. */
autoplay?: number
}
const SLOT = 230 // px between card centers
const ANGLE = 42 // deg rotation per slot
const SCALE_STEP = 0.14
const OPACITY_STEP = 0.22
const VISIBLE_SIDES = 2
export function CoverflowSlider({
slides,
cardWidth = 420,
cardHeight = 300,
radius = 20,
className = '',
autoplay = 0,
}: CoverflowSliderProps) {
const [active, setActive] = useState(0)
const autoTimer = useRef<ReturnType<typeof setInterval> | null>(null)
const n = slides.length
const prev = useCallback(() => setActive((a) => (a - 1 + n) % n), [n])
const next = useCallback(() => setActive((a) => (a + 1) % n), [n])
// Keyboard navigation
useEffect(() => {
const onKey = (e: KeyboardEvent) => {
if (e.key === 'ArrowLeft') prev()
if (e.key === 'ArrowRight') next()
}
window.addEventListener('keydown', onKey)
return () => window.removeEventListener('keydown', onKey)
}, [prev, next])
// Autoplay
useEffect(() => {
if (!autoplay) return
autoTimer.current = setInterval(next, autoplay)
return () => {
if (autoTimer.current) clearInterval(autoTimer.current)
}
}, [autoplay, next])
// Touch/drag support
const dragStart = useRef<number | null>(null)
const onTouchStart = (e: React.TouchEvent) => {
dragStart.current = e.touches[0]?.clientX ?? null
}
const onTouchEnd = (e: React.TouchEvent) => {
if (dragStart.current === null) return
const dx = (e.changedTouches[0]?.clientX ?? 0) - dragStart.current
if (Math.abs(dx) > 40) dx < 0 ? next() : prev()
dragStart.current = null
}
const containerHeight = Math.round(cardHeight * 1.25)
return (
<div className={`flex flex-col items-center ${className}`}>
{/* 3D stage */}
<div
className="relative w-full select-none"
style={{ height: containerHeight, perspective: '1100px' }}
onTouchStart={onTouchStart}
onTouchEnd={onTouchEnd}
>
{slides.map((slide, i) => {
const offset = i - active
// Wrap-around distance (shortest path)
const wrapped = offset > n / 2 ? offset - n : offset < -n / 2 ? offset + n : offset
const abs = Math.abs(wrapped)
if (abs > VISIBLE_SIDES + 0.5) return null
const tx = wrapped * SLOT
const ry = -wrapped * ANGLE
const scale = Math.max(0.55, 1 - abs * SCALE_STEP)
const opacity = Math.max(0.25, 1 - abs * OPACITY_STEP)
const zIndex = 20 - abs * 4
return (
<div
key={i}
role="button"
tabIndex={0}
aria-label={slide.alt}
onClick={() => setActive(i)}
onKeyDown={(e) => e.key === 'Enter' && setActive(i)}
className="absolute cursor-pointer overflow-hidden"
style={{
width: cardWidth,
height: cardHeight,
borderRadius: radius,
left: '50%',
top: '50%',
transform: `translate(-50%, -50%) translateX(${tx}px) rotateY(${ry}deg) scale(${scale})`,
transformStyle: 'preserve-3d',
opacity,
zIndex,
transition:
'transform 0.45s cubic-bezier(0.25, 0.46, 0.45, 0.94), opacity 0.45s ease',
outline: 'none',
}}
>
<img
src={slide.src}
alt={slide.alt}
className="h-full w-full object-cover"
draggable={false}
/>
{slide.overlay && <div className="absolute inset-0">{slide.overlay}</div>}
</div>
)
})}
</div>
{/* Controls row */}
<div className="mt-6 flex items-center gap-4">
{/* Prev button */}
<button
onClick={prev}
aria-label="Попередній слайд"
className="flex h-11 w-11 shrink-0 items-center justify-center rounded-full bg-[#396817] text-white shadow-md transition-colors hover:bg-[#f28b4a] focus-visible:ring-2 focus-visible:ring-[#f28b4a] focus-visible:outline-none"
>
<svg width="18" height="18" viewBox="0 0 20 20" fill="none" aria-hidden="true">
<path
d="M13 4L7 10L13 16"
stroke="currentColor"
strokeWidth="2.2"
strokeLinecap="round"
strokeLinejoin="round"
/>
</svg>
</button>
{/* Dots */}
<div className="flex flex-wrap items-center justify-center gap-[6px]">
{slides.map((_, i) => (
<button
key={i}
onClick={() => setActive(i)}
aria-label={`Слайд ${i + 1}`}
className="rounded-full transition-all duration-200 focus-visible:outline-none"
style={{
width: i === active ? 10 : 8,
height: i === active ? 10 : 8,
backgroundColor: i === active ? '#396817' : 'rgba(57,104,23,0.35)',
}}
/>
))}
</div>
{/* Next button */}
<button
onClick={next}
aria-label="Наступний слайд"
className="flex h-11 w-11 shrink-0 items-center justify-center rounded-full bg-[#396817] text-white shadow-md transition-colors hover:bg-[#f28b4a] focus-visible:ring-2 focus-visible:ring-[#f28b4a] focus-visible:outline-none"
>
<svg width="18" height="18" viewBox="0 0 20 20" fill="none" aria-hidden="true">
<path
d="M7 4L13 10L7 16"
stroke="currentColor"
strokeWidth="2.2"
strokeLinecap="round"
strokeLinejoin="round"
/>
</svg>
</button>
</div>
</div>
)
}