feat(pages): add dinosaur park page + redesign birthday, group visits, thank-you
Some checks are pending
CI / Type Check (push) Waiting to run
CI / Lint (push) Waiting to run
CI / Unit Tests (push) Waiting to run
Deploy / Build & Push Image (push) Waiting to run
Deploy / Deploy to VPS (push) Blocked by required conditions

New pages:
- /lokatsii/dynozavry — DinosaurPage (static ISR): DinoHero, DinoWheel (interactive
  8-species arc selector, auto-rotate 4s), DinoGallery, DinoActivities, DinoWhyVisit,
  DinoTickets (ezy API dyno+combo categories)
- /kvytky/dyakuiemo — thank-you page with ticket-shaped card design

Redesigned pages:
- /dni-narodzhennia — new hero, "Що входить" 6-card section, WhyVisit accordion,
  pricing grid from BirthdayPackages CMS, form preserved
- /grupovi-vidviduvannia — new hero overlay, description band, amenity 2x2 grid,
  350 грн ticket card, bottom CTA section, form preserved

New CMS:
- DinosaurPage global (slug: dinosaur-page) with 7 array sub-tables
- migration 0006_dinosaur_page.sql — idempotent, creates all tables + dynozavry location
- seed/route.ts — seeds DinosaurPage global with 8 dino species defaults
- payload.config.ts — registers DinosaurPage in globals, seoPlugin, livePreview

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Vadym Samoilenko 2026-05-28 13:28:52 +01:00
parent f3c3d2c978
commit ef629dbdbe
15 changed files with 2392 additions and 125 deletions

View file

@ -0,0 +1,110 @@
-- Migration: 0006 — DinosaurPage global + dynozavry location
-- Idempotent: uses IF NOT EXISTS / ON CONFLICT DO NOTHING
-- ─── Main global table ────────────────────────────────────────────────────────
CREATE TABLE IF NOT EXISTS "dinosaur_page" (
"id" integer PRIMARY KEY GENERATED BY DEFAULT AS IDENTITY,
"hero_title" varchar,
"hero_description" varchar,
"hero_stat" varchar,
"hero_stat_label" varchar,
"hero_image_id" integer REFERENCES "media"("id") ON DELETE SET NULL,
"activities_title" varchar,
"activities_description" varchar,
"why_visit_title" varchar,
"working_hours" varchar,
"combo_description" varchar,
"updated_at" timestamp(3) with time zone DEFAULT now() NOT NULL,
"created_at" timestamp(3) with time zone DEFAULT now() NOT NULL
);
-- ─── hero_features array ──────────────────────────────────────────────────────
CREATE TABLE IF NOT EXISTS "dinosaur_page_hero_features" (
"_order" integer NOT NULL,
"_parent_id" integer NOT NULL REFERENCES "dinosaur_page"("id") ON DELETE CASCADE,
"id" varchar PRIMARY KEY NOT NULL,
"text" varchar
);
CREATE INDEX IF NOT EXISTS "dinosaur_page_hero_features_order_idx" ON "dinosaur_page_hero_features" ("_order");
CREATE INDEX IF NOT EXISTS "dinosaur_page_hero_features_parent_idx" ON "dinosaur_page_hero_features" ("_parent_id");
-- ─── dinosaurs array (wheel) ──────────────────────────────────────────────────
CREATE TABLE IF NOT EXISTS "dinosaur_page_dinosaurs" (
"_order" integer NOT NULL,
"_parent_id" integer NOT NULL REFERENCES "dinosaur_page"("id") ON DELETE CASCADE,
"id" varchar PRIMARY KEY NOT NULL,
"name" varchar,
"epoch" varchar,
"length" varchar,
"weight" varchar,
"image_id" integer REFERENCES "media"("id") ON DELETE SET NULL,
"thumbnail_image_id" integer REFERENCES "media"("id") ON DELETE SET NULL
);
CREATE INDEX IF NOT EXISTS "dinosaur_page_dinosaurs_order_idx" ON "dinosaur_page_dinosaurs" ("_order");
CREATE INDEX IF NOT EXISTS "dinosaur_page_dinosaurs_parent_idx" ON "dinosaur_page_dinosaurs" ("_parent_id");
-- ─── gallery_images array ─────────────────────────────────────────────────────
CREATE TABLE IF NOT EXISTS "dinosaur_page_gallery_images" (
"_order" integer NOT NULL,
"_parent_id" integer NOT NULL REFERENCES "dinosaur_page"("id") ON DELETE CASCADE,
"id" varchar PRIMARY KEY NOT NULL,
"image_id" integer REFERENCES "media"("id") ON DELETE SET NULL
);
CREATE INDEX IF NOT EXISTS "dinosaur_page_gallery_images_order_idx" ON "dinosaur_page_gallery_images" ("_order");
CREATE INDEX IF NOT EXISTS "dinosaur_page_gallery_images_parent_idx" ON "dinosaur_page_gallery_images" ("_parent_id");
-- ─── activities array ─────────────────────────────────────────────────────────
CREATE TABLE IF NOT EXISTS "dinosaur_page_activities" (
"_order" integer NOT NULL,
"_parent_id" integer NOT NULL REFERENCES "dinosaur_page"("id") ON DELETE CASCADE,
"id" varchar PRIMARY KEY NOT NULL,
"name" varchar,
"price" varchar,
"description" varchar,
"image_id" integer REFERENCES "media"("id") ON DELETE SET NULL,
"href" varchar
);
CREATE INDEX IF NOT EXISTS "dinosaur_page_activities_order_idx" ON "dinosaur_page_activities" ("_order");
CREATE INDEX IF NOT EXISTS "dinosaur_page_activities_parent_idx" ON "dinosaur_page_activities" ("_parent_id");
-- ─── why_visit_items array ────────────────────────────────────────────────────
CREATE TABLE IF NOT EXISTS "dinosaur_page_why_visit_items" (
"_order" integer NOT NULL,
"_parent_id" integer NOT NULL REFERENCES "dinosaur_page"("id") ON DELETE CASCADE,
"id" varchar PRIMARY KEY NOT NULL,
"title" varchar,
"description" varchar
);
CREATE INDEX IF NOT EXISTS "dinosaur_page_why_visit_items_order_idx" ON "dinosaur_page_why_visit_items" ("_order");
CREATE INDEX IF NOT EXISTS "dinosaur_page_why_visit_items_parent_idx" ON "dinosaur_page_why_visit_items" ("_parent_id");
-- ─── review_videos array ─────────────────────────────────────────────────────
CREATE TABLE IF NOT EXISTS "dinosaur_page_review_videos" (
"_order" integer NOT NULL,
"_parent_id" integer NOT NULL REFERENCES "dinosaur_page"("id") ON DELETE CASCADE,
"id" varchar PRIMARY KEY NOT NULL,
"src" varchar,
"poster" varchar,
"label" varchar
);
CREATE INDEX IF NOT EXISTS "dinosaur_page_review_videos_order_idx" ON "dinosaur_page_review_videos" ("_order");
CREATE INDEX IF NOT EXISTS "dinosaur_page_review_videos_parent_idx" ON "dinosaur_page_review_videos" ("_parent_id");
-- ─── Seed initial dinosaur_page row (so findGlobal works before seed) ─────────
INSERT INTO "dinosaur_page" (id, hero_title, working_hours)
VALUES (1, 'Динопарк — портал у світ динозаврів', 'п''ятниця-субота-неділя з 11:00 до 20:00')
ON CONFLICT (id) DO NOTHING;
-- ─── Location record for dynozavry ───────────────────────────────────────────
INSERT INTO "locations" (
name, slug, tagline, short_desc,
show_in_menu, show_on_home, show_detail_page, sort
)
VALUES (
'Динопарк',
'dynozavry',
'Портал у світ динозаврів',
'Великі динозаври, що рухаються та гарчать. 26 унікальних експонатів.',
true, true, false, 20
)
ON CONFLICT (slug) DO NOTHING;

2
next-env.d.ts vendored
View file

@ -1,6 +1,6 @@
/// <reference types="next" />
/// <reference types="next/image-types/global" />
import "./.next/dev/types/routes.d.ts";
import './.next/types/routes.d.ts'
// NOTE: This file should not be edited
// see https://nextjs.org/docs/app/api-reference/config/typescript for more information.

View file

