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>
BIN
public/images/figma/birthday-altanka.jpg
Normal file
|
After Width: | Height: | Size: 524 KiB |
BIN
public/images/figma/birthday-animators.jpg
Normal file
|
After Width: | Height: | Size: 4.7 MiB |
BIN
public/images/figma/birthday-aquagrim.jpg
Normal file
|
After Width: | Height: | Size: 344 KiB |
BIN
public/images/figma/birthday-dinopark.jpg
Normal file
|
After Width: | Height: | Size: 10 MiB |
BIN
public/images/figma/birthday-dyvolis.jpg
Normal file
|
After Width: | Height: | Size: 10 MiB |
BIN
public/images/figma/birthday-hero-bg.jpg
Normal file
|
After Width: | Height: | Size: 5.6 MiB |
BIN
public/images/figma/birthday-labyrinth.jpg
Normal file
|
After Width: | Height: | Size: 10 MiB |
BIN
public/images/figma/confirm-bg.png
Normal file
|
After Width: | Height: | Size: 4.1 KiB |
BIN
public/images/figma/confirm-ellipse.png
Normal file
|
After Width: | Height: | Size: 62 KiB |
10
public/images/figma/confirm-ellipse2.svg
Normal 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 |
BIN
public/images/figma/dino-ankylosaurus.png
Normal file
|
After Width: | Height: | Size: 99 KiB |
BIN
public/images/figma/dino-baryonyx.png
Normal file
|
After Width: | Height: | Size: 463 KiB |
BIN
public/images/figma/dino-brachio-pair.png
Normal file
|
After Width: | Height: | Size: 7.1 KiB |
BIN
public/images/figma/dino-brachiosaurus-fixed.png
Normal file
|
After Width: | Height: | Size: 7.1 KiB |
BIN
public/images/figma/dino-brachiosaurus.png
Normal file
|
After Width: | Height: | Size: 7.1 KiB |
BIN
public/images/figma/dino-carno-rodeo.png
Normal file
|
After Width: | Height: | Size: 226 KiB |
BIN
public/images/figma/dino-carnotaurus.png
Normal file
|
After Width: | Height: | Size: 11 KiB |
BIN
public/images/figma/dino-dilophosaurus.png
Normal file
|
After Width: | Height: | Size: 33 KiB |
BIN
public/images/figma/dino-dino-eggs.png
Normal file
|
After Width: | Height: | Size: 658 B |
BIN
public/images/figma/dino-kosmoceratops.png
Normal file
|
After Width: | Height: | Size: 71 KiB |
BIN
public/images/figma/dino-ouranosaurus.png
Normal file
|
After Width: | Height: | Size: 93 KiB |
BIN
public/images/figma/dino-oviraptor.png
Normal file
|
After Width: | Height: | Size: 1.6 MiB |
BIN
public/images/figma/dino-parasaurolophus-family.png
Normal file
|
After Width: | Height: | Size: 1.8 KiB |
BIN
public/images/figma/dino-parasaurolophus.png
Normal file
|
After Width: | Height: | Size: 1.8 KiB |
BIN
public/images/figma/dino-pterodactyl.png
Normal file
|
After Width: | Height: | Size: 551 KiB |
BIN
public/images/figma/dino-quetzalcoatl.png
Normal file
|
After Width: | Height: | Size: 425 KiB |
BIN
public/images/figma/dino-spinosaurus.png
Normal file
|
After Width: | Height: | Size: 350 KiB |
BIN
public/images/figma/dino-stegosaurus.png
Normal file
|
After Width: | Height: | Size: 17 KiB |
BIN
public/images/figma/dino-styracosaurus.png
Normal file
|
After Width: | Height: | Size: 14 KiB |
BIN
public/images/figma/dino-trex-giant.png
Normal file
|
After Width: | Height: | Size: 452 KiB |
BIN
public/images/figma/dino-trex-rodeo.png
Normal file
|
After Width: | Height: | Size: 105 KiB |
BIN
public/images/figma/dino-trex2.png
Normal file
|
After Width: | Height: | Size: 989 KiB |
BIN
public/images/figma/dino-trex3.png
Normal file
|
After Width: | Height: | Size: 2.9 MiB |
BIN
public/images/figma/dino-triceratops.png
Normal file
|
After Width: | Height: | Size: 746 KiB |
BIN
public/images/figma/dino-tyrex-giant.png
Normal file
|
After Width: | Height: | Size: 452 KiB |
BIN
public/images/figma/dino-velociraptor.png
Normal file
|
After Width: | Height: | Size: 12 KiB |
BIN
public/images/figma/dino-velociraptor2.png
Normal file
|
After Width: | Height: | Size: 25 KiB |
BIN
public/images/figma/dyno-gallery-1.jpg
Normal file
|
After Width: | Height: | Size: 5.6 MiB |
BIN
public/images/figma/dyno-gallery-2.jpg
Normal file
|
After Width: | Height: | Size: 5.2 MiB |
BIN
public/images/figma/dyno-gallery-3.jpg
Normal file
|
After Width: | Height: | Size: 7.4 MiB |
BIN
public/images/figma/dyno-gallery-4.jpg
Normal file
|
After Width: | Height: | Size: 7.7 MiB |
10
public/images/figma/dyno-wave-tile.svg
Normal file
|
After Width: | Height: | Size: 251 KiB |
BIN
public/images/figma/group-amenity-1.jpg
Normal file
|
After Width: | Height: | Size: 10 MiB |
BIN
public/images/figma/group-amenity-2.jpg
Normal file
|
After Width: | Height: | Size: 19 MiB |
BIN
public/images/figma/group-amenity-3.jpg
Normal file
|
After Width: | Height: | Size: 344 KiB |
BIN
public/images/figma/group-amenity-4.jpg
Normal file
|
After Width: | Height: | Size: 3.1 KiB |
BIN
public/images/figma/group-banner-1.jpg
Normal file
|
After Width: | Height: | Size: 9.8 MiB |
BIN
public/images/figma/group-banner-2.jpg
Normal file
|
After Width: | Height: | Size: 7.2 MiB |
BIN
public/images/figma/group-bottom-1.jpg
Normal file
|
After Width: | Height: | Size: 9.6 MiB |
BIN
public/images/figma/group-bottom-2.jpg
Normal file
|
After Width: | Height: | Size: 9.4 MiB |
BIN
public/images/figma/group-hero-bg.jpg
Normal file
|
After Width: | Height: | Size: 8 MiB |
BIN
public/images/figma/ticket-autodrom.jpg
Normal file
|
After Width: | Height: | Size: 453 KiB |
BIN
public/images/figma/ticket-cinema.jpg
Normal file
|
After Width: | Height: | Size: 344 KiB |
BIN
public/images/figma/ticket-dinopark.jpg
Normal file
|
After Width: | Height: | Size: 492 KiB |
BIN
public/images/figma/ticket-divo-lis.jpg
Normal file
|
After Width: | Height: | Size: 264 KiB |
BIN
public/images/figma/ticket-dynarodeo.jpg
Normal file
|
After Width: | Height: | Size: 9.5 MiB |
BIN
public/images/figma/ticket-maze.jpg
Normal file
|
After Width: | Height: | Size: 300 KiB |
BIN
public/images/figma/ticket-tir1.jpg
Normal file
|
After Width: | Height: | Size: 426 KiB |
BIN
public/images/figma/ticket-tir2.jpg
Normal file
|
After Width: | Height: | Size: 271 KiB |
BIN
public/images/figma/ticket-train.jpg
Normal file
|
After Width: | Height: | Size: 195 KiB |
BIN
public/images/figma/wave-tile-correct.png
Normal file
|
After Width: | Height: | Size: 191 KiB |
|
|
@ -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>
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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}>
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
>
|
||||
На головну
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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%;
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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 — orange→yellow→orange 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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
)
|
||||
}
|
||||
|
|
|
|||
261
src/components/sections/HeroSlider.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
310
src/components/sections/KvytkyTicketsClient.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
194
src/components/ui/CoverflowSlider.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||