feat(pages): add dinosaur park page + redesign birthday, group visits, thank-you
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:
parent
f3c3d2c978
commit
ef629dbdbe
15 changed files with 2392 additions and 125 deletions
110
migrations/0006_dinosaur_page.sql
Normal file
110
migrations/0006_dinosaur_page.sql
Normal 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
2
next-env.d.ts
vendored
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
],
|
||||
},
|
||||
},
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
>
|
||||
п’ятниця — субота — неділя з 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>
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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' }}
|
||||
>
|
||||
п'ятниця-субота-неділя з 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>
|
||||
)
|
||||
|
|
|
|||
118
src/app/(frontend)/kvytky/dyakuiemo/page.tsx
Normal file
118
src/app/(frontend)/kvytky/dyakuiemo/page.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
141
src/app/(frontend)/lokatsii/dynozavry/page.tsx
Normal file
141
src/app/(frontend)/lokatsii/dynozavry/page.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
|
|
@ -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 })
|
||||
}
|
||||
|
|
|
|||
127
src/components/sections/DinoActivities.tsx
Normal file
127
src/components/sections/DinoActivities.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
132
src/components/sections/DinoGallery.tsx
Normal file
132
src/components/sections/DinoGallery.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
170
src/components/sections/DinoHero.tsx
Normal file
170
src/components/sections/DinoHero.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
204
src/components/sections/DinoTickets.tsx
Normal file
204
src/components/sections/DinoTickets.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
295
src/components/sections/DinoWheel.tsx
Normal file
295
src/components/sections/DinoWheel.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
298
src/components/sections/DinoWhyVisit.tsx
Normal file
298
src/components/sections/DinoWhyVisit.tsx
Normal 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
178
src/globals/DinosaurPage.ts
Normal 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: 'Динопарк + Диволіс із казковими топіарними фігурами + Дзеркальний лабіринт',
|
||||
},
|
||||
],
|
||||
}
|
||||
Loading…
Add table
Reference in a new issue