@ -34,6 +34,7 @@ import { BirthdayPage } from './src/globals/BirthdayPage'
import { TicketsPage } from './src/globals/TicketsPage'
import { LocationsPage } from './src/globals/LocationsPage'
import { BlogIndexPage } from './src/globals/BlogIndexPage'
import { DinosaurPage } from './src/globals/DinosaurPage'
const filename = fileURLToPath(import.meta.url)
const dirname = path.dirname(filename)
@ -88,6 +89,7 @@ export default buildConfig({
TicketsPage,
LocationsPage,
BlogIndexPage,
DinosaurPage,
],
plugins: [
@ -110,6 +112,7 @@ export default buildConfig({
'thank-you-page',
'locations-page',
'blog-index-page',
'dinosaur-page',
],
uploadsCollection: 'media',
tabbedUI: true,
@ -129,6 +132,7 @@ export default buildConfig({
'thank-you-page': `${base}/kvytky/dyakuiemo`,
'locations-page': `${base}/lokatsii`,
'blog-index-page': `${base}/blog`,
'dinosaur-page': `${base}/lokatsii/dynozavry`,
}
return globalURLs[globalSlug ?? ''] ?? base
},
@ -194,6 +198,7 @@ export default buildConfig({
'tickets-page',
'locations-page',
'blog-index-page',
'dinosaur-page',
],
},
},

View file

@ -1,13 +1,16 @@
import type { Metadata } from 'next'
import { getPayload } from 'payload'
import configPromise from '@payload-config'
import { PageHero } from '@/components/ui/PageHero'
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'
export const revalidate = 60
const FONT_MONT = { fontFamily: 'var(--font-montserrat, Montserrat), sans-serif' }
const FONT_POPPINS = { fontFamily: 'var(--font-poppins, Poppins), sans-serif' }
async function getBirthdayPageData() {
try {
const payload = await getPayload({ config: configPromise })
@ -45,6 +48,33 @@ export async function generateMetadata(): Promise<Metadata> {
}
}
const PACKAGE_LOCATIONS = [
{ name: 'ДинопаркArk', description: 'Справжні динозаври в натуральну величину' },
{ name: 'ДивоЛіс', description: 'Казкові топіарні фігури улюблених персонажів' },
{ name: 'Дзеркальний Лабіринт', description: 'Весела гра для дітей та дорослих' },
{ name: 'Костюмованих ведучих', description: 'Аніматори в яскравих костюмах проведуть свято' },
{ name: 'Аніматорів', description: 'Конкурси, ігри та розваги для всіх гостей' },
{ name: 'Затишну альтанку', description: 'Власна зона відпочинку для вашої родини' },
]
const WHY_ITEMS = [
{
title: 'Свято під ключ',
description:
'Ми беремо на себе всі деталі: аніматорів, конкурси, прикраси та окрему зону для вашої родини. Вам залишається лише насолоджуватись.',
},
{
title: 'Простір для дітей і дорослих',
description:
'Шуміленд — це 7 локацій, де кожен знайде щось для себе: від динозаврів до казкових лісів, від лабіринтів до фотозон.',
},
{
title: 'Незабутні фото та спогади',
description:
'Унікальні декорації, яскраві персонажі та щира радість дітей — ідеальний фон для фотографій, які хочеться переглядати знову і знову.',
},
]
export default async function BirthdayPage({
searchParams,
}: {
@ -55,88 +85,324 @@ export default async function BirthdayPage({
const [pageData, packages] = await Promise.all([getBirthdayPageData(), getPackages()])
return (
<div className="min-h-screen bg-[#f1fbeb]">
<PageHero
title={pageData?.heroTitle ?? 'Дні народження'}
subtitle={
pageData?.heroSubtitle ??
"Зробіть свято незабутнім! Оберіть пакет і наші менеджери зв'яжуться з вами для уточнення деталей."
}
/>
<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',
}}
>
{/* dark green overlay */}
<div
className="absolute inset-0"
style={{ background: 'rgba(30, 60, 10, 0.72)' }}
aria-hidden="true"
/>
<div className="mx-auto max-w-[1204px] px-8 py-16">
<div className="mb-16 grid grid-cols-1 gap-6 md:grid-cols-3">
{packages.map((pkg) => (
<div
key={pkg.id}
className={`flex flex-col gap-5 rounded-[24px] p-8 ${
pkg.featured
? 'bg-[#f28b4a] shadow-[0_4px_60px_0_rgba(242,139,74,0.4)]'
: 'bg-[#396817] shadow-[0_4px_60px_0_rgba(57,104,23,0.15)]'
}`}
{/* orange banner box */}
<div className="relative z-10 mx-auto mt-20 w-full max-w-[900px] px-6">
<div
className="rounded-[20px] px-8 py-7 text-center shadow-[0_4px_30px_rgba(242,139,74,0.4)]"
style={{
background: 'linear-gradient(90deg, #f28b4a 0%, #fdcf54 55%, #f28b4a 100%)',
}}
>
<h1
className="text-[24px] leading-tight font-black text-[#1a1a1a] uppercase sm:text-[32px] lg:text-[40px]"
style={FONT_MONT}
>
{pkg.featured && pkg.badge && (
<span
className="self-start rounded-full bg-white px-3 py-1 text-[12px] font-bold text-[#f28b4a]"
style={{ fontFamily: 'var(--font-montserrat, Montserrat), sans-serif' }}
>
{pkg.badge}
</span>
)}
<div>
<h2
className="text-[24px] font-bold text-white uppercase"
style={{ fontFamily: 'var(--font-montserrat, Montserrat), sans-serif' }}
>
{pkg.name}
</h2>
<p
className="mt-1 text-[42px] leading-none font-black text-white"
style={{ fontFamily: 'var(--font-inter, Inter), sans-serif' }}
>
{pkg.priceLabel ?? formatPrice(pkg.price)}{' '}
<span className="text-[24px]">{pkg.currency ?? '₴'}</span>
</p>
</div>
<ul className="flex flex-col gap-2">
{pkg.features?.map((f: { id?: string | null; text: string }) => (
<li
key={f.id}
className="flex items-center gap-2 text-[14px] text-white/80"
style={{ fontFamily: 'var(--font-montserrat, Montserrat), sans-serif' }}
>
<span className="text-white"></span> {f.text}
</li>
))}
</ul>
</div>
))}
{pageData?.heroTitle ?? 'ДЕНЬ НАРОДЖЕННЯ У ШУМІЛЕНДІ ПІД КЛЮЧ'}
</h1>
</div>
</div>
<div id="order-form" className="rounded-[24px] bg-[#396817] p-10">
<h2
className="mb-2 text-[28px] font-bold text-white"
style={{ fontFamily: 'var(--font-montserrat, Montserrat), sans-serif' }}
>
{pageData?.formTitle ?? 'Замовити святкування'}
</h2>
<p
className="mb-8 text-[15px] text-white/70"
style={{ fontFamily: 'var(--font-montserrat, Montserrat), sans-serif' }}
>
{pageData?.formSubtitle ??
"Залиште заявку і наш менеджер зв'яжеться з вами протягом 30 хвилин"}
</p>
{pageData?.form && typeof pageData.form === 'object' ? (
<FormBlock
form={pageData.form as unknown as FormBlockData}
submitLabel="Замовити святкування"
/>
) : (
<BirthdayBookingForm defaultPackage={defaultPackage} />
)}
{/* dark green subtitle band */}
<div
className="relative z-10 mt-6 w-full px-6 py-6"
style={{ background: 'rgba(30, 60, 10, 0.85)' }}
>
<div className="mx-auto flex max-w-[900px] flex-col items-center gap-4 sm:flex-row sm:justify-between">
<p
className="text-center text-[16px] leading-relaxed text-white/90 sm:text-left sm:text-[18px]"
style={FONT_POPPINS}
>
{pageData?.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',
}}
>
Забронювати пригоду
</a>
</div>
</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}
>
ЩО ВХОДИТЬ У ПАКЕТ СВЯТА
</h2>
<p className="mb-10 text-[16px] text-[#396817]" style={FONT_POPPINS}>
Єдиний квиток для іменинника та 15-ти гостей
</p>
{/* Desktop: 2 rows × 3 cols. Mobile: horizontal scroll carousel */}
<div className="hidden gap-5 sm:grid sm:grid-cols-3">
{PACKAGE_LOCATIONS.map((loc) => (
<div
key={loc.name}
className="flex flex-col gap-3 rounded-[20px] p-5 shadow-[0_4px_30px_rgba(242,139,74,0.25)]"
style={{ background: '#fff' }}
>
{/* image placeholder */}
<div
className="h-[160px] w-full rounded-[14px]"
style={{ background: '#e8f5dc' }}
aria-hidden="true"
/>
<div className="flex items-start justify-between gap-3">
<div>
<h3 className="text-[16px] font-bold text-[#272727]" style={FONT_MONT}>
{loc.name}
</h3>
<p className="mt-1 text-[13px] text-[#555]" style={FONT_POPPINS}>
{loc.description}
</p>
</div>
<button
className="flex h-9 w-9 flex-none items-center justify-center rounded-full text-white transition-opacity hover:opacity-80"
style={{ background: '#f28b4a' }}
aria-label={`Детальніше про ${loc.name}`}
>
<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>
</button>
</div>
</div>
))}
</div>
{/* Mobile: scroll carousel */}
<div
className="flex gap-4 overflow-x-auto pb-4 sm:hidden"
style={{ scrollSnapType: 'x mandatory' }}
>
{PACKAGE_LOCATIONS.map((loc) => (
<div
key={loc.name}
className="flex w-[260px] flex-none flex-col gap-3 rounded-[20px] p-5 shadow-[0_4px_30px_rgba(242,139,74,0.25)]"
style={{ background: '#fff', scrollSnapAlign: 'start' }}
>
<div
className="h-[130px] w-full rounded-[14px]"
style={{ background: '#e8f5dc' }}
aria-hidden="true"
/>
<div className="flex items-start justify-between gap-3">
<div>
<h3 className="text-[15px] font-bold text-[#272727]" style={FONT_MONT}>
{loc.name}
</h3>
<p className="mt-1 text-[12px] text-[#555]" style={FONT_POPPINS}>
{loc.description}
</p>
</div>
<button
className="flex h-8 w-8 flex-none items-center justify-center rounded-full text-white"
style={{ background: '#f28b4a' }}
aria-label={`Детальніше про ${loc.name}`}
>
<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>
</button>
</div>
</div>
))}
</div>
</div>
</section>
{/* ── 3. ЧОМУ ВАРТО ВІДВІДАТИ ───────────────────────────────────── */}
<DyvoLisWhyVisit title="Чому варто обрати Шуміленд" items={WHY_ITEMS} />
{/* ── 4. WORKING HOURS BANNER ───────────────────────────────────── */}
<section
className="py-10"
style={{
background: 'linear-gradient(90deg, #f28b4a 0%, #fdcf54 55%, #f28b4a 100%)',
}}
>
<div className="mx-auto flex max-w-[1204px] flex-col items-center gap-2 px-6 text-center lg:px-8">
<p
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}
>
п&#8217;ятниця&thinsp;&mdash;&thinsp;субота&thinsp;&mdash;&thinsp;неділя з 11:00 до
20:00
</p>
</div>
</section>
{/* ── 5. PRICING SECTION ────────────────────────────────────────── */}
<section className="rounded-t-[40px] py-16" style={{ background: '#396817' }}>
<div className="mx-auto max-w-[1204px] px-6 lg:px-8">
<h2
className="mb-10 text-[24px] font-black text-white uppercase lg:text-[32px]"
style={FONT_MONT}
>
ВАРТІСТЬ КВИТКІВ:
</h2>
<div className="grid grid-cols-1 gap-5 sm:grid-cols-2 lg:grid-cols-3">
{packages.length > 0
? packages.map((pkg) => (
<div
key={pkg.id}
className="flex flex-col gap-5 rounded-[20px] p-7 shadow-[0_4px_30px_rgba(242,139,74,0.25)]"
style={{ background: '#fdf2e8' }}
>
{pkg.featured && pkg.badge && (
<span
className="self-start rounded-full bg-[#f28b4a] px-3 py-1 text-[11px] font-bold text-white uppercase"
style={FONT_MONT}
>
{pkg.badge}
</span>
)}
<div>
<p
className="text-[42px] leading-none font-black text-[#272727]"
style={FONT_MONT}
>
{pkg.priceLabel ?? formatPrice(pkg.price)}{' '}
<span className="text-[22px]">{pkg.currency ?? '₴'}</span>
</p>
<h3 className="mt-1 text-[18px] font-bold text-[#396817]" style={FONT_MONT}>
{pkg.name}
</h3>
</div>
{pkg.features && pkg.features.length > 0 && (
<ul className="flex flex-col gap-2">
{pkg.features.map((f: { id?: string | null; text: string }) => (
<li
key={f.id}
className="flex items-center gap-2 text-[13px] text-[#555]"
style={FONT_POPPINS}
>
<span className="text-[#f28b4a]"></span> {f.text}
</li>
))}
</ul>
)}
<a
href="#order-form"
className="mt-auto flex items-center justify-center rounded-[56px] py-3 text-[15px] font-bold text-[#1a1a1a] transition-opacity hover:opacity-90"
style={{
...FONT_MONT,
background: 'linear-gradient(90deg, #f28b4a 0%, #fdcf54 55%, #f28b4a 100%)',
}}
>
Купити квиток
</a>
</div>
))
: /* placeholder cards when CMS is empty */
[1, 2, 3].map((n) => (
<div
key={n}
className="flex flex-col gap-5 rounded-[20px] p-7 shadow-[0_4px_30px_rgba(242,139,74,0.25)]"
style={{ background: '#fdf2e8' }}
>
<div>
<p
className="text-[42px] leading-none font-black text-[#272727]"
style={FONT_MONT}
>
</p>
<h3 className="mt-1 text-[18px] font-bold text-[#396817]" style={FONT_MONT}>
Пакет {n}
</h3>
</div>
<a
href="#order-form"
className="mt-auto flex items-center justify-center rounded-[56px] py-3 text-[15px] font-bold text-[#1a1a1a] transition-opacity hover:opacity-90"
style={{
...FONT_MONT,
background: 'linear-gradient(90deg, #f28b4a 0%, #fdcf54 55%, #f28b4a 100%)',
}}
>
Купити квиток
</a>
</div>
))}
</div>
{/* Free inclusions note */}
<p className="mt-8 text-[14px] leading-relaxed text-white/80" style={FONT_POPPINS}>
<span className="font-bold text-white">Безкоштовно:</span> До 3 дорослих (батьки та інші
супроводжуючі), Діти до 3 років, Вхід по запрошеннях для іменинника
</p>
</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">
<div className="rounded-[24px] bg-[#2d5414] p-8 lg:p-10">
<h2 className="mb-2 text-[28px] font-bold text-white" style={FONT_MONT}>
{pageData?.formTitle ?? 'Замовити святкування'}
</h2>
<p className="mb-8 text-[15px] text-white/70" style={FONT_POPPINS}>
{pageData?.formSubtitle ??
"Залиште заявку і наш менеджер зв'яжеться з вами протягом 30 хвилин"}
</p>
{pageData?.form && typeof pageData.form === 'object' ? (
<FormBlock
form={pageData.form as unknown as FormBlockData}
submitLabel="Замовити святкування"
/>
) : (
<BirthdayBookingForm defaultPackage={defaultPackage} />
)}
</div>
</div>
</section>
<RefreshRouteOnSave />
</div>
)

View file

@ -1,21 +1,12 @@
import type { Metadata } from 'next'
import { getPayload } from 'payload'
import configPromise from '@payload-config'
import { PageHero } from '@/components/ui/PageHero'
import { GroupRequestForm } from '@/components/forms/GroupRequestForm'
import { FormBlock, type FormData as FormBlockData } from '@/components/forms/FormBlock'
import { RefreshRouteOnSave } from '@/components/cms/RefreshRouteOnSave'
export const revalidate = 60
interface Group {
icon?: string | null
title: string
description: string
minPeople: string
discount: string
}
async function getGroupVisitsData() {
try {
const payload = await getPayload({ config: configPromise })
@ -38,59 +29,223 @@ export async function generateMetadata(): Promise<Metadata> {
export default async function GroupVisitsPage() {
const data = await getGroupVisitsData()
const heroTitle = data?.heroTitle ?? 'Групові відвідування'
const heroSubtitle =
data?.heroSubtitle ??
'Спеціальні умови для організованих груп. Мінімум 10 осіб — максимум вражень.'
const formTitle = data?.formTitle ?? 'Подати заявку на групове відвідування'
const formSubtitle =
data?.formSubtitle ??
'Вкажіть кількість учасників та бажану дату — менеджер зателефонує і погодить деталі.'
const groups = (data?.groups ?? []) as Group[]
return (
<div className="min-h-screen bg-[#f1fbeb]">
<PageHero title={heroTitle} subtitle={heroSubtitle} />
{/* 1. Hero */}
<section
className="relative flex min-h-[340px] items-center justify-center overflow-hidden md:min-h-[500px]"
style={{
backgroundImage: "url('/images/page-hero-default.webp')",
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)',
}}
>
<h1
className="text-[32px] leading-tight font-black tracking-widest text-[#1a1a1a] uppercase md:text-[48px]"
style={{ fontFamily: 'var(--font-montserrat, Montserrat), sans-serif' }}
>
ГРУПОВІ ВІЗИТИ
</h1>
<p
className="mt-2 text-[15px] font-medium text-[#1a1a1a] md:text-[18px]"
style={{ fontFamily: 'var(--font-montserrat, Montserrat), sans-serif' }}
>
{data?.heroSubtitle ?? 'Спеціальна пропозиція для садочків та шкіл'}
</p>
</div>
</div>
</section>
<div className="mx-auto max-w-[1204px] px-8 py-16">
{groups.length > 0 && (
<div className="mb-16 grid grid-cols-1 gap-6 md:grid-cols-3">
{groups.map((g) => (
<div
key={g.title}
className="flex flex-col gap-4 rounded-[24px] bg-[#396817] p-8 shadow-[0_4px_60px_0_rgba(57,104,23,0.15)]"
>
<span className="text-[40px]">{g.icon}</span>
<h2
className="text-[22px] font-bold text-white"
style={{ fontFamily: 'var(--font-montserrat, Montserrat), sans-serif' }}
>
{g.title}
</h2>
{/* 2. Green description band */}
<section className="bg-[#396817] px-4 py-10 md:px-8">
<div className="mx-auto max-w-[860px] text-center">
<p
className="mb-8 text-[16px] leading-relaxed text-white md:text-[18px]"
style={{ fontFamily: 'var(--font-montserrat, Montserrat), sans-serif' }}
>
{}
{(data as any)?.heroDescription ??
'Шукаєте ідеальне місце для групового виїзду класу чи садочка? Або яскраву локацію для фотосесії? Хочете, щоб дитячий випускний альбом був дійсно унікальним? Запрошуємо провести цей захопливий і незабутній день на казковій локації.'}
</p>
<a
href="#order-form"
className="inline-block w-full rounded-[12px] px-10 py-4 text-[17px] font-bold text-[#1a1a1a] transition-opacity hover:opacity-90 md:w-auto"
style={{
background: 'linear-gradient(90deg, #f28b4a, #fdcf54, #f28b4a)',
fontFamily: 'var(--font-montserrat, Montserrat), sans-serif',
}}
>
Забронювати пригоду
</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={{ fontFamily: 'var(--font-montserrat, Montserrat), sans-serif' }}
>
На дітлах чекає подорож ДинопарКом та ДивоЛісом. Це активне дозвілля на свіжому повітрі
та справжні казкові пригоди, де кожен стане героєм власної історії.
</p>
{/* Tilted image placeholders */}
<div className="relative flex h-[280px] items-center justify-center">
<div className="absolute top-[5%] left-[5%] h-[200px] w-[60%] rotate-[-2deg] rounded-[16px] bg-[#c8e6a0] shadow-md" />
<div className="absolute right-[5%] bottom-[5%] h-[200px] w-[60%] rotate-3 rounded-[16px] bg-[#c8e6a0] shadow-md" />
</div>
</div>
</section>
{/* 4. Amenity cards grid */}
<section className="bg-[#f1fbeb] px-4 pb-14 md:px-8">
<div className="mx-auto max-w-[1100px]">
<h2
className="mb-8 text-center text-[22px] font-black tracking-wide text-[#396817] uppercase md:text-[28px]"
style={{ fontFamily: 'var(--font-montserrat, Montserrat), sans-serif' }}
>
МИ ПОДБАЛИ ПРО ЗАТИШОК І КОМФОРТ
</h2>
<div className="grid grid-cols-2 gap-4 md:grid-cols-2 lg:grid-cols-4">
{[
{ label: '2 локації без обмежень у часі', hasPhoto: true },
{ label: 'Вбиральні та кафе на території', hasPhoto: true },
{ label: 'Укриття поруч', hasPhoto: false },
{ label: 'Огороджено забором, є охорона', hasPhoto: true },
].map((item) => (
<div key={item.label} className="overflow-hidden rounded-[16px] bg-white shadow-md">
{item.hasPhoto && <div className="h-[120px] bg-[#c8e6a0]" />}
<p
className="text-[14px] leading-relaxed text-white/70"
className="p-4 text-[14px] leading-snug font-semibold text-[#1a1a1a]"
style={{ fontFamily: 'var(--font-montserrat, Montserrat), sans-serif' }}
>
{g.description}
{item.label}
</p>
<div className="mt-auto flex gap-4 border-t border-white/10 pt-4">
<div>
<p className="text-[12px] text-white/50">Від</p>
<p className="text-[16px] font-bold text-white">{g.minPeople}</p>
</div>
<div>
<p className="text-[12px] text-white/50">Знижка</p>
<p className="text-[16px] font-bold text-[#f28b4a]">{g.discount}</p>
</div>
</div>
</div>
))}
</div>
)}
</div>
</section>
<div id="order-form" className="rounded-[24px] bg-[#396817] p-10">
{/* 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={{ fontFamily: 'var(--font-montserrat, Montserrat), sans-serif' }}
>
ЧАС РОБОТИ
</p>
<p
className="mt-1 text-[18px] font-black text-[#1a1a1a] md:text-[22px]"
style={{ fontFamily: 'var(--font-montserrat, Montserrat), sans-serif' }}
>
п&apos;ятниця-субота-неділя з 11:00 до 20:00
</p>
</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-2 text-[28px] font-bold text-white"
className="mb-8 text-center text-[22px] font-black tracking-wide text-white uppercase md:text-[28px]"
style={{ fontFamily: 'var(--font-montserrat, Montserrat), sans-serif' }}
>
ВАРТІСТЬ ГРУПОВОГО ВІЗИТУ:
</h2>
<div className="mb-8 rounded-[20px] bg-[#fdf2e8] px-8 py-10 text-center shadow-lg">
<p
className="mb-3 text-[12px] font-bold tracking-widest text-[#396817] uppercase"
style={{ fontFamily: 'var(--font-montserrat, Montserrat), sans-serif' }}
>
СПЕЦІАЛЬНА ЦІНА ДЛЯ ГРУП
</p>
<p
className="text-[72px] leading-none font-black text-[#1a1a1a] md:text-[96px]"
style={{ fontFamily: 'var(--font-montserrat, Montserrat), sans-serif' }}
>
350 грн
</p>
<p
className="mt-1 text-[16px] font-medium text-[#396817]"
style={{ fontFamily: 'var(--font-montserrat, Montserrat), sans-serif' }}
>
особа
</p>
<p
className="mx-auto mt-4 max-w-[420px] text-[14px] leading-relaxed text-[#1a1a1a]"
style={{ fontFamily: 'var(--font-montserrat, Montserrat), sans-serif' }}
>
Вхід для двох дорослих, що супроводжують дітей, безкоштовний.
</p>
<p
className="mt-3 text-[13px] font-semibold text-[#f28b4a]"
style={{ fontFamily: 'var(--font-montserrat, Montserrat), sans-serif' }}
>
Пропозиція для груп від 10 людей
</p>
<a
href="#order-form"
className="mt-6 inline-block rounded-[12px] px-10 py-4 text-[16px] font-bold text-[#1a1a1a] transition-opacity hover:opacity-90"
style={{
background: 'linear-gradient(90deg, #f28b4a, #fdcf54, #f28b4a)',
fontFamily: 'var(--font-montserrat, Montserrat), sans-serif',
}}
>
Купити квиток
</a>
</div>
<p
className="text-center text-[15px] leading-relaxed text-white/80"
style={{ fontFamily: 'var(--font-montserrat, Montserrat), sans-serif' }}
>
У вартість входить відвідування ДинопаркаTM та ДивоЛісу.
<br />
Час перебування на локаціях необмежений.
</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={{ fontFamily: 'var(--font-montserrat, Montserrat), sans-serif' }}
>
Хочете перетворити візит на справжню маленьку експедицію з розповідями або замовити
екскурсію з розповідями про динозаврів? Ми залюбки це організуємо! Телефонуйте нам і
ми все підготуємо та розрахуємо індивідуально для вашої групи.
</p>
{/* Tilted image placeholders */}
<div className="relative flex h-[280px] items-center justify-center">
<div className="absolute top-[5%] left-[5%] h-[200px] w-[60%] rotate-[-2deg] rounded-[16px] bg-[#c8e6a0] shadow-md" />
<div className="absolute right-[5%] bottom-[5%] h-[200px] w-[60%] rotate-3 rounded-[16px] bg-[#c8e6a0] shadow-md" />
</div>
</div>
</section>
{/* 8. Order form */}
<section id="order-form" className="bg-[#f1fbeb] px-4 pb-16 md:px-8">
<div className="mx-auto max-w-[860px] rounded-[24px] bg-[#396817] p-8 md:p-12">
<h2
className="mb-2 text-[24px] font-bold text-white md:text-[28px]"
style={{ fontFamily: 'var(--font-montserrat, Montserrat), sans-serif' }}
>
{formTitle}
@ -110,7 +265,8 @@ export default async function GroupVisitsPage() {
<GroupRequestForm />
)}
</div>
</div>
</section>
<RefreshRouteOnSave />
</div>
)

View file

@ -0,0 +1,118 @@
import type { Metadata } from 'next'
import Link from 'next/link'
export const metadata: Metadata = {
title: 'Дякуємо за покупку — Шуміленд',
description: 'Ваші квитки відправлені на email.',
}
const FONT_MONT = { fontFamily: 'var(--font-montserrat, Montserrat), sans-serif' }
export default function DyakuiemoPage() {
return (
<main
className="relative flex min-h-screen items-center justify-center overflow-hidden px-4 py-20"
style={{
background: 'linear-gradient(135deg, #1e3610 0%, #2d5414 40%, #396817 70%, #1e3610 100%)',
}}
>
{/* Background texture */}
<div
className="pointer-events-none absolute inset-0 opacity-20"
aria-hidden="true"
style={{
backgroundImage: `url('/images/page-hero-default.webp')`,
backgroundSize: 'cover',
backgroundPosition: 'center',
mixBlendMode: 'luminosity',
}}
/>
{/* 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' }}
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">
<h1
className="mb-4 text-[36px] leading-[1.1] font-black text-[#1a1a1a] uppercase lg:text-[52px]"
style={FONT_MONT}
>
Дякуємо
<br />
за покупку
</h1>
<p
className="mb-8 text-[16px] leading-[1.5] font-bold text-[#1a1a1a]/80 lg:text-[20px]"
style={FONT_MONT}
>
Ваші квитки відправлені на 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 }}
>
Купити ще квиток
</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 */}
<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"
style={FONT_MONT}
>
На головну
</Link>
</main>
)
}

View file

@ -0,0 +1,141 @@
import type { Metadata } from 'next'
import { getPayload } from 'payload'
import configPromise from '@payload-config'
import { DinoHero } from '@/components/sections/DinoHero'
import { DinoWheel } from '@/components/sections/DinoWheel'
import { DinoGallery } from '@/components/sections/DinoGallery'
import { DinoActivities } from '@/components/sections/DinoActivities'
import { DinoWhyVisit } from '@/components/sections/DinoWhyVisit'
import { DinoTickets } from '@/components/sections/DinoTickets'
import { RefreshRouteOnSave } from '@/components/cms/RefreshRouteOnSave'
import type { Media } from '@/payload-types'
export const revalidate = 60
interface DinoPageData {
heroTitle?: string
heroDescription?: string
heroStat?: string
heroStatLabel?: string
heroImage?: number | Media | null
heroFeatures?: { text: string; id?: string }[]
dinosaurs?: {
name: string
epoch?: string | null
length?: string | null
weight?: string | null
image?: number | Media | null
thumbnailImage?: number | Media | null
id?: string
}[]
galleryImages?: { image?: number | Media | null; id?: string }[]
activitiesTitle?: string
activitiesDescription?: string
activities?: {
name: string
price?: string | null
description?: string | null
image?: number | Media | null
href?: string | null
id?: string
}[]
whyVisitTitle?: string
whyVisitItems?: { title: string; description: string; id?: string }[]
reviewVideos?: { src: string; poster?: string | null; label?: string | null; id?: string }[]
workingHours?: string
comboDescription?: string
meta?: { title?: string; description?: string }
}
function mediaUrl(m: number | Media | null | undefined): string | null {
if (!m || typeof m === 'number') return null
return (m as Media).url ?? null
}
export async function generateMetadata(): Promise<Metadata> {
try {
const payload = await getPayload({ config: configPromise })
const raw = await payload.findGlobal({ slug: 'dinosaur-page' as never, overrideAccess: true })
const data = raw as unknown as DinoPageData
return {
title: data.meta?.title ?? 'Динопарк — Шуміленд',
description: data.meta?.description ?? data.heroDescription ?? 'Динопарк у Шуміленді',
}
} catch {
return { title: 'Динопарк — Шуміленд' }
}
}
export default async function DinosaurPage() {
let data: DinoPageData = {}
try {
const payload = await getPayload({ config: configPromise })
const raw = await payload.findGlobal({ slug: 'dinosaur-page' as never, overrideAccess: true })
data = raw as unknown as DinoPageData
} catch {
// DB unavailable at build time — render with defaults; ISR will hydrate at runtime
}
const heroImageUrl = mediaUrl(data.heroImage)
const features = data.heroFeatures?.map((f) => f.text).filter(Boolean) as string[] | undefined
const dinos = data.dinosaurs?.map((d) => ({
name: d.name,
epoch: d.epoch ?? null,
length: d.length ?? null,
weight: d.weight ?? null,
imageUrl: mediaUrl(d.image),
thumbnailUrl: mediaUrl(d.thumbnailImage),
}))
const galleryImages = data.galleryImages
?.map((g) => mediaUrl(g.image))
.filter((u): u is string => u !== null)
const activities = data.activities?.map((a) => ({
name: a.name,
price: a.price ?? null,
description: a.description ?? null,
imageUrl: mediaUrl(a.image),
href: a.href ?? '#tickets',
}))
const whyVisitItems = data.whyVisitItems?.map((w) => ({
title: w.title,
description: w.description,
}))
const reviewVideos = data.reviewVideos?.map((v) => ({
src: v.src,
poster: v.poster ?? null,
label: v.label ?? null,
}))
return (
<div className="bg-[#f1fbeb]">
<DinoHero
title={data.heroTitle}
description={data.heroDescription}
stat={data.heroStat}
statLabel={data.heroStatLabel}
features={features && features.length > 0 ? features : undefined}
heroImageUrl={heroImageUrl}
/>
<DinoWheel dinos={dinos && dinos.length > 0 ? dinos : undefined} />
{galleryImages && galleryImages.length > 0 && <DinoGallery images={galleryImages} />}
<DinoActivities
title={data.activitiesTitle}
description={data.activitiesDescription}
activities={activities && activities.length > 0 ? activities : undefined}
/>
<DinoWhyVisit
title={data.whyVisitTitle}
items={whyVisitItems && whyVisitItems.length > 0 ? whyVisitItems : undefined}
reviewVideos={reviewVideos && reviewVideos.length > 0 ? reviewVideos : undefined}
/>
<DinoTickets workingHours={data.workingHours} comboDescription={data.comboDescription} />
<RefreshRouteOnSave />
</div>
)
}

View file

@ -911,5 +911,72 @@ export async function POST(req: NextRequest) {
results.push('Forms already exist')
}
// ── DinosaurPage global ──────────────────────────────────────────────────
const existingDino = await payload.findGlobal({
slug: 'dinosaur-page' as never,
overrideAccess: true,
})
const dinoNeedsUpdate = !(existingDino as unknown as Record<string, unknown>)?.['heroStat']
if (dinoNeedsUpdate) {
await payload.updateGlobal({
slug: 'dinosaur-page' as never,
data: {
heroTitle: 'Динопарк — портал у світ динозаврів',
heroDescription:
'Великі динозаври, що рухаються та гарчать, справжнє роздоволлє, цікаві екскурсії та динородео — тут є все, щоб ваша дитина не нудьгувала.',
heroStat: '26',
heroStatLabel: 'унікальних експонатів',
heroFeatures: [
{ text: 'Повнорозмірні анімовані динозаври' },
{ text: 'Реалістичні рухи та звуки' },
],
dinosaurs: [
{ 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 т' },
],
activitiesTitle: 'Додаткові розваги у динопарку',
activitiesDescription:
'Хочете дізнатись ще більше про динозаврів? Замовте екскурсію з гідом, поринь у світ палеонтологічних розкопок або підкорюй справжнього динозавра!',
activities: [
{ name: 'Звичайна екскурсія', price: '150 грн', href: '#tickets' },
{ name: 'Палеонтологічна екскурсія', price: '300 грн', href: '#tickets' },
{ name: 'ДиноРодео', price: '50 грн', href: '#tickets' },
],
whyVisitTitle: 'Чому варто відвідати динопарк',
whyVisitItems: [
{
title: 'Навчання через гру',
description:
'Дітки дізнаються про стародавніх тварин через захопливі ігри та інтерактивні вправи з гідом.',
},
{
title: 'Дитячі очі, що палають захватом',
description:
'Реалістичні рухи та звуки динозаврів створюють ефект повного занурення — дитина точно не забуде цього дня.',
},
{
title: 'Неймовірні фотографії',
description:
'Сфотографуйтесь поруч із улюбленим динозавром або зробіть фото з екскурсоводом — тепла згадка для всієї родини.',
},
],
workingHours: "п'ятниця-субота-неділя з 11:00 до 20:00",
comboDescription:
'Динопарк + Диволіс із казковими топіарними фігурами + Дзеркальний лабіринт',
} as never,
overrideAccess: true,
})
results.push('Seeded dinosaur-page global')
} else {
results.push('dinosaur-page: already has content, skipping')
}
return NextResponse.json({ ok: true, results })
}

View file

@ -0,0 +1,127 @@
/* eslint-disable @next/next/no-img-element */
const FONT_MONT = { fontFamily: 'var(--font-montserrat, Montserrat), sans-serif' }
interface Activity {
name: string
price?: string | null
description?: string | null
imageUrl?: string | null
href?: string | null
}
interface DinoActivitiesProps {
title?: string
description?: string
activities?: Activity[]
}
const DEFAULT_ACTIVITIES: Activity[] = [
{ name: 'Звичайна екскурсія', price: '150 грн', href: '#tickets' },
{ name: 'Палеонтологічна екскурсія', price: '300 грн', href: '#tickets' },
{ name: 'ДиноРодео', price: '50 грн', href: '#tickets' },
]
export function DinoActivities({
title = 'Додаткові розваги у динопарку',
description = 'Хочете дізнатись ще більше про динозаврів? Замовте екскурсію з гідом, поринь у світ палеонтологічних розкопок або підкорюй справжнього динозавра!',
activities = DEFAULT_ACTIVITIES,
}: DinoActivitiesProps) {
if (!activities.length) return null
return (
<section className="py-14 lg:py-20" style={{ background: '#f1fbeb' }}>
<div className="mx-auto max-w-[1204px] px-8">
<h2
className="mb-3 text-[24px] font-bold text-[#272727] uppercase md:text-[32px]"
style={FONT_MONT}
>
{title}
</h2>
{description && (
<p
className="mb-10 max-w-[700px] text-[16px] leading-[1.6] text-[#444] lg:text-[18px]"
style={{ fontFamily: 'var(--font-poppins, Poppins), sans-serif' }}
>
{description}
</p>
)}
<div className="grid grid-cols-1 gap-6 sm:grid-cols-2 lg:grid-cols-3">
{activities.map((act, i) => (
<ActivityCard key={i} activity={act} />
))}
</div>
</div>
</section>
)
}
function ActivityCard({ activity }: { activity: Activity }) {
const href = activity.href ?? '#'
return (
<div className="group flex flex-col overflow-hidden rounded-[20px] bg-white shadow-[0_4px_30px_rgba(57,104,23,0.14)] transition-shadow hover:shadow-[0_8px_40px_rgba(57,104,23,0.22)]">
{/* Photo area */}
<div className="relative aspect-[4/3] overflow-hidden bg-[#e8f5dc]">
{activity.imageUrl ? (
<img
src={activity.imageUrl}
alt={activity.name}
className="h-full w-full object-cover transition-transform duration-500 group-hover:scale-105"
loading="lazy"
/>
) : (
<div className="flex h-full items-center justify-center">
<span style={{ fontSize: 72 }} aria-hidden="true">
🦕
</span>
</div>
)}
{/* Price badge */}
{activity.price && (
<div
className="absolute top-3 right-3 rounded-[12px] px-3 py-1.5 text-[14px] font-bold text-white"
style={{ background: '#f28b4a', ...FONT_MONT }}
>
{activity.price}
</div>
)}
</div>
{/* Content */}
<div className="flex flex-1 flex-col gap-3 p-5">
<h3 className="text-[18px] leading-[1.3] font-bold text-[#272727]" style={FONT_MONT}>
{activity.name}
</h3>
{activity.description && (
<p
className="flex-1 text-[14px] leading-[1.6] text-[#555]"
style={{ fontFamily: 'var(--font-poppins, Poppins), sans-serif' }}
>
{activity.description}
</p>
)}
<a
href={href}
className="mt-auto inline-flex items-center justify-center gap-2 rounded-[56px] px-6 py-[10px] text-[14px] font-bold text-[#1a1a1a] transition-opacity hover:opacity-80"
style={{
background: 'linear-gradient(90deg, #f28b4a 0%, #fdcf54 55%, #f28b4a 100%)',
backgroundSize: '200% auto',
...FONT_MONT,
}}
>
Замовити екскурсію
<svg width="14" height="14" viewBox="0 0 14 14" fill="none" aria-hidden="true">
<path
d="M2 7h10M8 3l4 4-4 4"
stroke="currentColor"
strokeWidth="1.5"
strokeLinecap="round"
strokeLinejoin="round"
/>
</svg>
</a>
</div>
</div>
)
}

View file

@ -0,0 +1,132 @@
'use client'
/* eslint-disable @next/next/no-img-element */
import { useState, useEffect } from 'react'
interface DinoGalleryProps {
images?: string[]
}
export function DinoGallery({ images }: DinoGalleryProps) {
const photos = images && images.length > 0 ? images : []
const [active, setActive] = useState(0)
const n = photos.length
useEffect(() => {
if (n <= 1) return
const t = setInterval(() => setActive((p) => (p + 1) % n), 3500)
return () => clearInterval(t)
}, [n])
if (n === 0) return null
function getOffset(i: number): number {
const d = (((i - active) % n) + n) % n
return d > n / 2 ? d - n : d
}
return (
<section className="overflow-hidden py-14 lg:py-20" style={{ background: '#f1fbeb' }}>
<div
className="relative mx-auto h-[260px] max-w-[1204px] lg:h-[420px]"
style={{ perspective: '1100px' }}
>
{photos.map((src, i) => {
const d = getOffset(i)
const absD = Math.abs(d)
const sign = d >= 0 ? 1 : -1
const cfg =
absD === 0
? { tx: 0, ry: 0, scale: 1, opacity: 1, z: 20 }
: absD === 1
? { tx: sign * 270, ry: -sign * 48, scale: 0.82, opacity: 0.6, z: 10 }
: { tx: sign * 450, ry: -sign * 62, scale: 0.6, opacity: 0.16, z: 1 }
return (
<div
key={i}
role={d !== 0 ? 'button' : undefined}
tabIndex={d !== 0 ? 0 : undefined}
aria-label={d !== 0 ? `Показати фото ${i + 1}` : undefined}
onClick={() => d !== 0 && setActive(i)}
onKeyDown={(e) => e.key === 'Enter' && d !== 0 && setActive(i)}
style={{
position: 'absolute',
top: '50%',
left: '50%',
width: 'clamp(200px, 30vw, 380px)',
aspectRatio: '4/3',
transform: `translate(calc(-50% + ${cfg.tx}px), -50%) rotateY(${cfg.ry}deg) scale(${cfg.scale})`,
opacity: cfg.opacity,
zIndex: cfg.z,
transition: 'transform 0.65s cubic-bezier(0.4, 0, 0.2, 1), opacity 0.65s ease',
borderRadius: '16px',
overflow: 'hidden',
cursor: d !== 0 ? 'pointer' : 'default',
boxShadow:
d === 0 ? '0 20px 60px rgba(57,104,23,0.28)' : '0 8px 24px rgba(0,0,0,0.14)',
}}
>
<img
src={src}
alt={d === 0 ? `Динопарк — фото ${i + 1}` : ''}
aria-hidden={d !== 0 ? true : undefined}
style={{ width: '100%', height: '100%', objectFit: 'cover' }}
loading="lazy"
/>
</div>
)
})}
</div>
{/* Navigation */}
<div className="mt-10 flex items-center justify-center gap-6">
<button
onClick={() => setActive((p) => (p - 1 + n) % n)}
className="flex h-10 w-10 items-center justify-center rounded-full bg-[#396817] text-white transition-all hover:scale-110 hover:bg-[#2d5414]"
aria-label="Попереднє фото"
>
<svg width="18" height="18" viewBox="0 0 18 18" fill="none" aria-hidden="true">
<path
d="M11 4L6 9L11 14"
stroke="white"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
/>
</svg>
</button>
<div className="flex gap-2.5">
{photos.map((_, i) => (
<button
key={i}
onClick={() => setActive(i)}
className="h-2.5 w-2.5 rounded-full transition-all duration-300"
style={{ background: i === active ? '#396817' : '#b8d8a0' }}
aria-label={`Фото ${i + 1}`}
aria-current={i === active ? true : undefined}
/>
))}
</div>
<button
onClick={() => setActive((p) => (p + 1) % n)}
className="flex h-10 w-10 items-center justify-center rounded-full bg-[#396817] text-white transition-all hover:scale-110 hover:bg-[#2d5414]"
aria-label="Наступне фото"
>
<svg width="18" height="18" viewBox="0 0 18 18" fill="none" aria-hidden="true">
<path
d="M7 4L12 9L7 14"
stroke="white"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
/>
</svg>
</button>
</div>
</section>
)
}

View file

@ -0,0 +1,170 @@
/* eslint-disable @next/next/no-img-element */
import { BtnPrimary } from '@/components/ui/BtnPrimary'
const FONT_MONT = { fontFamily: 'var(--font-montserrat, Montserrat), sans-serif' }
const DEFAULT_FEATURES = ['Повнорозмірні анімовані динозавра', 'Реалістичні рухи та звуки']
interface DinoHeroProps {
title?: string
description?: string
stat?: string
statLabel?: string
features?: string[]
heroImageUrl?: string | null
}
export function DinoHero({
title = 'Динопарк — портал у світ динозаврів',
description = 'Великі динозаври, що рухаються та гарчать, справжнє роздоволлє, цікаві екскурсії та динородео — тут є все, щоб ваша дитина не нудьгувала.',
stat = '26',
statLabel = 'унікальних експонатів',
features = DEFAULT_FEATURES,
heroImageUrl,
}: DinoHeroProps) {
return (
<section className="relative overflow-hidden" style={{ background: '#f1fbeb' }}>
{/* Left column */}
<div className="mx-auto max-w-[1204px] px-8">
<div className="min-h-[600px] pt-12 pb-16 lg:min-h-[960px] lg:pt-[98px] lg:pb-[60px]">
<div className="relative z-10 flex flex-col gap-10 lg:w-[608px] lg:gap-[60px]">
{/* Text */}
<div className="flex flex-col gap-6 lg:gap-[38px]">
<h1
className="text-[36px] leading-[1.15] font-bold text-[#272727] uppercase lg:text-[64px]"
style={FONT_MONT}
>
{title}
</h1>
<p
className="text-[16px] leading-[1.5] font-medium text-[#272727] lg:text-[24px]"
style={FONT_MONT}
>
{description}
</p>
<BtnPrimary href="/kvytky?category=dyno" className="self-start">
Купити квиток
</BtnPrimary>
</div>
{/* Stat badge + feature list */}
<div className="flex flex-col gap-4 lg:gap-[41px]">
<div className="relative flex items-center">
<div
className="z-10 flex flex-none items-center justify-center rounded-full text-white"
style={{
width: 'clamp(80px, 11.11vw, 160px)',
height: 'clamp(80px, 11.11vw, 160px)',
background: '#396817',
border: 'clamp(10px, 1.39vw, 20px) solid #fdf2e8',
marginRight: 'clamp(-101px, -7.01vw, -40px)',
fontFamily: 'Inter, sans-serif',
fontWeight: 800,
lineHeight: 1,
fontSize: 'clamp(20px, 3.47vw, 50px)',
}}
aria-hidden="true"
>
{stat}
</div>
<div
className="flex-1 rounded-[20px] text-[16px] leading-[1.3] font-medium text-white lg:text-[24px]"
style={{
background: '#396817',
...FONT_MONT,
paddingTop: 'clamp(24px, 2.85vw, 41px)',
paddingBottom: 'clamp(24px, 2.85vw, 41px)',
paddingLeft: 'clamp(56px, 10.28vw, 148px)',
paddingRight: '24px',
}}
>
{statLabel}
</div>
</div>
{features.map((f, i) => (
<div
key={i}
className="rounded-[20px] text-[16px] leading-[1.3] font-medium text-white lg:text-[24px]"
style={{
background: '#396817',
...FONT_MONT,
padding: 'clamp(24px, 3vw, 41px) clamp(32px, 2.7vw, 39px)',
}}
>
{f}
</div>
))}
</div>
</div>
</div>
</div>
{/* Right panel — hero image on yellow circle */}
<div
className="pointer-events-none absolute top-0 right-0 hidden h-full overflow-hidden lg:block"
aria-hidden="true"
style={{ width: '58vw' }}
>
{/* Yellow circle background */}
<div
className="absolute rounded-full"
style={{
width: '130%',
aspectRatio: '1',
background: 'radial-gradient(circle at 40% 40%, #f7d060 0%, #f0b429 70%)',
left: '-15%',
top: '-10%',
}}
/>
{/* Hero dino image */}
{heroImageUrl && (
<img
src={heroImageUrl}
alt="Динозавр динопарку"
className="absolute"
style={{
left: '5%',
top: '-15%',
width: '115%',
height: 'auto',
objectFit: 'contain',
objectPosition: 'bottom',
}}
loading="eager"
/>
)}
{/* Placeholder when no image uploaded yet */}
{!heroImageUrl && (
<div
className="absolute flex items-center justify-center"
style={{ left: '10%', top: '10%', width: '80%', height: '80%' }}
>
<span
style={{ fontSize: 'clamp(120px, 18vw, 260px)', lineHeight: 1, userSelect: 'none' }}
aria-hidden="true"
>
🦖
</span>
</div>
)}
</div>
{/* Mobile hero */}
<div className="relative mx-auto max-w-[420px] px-8 pb-8 lg:hidden" aria-hidden="true">
<div
className="mx-auto flex aspect-square items-center justify-center rounded-full"
style={{ background: 'radial-gradient(circle at 40% 40%, #f7d060 0%, #f0b429 70%)' }}
>
{heroImageUrl ? (
<img src={heroImageUrl} alt="" className="w-full object-contain" loading="eager" />
) : (
<span style={{ fontSize: 100 }} aria-hidden="true">
🦖
</span>
)}
</div>
</div>
</section>
)
}

View file

@ -0,0 +1,204 @@
'use client'
import { useEffect, 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
icon?: string | null
sort: number
}
function TicketCard({ 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)
}
return (
<div
className="flex flex-col rounded-[20px] p-5 shadow-[0_4px_30px_rgba(242,139,74,0.25)]"
style={{ background: '#fdf2e8' }}
>
<div className="flex flex-col gap-2 text-center">
{tariff.icon && <span className="text-[28px]">{tariff.icon}</span>}
<div className="border-t border-[#c8e8b0]" />
<p
className="text-[32px] leading-[1.3] font-black text-[#272727] lg:text-[40px]"
style={FONT_MONT}
>
{tariff.price}
</p>
<p className="text-[14px] leading-[1.5] font-bold text-[#272727]" style={FONT_MONT}>
{tariff.name}
</p>
</div>
<div className="mt-auto flex flex-col gap-3 pt-5">
<div className="flex items-center justify-center gap-3">
<button
onClick={() => setCount((c) => Math.max(1, c - 1))}
aria-label="Зменшити кількість"
className="flex h-9 w-9 items-center justify-center rounded-full text-[20px] font-bold text-white transition-opacity hover:opacity-80"
style={{ background: '#f28b4a' }}
>
</button>
<span
className="min-w-[28px] text-center text-[18px] font-bold text-[#272727]"
style={FONT_MONT}
>
{count}
</span>
<button
onClick={() => setCount((c) => Math.min(10, c + 1))}
aria-label="Збільшити кількість"
className="flex h-9 w-9 items-center justify-center rounded-full text-[20px] font-bold text-white transition-opacity hover:opacity-80"
style={{ background: '#f28b4a' }}
>
+
</button>
</div>
<button
onClick={handleAdd}
className="w-full rounded-[56px] py-[10px] text-[15px] font-bold transition-all"
style={{
...FONT_MONT,
background: added
? '#4caf50'
: 'linear-gradient(90deg, #f28b4a 0%, #fdcf54 55%, #f28b4a 100%)',
color: added ? '#fff' : '#1a1a1a',
backgroundSize: '200% auto',
}}
>
{added ? '✓ Додано' : '+ До кошика'}
</button>
</div>
</div>
)
}
function SkeletonCard() {
return (
<div
className="flex animate-pulse flex-col rounded-[20px] p-5 shadow-[0_4px_30px_rgba(242,139,74,0.25)]"
style={{ background: '#fdf2e8', minHeight: 180 }}
>
<div className="mx-auto mb-3 h-4 w-3/4 rounded bg-[#e8d8c4]" />
<div className="mx-auto mb-2 h-8 w-1/2 rounded bg-[#e8d8c4]" />
<div className="mx-auto mt-auto h-4 w-2/3 rounded bg-[#e8d8c4]" />
</div>
)
}
interface DinoTicketsProps {
workingHours?: string
comboDescription?: string
}
export function DinoTickets({
workingHours = "п'ятниця-субота-неділя з 11:00 до 20:00",
comboDescription = 'Динопарк + Диволіс із казковими топіарними фігурами + Дзеркальний лабіринт',
}: DinoTicketsProps) {
const [dynoTariffs, setDynoTariffs] = useState<Tariff[]>([])
const [comboTariffs, setComboTariffs] = useState<Tariff[]>([])
const [loading, setLoading] = useState(true)
useEffect(() => {
fetch('/api/tickets/tariffs')
.then((r) => r.json())
.then((data: { tariffs?: Tariff[] }) => {
const all = data.tariffs ?? []
setDynoTariffs(all.filter((t) => t.categoryTag === 'dyno'))
setComboTariffs(all.filter((t) => t.categoryTag === 'combo'))
})
.catch(() => {})
.finally(() => setLoading(false))
}, [])
return (
<section id="tickets" className="relative overflow-hidden">
<div
className="absolute inset-0 z-0 rounded-tl-[20px] rounded-tr-[20px]"
style={{ background: '#396817' }}
/>
<div className="relative z-10 mx-auto max-w-[1204px] px-8 pt-0 pb-16">
{/* Working hours banner */}
<div
className="mb-10 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] leading-[1.4] font-bold text-[#272727] uppercase lg:text-[26px]"
style={FONT_MONT}
>
Час роботи
</p>
<p
className="text-[16px] leading-[1.4] font-bold text-[#272727] lg:text-[22px]"
style={FONT_MONT}
>
{workingHours}
</p>
</div>
{/* Single tickets */}
<h3
className="mb-6 text-[22px] leading-[1.4] font-bold text-white uppercase lg:text-[28px]"
style={FONT_MONT}
>
Вартість квитка
</h3>
<div className="mb-10 grid grid-cols-1 gap-4 sm:grid-cols-2">
{loading
? Array.from({ length: 4 }).map((_, i) => <SkeletonCard key={i} />)
: dynoTariffs.map((t) => <TicketCard key={t.id} tariff={t} />)}
</div>
{/* Combo */}
{!loading && comboTariffs.length > 0 && (
<>
<div className="mb-6 flex flex-col gap-2">
<h3
className="text-[22px] leading-[1.4] font-bold text-white uppercase lg:text-[28px]"
style={FONT_MONT}
>
Комбо
</h3>
<p
className="text-[16px] leading-[1.4] font-semibold text-white/80 lg:text-[20px]"
style={FONT_MONT}
>
{comboDescription}
</p>
</div>
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
{comboTariffs.map((t) => (
<TicketCard key={t.id} tariff={t} />
))}
</div>
</>
)}
</div>
</section>
)
}

View file

@ -0,0 +1,295 @@
'use client'
/* eslint-disable @next/next/no-img-element */
import { useState, useEffect, useRef, useCallback } from 'react'
const FONT_MONT = { fontFamily: 'var(--font-montserrat, Montserrat), 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")`
interface DinoSpec {
name: string
epoch?: string | null
length?: string | null
weight?: string | null
imageUrl?: string | null
thumbnailUrl?: string | null
}
interface DinoWheelProps {
dinos?: DinoSpec[]
}
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 т' },
]
const DINO_EMOJIS = ['🦖', '🦕', '🦖', '🦕', '🦖', '🦕', '🦕', '🦖']
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)
}, [])
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)
},
[n, goTo]
)
useEffect(() => {
timer.current = setInterval(() => {
setVisible(false)
setTimeout(() => {
setActive((p) => (p + 1) % n)
setVisible(true)
}, 220)
}, 4000)
return () => {
if (timer.current) clearInterval(timer.current)
}
}, [n])
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 }
}
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]">
<div
className="rounded-[24px] p-8 shadow-[0_8px_40px_rgba(0,0,0,0.3)]"
style={{ background: 'rgba(255,255,255,0.97)' }}
>
<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>
{/* 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 */}
{items.map((dino, i) => {
const { x, y } = getArcPos(i)
const isActive = i === active
return (
<button
key={i}
onClick={() => resetTimer(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',
cursor: 'pointer',
zIndex: isActive ? 10 : 5,
}}
>
{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>
)}
</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>
</div>
</div>
</section>
)
}
function StatRow({ label, value }: { label: string; value: string }) {
return (
<div className="flex items-center justify-between gap-4 rounded-[10px] bg-[#f1fbeb] px-4 py-2.5">
<span
className="text-[13px] font-semibold tracking-wide text-[#396817] uppercase"
style={FONT_MONT}
>
{label}
</span>
<span className="text-[16px] font-bold text-[#272727]" style={FONT_MONT}>
{value}
</span>
</div>
)
}

View file

@ -0,0 +1,298 @@
'use client'
import { useState, useRef, useEffect } from 'react'
const FONT_MONT = { fontFamily: 'var(--font-montserrat, Montserrat), sans-serif' }
interface ReviewVideo {
src: string
poster?: string | null
label?: string | null
}
interface DinoWhyVisitProps {
title?: string
items?: Array<{ title: string; description: string }>
reviewVideos?: ReviewVideo[]
}
const DEFAULT_ITEMS = [
{
title: 'Навчання через гру',
description:
'Дітки дізнаються про стародавніх тварин через захопливі ігри та інтерактивні вправи з гідом.',
},
{
title: 'Дитячі очі, що палають захватом',
description:
'Реалістичні рухи та звуки динозаврів створюють ефект повного занурення — дитина точно не забуде цього дня.',
},
{
title: 'Неймовірні фотографії',
description:
'Сфотографуйтесь поруч із улюбленим динозавром або зробіть фото з екскурсоводом — тепла згадка для всієї родини.',
},
]
export function DinoWhyVisit({
title = 'Чому варто відвідати динопарк',
items = DEFAULT_ITEMS,
reviewVideos,
}: DinoWhyVisitProps) {
const videos = reviewVideos && reviewVideos.length > 0 ? reviewVideos : []
const vn = videos.length
const [openIndex, setOpenIndex] = useState(0)
const [videoActive, setVideoActive] = useState(0)
const [playingIndex, setPlayingIndex] = useState<number | null>(null)
const videoPausedRef = useRef(false)
const accordionTimer = useRef<ReturnType<typeof setInterval> | null>(null)
const videoTimer = useRef<ReturnType<typeof setInterval> | null>(null)
const videoRefs = useRef<(HTMLVideoElement | null)[]>([])
useEffect(() => {
accordionTimer.current = setInterval(() => {
setOpenIndex((prev) => (prev + 1) % items.length)
}, 4000)
return () => {
if (accordionTimer.current) clearInterval(accordionTimer.current)
}
}, [items.length])
useEffect(() => {
if (vn <= 0) return
videoTimer.current = setInterval(() => {
if (!videoPausedRef.current) {
setVideoActive((prev) => (prev + 1) % vn)
}
}, 5000)
return () => {
if (videoTimer.current) clearInterval(videoTimer.current)
}
}, [vn])
function handleItemClick(i: number) {
setOpenIndex(i)
if (accordionTimer.current) clearInterval(accordionTimer.current)
accordionTimer.current = setInterval(() => {
setOpenIndex((prev) => (prev + 1) % items.length)
}, 4000)
}
function handlePlayVideo(i: number) {
if (playingIndex === i) return
if (playingIndex !== null && videoRefs.current[playingIndex]) {
videoRefs.current[playingIndex]!.pause()
videoRefs.current[playingIndex]!.currentTime = 0
}
videoPausedRef.current = true
setPlayingIndex(i)
setVideoActive(i)
setTimeout(() => {
videoRefs.current[i]?.play()
}, 50)
}
function handleVideoNav(i: number) {
if (playingIndex !== null && videoRefs.current[playingIndex]) {
videoRefs.current[playingIndex]!.pause()
videoRefs.current[playingIndex]!.currentTime = 0
}
setPlayingIndex(null)
videoPausedRef.current = false
setVideoActive(i)
}
return (
<section className="py-[20px] md:py-[60px] lg:py-[40px]" style={{ background: '#f1fbeb' }}>
<div className="mx-auto max-w-[1204px] px-8">
<h2
className="mb-[40px] text-[24px] font-bold text-[#272727] uppercase md:mb-[60px] md:text-[32px]"
style={FONT_MONT}
>
{title}
</h2>
<div className="flex flex-col items-start gap-16 lg:flex-row lg:items-start">
{/* Accordion */}
<div className="relative w-full flex-none lg:w-auto">
<div className="absolute top-8 left-0 hidden h-[488px] w-[333px] rounded-[30px] bg-[#396817] lg:block" />
<div className="relative flex flex-col gap-6 lg:ml-[76px] lg:min-h-[560px]">
{items.map((item, i) => {
const isOpen = openIndex === i
return (
<button
key={i}
onClick={() => handleItemClick(i)}
className="flex w-full flex-col gap-2.5 rounded-[10px] bg-[#f1fbeb] px-[50px] py-[20px] text-left shadow-[0_4px_60px_0_rgba(242,139,74,0.25)] transition-all duration-200 lg:w-[628px]"
aria-expanded={isOpen}
>
<div className="flex items-center gap-5">
<span
className="flex-1 text-[20px] leading-tight font-bold text-[#272727]"
style={FONT_MONT}
>
{item.title}
</span>
<svg
width="19"
height="10"
viewBox="0 0 19 10"
fill="none"
className="flex-none transition-transform duration-200"
style={{ transform: isOpen ? 'rotate(180deg)' : 'rotate(0deg)' }}
aria-hidden="true"
>
<path
d="M1 1L9.5 9L18 1"
stroke="#f28b4a"
strokeWidth="2"
strokeLinecap="round"
/>
</svg>
</div>
<div
className={`grid transition-[grid-template-rows] duration-300 ease-out ${isOpen ? 'grid-rows-[1fr]' : 'grid-rows-[0fr]'}`}
>
<div className="overflow-hidden">
<p
className="pt-2 text-[16px] leading-[1.6] font-light text-[#272727]"
style={{ fontFamily: 'var(--font-poppins, Poppins), sans-serif' }}
>
{item.description}
</p>
</div>
</div>
</button>
)
})}
</div>
</div>
{/* Video carousel (only if videos provided) */}
{vn > 0 && (
<div className="w-full flex-1">
<div
className="relative mx-auto max-w-[600px] overflow-hidden rounded-[20px] lg:mx-0"
style={{ aspectRatio: '4/3' }}
onMouseEnter={() => {
videoPausedRef.current = true
}}
onMouseLeave={() => {
if (playingIndex === null) videoPausedRef.current = false
}}
>
{videos.map((v, i) => {
const isActive = i === videoActive
const isPlaying = i === playingIndex
return (
<div
key={i}
className={`absolute inset-0 transition-opacity duration-500 ${isActive ? 'opacity-100' : 'pointer-events-none opacity-0'}`}
aria-hidden={!isActive}
>
<video
ref={(el) => {
videoRefs.current[i] = el
}}
src={v.src}
poster={v.poster ?? undefined}
className="h-full w-full object-cover"
playsInline
controls={isPlaying}
preload="none"
onEnded={() => {
setPlayingIndex(null)
videoPausedRef.current = false
}}
/>
{!isPlaying && (
<button
onClick={() => handlePlayVideo(i)}
className="absolute inset-0 flex items-center justify-center bg-black/10 transition-colors hover:bg-black/25"
aria-label={`Відтворити відео ${i + 1}`}
>
<div className="flex h-20 w-20 items-center justify-center rounded-full bg-white/90 shadow-xl transition-transform hover:scale-110">
<svg
width="28"
height="28"
viewBox="0 0 24 24"
fill="none"
aria-hidden="true"
className="ml-1"
>
<path d="M5 3L19 12L5 21V3Z" fill="#396817" />
</svg>
</div>
</button>
)}
</div>
)
})}
</div>
<div className="mt-6 flex items-center justify-center gap-6">
<button
onClick={() => handleVideoNav((videoActive - 1 + vn) % vn)}
className="flex h-10 w-10 items-center justify-center rounded-full bg-[#396817] text-white transition-all hover:scale-110 hover:bg-[#2d5414]"
aria-label="Попереднє відео"
>
<svg width="18" height="18" viewBox="0 0 18 18" fill="none" aria-hidden="true">
<path
d="M11 4L6 9L11 14"
stroke="white"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
/>
</svg>
</button>
<div className="flex gap-2.5">
{videos.map((_, i) => (
<button
key={i}
onClick={() => handleVideoNav(i)}
className="h-2.5 w-2.5 rounded-full transition-all duration-300"
style={{ background: i === videoActive ? '#396817' : '#b8d8a0' }}
aria-label={`Відео ${i + 1}`}
aria-current={i === videoActive ? true : undefined}
/>
))}
</div>
<button
onClick={() => handleVideoNav((videoActive + 1) % vn)}
className="flex h-10 w-10 items-center justify-center rounded-full bg-[#396817] text-white transition-all hover:scale-110 hover:bg-[#2d5414]"
aria-label="Наступне відео"
>
<svg width="18" height="18" viewBox="0 0 18 18" fill="none" aria-hidden="true">
<path
d="M7 4L12 9L7 14"
stroke="white"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
/>
</svg>
</button>
</div>
</div>
)}
</div>
{/* Bottom quote */}
<div
className="mt-12 w-full rounded-[20px] px-8 py-5 shadow-[0_4px_30px_rgba(242,139,74,0.25)] lg:mt-16 lg:px-10"
style={{ background: '#f1fbeb' }}
>
<p
className="text-center text-[16px] leading-[1.5] font-medium text-[#272727] lg:text-[20px]"
style={FONT_MONT}
>
Запросіть традицію щоразу знайомитись з новим динозавром або щоразу фотографуватись біля
улюбленого динозавра. З часом ці знімки складуться у захопливий ковток улюблених / назад
тепла згадка для всієї родини.
</p>
</div>
</div>
</section>
)
}

178
src/globals/DinosaurPage.ts Normal file
View file

@ -0,0 +1,178 @@
import type { GlobalConfig } from 'payload'
import { isAdminOrEditor } from '@/access/isAdminOrEditor'
import { revalidateGlobalAfterChange } from '@/hooks/revalidatePath'
export const DinosaurPage: GlobalConfig = {
slug: 'dinosaur-page',
label: 'Динопарк — сторінка',
admin: { group: 'Сторінки' },
access: { read: () => true, update: isAdminOrEditor },
hooks: { afterChange: [revalidateGlobalAfterChange] },
fields: [
// Hero
{
name: 'heroTitle',
type: 'text',
defaultValue: 'Динопарк — портал у світ динозаврів',
},
{
name: 'heroDescription',
type: 'textarea',
defaultValue:
'Великі динозаври, що рухаються та гарчать, справжнє роздоволлє, цікаві екскурсії та динородео — тут є все, щоб ваша дитина не нудьгувала.',
},
{
name: 'heroStat',
type: 'text',
defaultValue: '26',
admin: { description: 'Число у круглому бейджі (наприклад "26")' },
},
{
name: 'heroStatLabel',
type: 'text',
defaultValue: 'унікальних експонатів',
},
{
name: 'heroImage',
type: 'upload',
relationTo: 'media',
admin: { description: 'Головне зображення героя (T-Rex / динозавр)' },
},
{
name: 'heroFeatures',
type: 'array',
label: 'Особливості (зелені блоки під бейджем)',
fields: [{ name: 'text', type: 'text', required: true }],
defaultValue: [
{ text: 'Повнорозмірні анімовані динозавра' },
{ text: 'Реалістичні рухи та звуки' },
],
},
// Dino wheel
{
name: 'dinosaurs',
type: 'array',
label: 'Динозаври (колесо видів)',
admin: { description: '8 видів для інтерактивного колеса' },
fields: [
{ name: 'name', type: 'text', required: true, admin: { description: 'Назва виду' } },
{ name: 'epoch', type: 'text', admin: { description: 'Наприклад: Крейдяний' } },
{ name: 'length', type: 'text', admin: { description: 'Наприклад: 12 м' } },
{ name: 'weight', type: 'text', admin: { description: 'Наприклад: 7 т' } },
{
name: 'image',
type: 'upload',
relationTo: 'media',
admin: { description: 'Велике зображення (центр колеса)' },
},
{
name: 'thumbnailImage',
type: 'upload',
relationTo: 'media',
admin: { description: 'Мініатюра для дуги колеса' },
},
],
defaultValue: [
{ 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 т' },
],
},
// Gallery
{
name: 'galleryImages',
type: 'array',
label: 'Галерея фото',
admin: { description: 'Фото з динопарку' },
fields: [{ name: 'image', type: 'upload', relationTo: 'media', required: true }],
},
// Activities
{
name: 'activitiesTitle',
type: 'text',
defaultValue: 'Додаткові розваги у динопарку',
},
{
name: 'activitiesDescription',
type: 'textarea',
defaultValue:
'Хочете дізнатись ще більше про динозаврів? Замовте екскурсію з гідом, поринь у світ палеонтологічних розкопок або підкорюй справжнього динозавра!',
},
{
name: 'activities',
type: 'array',
label: 'Активності',
fields: [
{ name: 'name', type: 'text', required: true },
{ name: 'price', type: 'text', admin: { description: 'Наприклад: 150 грн' } },
{ name: 'description', type: 'textarea' },
{ name: 'image', type: 'upload', relationTo: 'media' },
{ name: 'href', type: 'text', defaultValue: '#tickets' },
],
defaultValue: [
{ name: 'Звичайна екскурсія', price: '150 грн', href: '#tickets' },
{ name: 'Палеонтологічна екскурсія', price: '300 грн', href: '#tickets' },
{ name: 'ДиноРодео', price: '50 грн', href: '#tickets' },
],
},
// Why visit
{
name: 'whyVisitTitle',
type: 'text',
defaultValue: 'Чому варто відвідати динопарк',
},
{
name: 'whyVisitItems',
type: 'array',
fields: [
{ name: 'title', type: 'text', required: true },
{ name: 'description', type: 'textarea', required: true },
],
defaultValue: [
{
title: 'Навчання через гру',
description:
'Дітки дізнаються про стародавніх тварин через захопливі ігри та інтерактивні вправи з гідом.',
},
{
title: 'Дитячі очі, що палають захватом',
description:
'Реалістичні рухи та звуки динозаврів створюють ефект повного занурення — дитина точно не забуде цього дня.',
},
{
title: 'Неймовірні фотографії',
description:
'Сфотографуйтесь поруч із улюбленим динозавром або зробіть фото з екскурсоводом — тепла згадка для всієї родини.',
},
],
},
{
name: 'reviewVideos',
type: 'array',
label: 'Відео-відгуки (права колонка)',
admin: { description: 'Якщо порожньо — секція приховується' },
fields: [
{ name: 'src', type: 'text', required: true },
{ name: 'poster', type: 'text' },
{ name: 'label', type: 'text' },
],
},
// Tickets
{
name: 'workingHours',
type: 'text',
defaultValue: "п'ятниця-субота-неділя з 11:00 до 20:00",
admin: { description: 'Час роботи' },
},
{
name: 'comboDescription',
type: 'text',
defaultValue: 'Динопарк + Диволіс із казковими топіарними фігурами + Дзеркальний лабіринт',
},
],
}