feat: wire all sections to Payload CMS + header dropdown + deploy config

- New collections: Locations, Reviews, BirthdayPackages
- New lib: getHomeData() — single cache-wrapped data fetch for homepage
- All sections now read from CMS with static fallbacks
- Header dropdown auto-populates 5 locations from Locations collection
- Seed API at /api/admin/seed creates all collections + uploads media
- docker-compose.prod.yml: replaced nginx/certbot with Traefik labels (shumi.ai-impress.com)
- Fixed image assignments: Locations 3-5 use gallery images (not news-bg)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Vadym Samoilenko 2026-05-10 21:39:29 +01:00
parent 3226789bd1
commit 1cd20291d0
31 changed files with 1711 additions and 572 deletions

View file

@ -1,6 +1,7 @@
services:
postgres:
image: postgres:16-alpine
container_name: shumiland-postgres
environment:
POSTGRES_USER: shumiland
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD}
@ -13,40 +14,27 @@ services:
timeout: 5s
retries: 5
restart: unless-stopped
networks:
- internal
app:
image: ghcr.io/aimpress/shumiland:${TAG:-latest}
build: .
container_name: shumiland-app
env_file: .env.production
depends_on:
postgres:
condition: service_healthy
restart: unless-stopped
nginx:
image: nginx:alpine
ports:
- '80:80'
- '443:443'
volumes:
- ./nginx.conf:/etc/nginx/conf.d/default.conf:ro
- ./certbot/conf:/etc/letsencrypt:ro
- ./certbot/www:/var/www/certbot:ro
depends_on:
- app
restart: unless-stopped
certbot:
image: certbot/certbot
volumes:
- ./certbot/conf:/etc/letsencrypt
- ./certbot/www:/var/www/certbot
command: >-
certonly --webroot
--webroot-path=/var/www/certbot
--email ${CERTBOT_EMAIL}
--agree-tos
--no-eff-email
-d ${CERTBOT_DOMAIN}
networks:
- internal
- traefik-public
labels:
- traefik.enable=true
- traefik.http.routers.shumiland.rule=Host(`shumi.ai-impress.com`)
- traefik.http.routers.shumiland.entrypoints=websecure
- traefik.http.routers.shumiland.tls.certresolver=cloudflare
- traefik.http.services.shumiland.loadbalancer.server.port=3000
- traefik.http.routers.shumiland.middlewares=security-headers@file
pg_backup:
image: alpine:3
@ -65,6 +53,15 @@ services:
depends_on:
- postgres
restart: unless-stopped
networks:
- internal
networks:
internal:
name: shumiland-internal
traefik-public:
external: true
volumes:
postgres_data:
name: shumiland-postgres-data

View file

@ -14,6 +14,9 @@ import { Tags } from './src/collections/Tags'
import { Tariffs } from './src/collections/Tariffs'
import { Leads } from './src/collections/Leads'
import { Orders } from './src/collections/Orders'
import { Locations } from './src/collections/Locations'
import { Reviews } from './src/collections/Reviews'
import { BirthdayPackages } from './src/collections/BirthdayPackages'
import { HomePage } from './src/globals/HomePage'
import { CheckoutPage } from './src/globals/CheckoutPage'
@ -42,7 +45,7 @@ export default buildConfig({
editor: lexicalEditor(),
sharp,
collections: [Users, Media, Pages, BlogPosts, Categories, Tags, Tariffs, Leads, Orders],
collections: [Users, Media, Pages, BlogPosts, Categories, Tags, Tariffs, Leads, Orders, Locations, Reviews, BirthdayPackages],
globals: [HomePage, CheckoutPage, ThankYouPage, Header, Footer, SiteSettings],
@ -62,6 +65,10 @@ export default buildConfig({
},
},
i18n: {
fallbackLanguage: 'en',
},
cors: [siteURL],
typescript: {

View file

@ -39,43 +39,43 @@ export default async function BlogPage() {
const posts = await getPosts()
return (
<div className="bg-[#fdf2e8] min-h-screen">
<div className="min-h-screen bg-[#fdf2e8]">
<PageHero title="Блог" subtitle="Новини та статті від Шуміленду" />
<div className="max-w-[1204px] mx-auto px-8 py-16">
<div className="mx-auto max-w-[1204px] px-8 py-16">
{posts.length === 0 ? (
<div className="text-center py-20">
<div className="py-20 text-center">
<p
className="text-[#272727]/60 text-[20px]"
className="text-[20px] text-[#272727]/60"
style={{ fontFamily: 'var(--font-montserrat, Montserrat), sans-serif' }}
>
Статті незабаром з&apos;являться тут.
</p>
</div>
) : (
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-8">
<div className="grid grid-cols-1 gap-8 sm:grid-cols-2 lg:grid-cols-3">
{posts.map((post) => (
<Link
key={post.id}
href={`/blog/${post.slug}`}
className="bg-white rounded-[20px] p-6 flex flex-col gap-3 shadow-sm hover:shadow-md transition-shadow"
className="flex flex-col gap-3 rounded-[20px] bg-white p-6 shadow-sm transition-shadow hover:shadow-md"
>
<h2
className="text-[#272727] font-bold text-[18px] leading-snug"
className="text-[18px] leading-snug font-bold text-[#272727]"
style={{ fontFamily: 'var(--font-montserrat, Montserrat), sans-serif' }}
>
{post.title}
</h2>
{post.excerpt && (
<p
className="text-[#272727]/60 text-[14px] leading-relaxed line-clamp-3"
className="line-clamp-3 text-[14px] leading-relaxed text-[#272727]/60"
style={{ fontFamily: 'var(--font-montserrat, Montserrat), sans-serif' }}
>
{post.excerpt}
</p>
)}
{post.publishedAt && (
<p className="text-[#f28b4a] text-[13px] font-bold mt-auto">
<p className="mt-auto text-[13px] font-bold text-[#f28b4a]">
{new Date(post.publishedAt).toLocaleDateString('uk-UA', {
day: 'numeric',
month: 'long',

View file

@ -4,7 +4,8 @@ import { PageHero } from '@/components/ui/PageHero'
export const metadata: Metadata = {
title: 'Дні народження — Шуміленд',
description: 'Святкуйте день народження у Шуміленді! Пакети для дітей та дорослих з розвагами, аніматорами та кейтерингом.',
description:
'Святкуйте день народження у Шуміленді! Пакети для дітей та дорослих з розвагами, аніматорами та кейтерингом.',
}
const PACKAGES = [
@ -17,31 +18,46 @@ const PACKAGES = [
{
name: 'Преміум',
price: '8 900',
features: ['До 20 дітей', 'Аніматор 3 год', 'Вхідні квитки', 'Торт + солодкий стіл', 'Декор зали', 'Фотограф 1 год'],
features: [
'До 20 дітей',
'Аніматор 3 год',
'Вхідні квитки',
'Торт + солодкий стіл',
'Декор зали',
'Фотограф 1 год',
],
highlight: true,
},
{
name: 'VIP',
price: '15 000',
features: ['До 40 гостей', 'Аніматор 4 год', 'Вхідні квитки', 'Банкетний зал', 'Повне меню', 'Фотограф + відео', 'Персональний менеджер'],
features: [
'До 40 гостей',
'Аніматор 4 год',
'Вхідні квитки',
'Банкетний зал',
'Повне меню',
'Фотограф + відео',
'Персональний менеджер',
],
highlight: false,
},
]
export default function BirthdayPage() {
return (
<div className="bg-[#fdf2e8] min-h-screen">
<div className="min-h-screen bg-[#fdf2e8]">
<PageHero
title="Дні народження"
subtitle="Зробіть свято незабутнім! Оберіть пакет і наші менеджери зв'яжуться з вами для уточнення деталей."
/>
<div className="max-w-[1204px] mx-auto px-8 py-16">
<div className="grid grid-cols-1 md:grid-cols-3 gap-6 mb-16">
<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.name}
className={`rounded-[24px] p-8 flex flex-col gap-5 ${
className={`flex flex-col gap-5 rounded-[24px] p-8 ${
pkg.highlight
? 'bg-[#f28b4a] shadow-[0_4px_60px_0_rgba(242,139,74,0.4)]'
: 'bg-[#223e0d] shadow-[0_4px_60px_0_rgba(34,62,13,0.15)]'
@ -49,7 +65,7 @@ export default function BirthdayPage() {
>
{pkg.highlight && (
<span
className="bg-white text-[#f28b4a] text-[12px] font-bold px-3 py-1 rounded-full self-start"
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' }}
>
Найпопулярніший
@ -57,13 +73,13 @@ export default function BirthdayPage() {
)}
<div>
<h2
className="text-white font-bold text-[24px] uppercase"
className="text-[24px] font-bold text-white uppercase"
style={{ fontFamily: 'var(--font-montserrat, Montserrat), sans-serif' }}
>
{pkg.name}
</h2>
<p
className="text-white font-black text-[42px] leading-none mt-1"
className="mt-1 text-[42px] leading-none font-black text-white"
style={{ fontFamily: 'var(--font-inter, Inter), sans-serif' }}
>
{pkg.price} <span className="text-[24px]"></span>
@ -73,7 +89,7 @@ export default function BirthdayPage() {
{pkg.features.map((f) => (
<li
key={f}
className="text-white/80 text-[14px] flex items-center gap-2"
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}
@ -84,22 +100,22 @@ export default function BirthdayPage() {
))}
</div>
<div className="bg-[#223e0d] rounded-[24px] p-10 text-center">
<div id="order-form" className="rounded-[24px] bg-[#223e0d] p-10 text-center">
<h2
className="text-white font-bold text-[28px] mb-4"
className="mb-4 text-[28px] font-bold text-white"
style={{ fontFamily: 'var(--font-montserrat, Montserrat), sans-serif' }}
>
Замовити святкування
</h2>
<p
className="text-white/70 text-[16px] mb-8 max-w-[500px] mx-auto"
className="mx-auto mb-8 max-w-[500px] text-[16px] text-white/70"
style={{ fontFamily: 'var(--font-montserrat, Montserrat), sans-serif' }}
>
Залиште заявку і наш менеджер зв&apos;яжеться з вами протягом 30 хвилин
</p>
<Link
href="/#contact"
className="inline-flex items-center bg-[#f28b4a] text-white font-bold text-[16px] px-10 py-4 rounded-[64px] hover:shadow-[0_0_20px_0_#f28b4a] transition-shadow"
href="#order-form"
className="inline-flex items-center rounded-[64px] bg-[#f28b4a] px-10 py-4 text-[16px] font-bold text-white transition-shadow hover:shadow-[0_0_20px_0_#f28b4a]"
style={{ fontFamily: 'var(--font-montserrat, Montserrat), sans-serif' }}
>
Залишити заявку

View file

@ -4,27 +4,31 @@ import { PageHero } from '@/components/ui/PageHero'
export const metadata: Metadata = {
title: 'Групові відвідування — Шуміленд',
description: 'Організуйте групове відвідування Шуміленду. Спеціальні ціни для шкіл, дитячих садків і корпоративних груп.',
description:
'Організуйте групове відвідування Шуміленду. Спеціальні ціни для шкіл, дитячих садків і корпоративних груп.',
}
const GROUPS = [
{
title: 'Шкільні екскурсії',
description: 'Пізнавальні екскурсії для учнів початкової та середньої школи. Екскурсовод, адаптована програма, безпечний маршрут.',
description:
'Пізнавальні екскурсії для учнів початкової та середньої школи. Екскурсовод, адаптована програма, безпечний маршрут.',
minPeople: '15 осіб',
discount: '15%',
icon: '🏫',
},
{
title: 'Дитячі садки',
description: 'Програма для наймолодших — безпечний формат, розвивальні активності, відповідальний супровід.',
description:
'Програма для наймолодших — безпечний формат, розвивальні активності, відповідальний супровід.',
minPeople: '10 осіб',
discount: '20%',
icon: '🎒',
},
{
title: 'Корпоративи',
description: 'Тімбілдинг та корпоративний відпочинок у форматі парку розваг. Ексклюзивні зони, кейтеринг, програма на замовлення.',
description:
'Тімбілдинг та корпоративний відпочинок у форматі парку розваг. Ексклюзивні зони, кейтеринг, програма на замовлення.',
minPeople: '20 осіб',
discount: '10%',
icon: '🏢',
@ -33,64 +37,64 @@ const GROUPS = [
export default function GroupVisitsPage() {
return (
<div className="bg-[#fdf2e8] min-h-screen">
<div className="min-h-screen bg-[#fdf2e8]">
<PageHero
title="Групові відвідування"
subtitle="Спеціальні умови для організованих груп. Мінімум 10 осіб — максимум вражень."
/>
<div className="max-w-[1204px] mx-auto px-8 py-16">
<div className="grid grid-cols-1 md:grid-cols-3 gap-6 mb-16">
<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">
{GROUPS.map((g) => (
<div
key={g.title}
className="bg-[#223e0d] rounded-[24px] p-8 flex flex-col gap-4 shadow-[0_4px_60px_0_rgba(34,62,13,0.15)]"
className="flex flex-col gap-4 rounded-[24px] bg-[#223e0d] p-8 shadow-[0_4px_60px_0_rgba(34,62,13,0.15)]"
>
<span className="text-[40px]">{g.icon}</span>
<h2
className="text-white font-bold text-[22px]"
className="text-[22px] font-bold text-white"
style={{ fontFamily: 'var(--font-montserrat, Montserrat), sans-serif' }}
>
{g.title}
</h2>
<p
className="text-white/70 text-[14px] leading-relaxed"
className="text-[14px] leading-relaxed text-white/70"
style={{ fontFamily: 'var(--font-montserrat, Montserrat), sans-serif' }}
>
{g.description}
</p>
<div className="flex gap-4 mt-auto pt-4 border-t border-white/10">
<div className="mt-auto flex gap-4 border-t border-white/10 pt-4">
<div>
<p className="text-white/50 text-[12px]">Від</p>
<p className="text-white font-bold text-[16px]">{g.minPeople}</p>
<p className="text-[12px] text-white/50">Від</p>
<p className="text-[16px] font-bold text-white">{g.minPeople}</p>
</div>
<div>
<p className="text-white/50 text-[12px]">Знижка</p>
<p className="text-[#f28b4a] font-bold text-[16px]">{g.discount}</p>
<p className="text-[12px] text-white/50">Знижка</p>
<p className="text-[16px] font-bold text-[#f28b4a]">{g.discount}</p>
</div>
</div>
</div>
))}
</div>
<div className="bg-[#223e0d] rounded-[24px] p-10 flex flex-col md:flex-row items-center gap-8">
<div id="order-form" className="flex flex-col items-center gap-8 rounded-[24px] bg-[#223e0d] p-10 md:flex-row">
<div className="flex-1">
<h2
className="text-white font-bold text-[28px] mb-3"
className="mb-3 text-[28px] font-bold text-white"
style={{ fontFamily: 'var(--font-montserrat, Montserrat), sans-serif' }}
>
Подати заявку на групове відвідування
</h2>
<p
className="text-white/70 text-[15px]"
className="text-[15px] text-white/70"
style={{ fontFamily: 'var(--font-montserrat, Montserrat), sans-serif' }}
>
Вкажіть кількість учасників та бажану дату менеджер зателефонує і погодить деталі.
</p>
</div>
<Link
href="/#contact"
className="shrink-0 inline-flex items-center bg-[#f28b4a] text-white font-bold text-[16px] px-10 py-4 rounded-[64px] hover:shadow-[0_0_20px_0_#f28b4a] transition-shadow"
href="#order-form"
className="inline-flex shrink-0 items-center rounded-[64px] bg-[#f28b4a] px-10 py-4 text-[16px] font-bold text-white transition-shadow hover:shadow-[0_0_20px_0_#f28b4a]"
style={{ fontFamily: 'var(--font-montserrat, Montserrat), sans-serif' }}
>
Написати нам

View file

@ -1,4 +1,3 @@
/* eslint-disable @next/next/no-img-element */
import type { Metadata } from 'next'
import Link from 'next/link'
import { PageHero } from '@/components/ui/PageHero'
@ -53,30 +52,30 @@ export default async function TicketsPage() {
}, {})
return (
<div className="bg-[#fdf2e8] min-h-screen">
<div className="min-h-screen bg-[#fdf2e8]">
<PageHero
title="Купити квиток"
subtitle="Оберіть квиток та придбайте онлайн — без черги на касі"
/>
<div className="max-w-[1204px] mx-auto px-8 py-16">
<div className="mx-auto max-w-[1204px] px-8 py-16">
{hasWarning && (
<div className="bg-amber-50 border border-amber-200 rounded-xl px-6 py-4 mb-10 text-amber-800 text-[14px]">
<div className="mb-10 rounded-xl border border-amber-200 bg-amber-50 px-6 py-4 text-[14px] text-amber-800">
Ціни можуть бути застарілими сервіс ezy.com.ua тимчасово недоступний.
</div>
)}
{tariffs.length === 0 ? (
<div className="text-center py-20">
<div className="py-20 text-center">
<p
className="text-[#272727] text-[20px] mb-6"
className="mb-6 text-[20px] text-[#272727]"
style={{ fontFamily: 'var(--font-montserrat, Montserrat), sans-serif' }}
>
Квитки тимчасово недоступні. Спробуйте пізніше або зателефонуйте нам.
</p>
<a
href="tel:+380000000000"
className="inline-flex bg-[#f28b4a] text-white font-bold px-8 py-3 rounded-[64px]"
className="inline-flex rounded-[64px] bg-[#f28b4a] px-8 py-3 font-bold text-white"
style={{ fontFamily: 'var(--font-montserrat, Montserrat), sans-serif' }}
>
Зателефонувати
@ -87,12 +86,12 @@ export default async function TicketsPage() {
{Object.entries(grouped).map(([category, items]) => (
<div key={category}>
<h2
className="font-bold text-[24px] text-[#272727] uppercase mb-6"
className="mb-6 text-[24px] font-bold text-[#272727] uppercase"
style={{ fontFamily: 'var(--font-montserrat, Montserrat), sans-serif' }}
>
{CATEGORY_LABELS[category] ?? category}
</h2>
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-5">
<div className="grid grid-cols-1 gap-5 sm:grid-cols-2 lg:grid-cols-3">
{items.map((tariff) => (
<TariffCard key={tariff.id} tariff={tariff} />
))}
@ -108,19 +107,17 @@ export default async function TicketsPage() {
function TariffCard({ tariff }: { tariff: Tariff }) {
return (
<div className="bg-[#223e0d] rounded-[20px] p-8 flex flex-col gap-6 shadow-[0_4px_60px_0_rgba(242,139,74,0.25)]">
{tariff.icon && (
<span className="text-[40px]">{tariff.icon}</span>
)}
<div className="flex flex-col gap-6 rounded-[20px] bg-[#223e0d] p-8 shadow-[0_4px_60px_0_rgba(242,139,74,0.25)]">
{tariff.icon && <span className="text-[40px]">{tariff.icon}</span>}
<div>
<h3
className="text-white font-bold text-[20px] leading-tight"
className="text-[20px] leading-tight font-bold text-white"
style={{ fontFamily: 'var(--font-montserrat, Montserrat), sans-serif' }}
>
{tariff.name}
</h3>
<p
className="text-[#f28b4a] font-black text-[36px] mt-2"
className="mt-2 text-[36px] font-black text-[#f28b4a]"
style={{ fontFamily: 'var(--font-inter, Inter), sans-serif' }}
>
{tariff.price}
@ -136,7 +133,7 @@ function CheckoutButton({ tariffId }: { tariffId: string }) {
return (
<Link
href={`/kvytky/checkout?tariff=${tariffId}`}
className="flex items-center justify-center bg-[#f28b4a] text-white font-bold text-[16px] py-[10px] rounded-[56px] hover:shadow-[0_0_20px_0_#f28b4a] transition-shadow"
className="flex items-center justify-center rounded-[56px] bg-[#f28b4a] py-[10px] text-[16px] font-bold text-white transition-shadow hover:shadow-[0_0_20px_0_#f28b4a]"
style={{ fontFamily: 'var(--font-montserrat, Montserrat), sans-serif' }}
>
Придбати

View file

@ -31,13 +31,14 @@ export const metadata: Metadata = {
template: '%s | Shumiland',
default: 'Шуміленд — світ, де казка оживає',
},
description: 'Сімейний тематичний парк, де гра допомагає пізнавати світ, а кожна прогулянка перетворюється на незабутню пригоду.',
description:
'Сімейний тематичний парк, де гра допомагає пізнавати світ, а кожна прогулянка перетворюється на незабутню пригоду.',
}
export default function FrontendLayout({ children }: { children: React.ReactNode }) {
return (
<div
className={`${montserrat.variable} ${inter.variable} ${poppins.variable} flex flex-col min-h-screen bg-background text-foreground`}
className={`${montserrat.variable} ${inter.variable} ${poppins.variable} bg-background text-foreground flex min-h-screen flex-col`}
>
<Header />
<main className="flex-1">{children}</main>

View file

@ -1,7 +1,9 @@
/* eslint-disable @next/next/no-img-element */
import type { Metadata } from 'next'
import Link from 'next/link'
import { getPayload } from 'payload'
import configPromise from '@payload-config'
import { PageHero } from '@/components/ui/PageHero'
import type { LocationCMS, Media } from '@/types/globals'
export const metadata: Metadata = {
title: 'Локації — Шуміленд',
@ -10,71 +12,140 @@ export const metadata: Metadata = {
export const revalidate = 3600
const LOCATIONS = [
const STATIC_LOCATIONS: LocationCMS[] = [
{
slug: 'dynopark',
id: 'dynopark',
name: 'ДиноПарк',
description: 'Прогуляйтесь серед реалістичних динозаврів у повний зріст. Понад 20 видів доісторичних тварин у природному середовищі.',
tag: 'dyno',
color: '#223e0d',
slug: 'dynopark',
tagline: 'портал у світ динозаврів',
shortDesc: 'Прогуляйтесь серед реалістичних динозаврів у повний зріст. Понад 20 видів доісторичних тварин у природному середовищі.',
image: '/images/figma/loc-dinopark.jpg',
},
{
slug: 'dyvolist',
id: 'dyvolis',
name: 'Диво Ліс',
description: 'Чарівний ліс з інтерактивними атракціонами, мотузковими парками та пригодами для всіх вікових груп.',
tag: 'dyvolis',
color: '#2d5414',
slug: 'dyvolis',
tagline: 'зона казкових топіарних фігур',
shortDesc: 'Чарівний ліс з інтерактивними атракціонами, мотузковими парками та пригодами для всіх вікових груп.',
image: '/images/figma/loc-divo-lis.png',
},
{
slug: 'labirynth',
id: 'maze',
name: 'Дзеркальний Лабіринт',
description: 'Захоплюючий лабіринт з дзеркалами, оптичними ілюзіями та таємничими кімнатами.',
tag: 'maze',
color: '#1a3009',
slug: 'maze',
tagline: 'справжній виклик кмітливості',
shortDesc: 'Захоплюючий лабіринт з дзеркалами, оптичними ілюзіями та таємничими кімнатами.',
image: '/images/figma/gallery-1.png',
},
{
id: 'tir',
name: 'Тир з призами',
slug: 'tir',
tagline: 'перемога, яку ви розділите разом',
shortDesc: 'Влаштуйте дружні змагання, дайте малечі декілька уроків та виграйте класний приз.',
image: '/images/figma/gallery-3.png',
},
{
id: 'playground',
name: 'Дитячий майданчик',
slug: 'playground',
tagline: 'територія забав і нових друзів',
shortDesc: 'Поки малеча підкорює гірки та знаходить перших друзів, ви можете нарешті зробити паузу.',
image: '/images/figma/gallery-8.png',
},
]
export default function LocationsPage() {
return (
<div className="bg-[#fdf2e8] min-h-screen">
<PageHero
title="Локації"
subtitle="Три неповторні зони розваг для всієї родини"
/>
function getMediaUrl(img: Media | string | null | undefined): string | null {
if (!img) return null
if (typeof img === 'string') return img
return img.url ?? null
}
<div className="max-w-[1204px] mx-auto px-8 py-16">
const COLORS = ['#223e0d', '#2d5414', '#1a3009', '#2a5a12', '#1d4710']
async function getLocations(): Promise<LocationCMS[]> {
try {
const payload = await getPayload({ config: configPromise })
const result = await payload.find({
collection: 'locations',
sort: 'sort',
limit: 20,
overrideAccess: true,
})
const docs = result.docs as unknown as LocationCMS[]
if (docs.length > 0) return docs
} catch {
// DB not available
}
return STATIC_LOCATIONS
}
export default async function LocationsPage() {
const locations = await getLocations()
return (
<div className="min-h-screen bg-[#fdf2e8]">
<PageHero title="Локації" subtitle="Неповторні зони розваг для всієї родини" />
<div className="mx-auto max-w-[1204px] px-8 py-16">
<div className="flex flex-col gap-10">
{LOCATIONS.map((loc) => (
<div
key={loc.slug}
className="rounded-[24px] overflow-hidden flex flex-col md:flex-row shadow-[0_4px_60px_0_rgba(34,62,13,0.12)]"
>
{locations.map((loc, i) => {
const imgUrl = getMediaUrl(loc.image) ?? '/images/figma/loc-dinopark.jpg'
const color = COLORS[i % COLORS.length]
const slug = loc.slug
return (
<div
className="flex-1 p-10 flex flex-col justify-center gap-4"
style={{ backgroundColor: loc.color }}
id={slug}
key={loc.id}
className="flex flex-col overflow-hidden rounded-[24px] shadow-[0_4px_60px_0_rgba(34,62,13,0.12)] md:flex-row"
>
<h2
className="text-white font-bold text-[36px] uppercase leading-tight"
style={{ fontFamily: 'var(--font-montserrat, Montserrat), sans-serif' }}
{/* Image */}
<div className="relative h-[240px] md:h-auto md:w-[420px] flex-none overflow-hidden">
{/* eslint-disable-next-line @next/next/no-img-element */}
<img
src={imgUrl}
alt={loc.name}
className="absolute inset-0 h-full w-full object-cover"
/>
</div>
{/* Text */}
<div
className="flex flex-1 flex-col justify-center gap-4 p-10"
style={{ backgroundColor: color }}
>
{loc.name}
</h2>
<p
className="text-white/80 text-[16px] leading-relaxed"
style={{ fontFamily: 'var(--font-montserrat, Montserrat), sans-serif' }}
>
{loc.description}
</p>
<Link
href={`/kvytky?category=${loc.tag}`}
className="inline-flex self-start items-center bg-[#f28b4a] text-white font-bold text-[15px] px-7 py-[10px] rounded-[64px] hover:shadow-[0_0_20px_0_#f28b4a] transition-shadow mt-2"
style={{ fontFamily: 'var(--font-montserrat, Montserrat), sans-serif' }}
>
Купити квиток
</Link>
{loc.tagline && (
<p
className="text-[#fdcf54] text-[16px] font-medium uppercase"
style={{ fontFamily: 'var(--font-montserrat, Montserrat), sans-serif' }}
>
{loc.tagline}
</p>
)}
<h2
className="text-[36px] leading-tight font-bold text-white uppercase"
style={{ fontFamily: 'var(--font-montserrat, Montserrat), sans-serif' }}
>
{loc.name}
</h2>
<p
className="text-[16px] leading-relaxed text-white/80"
style={{ fontFamily: 'var(--font-montserrat, Montserrat), sans-serif' }}
>
{loc.shortDesc}
</p>
<Link
href={`/kvytky?category=${slug}`}
className="mt-2 inline-flex items-center self-start rounded-[64px] bg-[#f28b4a] px-7 py-[10px] text-[15px] font-bold text-white transition-shadow hover:shadow-[0_0_20px_0_#f28b4a]"
style={{ fontFamily: 'var(--font-montserrat, Montserrat), sans-serif' }}
>
Купити квиток
</Link>
</div>
</div>
</div>
))}
)
})}
</div>
</div>
</div>

View file

@ -1,4 +1,3 @@
import { getGlobal } from '@/lib/payload'
import { Hero } from '@/components/sections/Hero'
import { Locations } from '@/components/sections/Locations'
import { WhyParents } from '@/components/sections/WhyParents'
@ -7,11 +6,12 @@ import { VideoSection } from '@/components/sections/VideoSection'
import { Gallery } from '@/components/sections/Gallery'
import { Reviews } from '@/components/sections/Reviews'
import { News } from '@/components/sections/News'
import type { HomePageGlobal } from '@/types/globals'
import { getHomeData } from '@/lib/getHomeData'
import type { HomePageHero } from '@/types/globals'
export const revalidate = 60
const STATIC_HERO: NonNullable<HomePageGlobal['hero']> = {
const STATIC_HERO: NonNullable<HomePageHero> = {
title: 'Шуміленд — світ, де казка оживає',
subtitle:
'Сімейний тематичний парк, де гра допомагає пізнавати світ, а кожна прогулянка перетворюється на незабутню пригоду.',
@ -20,12 +20,7 @@ const STATIC_HERO: NonNullable<HomePageGlobal['hero']> = {
}
export default async function HomePage() {
let home: HomePageGlobal | null = null
try {
home = await getGlobal<HomePageGlobal>('home-page')
} catch {
// Fail gracefully — DB may not be available during static build
}
const { home, locations, reviews, birthdayPackages } = await getHomeData()
const heroData = home?.hero
const hero = heroData?.title ? heroData : STATIC_HERO
@ -33,12 +28,32 @@ export default async function HomePage() {
return (
<div className="bg-[#fdf2e8]">
<Hero hero={hero} />
<Locations />
<WhyParents />
<BirthdayPricing />
<VideoSection />
<Gallery />
<Reviews />
<Locations
data={locations}
title={home?.sectionTitles?.locations ?? undefined}
/>
<WhyParents
items={home?.whyParents?.items ?? undefined}
sideGallery={home?.whyParents?.sideGallery ?? undefined}
title={home?.sectionTitles?.whyParents ?? undefined}
/>
<BirthdayPricing
packages={birthdayPackages}
title={home?.sectionTitles?.birthday ?? undefined}
intro={home?.birthdayIntro?.text ?? undefined}
/>
<VideoSection
poster={home?.video?.poster ?? undefined}
src={home?.video?.src ?? undefined}
/>
<Gallery
images={home?.gallery?.images ?? undefined}
title={home?.sectionTitles?.gallery ?? undefined}
/>
<Reviews
data={reviews}
title={home?.sectionTitles?.reviews ?? undefined}
/>
<News />
</div>
)

View file

@ -0,0 +1,306 @@
import { NextResponse } from 'next/server'
import { getPayload } from 'payload'
import config from '@payload-config'
import path from 'path'
import fs from 'fs'
async function uploadMedia(payload: Awaited<ReturnType<typeof getPayload>>, filename: string, alt: string) {
const filePath = path.join(process.cwd(), 'public/images/figma', filename)
if (!fs.existsSync(filePath)) return null
try {
const buffer = fs.readFileSync(filePath)
const mimeType = filename.endsWith('.jpg') || filename.endsWith('.jpeg') ? 'image/jpeg'
: filename.endsWith('.png') ? 'image/png'
: filename.endsWith('.svg') ? 'image/svg+xml'
: 'image/png'
const doc = await payload.create({
collection: 'media',
data: { alt } as never,
file: { data: buffer, mimetype: mimeType, name: filename, size: buffer.length },
overrideAccess: true,
})
return doc.id as string
} catch {
return null
}
}
export async function POST() {
const payload = await getPayload({ config })
const results: string[] = []
// Admin user
const { totalDocs } = await payload.find({ collection: 'users', limit: 1, overrideAccess: true })
if (totalDocs === 0) {
await payload.create({
collection: 'users',
data: { email: 'admin@shumiland.ua', password: 'changeMe123!', name: 'Admin', role: 'admin' } as never,
overrideAccess: true,
})
results.push('Created admin user: admin@shumiland.ua / changeMe123!')
} else {
results.push('Admin user already exists')
}
// === LOCATIONS ===
const { totalDocs: locCount } = await payload.find({ collection: 'locations', limit: 1, overrideAccess: true })
if (locCount === 0) {
const locationDefs = [
{ name: 'ДиноПарк', slug: 'dynopark', tagline: 'портал у світ динозаврів', shortDesc: 'Ви бачили їх у фільмах та мультиках, а тепер час зустріти в реальному житті та роздивитися їх зблизька! Найбільші динозаври України, які гарчать і рухаються, як справжні.', imageFile: 'loc-dinopark.jpg', imageAlt: 'ДиноПарк', sort: 1 },
{ name: 'Диво Ліс', slug: 'dyvolis', tagline: 'зона казкових топіарних фігур', shortDesc: 'Тут на лісових стежках оселилися єдинороги, дракони та добрі лісові звірята. Це ідеальне місце, щоб пофантазувати разом із дитиною.', imageFile: 'loc-divo-lis.png', imageAlt: 'Диво Ліс', sort: 2 },
{ name: 'Дзеркальний Лабіринт', slug: 'maze', tagline: 'справжній виклик кмітливості', shortDesc: 'Захоплюючий лабіринт з дзеркалами, оптичними ілюзіями та таємничими кімнатами.', imageFile: 'gallery-1.png', imageAlt: 'Дзеркальний Лабіринт', sort: 3 },
{ name: 'Тир з призами', slug: 'tir', tagline: 'перемога, яку ви розділите разом', shortDesc: 'Влаштуйте дружні змагання, дайте малечі декілька уроків та виграйте класний приз.', imageFile: 'gallery-3.png', imageAlt: 'Тир з призами', sort: 4 },
{ name: 'Дитячий майданчик', slug: 'playground', tagline: 'територія забав і нових друзів', shortDesc: 'Поки малеча підкорює гірки та знаходить перших друзів, ви можете нарешті зробити паузу.', imageFile: 'gallery-8.png', imageAlt: 'Дитячий майданчик', sort: 5 },
]
for (const loc of locationDefs) {
const imageId = await uploadMedia(payload, loc.imageFile, loc.imageAlt)
await payload.create({
collection: 'locations',
data: {
name: loc.name,
slug: loc.slug,
tagline: loc.tagline,
shortDesc: loc.shortDesc,
image: imageId ?? undefined,
showInMenu: true,
showOnHome: true,
sort: loc.sort,
} as never,
overrideAccess: true,
})
}
results.push('Seeded 5 locations')
} else {
results.push('Locations already exist')
}
// === REVIEWS ===
const { totalDocs: revCount } = await payload.find({ collection: 'reviews', limit: 1, overrideAccess: true })
if (revCount === 0) {
const avatarId = await uploadMedia(payload, 'review-avatar-bg.jpg', 'Review avatar background')
const reviewDefs = [
{ name: 'Женя Олейник', initial: 'Ж', ago: '2 місяці тому', text: 'Гарне місце, цікаво для дітей і дорослих. Велика площа і різні локації. Мало відвідувачів у будні.', sort: 1 },
{ name: 'Анна Калініченко', initial: 'А', ago: '6 місяців тому', text: 'Чудовий динозавровий парк, фігури з трав\'яного покриття. Можна залізти на фігури, дітям в захваті! Динозаври рухаються і гарчать.', sort: 2 },
{ name: 'Volodymyr Prisajnuk', initial: 'V', ago: '10 місяців тому', text: 'Ми з сім\'єю відвідали відкритий парк, нам дуже сподобалось! Значно краще, ніж очікував. Динозаври дуже запам\'яталися - неймовірно!', sort: 3 },
]
for (const rev of reviewDefs) {
await payload.create({
collection: 'reviews',
data: {
name: rev.name,
initial: rev.initial,
avatarBg: avatarId ?? undefined,
ago: rev.ago,
rating: 5,
text: rev.text,
source: 'google',
showOnHome: true,
sort: rev.sort,
} as never,
overrideAccess: true,
})
}
results.push('Seeded 3 reviews')
} else {
results.push('Reviews already exist')
}
// === BIRTHDAY PACKAGES ===
const { totalDocs: pkgCount } = await payload.find({ collection: 'birthday-packages', limit: 1, overrideAccess: true })
if (pkgCount === 0) {
const packages = [
{
name: 'Стандарт', slug: 'standard', price: 4500,
features: [
{ text: 'Вхід для іменинника + 5 дітей' },
{ text: 'Анімаційна програма (1 год)' },
{ text: 'Декорації столу' },
{ text: 'Торт від кондитера' },
],
featured: false, sort: 1,
ctaHref: '/dni-narodzhennia#order-form',
},
{
name: 'Преміум', slug: 'premium', price: 8900,
features: [
{ text: 'Вхід для іменинника + 10 дітей' },
{ text: 'Анімаційна програма (3 год)' },
{ text: 'Повна декорація локації' },
{ text: 'Фотозона та фотограф' },
{ text: 'Сюрприз-подарунок кожному' },
],
featured: true, badge: 'Найпопулярніший', sort: 2,
ctaHref: '/dni-narodzhennia#order-form',
},
{
name: 'VIP', slug: 'vip', price: 15000,
features: [
{ text: 'Вхід для іменинника + 20 гостей' },
{ text: 'Індивідуальний сценарій' },
{ text: 'Квест по всьому парку' },
{ text: 'Відео-звіт свята' },
{ text: 'Персональний менеджер' },
],
featured: false, sort: 3,
ctaHref: '/dni-narodzhennia#order-form',
},
]
for (const pkg of packages) {
await payload.create({
collection: 'birthday-packages',
data: pkg as never,
overrideAccess: true,
})
}
results.push('Seeded 3 birthday packages')
} else {
results.push('Birthday packages already exist')
}
// === HOME PAGE GLOBAL ===
const wpGalleryFiles = [
{ file: 'why-parents-1.png', alt: 'Шуміленд — фото 1' },
{ file: 'why-parents-2.png', alt: 'Шуміленд — фото 2' },
{ file: 'why-parents-3.png', alt: 'Шуміленд — фото 3' },
{ file: 'why-parents-4.png', alt: 'Шуміленд — фото 4' },
{ file: 'gallery-1.png', alt: 'Шуміленд — фото 5' },
{ file: 'gallery-3.png', alt: 'Шуміленд — фото 6' },
]
const galleryFiles = [
{ file: 'gallery-1.png', alt: 'Шуміленд — галерея 1' },
{ file: 'why-parents-1.png', alt: 'Шуміленд — галерея 2' },
{ file: 'gallery-2.png', alt: 'Шуміленд — галерея 3' },
{ file: 'gallery-3.png', alt: 'Шуміленд — галерея 4' },
{ file: 'why-parents-2.png', alt: 'Шуміленд — галерея 5' },
{ file: 'gallery-4.png', alt: 'Шуміленд — галерея 6' },
{ file: 'gallery-6.png', alt: 'Шуміленд — галерея 7' },
{ file: 'gallery-7.png', alt: 'Шуміленд — галерея 8' },
{ file: 'gallery-8.png', alt: 'Шуміленд — галерея 9' },
]
const videoPosterId = await uploadMedia(payload, 'video-preview.png', 'Відео про Шуміленд')
const wpGalleryIds = await Promise.all(wpGalleryFiles.map(({ file, alt }) => uploadMedia(payload, file, alt)))
const galleryIds = await Promise.all(galleryFiles.map(({ file, alt }) => uploadMedia(payload, file, alt)))
await payload.updateGlobal({
slug: 'home-page',
data: {
hero: {
title: 'ШУМІЛЕНД \nСВІТ, ДЕ КАЗКА\nОЖИВАЄ',
subtitle: 'Сімейний тематичний парк, де гра допомагає пізнавати світ, а кожна прогулянка перетворюється на незабутню пригоду.',
ctaLabel: 'Купити квиток',
ctaHref: '/kvytky',
},
sectionTitles: {
locations: 'ЛАСКАВО ПРОСИМО ДО ШУМІЛЕНДУ',
whyParents: 'Чому батьки обирають Шуміленд',
birthday: 'День Народження в Шуміленді',
gallery: 'Фотогалерея',
reviews: 'Відгуки',
news: 'Новини',
},
whyParents: {
items: [
{ title: 'Подорож кількома світами за один день', description: 'ДиноПарк, Диво Ліс, Дзеркальний лабіринт — кожна локація це окремий всесвіт пригод для дітей і батьків.' },
{ title: 'Свіже повітря та затишок лісу', description: "Ми оновлюємо тематику та декорації до кожного сезону, тому тут буде цікаво кожного візиту." },
{ title: 'Нова казка кожної пори року', description: 'Зима, весна, літо, осінь — кожен сезон у парку неповторний. Святкові декорації та тематичні заходи чекають на вас.' },
{ title: 'Безпека понад усе', description: 'Всі атракції та зони проходять регулярну перевірку. Охоронці, медичний персонал та чіткі правила безпеки.' },
{ title: 'Все необхідне — поруч і без пошуків', description: 'Паркування, вбиральні, зона для годування немовлят, укриття, фудкорт — все на місці.' },
{ title: 'Фудкорт — смачно для всієї родини', description: 'Хот-доги, піца, кава, лимонади та багато іншого. Є дитяче меню та здорові перекуси.' },
],
sideGallery: wpGalleryIds.filter(Boolean).map((id) => ({ image: id })),
},
gallery: {
images: galleryIds.filter((id): id is string => !!id).map((id, i) => ({ image: id, alt: galleryFiles[i]?.alt ?? '' })),
},
video: {
poster: videoPosterId ?? undefined,
src: null,
},
birthdayIntro: {
text: 'Незабутнє свято для вашої дитини. Ми подбаємо про все: від декорацій до аніматорів!',
},
news: { title: 'Новини', limit: 3 },
} as never,
overrideAccess: true,
})
results.push('Seeded home-page global')
// === HEADER ===
await payload.updateGlobal({
slug: 'header',
data: {
ctaLabel: 'Купити квиток',
ctaHref: '/kvytky',
navLinks: [
{ label: 'Головна', href: '/', autoChildrenFrom: 'none' },
{ label: 'Локації', href: '/lokatsii', autoChildrenFrom: 'locations' },
{ label: 'Блог', href: '/blog', autoChildrenFrom: 'none' },
{ label: 'Дні народження', href: '/dni-narodzhennia', autoChildrenFrom: 'none' },
{ label: 'Групові відвідування', href: '/grupovi-vidviduvannia', autoChildrenFrom: 'none' },
],
} as never,
overrideAccess: true,
})
results.push('Seeded header global')
// === FOOTER ===
await payload.updateGlobal({
slug: 'footer',
data: { copyrightText: `© Шуміленд ${new Date().getFullYear()}` } as never,
overrideAccess: true,
})
results.push('Seeded footer global')
// === SITE SETTINGS ===
await payload.updateGlobal({
slug: 'site-settings',
data: {} as never,
overrideAccess: true,
})
results.push('Seeded site-settings global')
// === BLOG POSTS ===
const { totalDocs: postCount } = await payload.find({ collection: 'blog-posts', limit: 1, overrideAccess: true })
if (postCount === 0) {
const postDefs = [
{ title: 'Сезон динозаврів відкрито!', slug: 'sezon-dynozavriv-vidkryto', excerpt: 'Шуміленд вітає нових мешканців ДиноПарку — зустрічайте 12 нових динозаврів!', status: 'published', publishedAt: '2025-04-01T10:00:00.000Z', imgFile: 'news-bg1.jpg' },
{ title: 'Весняні канікули в Шуміленді', slug: 'vesniani-kanikuly', excerpt: 'Проведіть весняні канікули незабутньо! Спеціальні активності щодня з 28 березня по 6 квітня.', status: 'published', publishedAt: '2025-03-20T10:00:00.000Z', imgFile: 'news-bg2.png' },
{ title: 'Нова локація: Тир з призами', slug: 'nova-lokatsiya-tyr-z-pryzamy', excerpt: 'Відтепер у Шуміленді є Тир з призами — точний постріл приносить реальний виграш!', status: 'published', publishedAt: '2025-03-10T10:00:00.000Z', imgFile: 'news-bg3.jpg' },
]
for (const post of postDefs) {
const heroId = await uploadMedia(payload, post.imgFile, post.title)
await payload.create({
collection: 'blog-posts',
data: { title: post.title, slug: post.slug, excerpt: post.excerpt, status: post.status, publishedAt: post.publishedAt, hero: heroId ? { image: heroId } : undefined } as never,
overrideAccess: true,
})
}
results.push('Seeded 3 blog posts')
} else {
results.push('Blog posts already exist')
}
// === TARIFFS ===
const { totalDocs: tariffCount } = await payload.find({ collection: 'tariffs', limit: 1, overrideAccess: true })
if (tariffCount === 0) {
const tariffs = [
{ ezy_id: 1001, last_synced_name: 'Дорослий — ДиноПарк', display_name: 'Дорослий', last_synced_price: 350, category_tag: 'dyno', sort: 1, visible: true },
{ ezy_id: 1002, last_synced_name: 'Дитячий — ДиноПарк', display_name: 'Дитячий (312 років)', last_synced_price: 250, category_tag: 'dyno', sort: 2, visible: true },
{ ezy_id: 1003, last_synced_name: 'До 3 років — ДиноПарк', display_name: 'До 3 років (безкоштовно)', last_synced_price: 0, category_tag: 'dyno', sort: 3, visible: true },
{ ezy_id: 2001, last_synced_name: 'Дорослий — Диво Ліс', display_name: 'Дорослий', last_synced_price: 300, category_tag: 'dyvolis', sort: 1, visible: true },
{ ezy_id: 2002, last_synced_name: 'Дитячий — Диво Ліс', display_name: 'Дитячий (312 років)', last_synced_price: 200, category_tag: 'dyvolis', sort: 2, visible: true },
{ ezy_id: 3001, last_synced_name: 'Комбо дорослий', display_name: 'Комбо дорослий', last_synced_price: 550, category_tag: 'combo', sort: 1, visible: true },
{ ezy_id: 3002, last_synced_name: 'Комбо дитячий', display_name: 'Комбо дитячий', last_synced_price: 400, category_tag: 'combo', sort: 2, visible: true },
{ ezy_id: 4001, last_synced_name: 'Сімейний (2+2)', display_name: 'Сімейний квиток', last_synced_price: 1200, category_tag: 'family', sort: 1, visible: true },
]
for (const t of tariffs) {
await payload.create({ collection: 'tariffs', data: t as never, overrideAccess: true })
}
results.push('Seeded 8 tariffs')
} else {
results.push('Tariffs already exist')
}
return NextResponse.json({ ok: true, results })
}

View file

@ -0,0 +1,28 @@
import type { CollectionConfig } from 'payload'
import { isAdminOrEditor } from '@/access/isAdminOrEditor'
export const BirthdayPackages: CollectionConfig = {
slug: 'birthday-packages',
access: { read: () => true, create: isAdminOrEditor, update: isAdminOrEditor, delete: isAdminOrEditor },
admin: {
useAsTitle: 'name',
defaultColumns: ['name', 'price', 'featured', 'sort'],
},
fields: [
{ name: 'name', type: 'text', required: true },
{ name: 'slug', type: 'text', required: true, unique: true },
{ name: 'price', type: 'number', required: true },
{ name: 'currency', type: 'text', defaultValue: '₴' },
{ name: 'priceLabel', type: 'text' },
{
name: 'features',
type: 'array',
fields: [{ name: 'text', type: 'text', required: true }],
},
{ name: 'featured', type: 'checkbox', defaultValue: false },
{ name: 'badge', type: 'text' },
{ name: 'ctaLabel', type: 'text', defaultValue: 'Обрати пакет' },
{ name: 'ctaHref', type: 'text' },
{ name: 'sort', type: 'number', defaultValue: 0 },
],
}

View file

@ -0,0 +1,28 @@
import type { CollectionConfig } from 'payload'
import { isAdminOrEditor } from '@/access/isAdminOrEditor'
export const Locations: CollectionConfig = {
slug: 'locations',
access: { read: () => true, create: isAdminOrEditor, update: isAdminOrEditor, delete: isAdminOrEditor },
admin: {
useAsTitle: 'name',
defaultColumns: ['name', 'slug', 'showInMenu', 'showOnHome', 'sort'],
},
fields: [
{ name: 'name', type: 'text', required: true },
{ name: 'slug', type: 'text', required: true, unique: true },
{ name: 'tagline', type: 'text' },
{ name: 'shortDesc', type: 'textarea' },
{ name: 'description', type: 'richText' },
{ name: 'image', type: 'upload', relationTo: 'media' },
{
name: 'gallery',
type: 'array',
fields: [{ name: 'image', type: 'upload', relationTo: 'media' }],
},
{ name: 'href', type: 'text' },
{ name: 'showInMenu', type: 'checkbox', defaultValue: true },
{ name: 'showOnHome', type: 'checkbox', defaultValue: true },
{ name: 'sort', type: 'number', defaultValue: 0 },
],
}

View file

@ -0,0 +1,32 @@
import type { CollectionConfig } from 'payload'
import { isAdminOrEditor } from '@/access/isAdminOrEditor'
export const Reviews: CollectionConfig = {
slug: 'reviews',
access: { read: () => true, create: isAdminOrEditor, update: isAdminOrEditor, delete: isAdminOrEditor },
admin: {
useAsTitle: 'name',
defaultColumns: ['name', 'source', 'rating', 'showOnHome', 'sort'],
},
fields: [
{ name: 'name', type: 'text', required: true },
{ name: 'initial', type: 'text', maxLength: 2 },
{ name: 'avatarBg', type: 'upload', relationTo: 'media' },
{ name: 'ago', type: 'text' },
{ name: 'rating', type: 'number', defaultValue: 5, min: 1, max: 5 },
{ name: 'text', type: 'textarea', required: true },
{
name: 'source',
type: 'select',
options: [
{ label: 'Google', value: 'google' },
{ label: 'Facebook', value: 'facebook' },
{ label: 'Instagram', value: 'instagram' },
{ label: 'Manual', value: 'manual' },
],
defaultValue: 'google',
},
{ name: 'showOnHome', type: 'checkbox', defaultValue: true },
{ name: 'sort', type: 'number', defaultValue: 0 },
],
}

View file

@ -1,11 +1,22 @@
import { getGlobal } from '@/lib/payload'
import { getHeaderLocations } from '@/lib/getHomeData'
import { safeCmsHref } from '@/lib/cn'
import { HeaderClient, type NavLinkItem } from './HeaderClient'
import type { HeaderGlobal } from '@/types/globals'
const DEFAULT_NAV: NavLinkItem[] = [
{ label: 'Головна', href: '/' },
{ label: 'Локації', href: '/lokatsii' },
{
label: 'Локації',
href: '/lokatsii',
children: [
{ label: 'ДиноПарк', href: '/lokatsii#dynopark' },
{ label: 'Диво Ліс', href: '/lokatsii#dyvolis' },
{ label: 'Дзеркальний Лабіринт', href: '/lokatsii#maze' },
{ label: 'Тир з призами', href: '/lokatsii#tir' },
{ label: 'Дитячий майданчик', href: '/lokatsii#playground' },
],
},
{ label: 'Блог', href: '/blog' },
{ label: 'Дні народження', href: '/dni-narodzhennia' },
{ label: 'Групові відвідування', href: '/grupovi-vidviduvannia' },
@ -17,11 +28,37 @@ export async function Header() {
let ctaLabel = 'Купити квиток'
try {
const header = await getGlobal<HeaderGlobal>('header')
const [header, menuLocations] = await Promise.all([
getGlobal<HeaderGlobal>('header'),
getHeaderLocations(),
])
if (header?.ctaLabel) ctaLabel = header.ctaLabel
if (header?.ctaHref) ctaHref = safeCmsHref(header.ctaHref) ?? header.ctaHref
if (header?.navLinks && header.navLinks.length > 0) {
const parsed = header.navLinks
const parsed: NavLinkItem[] = header.navLinks
.filter((l) => l.label && l.href)
.map((l) => ({ label: l.label!, href: safeCmsHref(l.href) ?? l.href! }))
.map((l) => {
const item: NavLinkItem = {
label: l.label!,
href: safeCmsHref(l.href) ?? l.href!,
}
if (l.autoChildrenFrom === 'locations' && menuLocations.length > 0) {
item.children = menuLocations.map((loc) => ({
label: loc.name,
href: loc.href ?? `/lokatsii#${loc.slug}`,
}))
} else if (l.children && l.children.length > 0) {
item.children = l.children
.filter((c) => c.label && c.href)
.map((c) => ({ label: c.label!, href: c.href! }))
}
return item
})
if (parsed.length > 0) navLinks = parsed
}
} catch {

View file

@ -13,6 +13,7 @@ const LOGO_G3 = '/images/figma/logo-g3.svg'
export interface NavLinkItem {
label: string
href: string
children?: { label: string; href: string }[]
}
interface HeaderClientProps {
@ -37,7 +38,7 @@ export function HeaderClient({ navLinks, ctaLabel, ctaHref }: HeaderClientProps)
return (
<header className="sticky top-0 z-50 px-[10px]">
<div
className="relative mx-auto max-w-[1204px] overflow-hidden rounded-b-[20px]"
className="relative mx-auto max-w-[1204px] rounded-b-[20px]"
style={{
// Liquid-glass: heavy blur + saturate + thin dark-green wash.
// Webkit prefix kept for Safari < 18.
@ -87,8 +88,41 @@ export function HeaderClient({ navLinks, ctaLabel, ctaHref }: HeaderClientProps)
<nav aria-label="Головна навігація" className="hidden lg:flex">
<ul className="flex items-center gap-[24px] list-none m-0 p-0">
{navLinks.map((link) => (
<li key={link.href}>
<li key={link.href} className="relative group">
<NavLink href={link.href}>{link.label}</NavLink>
{link.children && (
<div
className="pointer-events-none absolute top-full left-0 mt-2 min-w-[220px] overflow-hidden rounded-[16px] py-2 z-50 opacity-0 translate-y-1 transition-all duration-150 group-hover:pointer-events-auto group-hover:opacity-100 group-hover:translate-y-0"
style={{
backgroundColor: 'rgba(34, 62, 13, 0.42)',
backdropFilter: 'blur(22px) saturate(160%)',
WebkitBackdropFilter: 'blur(22px) saturate(160%)',
boxShadow:
'inset 0 1px 0 0 rgba(255,255,255,0.18), 0 8px 32px rgba(0,0,0,0.28)',
}}
>
{/* Highlight sheen — matches header glass top edge */}
<span
aria-hidden="true"
className="pointer-events-none absolute inset-x-0 top-0 h-[20px]"
style={{
background:
'linear-gradient(to bottom, rgba(255,255,255,0.10), rgba(255,255,255,0))',
}}
/>
{link.children.map((child) => (
<Link
key={child.href}
href={child.href}
className="relative block px-5 py-[10px] text-white font-bold text-[16px] transition-colors hover:text-[#f28b4a]"
style={{ fontFamily: 'var(--font-montserrat, Montserrat), sans-serif' }}
>
{child.label}
</Link>
))}
</div>
)}
</li>
))}
</ul>
@ -148,6 +182,22 @@ export function HeaderClient({ navLinks, ctaLabel, ctaHref }: HeaderClientProps)
>
{link.label}
</Link>
{link.children && (
<ul className="flex flex-col gap-2 mt-2 ml-4 list-none p-0">
{link.children.map((child) => (
<li key={child.href}>
<Link
href={child.href}
onClick={() => setMenuOpen(false)}
className="text-white/70 font-medium text-[16px] hover:text-[#f28b4a] transition-colors"
style={{ fontFamily: 'var(--font-montserrat, Montserrat), sans-serif' }}
>
{child.label}
</Link>
</li>
))}
</ul>
)}
</li>
))}
<li>

View file

@ -1,57 +1,69 @@
/* eslint-disable @next/next/no-img-element */
import Link from 'next/link'
import type { BirthdayPackageCMS } from '@/types/globals'
const IMG_CHECK = '/images/figma/check-mark.png'
const PACKAGES = [
const STATIC_PACKAGES: BirthdayPackageCMS[] = [
{
id: 'standard',
name: 'Стандарт',
price: '4 500 ₴',
slug: 'standard',
price: 4500,
featured: false,
features: [
'Вхід для іменинника + 5 дітей',
'Анімаційна програма (1 год)',
'Декорації столу',
'Торт від кондитера',
{ text: 'Вхід для іменинника + 5 дітей' },
{ text: 'Анімаційна програма (1 год)' },
{ text: 'Декорації столу' },
{ text: 'Торт від кондитера' },
],
ctaHref: '/dni-narodzhennia#order-form',
ctaLabel: 'Обрати пакет',
},
{
id: 'premium',
name: 'Преміум',
price: '8 900 ₴',
slug: 'premium',
price: 8900,
featured: true,
badge: 'Найпопулярніший',
features: [
'Вхід для іменинника + 10 дітей',
'Анімаційна програма (3 год)',
'Повна декорація локації',
'Фотозона та фотограф',
'Сюрприз-подарунок кожному',
{ text: 'Вхід для іменинника + 10 дітей' },
{ text: 'Анімаційна програма (3 год)' },
{ text: 'Повна декорація локації' },
{ text: 'Фотозона та фотограф' },
{ text: 'Сюрприз-подарунок кожному' },
],
ctaHref: '/dni-narodzhennia#order-form',
ctaLabel: 'Обрати пакет',
},
{
id: 'vip',
name: 'VIP',
price: '15 000 ₴',
slug: 'vip',
price: 15000,
featured: false,
features: [
'Вхід для іменинника + 20 гостей',
'Індивідуальний сценарій',
'Квест по всьому парку',
'Відео-звіт свята',
'Персональний менеджер',
{ text: 'Вхід для іменинника + 20 гостей' },
{ text: 'Індивідуальний сценарій' },
{ text: 'Квест по всьому парку' },
{ text: 'Відео-звіт свята' },
{ text: 'Персональний менеджер' },
],
ctaHref: '/dni-narodzhennia#order-form',
ctaLabel: 'Обрати пакет',
},
]
/**
* Birthday pricing block Figma 1204×1052 (212 + 840).
* Cards holder: 3 columns at left:0 / 421 / 842 (gap 59), each 362 wide.
* Featured card sticks up by 84px (badge + slight overlap).
*
* Each card is a static stack: header (title + divider + price) features
* list CTA button. No flex-1 on the list content stacks naturally and
* any extra card height shows as bottom padding (matches Figma exactly).
*/
export function BirthdayPricing() {
interface BirthdayPricingProps {
packages?: BirthdayPackageCMS[]
title?: string
intro?: string
}
export function BirthdayPricing({ packages, title, intro }: BirthdayPricingProps) {
const activePackages = packages && packages.length > 0 ? packages : STATIC_PACKAGES
return (
<section className="py-[20px] md:py-[60px] lg:py-[40px]">
<div className="max-w-[1204px] mx-auto px-8">
@ -60,19 +72,20 @@ export function BirthdayPricing() {
className="font-bold text-[24px] md:text-[32px] text-[#272727] uppercase"
style={{ fontFamily: 'var(--font-montserrat, Montserrat), sans-serif' }}
>
День Народження в Шуміленді
{title ?? 'День Народження в Шуміленді'}
</h2>
<p
className="text-[#272727] text-[16px]"
style={{ fontFamily: 'var(--font-poppins, Poppins), sans-serif', fontWeight: 300 }}
>
Незабутнє свято для вашої дитини. Ми подбаємо про все: від декорацій до аніматорів!
{intro ?? 'Незабутнє свято для вашої дитини. Ми подбаємо про все: від декорацій до аніматорів!'}
</p>
</div>
{/* Figma 1:231 — cards same width 362px, featured lifts up by 60px via negative margin */}
<div className="flex flex-col lg:flex-row lg:items-end justify-center gap-10 lg:gap-[59px]">
{PACKAGES.map((pkg) => (
<PricingCard key={pkg.name} {...pkg} />
{activePackages.map((pkg) => (
<PricingCard key={pkg.id} pkg={pkg} />
))}
</div>
</div>
@ -80,23 +93,20 @@ export function BirthdayPricing() {
)
}
interface PricingCardProps {
name: string
price: string
featured: boolean
badge?: string
features: string[]
function formatPrice(price: number, currency: string | null | undefined): string {
return `${price.toLocaleString('uk-UA')} ${currency ?? '₴'}`
}
function PricingCard({ name, price, featured, badge, features }: PricingCardProps) {
function PricingCard({ pkg }: { pkg: BirthdayPackageCMS }) {
const { name, price, currency = '₴', priceLabel, featured, badge, features, ctaLabel, ctaHref, slug } = pkg
const href = ctaHref ?? `/dni-narodzhennia#order-form${slug ? `?package=${slug}` : ''}`
return (
<div
className="flex flex-col items-center w-full lg:w-[362px]"
// Featured wrapper is 772 (badge 30 + overlap -14 + card 756);
// non-featured wrapper is 840 (84 padding-top + card 756).
// The whole row is `lg:items-end`, so featured pops up.
style={featured ? { marginBottom: '60px' } : undefined}
>
{/* Badge — only on featured. Sits *above* the card, overlapping by 14px. */}
{/* Desktop badge — overlaps card top by 14px */}
{featured && badge && (
<div
className="relative z-10 hidden lg:flex items-center justify-center text-white font-medium px-5 h-[30px] rounded-[24px]"
@ -112,7 +122,7 @@ function PricingCard({ name, price, featured, badge, features }: PricingCardProp
</div>
)}
{/* Mobile badge above the card */}
{/* Mobile badge */}
{featured && badge && (
<div
className="lg:hidden mb-3 inline-flex items-center justify-center text-white font-medium px-5 h-[30px] rounded-[24px]"
@ -148,24 +158,32 @@ function PricingCard({ name, price, featured, badge, features }: PricingCardProp
>
{name}
</p>
<div className="w-full h-px bg-white/100" />
<div className="w-full h-px bg-white" />
<p
className="text-white text-center leading-none"
style={{
fontFamily: 'var(--font-inter, Inter), sans-serif',
fontSize: 64,
fontSize: 56,
fontWeight: 400,
margin: 0,
}}
>
{price}
{formatPrice(price, currency)}
</p>
{priceLabel && (
<p
className="text-white/70 text-center text-[14px] -mt-4"
style={{ fontFamily: 'var(--font-inter, Inter), sans-serif' }}
>
{priceLabel}
</p>
)}
</div>
{/* Features — natural flow, NOT flex-1. Gap 16, top margin 40. */}
{/* Features */}
<ul className="list-none m-0 p-0 flex flex-col gap-4" style={{ marginTop: 40 }}>
{features.map((f) => (
<li key={f} className="flex items-start gap-6">
{(features ?? []).map((f, i) => (
<li key={i} className="flex items-start gap-6">
<img
src={IMG_CHECK}
alt=""
@ -176,15 +194,15 @@ function PricingCard({ name, price, featured, badge, features }: PricingCardProp
className="text-white text-[20px] font-medium leading-[1.5]"
style={{ fontFamily: 'var(--font-montserrat, Montserrat), sans-serif' }}
>
{f}
{f.text}
</span>
</li>
))}
</ul>
{/* CTA — sits 40px below the features list */}
{/* CTA */}
<Link
href="/kvytky"
href={href}
className="mt-10 flex items-center justify-center w-full rounded-[56px] text-white font-bold text-[20px] text-center transition-opacity hover:opacity-90"
style={{
backgroundColor: featured ? '#223e0d' : '#f28b4a',
@ -192,7 +210,7 @@ function PricingCard({ name, price, featured, badge, features }: PricingCardProp
height: 44,
}}
>
Обрати пакет
{ctaLabel ?? 'Обрати пакет'}
</Link>
</div>
</div>

View file

@ -1,33 +1,55 @@
import { GallerySlider } from './GallerySlider'
import type { GalleryImage } from './GallerySlider'
import type { HomePageGalleryImage, Media } from '@/types/globals'
const IMAGES: GalleryImage[] = [
{ src: '/images/figma/why-parents-1.png', alt: 'Шуміленд — сімейні враження', width: 320, height: 420, radius: 20 },
{ src: '/images/figma/gallery-6.png', alt: 'Шуміленд — атракціони', width: 380, height: 420, radius: 20 },
{ src: '/images/figma/gallery-7.png', alt: 'Шуміленд — фото 3', width: 320, height: 420, radius: 20 },
{ src: '/images/figma/why-parents-2.png', alt: 'Шуміленд — прогулянка', width: 380, height: 420, radius: 20 },
{ src: '/images/figma/news-bg2.png', alt: 'Шуміленд — зони парку', width: 320, height: 420, radius: 20 },
{ src: '/images/figma/gallery-4.png', alt: 'Шуміленд — фото 5', width: 380, height: 420, radius: 20 },
{ src: '/images/figma/why-parents-3.png', alt: 'Шуміленд — відпочинок', width: 320, height: 420, radius: 20 },
{ src: '/images/figma/gallery-2.png', alt: 'Шуміленд — фото 7', width: 380, height: 420, radius: 20 },
{ src: '/images/figma/news-bg6.png', alt: 'Шуміленд — Шумі Кафе', width: 320, height: 420, radius: 20 },
{ src: '/images/figma/why-parents-4.png', alt: 'Шуміленд — карта парку', width: 380, height: 420, radius: 20 },
function getMediaUrl(img: Media | string | null | undefined): string | null {
if (!img) return null
if (typeof img === 'string') return img
return img.url ?? null
}
const STATIC_IMAGES: GalleryImage[] = [
{ src: '/images/figma/gallery-1.png', alt: 'Шуміленд — фото 1', width: 320, height: 420, radius: 20 },
{ src: '/images/figma/why-parents-1.png', alt: 'Шуміленд — сімейні враження', width: 380, height: 420, radius: 20 },
{ src: '/images/figma/gallery-2.png', alt: 'Шуміленд — фото 2', width: 320, height: 420, radius: 20 },
{ src: '/images/figma/gallery-3.png', alt: 'Шуміленд — фото 3', width: 380, height: 420, radius: 20 },
{ src: '/images/figma/why-parents-2.png', alt: 'Шуміленд — прогулянка', width: 320, height: 420, radius: 20 },
{ src: '/images/figma/gallery-4.png', alt: 'Шуміленд — фото 4', width: 380, height: 420, radius: 20 },
{ src: '/images/figma/gallery-6.png', alt: 'Шуміленд — атракціони', width: 320, height: 420, radius: 20 },
{ src: '/images/figma/gallery-7.png', alt: 'Шуміленд — фото 5', width: 380, height: 420, radius: 20 },
{ src: '/images/figma/gallery-8.png', alt: 'Шуміленд — фото 6', width: 320, height: 420, radius: 20 },
]
export function Gallery() {
interface GalleryProps {
images?: HomePageGalleryImage[]
title?: string
}
export function Gallery({ images, title }: GalleryProps) {
const items: GalleryImage[] =
images && images.length > 0
? images.map((img, i) => ({
src: getMediaUrl(img.image) ?? '/images/figma/gallery-1.png',
alt: img.alt ?? `Шуміленд — фото ${i + 1}`,
width: i % 2 === 0 ? 320 : 380,
height: 420,
radius: 20,
}))
: STATIC_IMAGES
return (
<section className="py-[20px] md:py-[60px] lg:py-[40px]">
<div className="max-w-[1204px] mx-auto px-8 mb-[40px] md:mb-[60px]">
<div className="mx-auto mb-[40px] max-w-[1204px] px-8 md:mb-[60px]">
<h2
className="font-bold text-[24px] md:text-[32px] text-[#272727] uppercase"
className="text-[24px] font-bold text-[#272727] uppercase md:text-[32px]"
style={{ fontFamily: 'var(--font-montserrat, Montserrat), sans-serif' }}
>
Фотогалерея
{title ?? 'Фотогалерея'}
</h2>
</div>
<div className="max-w-[1204px] mx-auto px-8 relative">
<GallerySlider images={IMAGES} speed={40} />
<div className="relative mx-auto max-w-[1204px] px-8">
<GallerySlider images={items} speed={60} />
</div>
</section>
)

View file

@ -1,7 +1,8 @@
'use client'
/* eslint-disable @next/next/no-img-element */
import { useState } from 'react'
import { useState, useRef, useEffect } from 'react'
import { ImageLightbox } from '@/components/ui/ImageLightbox'
export interface GalleryImage {
src: string
@ -13,65 +14,137 @@ export interface GalleryImage {
interface GallerySliderProps {
images: GalleryImage[]
/** Approximate seconds for one full loop. Default 60. */
speed?: number
}
export function GallerySlider({ images, speed = 35 }: GallerySliderProps) {
const [paused, setPaused] = useState(false)
export function GallerySlider({ images, speed = 60 }: GallerySliderProps) {
const scrollRef = useRef<HTMLDivElement>(null)
const [scrollPaused, setScrollPaused] = useState(false)
const pauseTimer = useRef<ReturnType<typeof setTimeout> | null>(null)
const [lightboxIndex, setLightboxIndex] = useState<number | null>(null)
// Duplicate for seamless infinite loop
const doubled = [...images, ...images]
return (
<div
className="relative w-full overflow-hidden"
style={{
maskImage:
'linear-gradient(90deg, transparent 0%, black 8%, black 92%, transparent 100%)',
WebkitMaskImage:
'linear-gradient(90deg, transparent 0%, black 8%, black 92%, transparent 100%)',
}}
onMouseEnter={() => setPaused(true)}
onMouseLeave={() => setPaused(false)}
>
<style>{`
@keyframes shumiland-marquee {
0% { transform: translateX(0); }
100% { transform: translateX(-50%); }
}
.shumiland-marquee-track {
animation: shumiland-marquee ${speed}s linear infinite;
will-change: transform;
}
.shumiland-marquee-track.paused {
animation-play-state: paused;
}
`}</style>
useEffect(() => {
const el = scrollRef.current
if (!el) return
if (typeof window !== 'undefined' && window.matchMedia?.('(prefers-reduced-motion: reduce)').matches) return
<div
className={`shumiland-marquee-track flex gap-[26px] pb-2${paused ? ' paused' : ''}`}
style={{ width: 'max-content' }}
>
{doubled.map((img, i) => {
const w = img.width ?? 384
const h = img.height ?? 280
const r = img.radius ?? 20
return (
<div
key={i}
className="flex-none overflow-hidden shrink-0 group cursor-pointer"
style={{ width: `${w}px`, height: `${h}px`, borderRadius: `${r}px` }}
>
<img
src={img.src}
alt={img.alt}
className="w-full h-full object-cover transition-transform duration-700 ease-out group-hover:scale-110"
loading="lazy"
/>
</div>
)
})}
// ~1px per tick — tune with speed prop
const pxPerTick = 1
const intervalMs = Math.max(8, Math.round((speed * 1000) / (el.scrollWidth / 2)))
const id = setInterval(() => {
if (scrollPaused) return
const half = el.scrollWidth / 2
el.scrollLeft += pxPerTick
if (el.scrollLeft >= half) el.scrollLeft = 0
}, intervalMs)
return () => clearInterval(id)
}, [scrollPaused, speed])
function scrollByCard(dir: 1 | -1) {
const cardWidth = 380 + 26
scrollRef.current?.scrollBy({ left: dir * cardWidth, behavior: 'smooth' })
setScrollPaused(true)
if (pauseTimer.current) clearTimeout(pauseTimer.current)
pauseTimer.current = setTimeout(() => setScrollPaused(false), 3000)
}
function openLightbox(idx: number) {
setLightboxIndex(idx % images.length)
setScrollPaused(true)
}
function closeLightbox() {
setLightboxIndex(null)
setScrollPaused(false)
}
function prevImage() {
setLightboxIndex((i) => (i === null ? 0 : (i - 1 + images.length) % images.length))
}
function nextImage() {
setLightboxIndex((i) => (i === null ? 0 : (i + 1) % images.length))
}
return (
<>
<div className="relative">
<button
onClick={() => scrollByCard(-1)}
className="absolute left-0 top-1/2 z-10 hidden -translate-x-6 -translate-y-1/2 md:flex h-12 w-12 items-center justify-center rounded-full bg-[#223e0d] text-white shadow-lg transition-all duration-200 hover:scale-110 hover:bg-[#f28b4a]"
aria-label="Попередні фото"
>
<svg width="20" height="20" viewBox="0 0 20 20" fill="none" aria-hidden="true">
<path d="M13 4L7 10L13 16" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" />
</svg>
</button>
<div
className="relative w-full overflow-hidden"
style={{
maskImage: 'linear-gradient(90deg, transparent 0%, black 8%, black 92%, transparent 100%)',
WebkitMaskImage: 'linear-gradient(90deg, transparent 0%, black 8%, black 92%, transparent 100%)',
}}
onMouseEnter={() => setScrollPaused(true)}
onMouseLeave={() => setScrollPaused(false)}
>
<div
ref={scrollRef}
className="flex gap-[26px] pb-2 overflow-x-scroll"
style={{ scrollbarWidth: 'none', msOverflowStyle: 'none' }}
>
{doubled.map((img, i) => {
const w = img.width ?? 384
const h = img.height ?? 280
const r = img.radius ?? 20
return (
<div
key={i}
className="group flex-none shrink-0 cursor-pointer overflow-hidden"
style={{ width: `${w}px`, height: `${h}px`, borderRadius: `${r}px` }}
onClick={() => openLightbox(i)}
role="button"
aria-label={`Відкрити фото: ${img.alt}`}
tabIndex={0}
onKeyDown={(e) => e.key === 'Enter' && openLightbox(i)}
>
<img
src={img.src}
alt={img.alt}
className="h-full w-full object-cover transition-transform duration-700 ease-out group-hover:scale-110"
loading="lazy"
/>
</div>
)
})}
</div>
</div>
<button
onClick={() => scrollByCard(1)}
className="absolute right-0 top-1/2 z-10 hidden translate-x-6 -translate-y-1/2 md:flex h-12 w-12 items-center justify-center rounded-full bg-[#223e0d] text-white shadow-lg transition-all duration-200 hover:scale-110 hover:bg-[#f28b4a]"
aria-label="Наступні фото"
>
<svg width="20" height="20" viewBox="0 0 20 20" fill="none" aria-hidden="true">
<path d="M7 4L13 10L7 16" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" />
</svg>
</button>
</div>
</div>
{lightboxIndex !== null && (
<ImageLightbox
images={images}
index={lightboxIndex}
onClose={closeLightbox}
onPrev={prevImage}
onNext={nextImage}
/>
)}
</>
)
}

View file

@ -3,6 +3,7 @@ import type { HomePageHero, Media } from '@/types/globals'
import { BtnPrimary } from '@/components/ui/BtnPrimary'
const IMG_BG2 = '/images/figma/hero-bg2.png'
const IMG_BG1 = '/images/figma/hero-bg1.png'
const IMG_FAMILY = '/images/figma/hero-bg-family.png'
interface HeroProps {
@ -23,13 +24,14 @@ export function Hero({ hero }: HeroProps) {
const { title, subtitle, ctaLabel, ctaHref, backgroundImage, backgroundVideo } = hero
const showCta = Boolean(ctaLabel && ctaHref && isSafeHref(ctaHref))
const bgMedia = isMedia(backgroundImage) ? backgroundImage : null
const useDefaultLayers = !bgMedia && !backgroundVideo
return (
<section
className="relative mx-[10px] -mt-[60px] overflow-hidden rounded-b-[20px] bg-black lg:-mt-[120px]"
style={{ minHeight: 'min(1080px, 100vh)' }}
>
{/* Background layers */}
{/* ── Background ──────────────────────────────────────────────── */}
{backgroundVideo ? (
<video
src={backgroundVideo}
@ -55,104 +57,73 @@ export function Hero({ hero }: HeroProps) {
className="pointer-events-none absolute inset-0 h-full w-full object-cover"
/>
<img
src={IMG_FAMILY}
src={IMG_BG1}
alt=""
aria-hidden="true"
className="pointer-events-none absolute inset-0 h-full w-full object-cover"
className="pointer-events-none absolute inset-0 hidden h-full w-full object-cover lg:block"
/>
</>
)}
{/* Desktop layout aria-hidden: visual layer only, semantic content is in mobile layout below.
The mobile layout is always in the DOM; CSS hides it visually on desktop. */}
{/* ── Gradient: mobile ── */}
<div
aria-hidden="true"
className="absolute hidden lg:block"
style={{ left: '10.83%', top: '12.04%', width: '78.33%', height: '81.39%' }}
>
{/* L-shaped frosted layer */}
<svg
viewBox="0 0 1504 879"
preserveAspectRatio="none"
className="pointer-events-none absolute inset-0 h-full w-full"
aria-hidden="true"
>
<defs>
<clipPath id="hero-blur-clip">
<path d="M 1221 538.998 C 1221 550.044 1229.954 558.998 1241 558.998 L 1484 558.998 C 1495.046 558.998 1504 567.952 1504 578.998 L 1504 859 C 1504 870.046 1495.046 879 1484 879 L 20 879 C 8.954 879 0 870.046 0 859 L 0 20 C 0 8.954 8.954 0 20 0 L 1201 0 C 1212.046 0 1221 8.954 1221 20 L 1221 538.998 Z" />
</clipPath>
</defs>
<foreignObject x="0" y="0" width="1504" height="879" clipPath="url(#hero-blur-clip)">
<div
// @ts-expect-error xmlns on div in foreignObject is fine in JSX
xmlns="http://www.w3.org/1999/xhtml"
style={{
width: '100%',
height: '100%',
backdropFilter: 'blur(28px) saturate(120%)',
WebkitBackdropFilter: 'blur(28px) saturate(120%)',
backgroundColor: 'rgba(34, 62, 13, 0.49)',
}}
/>
</foreignObject>
</svg>
className="pointer-events-none absolute inset-0 lg:hidden"
style={{
background: 'linear-gradient(145deg, rgba(0,12,0,0.62) 40%, rgba(0,12,0,0.10) 90%)',
}}
/>
{title && (
<div className="absolute" style={{ left: '10.24%', top: '11.15%', width: '67.59%' }}>
<div
{/* ── Gradient: desktop — light left-to-right, keeps background visible ── */}
<div
aria-hidden="true"
className="pointer-events-none absolute inset-0 hidden lg:block"
style={{
background:
'linear-gradient(to right, rgba(0,12,0,0.58) 0%, rgba(0,12,0,0.42) 30%, rgba(0,12,0,0.10) 58%, transparent 76%)',
}}
/>
{/* ── Family + dino — ABOVE text so dino overlays the title on the right ── */}
{useDefaultLayers && (
<img
src={IMG_FAMILY}
alt=""
aria-hidden="true"
className="pointer-events-none absolute inset-0 z-[20] h-full w-full object-cover"
style={{ objectPosition: 'right center' }}
/>
)}
{/* ── Text content — single layout, responsive padding ─────────── */}
<div className="relative z-10 flex flex-col px-6 pt-[100px] pb-[60px] lg:px-0 lg:pt-[200px] lg:pb-[80px]">
{/* Align with header: max-w-[1204px] + px-[30px] mirrors HeaderClient inner container */}
<div className="lg:max-w-[1204px] lg:mx-auto lg:w-full lg:px-[30px]">
<div className="flex max-w-[85vw] flex-col gap-5 md:gap-6 lg:max-w-[66vw]">
{title && (
<h1
className="font-bold text-white uppercase"
style={{
fontFamily: 'var(--font-montserrat, Montserrat), sans-serif',
fontSize: 'clamp(48px, 6.25vw, 120px)',
lineHeight: 1.2,
fontSize: 'clamp(36px, 7.5vw, 130px)',
lineHeight: 1.05,
fontWeight: 700,
margin: 0,
whiteSpace: 'pre-line',
}}
>
{title}
</div>
</div>
)}
{subtitle && (
<div className="absolute" style={{ left: '10.24%', top: '64.62%', width: '41.82%' }}>
<p
className="text-white"
style={{
fontFamily: 'var(--font-montserrat, Montserrat), sans-serif',
fontSize: 24,
fontWeight: 500,
lineHeight: 1.5,
margin: 0,
}}
>
{subtitle}
</p>
</div>
)}
{showCta && (
<div className="absolute" style={{ left: '10.24%', top: '81.23%' }}>
<BtnPrimary href={ctaHref!}>{ctaLabel}</BtnPrimary>
</div>
)}
</div>
{/* Mobile / tablet — semantic layout (always in DOM, provides accessible content) */}
<div className="relative z-10 px-6 pt-[120px] pb-[60px] md:pt-[180px] md:pb-[80px] lg:hidden">
<div className="flex max-w-[680px] flex-col gap-[24px] md:gap-[32px]">
{title && (
<h1
className="text-[36px] leading-[1.15] font-bold text-white uppercase md:text-[64px]"
style={{ fontFamily: 'var(--font-montserrat, Montserrat), sans-serif' }}
>
{title}
</h1>
)}
{subtitle && (
<p
className="max-w-[560px] text-[16px] leading-[1.5] font-medium text-white md:text-[20px]"
style={{ fontFamily: 'var(--font-montserrat, Montserrat), sans-serif' }}
className="text-white"
style={{
fontFamily: 'var(--font-montserrat, Montserrat), sans-serif',
fontSize: 'clamp(14px, 1.25vw, 20px)',
fontWeight: 500,
lineHeight: 1.5,
maxWidth: '440px',
}}
>
{subtitle}
</p>
@ -163,6 +134,7 @@ export function Hero({ hero }: HeroProps) {
</BtnPrimary>
)}
</div>
</div>
</div>
</section>
)

View file

@ -1,63 +1,92 @@
import { LocationsSlider } from './LocationsSlider'
import type { LocationData } from './LocationsSlider'
import type { LocationCMS, Media } from '@/types/globals'
const LOCATIONS: LocationData[] = [
function getMediaUrl(img: Media | string | null | undefined): string | null {
if (!img) return null
if (typeof img === 'string') return img
return img.url ?? null
}
const STATIC_LOCATIONS: LocationData[] = [
{
name: 'ДиноПарк',
slug: 'dynopark',
tagline: 'портал у світ динозаврів',
description:
'Ви бачили їх у фільмах та мультиках, а тепер час зустріти в реальному житті та роздивитися їх зблизька! Найбільші динозаври України, які гарчать і рухаються, як справжні, захопливі відкриття та атмосфера справжньої наукової експедиції.',
'Ви бачили їх у фільмах та мультиках, а тепер час зустріти в реальному житті та роздивитися їх зблизька! Найбільші динозаври України, які гарчать і рухаються, як справжні.',
image: '/images/figma/loc-dinopark.jpg',
href: '/lokatsii',
href: '/lokatsii#dynopark',
},
{
name: 'Диво Ліс',
slug: 'dyvolis',
tagline: 'зона казкових топіарних фігур',
description:
'Тут на лісових стежках оселилися єдинороги, дракони та добрі лісові звірята. Це ідеальне місце, щоб пофантазувати разом із дитиною та придумати спільну яскраву історію.',
'Тут на лісових стежках оселилися єдинороги, дракони та добрі лісові звірята. Це ідеальне місце, щоб пофантазувати разом із дитиною.',
image: '/images/figma/loc-divo-lis.png',
href: '/lokatsii',
href: '/lokatsii#dyvolis',
},
{
name: 'Дзеркальний Лабіринт',
slug: 'maze',
tagline: 'справжній виклик кмітливості',
description:
'Чи зможете ви разом знайти вихід? Це справжній пригодницький виклик для всієї родини! Тут діти вчаться бути уважними та впевненими у собі, адже вони стають героями справжнього квесту.',
image: '/images/figma/news-bg3.jpg',
href: '/lokatsii',
'Чи зможете ви разом знайти вихід? Це справжній пригодницький виклик для всієї родини! Тут діти вчаться бути уважними та впевненими у собі.',
image: '/images/figma/gallery-1.png',
href: '/lokatsii#maze',
},
{
name: 'Тир з призами',
slug: 'tir',
tagline: 'перемога, яку ви розділите разом',
description:
'Для дітей це не просто гра, а можливість проявити себе та "заробити" подарунок. Влаштуйте дружні змагання, дайте малечі декілька уроків та виграйте класний приз.',
image: '/images/figma/news-bg4.png',
href: '/lokatsii',
image: '/images/figma/gallery-3.png',
href: '/lokatsii#tir',
},
{
name: 'Дитячий майданчик',
slug: 'playground',
tagline: 'територія забав і нових друзів',
description:
'Поки малеча підкорює гірки, випробовує безпечні лазанки та знаходить перших друзів, ви можете нарешті зробити паузу та просто спостерігати за їхніми розвагами.',
image: '/images/figma/news-bg5.png',
href: '/lokatsii',
'Поки малеча підкорює гірки, випробовує безпечні лазанки та знаходить перших друзів, ви можете нарешті зробити паузу та просто спостерігати.',
image: '/images/figma/gallery-8.png',
href: '/lokatsii#playground',
},
]
export function Locations() {
interface LocationsProps {
data?: LocationCMS[]
title?: string
}
export function Locations({ data, title }: LocationsProps) {
const locations: LocationData[] =
data && data.length > 0
? data.map((loc) => ({
name: loc.name,
slug: loc.slug,
tagline: loc.tagline ?? '',
description: loc.shortDesc ?? '',
image: getMediaUrl(loc.image) ?? '/images/figma/loc-dinopark.jpg',
href: loc.href ?? `/lokatsii#${loc.slug}`,
}))
: STATIC_LOCATIONS
return (
<section className="py-[20px] md:py-[60px] lg:py-[40px]">
<div className="max-w-[1204px] mx-auto px-8">
<div className="mx-auto max-w-[1204px] px-8">
<h2
className="font-bold text-[24px] md:text-[32px] text-[#272727] uppercase mb-[40px] md:mb-[60px]"
className="mb-[40px] text-[24px] font-bold text-[#272727] uppercase md:mb-[60px] md:text-[32px]"
style={{ fontFamily: 'var(--font-montserrat, Montserrat), sans-serif' }}
>
ЛАСКАВО ПРОСИМО ДО ШУМІЛЕНДУ
{title ?? 'ЛАСКАВО ПРОСИМО ДО ШУМІЛЕНДУ'}
</h2>
</div>
<div className="max-w-[1204px] mx-auto px-8 relative">
<LocationsSlider locations={LOCATIONS} />
<div className="relative mx-auto max-w-[1204px] px-8">
<LocationsSlider locations={locations} />
</div>
</section>
)

View file

@ -1,11 +1,12 @@
'use client'
import { useRef } from 'react'
import { useRef, useState } from 'react'
import { BtnGradient } from '@/components/ui/BtnGradient'
import { useAutoScroll } from '@/hooks/useAutoScroll'
export interface LocationData {
name: string
slug: string
tagline: string
description: string
image: string
@ -18,21 +19,32 @@ interface LocationsSliderProps {
export function LocationsSlider({ locations }: LocationsSliderProps) {
const trackRef = useRef<HTMLDivElement>(null)
useAutoScroll(trackRef, { speed: 0.5, intervalMs: 16 })
const pauseTimer = useRef<ReturnType<typeof setTimeout> | null>(null)
const [autoPaused, setAutoPaused] = useState(false)
useAutoScroll(trackRef, { speed: 1.5, intervalMs: 16, disabled: autoPaused })
function scrollByOne(dir: 1 | -1) {
trackRef.current?.scrollBy({ left: dir * 714, behavior: 'smooth' })
trackRef.current?.scrollBy({ left: dir * 580, behavior: 'smooth' })
setAutoPaused(true)
if (pauseTimer.current) clearTimeout(pauseTimer.current)
pauseTimer.current = setTimeout(() => setAutoPaused(false), 3000)
}
return (
<div className="relative">
<button
onClick={() => scrollByOne(-1)}
className="hidden lg:flex absolute left-0 top-1/2 -translate-y-1/2 -translate-x-1/2 z-10 w-12 h-12 items-center justify-center rounded-full bg-[#223e0d] text-white shadow-lg hover:bg-[#f28b4a] hover:scale-110 transition-all duration-200"
className="absolute top-1/2 left-0 z-10 hidden h-12 w-12 -translate-x-1/2 -translate-y-1/2 items-center justify-center rounded-full bg-[#223e0d] text-white shadow-lg transition-all duration-200 hover:scale-110 hover:bg-[#f28b4a] md:flex"
aria-label="Попередня локація"
>
<svg width="20" height="20" viewBox="0 0 20 20" fill="none" aria-hidden="true">
<path d="M13 4L7 10L13 16" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" />
<path
d="M13 4L7 10L13 16"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
/>
</svg>
</button>
@ -47,36 +59,36 @@ export function LocationsSlider({ locations }: LocationsSliderProps) {
>
<div
ref={trackRef}
className="flex gap-5 overflow-x-auto pb-4 scroll-smooth"
className="flex gap-5 overflow-x-auto scroll-smooth pb-4"
style={{ scrollbarWidth: 'none', msOverflowStyle: 'none' }}
>
{locations.map((loc) => (
<article
key={loc.name}
className="flex-none w-full md:w-[min(694px,90vw)] lg:w-[694px] rounded-[20px] overflow-hidden group"
className="group w-full flex-none overflow-hidden rounded-[20px] md:w-[min(560px,90vw)] lg:w-[560px]"
>
<div className="relative h-[491px]">
<div className="relative h-[420px]">
<img
src={loc.image}
alt={loc.name}
className="absolute inset-0 w-full h-full object-cover pointer-events-none transition-transform duration-700 ease-out group-hover:scale-105"
className="pointer-events-none absolute inset-0 h-full w-full object-cover transition-transform duration-700 ease-out group-hover:scale-105"
/>
<div className="absolute right-0 top-0 h-full w-[327px] bg-[rgba(34,62,13,0.85)] backdrop-blur-[2px] flex flex-col justify-center gap-7 px-[30px] transition-all duration-300 group-hover:bg-[rgba(34,62,13,0.92)]">
<div className="absolute top-0 right-0 flex h-full w-[260px] flex-col justify-center gap-5 bg-[rgba(34,62,13,0.85)] px-[24px] backdrop-blur-[2px] transition-all duration-300 group-hover:bg-[rgba(34,62,13,0.92)]">
<div className="flex flex-col gap-3 text-white">
<h3
className="font-bold text-[24px] leading-[1.1] uppercase"
className="text-[24px] leading-[1.1] font-bold uppercase"
style={{ fontFamily: 'var(--font-montserrat, Montserrat), sans-serif' }}
>
{loc.name}
</h3>
<p
className="font-medium text-[16px] leading-[1.5] text-[#fdcf54]"
className="text-[16px] leading-[1.5] font-medium text-[#fdcf54]"
style={{ fontFamily: 'var(--font-montserrat, Montserrat), sans-serif' }}
>
{loc.tagline}
</p>
<p
className="font-normal text-[15px] leading-[1.5] line-clamp-6 text-white/90"
className="line-clamp-6 text-[15px] leading-[1.5] font-normal text-white/90"
style={{ fontFamily: 'var(--font-montserrat, Montserrat), sans-serif' }}
>
{loc.description}
@ -92,11 +104,17 @@ export function LocationsSlider({ locations }: LocationsSliderProps) {
<button
onClick={() => scrollByOne(1)}
className="hidden lg:flex absolute right-0 top-1/2 -translate-y-1/2 translate-x-1/2 z-10 w-12 h-12 items-center justify-center rounded-full bg-[#223e0d] text-white shadow-lg hover:bg-[#f28b4a] hover:scale-110 transition-all duration-200"
className="absolute top-1/2 right-0 z-10 hidden h-12 w-12 translate-x-1/2 -translate-y-1/2 items-center justify-center rounded-full bg-[#223e0d] text-white shadow-lg transition-all duration-200 hover:scale-110 hover:bg-[#f28b4a] md:flex"
aria-label="Наступна локація"
>
<svg width="20" height="20" viewBox="0 0 20 20" fill="none" aria-hidden="true">
<path d="M7 4L13 10L7 16" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" />
<path
d="M7 4L13 10L7 16"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
/>
</svg>
</button>
</div>

View file

@ -1,39 +1,68 @@
'use client'
/* eslint-disable @next/next/no-img-element */
import { useRef } from 'react'
import { BtnDetails } from '@/components/ui/BtnDetails'
import { useRef, useState } from 'react'
import { useAutoScroll } from '@/hooks/useAutoScroll'
import type { ReviewCMS, Media } from '@/types/globals'
const IMG_RATE = '/images/figma/rate-stars.svg'
const IMG_AVATAR_DEFAULT = '/images/figma/review-avatar-bg.jpg'
const REVIEWS = [
function getMediaUrl(img: Media | string | null | undefined): string | null {
if (!img) return null
if (typeof img === 'string') return img
return img.url ?? null
}
const STATIC_REVIEWS: ReviewCMS[] = [
{
initial: 'Ж',
id: 'r1',
name: 'Женя Олейник',
ago: '2 months ago',
initial: 'Ж',
ago: '2 місяці тому',
rating: 5,
text: 'Beautiful, interesting for children and adults. Large area and different locations. Few visitors on weekdays.',
source: 'google',
},
{
initial: 'А',
id: 'r2',
name: 'Анна Калініченко',
ago: '6 months ago',
initial: 'А',
ago: '6 місяців тому',
rating: 5,
text: 'A wonderful dinosaur park, a park of figures made of grass. You can climb on the figures, the kids are delighted! The dinosaurs move, roar, everyone works.',
source: 'google',
},
{
initial: 'V',
id: 'r3',
name: 'Volodymyr Prisajnuk',
ago: '10 months ago',
initial: 'V',
ago: '10 місяців тому',
rating: 5,
text: 'My family and I visited the open-air park at VDNH, we really liked it! It was much better than I expected. The dinosaurs were very memorable — incredible!',
source: 'google',
},
]
export function Reviews() {
interface ReviewsProps {
data?: ReviewCMS[]
title?: string
}
export function Reviews({ data, title }: ReviewsProps) {
const activeReviews = data && data.length > 0 ? data : STATIC_REVIEWS
const doubled = [...activeReviews, ...activeReviews]
const trackRef = useRef<HTMLDivElement>(null)
useAutoScroll(trackRef, { speed: 0.5, intervalMs: 16 })
const pauseTimer = useRef<ReturnType<typeof setTimeout> | null>(null)
const [autoPaused, setAutoPaused] = useState(false)
useAutoScroll(trackRef, { speed: 0.3, intervalMs: 16, disabled: autoPaused })
function scrollByOne(dir: 1 | -1) {
trackRef.current?.scrollBy({ left: dir * 611, behavior: 'smooth' })
setAutoPaused(true)
if (pauseTimer.current) clearTimeout(pauseTimer.current)
pauseTimer.current = setTimeout(() => setAutoPaused(false), 3000)
}
return (
@ -43,13 +72,13 @@ export function Reviews() {
className="font-bold text-[24px] md:text-[32px] text-[#272727] uppercase mb-[40px] md:mb-[60px]"
style={{ fontFamily: 'var(--font-montserrat, Montserrat), sans-serif' }}
>
Відгуки
{title ?? 'Відгуки'}
</h2>
<div className="relative">
<button
onClick={() => scrollByOne(-1)}
className="hidden lg:flex absolute left-0 top-1/2 -translate-y-1/2 -translate-x-6 z-10 w-12 h-12 items-center justify-center rounded-full bg-[#223e0d] text-white shadow-lg hover:bg-[#f28b4a] transition-colors"
className="hidden md:flex absolute left-0 top-1/2 -translate-y-1/2 -translate-x-6 z-10 w-12 h-12 items-center justify-center rounded-full bg-[#223e0d] text-white shadow-lg hover:bg-[#f28b4a] transition-colors"
aria-label="Попередній відгук"
>
<svg width="20" height="20" viewBox="0 0 20 20" fill="none" aria-hidden="true">
@ -62,58 +91,66 @@ export function Reviews() {
className="flex flex-col md:flex-row gap-5 overflow-x-auto pb-2 scroll-smooth"
style={{ scrollbarWidth: 'none', msOverflowStyle: 'none' }}
>
{REVIEWS.map((review) => (
<article
key={review.name}
className="flex-none w-full md:w-[591px] bg-[#223e0d] rounded-[20px] px-[39px] py-[41px] flex flex-col gap-2.5 shadow-[0_4px_60px_0_rgba(242,139,74,0.25)]"
>
<div className="flex flex-col gap-6">
<div className="flex items-center gap-[27px]">
<div className="flex-none w-[94px] h-[94px] rounded-full bg-[#f28b4a] flex items-center justify-center overflow-hidden">
<span
className="text-white font-medium text-[48px] leading-none"
style={{ fontFamily: 'var(--font-inter, Inter), sans-serif' }}
>
{review.initial}
</span>
</div>
<div className="flex flex-col gap-2.5">
<p
className="text-white font-medium text-[24px]"
style={{ fontFamily: 'var(--font-inter, Inter), sans-serif' }}
>
{review.name}
</p>
<div className="flex items-center gap-[22px]">
<span
className="text-white font-medium text-[16px]"
{doubled.map((review, idx) => {
const avatarUrl = getMediaUrl(review.avatarBg) ?? IMG_AVATAR_DEFAULT
return (
<article
key={`${review.id}-${idx}`}
className="flex-none w-full md:w-[591px] bg-[#223e0d] rounded-[20px] px-[39px] py-[41px] flex flex-col gap-2.5 shadow-[0_4px_60px_0_rgba(242,139,74,0.25)]"
>
<div className="flex flex-col gap-6">
<div className="flex items-center gap-[27px]">
{/* Avatar: photo background + initial letter overlay */}
<div className="flex-none w-[94px] h-[94px] rounded-full overflow-hidden relative">
<img
src={avatarUrl}
alt=""
aria-hidden="true"
className="absolute inset-0 w-full h-full object-cover"
/>
<div className="absolute inset-0 flex items-center justify-center bg-[#f28b4a]/70">
<span
className="text-white font-medium text-[48px] leading-none"
style={{ fontFamily: 'var(--font-inter, Inter), sans-serif' }}
>
{review.initial ?? review.name[0]}
</span>
</div>
</div>
<div className="flex flex-col gap-2.5">
<p
className="text-white font-medium text-[24px]"
style={{ fontFamily: 'var(--font-inter, Inter), sans-serif' }}
>
{review.ago}
</span>
<img src={IMG_RATE} alt="5 зірок" className="h-5 w-[120px] object-contain" />
{review.name}
</p>
<div className="flex items-center gap-[22px]">
<span
className="text-white font-medium text-[16px]"
style={{ fontFamily: 'var(--font-inter, Inter), sans-serif' }}
>
{review.ago}
</span>
<img src={IMG_RATE} alt={`${review.rating ?? 5} зірок`} className="h-5 w-[120px] object-contain" />
</div>
</div>
</div>
<p
className="text-white font-medium text-[16px] leading-[1.5] line-clamp-2"
style={{ fontFamily: 'var(--font-inter, Inter), sans-serif' }}
>
{review.text}
</p>
</div>
<p
className="text-white font-medium text-[16px] leading-[1.5] line-clamp-2"
style={{ fontFamily: 'var(--font-inter, Inter), sans-serif' }}
>
{review.text}
</p>
</div>
<div className="mt-auto pt-2">
<BtnDetails variant="white" href="#">Детальніше</BtnDetails>
</div>
</article>
))}
</article>
)
})}
</div>
<button
onClick={() => scrollByOne(1)}
className="hidden lg:flex absolute right-0 top-1/2 -translate-y-1/2 translate-x-6 z-10 w-12 h-12 items-center justify-center rounded-full bg-[#223e0d] text-white shadow-lg hover:bg-[#f28b4a] transition-colors"
className="hidden md:flex absolute right-0 top-1/2 -translate-y-1/2 translate-x-6 z-10 w-12 h-12 items-center justify-center rounded-full bg-[#223e0d] text-white shadow-lg hover:bg-[#f28b4a] transition-colors"
aria-label="Наступний відгук"
>
<svg width="20" height="20" viewBox="0 0 20 20" fill="none" aria-hidden="true">

View file

@ -2,32 +2,55 @@
/* eslint-disable @next/next/no-img-element */
import { useState } from 'react'
import type { Media } from '@/types/globals'
const IMG_POSTER = '/images/figma/video-preview.png'
const IMG_POSTER_DEFAULT = '/images/figma/video-preview.png'
const IMG_PLAY = '/images/figma/btn-video-play.svg'
export function VideoSection() {
function getMediaUrl(img: Media | string | null | undefined): string | null {
if (!img) return null
if (typeof img === 'string') return img
return img.url ?? null
}
interface VideoSectionProps {
poster?: Media | string | null
src?: string | null
}
export function VideoSection({ poster, src }: VideoSectionProps) {
const [playing, setPlaying] = useState(false)
const posterUrl = getMediaUrl(poster) ?? IMG_POSTER_DEFAULT
return (
<section className="relative w-full flex items-center justify-center overflow-hidden bg-[#223e0d] min-h-[400px] md:min-h-[576px] lg:min-h-[960px]">
{/* Poster */}
<img
src={IMG_POSTER}
src={posterUrl}
alt="Відео про Шуміленд"
className="absolute inset-0 w-full h-full object-cover pointer-events-none"
/>
{/* Play button */}
{!playing && (
<button
onClick={() => setPlaying(true)}
onClick={() => src ? setPlaying(true) : undefined}
className="relative z-10 w-[100px] h-[100px] flex items-center justify-center transition-transform hover:scale-110 focus-visible:outline-none focus-visible:ring-4 focus-visible:ring-white/60"
aria-label="Дивитися відео"
>
<img src={IMG_PLAY} alt="" aria-hidden="true" className="w-full h-full" />
</button>
)}
{playing && src && (
<div className="absolute inset-0 z-20 bg-black">
<video
src={src}
className="w-full h-full object-cover"
controls
autoPlay
onEnded={() => setPlaying(false)}
/>
</div>
)}
</section>
)
}

View file

@ -1,91 +1,122 @@
'use client'
/* eslint-disable @next/next/no-img-element */
import { useState } from 'react'
import { useState, useRef, useEffect } from 'react'
import type { HomePageWhyParentsItem, Media } from '@/types/globals'
const IMG_GALLERY = [
function getMediaUrl(img: Media | string | null | undefined): string | null {
if (!img) return null
if (typeof img === 'string') return img
return img.url ?? null
}
const STATIC_GALLERY = [
'/images/figma/why-parents-1.png',
'/images/figma/gallery-6.png',
'/images/figma/why-parents-2.png',
'/images/figma/gallery-7.png',
'/images/figma/why-parents-3.png',
'/images/figma/news-bg2.png',
'/images/figma/why-parents-4.png',
'/images/figma/gallery-4.png',
'/images/figma/gallery-1.png',
'/images/figma/gallery-3.png',
]
const ITEMS = [
const STATIC_ITEMS: HomePageWhyParentsItem[] = [
{
title: 'Подорож кількома світами за один день',
body: 'ДиноПарк, Диво Ліс, Дзеркальний лабіринт — кожна локація це окремий всесвіт пригод для дітей і батьків.',
description: 'ДиноПарк, Диво Ліс, Дзеркальний лабіринт — кожна локація це окремий всесвіт пригод для дітей і батьків.',
},
{
title: 'Свіже повітря та затишок лісу',
body: 'Ми оновлюємо тематику та декорації до кожного сезону, тому тут буде цікаво кожного візиту. А у вас з\'являться нові яскраві фото у сімейному альбомі.',
description: "Ми оновлюємо тематику та декорації до кожного сезону, тому тут буде цікаво кожного візиту. А у вас з'являться нові яскраві фото у сімейному альбомі.",
},
{
title: 'Нова казка кожної пори року',
body: 'Зима, весна, літо, осінь — кожен сезон у парку неповторний. Святкові декорації, сезонні активності та тематичні заходи чекають на вас.',
description: 'Зима, весна, літо, осінь — кожен сезон у парку неповторний. Святкові декорації, сезонні активності та тематичні заходи чекають на вас.',
},
{
title: 'Безпека понад усе',
body: 'Всі атракції та зони проходять регулярну перевірку. Охоронці, медичний персонал та чіткі правила безпеки забезпечують спокій для батьків.',
description: 'Всі атракції та зони проходять регулярну перевірку. Охоронці, медичний персонал та чіткі правила безпеки забезпечують спокій для батьків.',
},
{
title: 'Все необхідне — поруч і без пошуків',
body: 'Паркування, вбиральні, зона для годування немовлят, укриття, фудкорт — все на місці, щоб ваш відпочинок був справді комфортним.',
description: 'Паркування, вбиральні, зона для годування немовлят, укриття, фудкорт — все на місці, щоб ваш відпочинок був справді комфортним.',
},
{
title: 'Фудкорт — смачно для всієї родини',
body: 'Хот-доги, піца, кава, лимонади та багато іншого. Є дитяче меню та здорові перекуси — ніхто не залишиться голодним.',
description: 'Хот-доги, піца, кава, лимонади та багато іншого. Є дитяче меню та здорові перекуси — ніхто не залишиться голодним.',
},
]
export function WhyParents() {
interface WhyParentsProps {
items?: HomePageWhyParentsItem[] | null
sideGallery?: { image?: Media | string | null }[] | null
title?: string
}
export function WhyParents({ items, sideGallery, title }: WhyParentsProps) {
const [openIndex, setOpenIndex] = useState<number>(1)
const doubled = [...IMG_GALLERY, ...IMG_GALLERY]
const galleryRef = useRef<HTMLDivElement>(null)
const [galleryPaused, setGalleryPaused] = useState(false)
const pauseTimer = useRef<ReturnType<typeof setTimeout> | null>(null)
const activeItems = (items && items.length > 0) ? items : STATIC_ITEMS
const galleryUrls: string[] =
sideGallery && sideGallery.length > 0
? sideGallery.map((g) => getMediaUrl(g.image) ?? '/images/figma/gallery-1.png')
: STATIC_GALLERY
const doubled = [...galleryUrls, ...galleryUrls]
useEffect(() => {
const el = galleryRef.current
if (!el) return
if (typeof window !== 'undefined' && window.matchMedia?.('(prefers-reduced-motion: reduce)').matches) return
const id = setInterval(() => {
if (galleryPaused) return
const max = el.scrollHeight - el.clientHeight
if (max <= 0) return
el.scrollTop += 0.5
if (el.scrollTop >= max / 2) el.scrollTop = 0
}, 20)
return () => clearInterval(id)
}, [galleryPaused])
function scrollGallery(dir: 1 | -1) {
galleryRef.current?.scrollBy({ top: dir * 360, behavior: 'smooth' })
setGalleryPaused(true)
if (pauseTimer.current) clearTimeout(pauseTimer.current)
pauseTimer.current = setTimeout(() => setGalleryPaused(false), 3000)
}
return (
<section className="py-[20px] md:py-[60px] lg:py-[40px]">
<style>{`
@keyframes shumiland-marquee-vertical {
0% { transform: translateY(0); }
100% { transform: translateY(-50%); }
}
.why-marquee-track {
animation: shumiland-marquee-vertical 28s linear infinite;
will-change: transform;
}
.why-marquee-wrap:hover .why-marquee-track {
animation-play-state: paused;
}
`}</style>
<div className="max-w-[1204px] mx-auto px-8">
<div className="mx-auto max-w-[1204px] px-8">
<h2
className="font-bold text-[24px] md:text-[32px] text-[#272727] uppercase mb-[40px] md:mb-[60px]"
className="mb-[40px] text-[24px] font-bold text-[#272727] uppercase md:mb-[60px] md:text-[32px]"
style={{ fontFamily: 'var(--font-montserrat, Montserrat), sans-serif' }}
>
Чому батьки обирають Шуміленд
{title ?? 'Чому батьки обирають Шуміленд'}
</h2>
<div className="flex flex-col lg:flex-row gap-16 items-start">
<div className="relative flex-none w-full lg:w-auto">
<div className="hidden lg:block absolute left-0 top-8 w-[333px] h-[488px] bg-[#223e0d] rounded-[30px]" />
<div className="flex flex-col items-start gap-16 lg:flex-row lg:items-stretch">
<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-[#223e0d] lg:block" />
<div className="flex flex-col gap-6 relative lg:ml-[76px]">
{ITEMS.map((item, i) => {
<div className="relative flex flex-col gap-6 lg:ml-[76px]">
{activeItems.map((item, i) => {
const isOpen = openIndex === i
return (
<button
key={i}
onClick={() => setOpenIndex(isOpen ? -1 : i)}
className="text-left bg-[#fdf2e8] flex flex-col gap-2.5 px-[50px] py-[20px] rounded-[10px] shadow-[0_4px_60px_0_rgba(242,139,74,0.25)] w-full lg:w-[628px] transition-all duration-200"
onClick={() => setOpenIndex(i)}
className="flex w-full flex-col gap-2.5 rounded-[10px] bg-[#fdf2e8] 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 font-bold text-[20px] text-[#272727] leading-tight"
className="flex-1 text-[20px] leading-tight font-bold text-[#272727]"
style={{ fontFamily: 'var(--font-montserrat, Montserrat), sans-serif' }}
>
{item.title}
@ -107,10 +138,10 @@ export function WhyParents() {
style={{ maxHeight: isOpen ? '200px' : '0', opacity: isOpen ? 1 : 0 }}
>
<p
className="text-[#272727] text-[16px] leading-[1.6] font-light pt-1"
className="pt-1 text-[16px] leading-[1.6] font-light text-[#272727]"
style={{ fontFamily: 'var(--font-poppins, Poppins), sans-serif' }}
>
{item.body}
{item.description}
</p>
</div>
</button>
@ -119,44 +150,82 @@ export function WhyParents() {
</div>
</div>
{/* Vertical marquee gallery */}
<div
className="why-marquee-wrap relative flex-none hidden lg:block overflow-hidden rounded-[30px] cursor-pointer"
style={{
width: '505px',
height: '488px',
maskImage: 'linear-gradient(180deg, transparent 0%, black 12%, black 88%, transparent 100%)',
WebkitMaskImage: 'linear-gradient(180deg, transparent 0%, black 12%, black 88%, transparent 100%)',
}}
>
<div className="why-marquee-track flex flex-col gap-5" style={{ width: '505px' }}>
{doubled.map((src, i) => (
<div
key={i}
className="flex-none overflow-hidden rounded-[20px] group"
style={{ width: '505px', height: '340px' }}
>
<img
src={src}
alt=""
aria-hidden="true"
className="w-full h-full object-cover transition-transform duration-700 ease-out group-hover:scale-110"
loading="lazy"
/>
{/* Vertical auto-scroll gallery */}
<div className="hidden flex-none lg:block" style={{ width: '505px' }}>
<button
onClick={() => scrollGallery(-1)}
className="mb-3 flex mx-auto h-10 w-10 items-center justify-center rounded-full bg-[#223e0d] text-white shadow-lg transition-all duration-200 hover:bg-[#f28b4a]"
aria-label="Попередні фото"
>
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" aria-hidden="true">
<path d="M3 11L8 5L13 11" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" />
</svg>
</button>
<div
style={{
height: '488px',
maskImage: 'linear-gradient(180deg, transparent 0%, black 12%, black 88%, transparent 100%)',
WebkitMaskImage: 'linear-gradient(180deg, transparent 0%, black 12%, black 88%, transparent 100%)',
borderRadius: '30px',
overflow: 'hidden',
}}
>
<div
ref={galleryRef}
onMouseEnter={() => setGalleryPaused(true)}
onMouseLeave={() => setGalleryPaused(false)}
style={{ height: '100%', overflowY: 'scroll', scrollbarWidth: 'none' }}
>
<div className="flex flex-col gap-5">
{doubled.map((src, i) => (
<div
key={i}
className="group flex-none overflow-hidden rounded-[20px]"
style={{ width: '505px', height: '340px' }}
>
<img
src={src}
alt=""
aria-hidden="true"
className="h-full w-full object-cover transition-transform duration-700 ease-out group-hover:scale-110"
loading="lazy"
/>
</div>
))}
</div>
))}
</div>
</div>
<button
onClick={() => scrollGallery(1)}
className="mt-3 flex mx-auto h-10 w-10 items-center justify-center rounded-full bg-[#223e0d] text-white shadow-lg transition-all duration-200 hover:bg-[#f28b4a]"
aria-label="Наступні фото"
>
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" aria-hidden="true">
<path d="M3 5L8 11L13 5" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" />
</svg>
</button>
</div>
{/* Mobile: horizontal scroll */}
<div className="lg:hidden w-full flex gap-5 overflow-x-auto pb-2" style={{ scrollbarWidth: 'none' }}>
{IMG_GALLERY.map((src, i) => (
<div
className="flex w-full gap-5 overflow-x-auto pb-2 lg:hidden"
style={{ scrollbarWidth: 'none' }}
>
{galleryUrls.map((src, i) => (
<div
key={i}
className="flex-none overflow-hidden rounded-[20px]"
style={{ width: '280px', height: '200px' }}
>
<img src={src} alt="" aria-hidden="true" className="w-full h-full object-cover" loading="lazy" />
<img
src={src}
alt=""
aria-hidden="true"
className="h-full w-full object-cover"
loading="lazy"
/>
</div>
))}
</div>

View file

@ -0,0 +1,66 @@
'use client'
/* eslint-disable @next/next/no-img-element */
import { useEffect } from 'react'
interface ImageLightboxProps {
images: Array<{ src: string; alt: string }>
index: number
onClose: () => void
onPrev: () => void
onNext: () => void
}
export function ImageLightbox({ images, index, onClose, onPrev, onNext }: ImageLightboxProps) {
useEffect(() => {
const handler = (e: KeyboardEvent) => {
if (e.key === 'Escape') onClose()
if (e.key === 'ArrowLeft') onPrev()
if (e.key === 'ArrowRight') onNext()
}
document.addEventListener('keydown', handler)
return () => document.removeEventListener('keydown', handler)
}, [onClose, onPrev, onNext])
return (
<div
className="fixed inset-0 z-[100] flex items-center justify-center bg-black/90"
onClick={onClose}
>
<button
onClick={(e) => { e.stopPropagation(); onPrev() }}
className="absolute left-4 top-1/2 -translate-y-1/2 flex h-12 w-12 items-center justify-center rounded-full bg-white/20 text-white text-2xl hover:bg-white/40 transition-colors"
aria-label="Попереднє фото"
>
</button>
<img
src={images[index]?.src ?? ''}
alt={images[index]?.alt ?? ''}
className="max-h-[90vh] max-w-[90vw] rounded-xl object-contain"
onClick={(e) => e.stopPropagation()}
/>
<button
onClick={(e) => { e.stopPropagation(); onNext() }}
className="absolute right-4 top-1/2 -translate-y-1/2 flex h-12 w-12 items-center justify-center rounded-full bg-white/20 text-white text-2xl hover:bg-white/40 transition-colors"
aria-label="Наступне фото"
>
</button>
<button
onClick={onClose}
className="absolute top-4 right-4 flex h-10 w-10 items-center justify-center rounded-full bg-white/20 text-white text-xl hover:bg-white/40 transition-colors"
aria-label="Закрити"
>
×
</button>
<span className="absolute bottom-4 text-white/60 text-sm">
{index + 1} / {images.length}
</span>
</div>
)
}

View file

@ -13,17 +13,15 @@ interface PageHeroProps {
export function PageHero({ title, subtitle, children }: PageHeroProps) {
return (
<div
className="bg-[#223e0d] -mt-[60px] lg:-mt-[120px] pt-[calc(60px+48px)] lg:pt-[calc(120px+64px)] pb-16 px-8"
className="-mt-[60px] bg-[#223e0d] px-8 pt-[calc(60px+48px)] pb-16 lg:-mt-[120px] lg:pt-[calc(120px+64px)]"
style={{ fontFamily: 'var(--font-montserrat, Montserrat), sans-serif' }}
>
<div className="max-w-[1204px] mx-auto">
<h1 className="text-white font-bold text-[clamp(32px,5vw,64px)] uppercase leading-tight">
<div className="mx-auto max-w-[1204px]">
<h1 className="text-[clamp(32px,5vw,64px)] leading-tight font-bold text-white uppercase">
{title}
</h1>
{subtitle && (
<p className="text-white/80 text-[18px] mt-4 max-w-[600px] leading-relaxed">
{subtitle}
</p>
<p className="mt-4 max-w-[600px] text-[18px] leading-relaxed text-white/80">{subtitle}</p>
)}
{children}
</div>

View file

@ -9,6 +9,8 @@ export const Header: GlobalConfig = {
fields: [
{ name: 'logo', type: 'upload', relationTo: 'media' },
{ name: 'logoAlt', type: 'text' },
{ name: 'ctaLabel', type: 'text' },
{ name: 'ctaHref', type: 'text' },
{
name: 'navLinks',
type: 'array',
@ -16,6 +18,23 @@ export const Header: GlobalConfig = {
{ name: 'label', type: 'text' },
{ name: 'href', type: 'text' },
{ name: 'openInNewTab', type: 'checkbox', defaultValue: false },
{
name: 'autoChildrenFrom',
type: 'select',
options: [
{ label: 'None', value: 'none' },
{ label: 'Locations (auto)', value: 'locations' },
],
defaultValue: 'none',
},
{
name: 'children',
type: 'array',
fields: [
{ name: 'label', type: 'text' },
{ name: 'href', type: 'text' },
],
},
],
},
],

View file

@ -20,24 +20,63 @@ export const HomePage: GlobalConfig = {
],
},
{
name: 'locations',
type: 'array',
name: 'sectionTitles',
type: 'group',
fields: [
{ name: 'name', type: 'text' },
{ name: 'shortDesc', type: 'textarea' },
{ name: 'image', type: 'upload', relationTo: 'media' },
{ name: 'href', type: 'text' },
{ name: 'locations', type: 'text' },
{ name: 'whyParents', type: 'text' },
{ name: 'birthday', type: 'text' },
{ name: 'gallery', type: 'text' },
{ name: 'reviews', type: 'text' },
{ name: 'news', type: 'text' },
],
},
{
name: 'features',
type: 'array',
name: 'whyParents',
type: 'group',
fields: [
{ name: 'icon', type: 'text' },
{ name: 'title', type: 'text' },
{ name: 'description', type: 'text' },
{
name: 'items',
type: 'array',
fields: [
{ name: 'title', type: 'text' },
{ name: 'description', type: 'textarea' },
],
},
{
name: 'sideGallery',
type: 'array',
fields: [{ name: 'image', type: 'upload', relationTo: 'media' }],
},
],
},
{
name: 'gallery',
type: 'group',
fields: [
{
name: 'images',
type: 'array',
fields: [
{ name: 'image', type: 'upload', relationTo: 'media' },
{ name: 'alt', type: 'text' },
],
},
],
},
{
name: 'video',
type: 'group',
fields: [
{ name: 'poster', type: 'upload', relationTo: 'media' },
{ name: 'src', type: 'text' },
],
},
{
name: 'birthdayIntro',
type: 'group',
fields: [{ name: 'text', type: 'textarea' }],
},
{
name: 'news',
type: 'group',
@ -46,14 +85,5 @@ export const HomePage: GlobalConfig = {
{ name: 'limit', type: 'number', defaultValue: 3, min: 1, max: 12 },
],
},
{
name: 'newsletter',
type: 'group',
fields: [
{ name: 'title', type: 'text' },
{ name: 'subtitle', type: 'text' },
{ name: 'ctaLabel', type: 'text' },
],
},
],
}

51
src/lib/getHomeData.ts Normal file
View file

@ -0,0 +1,51 @@
import { cache } from 'react'
import { getPayload } from 'payload'
import configPromise from '@payload-config'
import type { HomePageGlobal, LocationCMS, ReviewCMS, BirthdayPackageCMS } from '@/types/globals'
export interface HomeData {
home: HomePageGlobal | null
locations: LocationCMS[]
reviews: ReviewCMS[]
birthdayPackages: BirthdayPackageCMS[]
}
export const getHomeData = cache(async (): Promise<HomeData> => {
try {
const payload = await getPayload({ config: configPromise })
const [home, locResult, revResult, pkgResult] = await Promise.all([
payload.findGlobal({ slug: 'home-page' }) as Promise<HomePageGlobal>,
payload.find({ collection: 'locations', where: { showOnHome: { equals: true } }, sort: 'sort', limit: 20, overrideAccess: true }),
payload.find({ collection: 'reviews', where: { showOnHome: { equals: true } }, sort: 'sort', limit: 20, overrideAccess: true }),
payload.find({ collection: 'birthday-packages', sort: 'sort', limit: 10, overrideAccess: true }),
])
return {
home,
locations: locResult.docs as unknown as LocationCMS[],
reviews: revResult.docs as unknown as ReviewCMS[],
birthdayPackages: pkgResult.docs as unknown as BirthdayPackageCMS[],
}
} catch {
return { home: null, locations: [], reviews: [], birthdayPackages: [] }
}
})
export function getHeaderLocations() {
return cache(async (): Promise<LocationCMS[]> => {
try {
const payload = await getPayload({ config: configPromise })
const result = await payload.find({
collection: 'locations',
where: { showInMenu: { equals: true } },
sort: 'sort',
limit: 20,
overrideAccess: true,
})
return result.docs as unknown as LocationCMS[]
} catch {
return []
}
})()
}

View file

@ -116,6 +116,61 @@ async function seed(): Promise<void> {
}
}
// Blog posts
const { totalDocs: postCount } = await payload.find({ collection: 'blog-posts', limit: 1, overrideAccess: true })
if (postCount === 0) {
const posts = [
{
title: 'Сезон динозаврів відкрито!',
slug: 'sezon-dynozavriv-vidkryto',
excerpt: 'Шуміленд вітає нових мешканців ДиноПарку — зустрічайте 12 нових динозаврів!',
status: 'published' as const,
publishedAt: new Date('2025-04-01').toISOString(),
},
{
title: 'Весняні канікули в Шуміленді',
slug: 'vesniani-kanikuly-v-shumilenди',
excerpt: 'Проведіть весняні канікули незабутньо! Спеціальні активності щодня з 28 березня по 6 квітня.',
status: 'published' as const,
publishedAt: new Date('2025-03-20').toISOString(),
},
{
title: 'Нова локація: Тир з призами',
slug: 'nova-lokatsiya-tyr-z-pryzamy',
excerpt: 'Відтепер у Шуміленді є новий Тир з призами — точний постріл приносить реальний виграш!',
status: 'published' as const,
publishedAt: new Date('2025-03-10').toISOString(),
},
]
for (const post of posts) {
await payload.create({ collection: 'blog-posts', data: post as never, overrideAccess: true })
}
console.log('Seeded blog posts')
} else {
console.log('Blog posts already exist, skipping.')
}
// Tariffs (sample — normally synced from ezy API)
const { totalDocs: tariffCount } = await payload.find({ collection: 'tariffs', limit: 1, overrideAccess: true })
if (tariffCount === 0) {
const tariffs = [
{ ezy_id: 1001, last_synced_name: 'Дорослий — ДиноПарк', display_name: 'Дорослий', last_synced_price: 350, category_tag: 'dyno', sort: 1, visible: true },
{ ezy_id: 1002, last_synced_name: 'Дитячий — ДиноПарк', display_name: 'Дитячий (312 років)', last_synced_price: 250, category_tag: 'dyno', sort: 2, visible: true },
{ ezy_id: 1003, last_synced_name: 'Дитина до 3 років — ДиноПарк', display_name: 'До 3 років (безкоштовно)', last_synced_price: 0, category_tag: 'dyno', sort: 3, visible: true },
{ ezy_id: 2001, last_synced_name: 'Дорослий — Диво Ліс', display_name: 'Дорослий', last_synced_price: 300, category_tag: 'dyvolis', sort: 1, visible: true },
{ ezy_id: 2002, last_synced_name: 'Дитячий — Диво Ліс', display_name: 'Дитячий (312 років)', last_synced_price: 200, category_tag: 'dyvolis', sort: 2, visible: true },
{ ezy_id: 3001, last_synced_name: 'Комбо — ДиноПарк + Диво Ліс', display_name: 'Комбо дорослий', last_synced_price: 550, category_tag: 'combo', sort: 1, visible: true },
{ ezy_id: 3002, last_synced_name: 'Комбо дитячий — ДиноПарк + Диво Ліс', display_name: 'Комбо дитячий', last_synced_price: 400, category_tag: 'combo', sort: 2, visible: true },
{ ezy_id: 4001, last_synced_name: 'Сімейний (2 дор + 2 діт)', display_name: 'Сімейний квиток', last_synced_price: 1200, category_tag: 'family', sort: 1, visible: true },
]
for (const t of tariffs) {
await payload.create({ collection: 'tariffs', data: t as never, overrideAccess: true })
}
console.log('Seeded tariffs')
} else {
console.log('Tariffs already exist, skipping.')
}
process.exit(0)
}

View file

@ -12,12 +12,16 @@ export interface NavLink {
label?: string | null
href?: string | null
openInNewTab?: boolean | null
autoChildrenFrom?: 'none' | 'locations' | null
children?: { label?: string | null; href?: string | null }[] | null
}
export interface HeaderGlobal {
id?: string
logo?: Media | string | null
logoAlt?: string | null
ctaLabel?: string | null
ctaHref?: string | null
navLinks?: NavLink[] | null
updatedAt?: string
createdAt?: string
@ -60,37 +64,103 @@ export interface HomePageHero {
backgroundImage?: Media | string | null
}
export interface HomePageLocation {
name?: string | null
shortDesc?: string | null
image?: Media | string | null
href?: string | null
export interface HomePageSectionTitles {
locations?: string | null
whyParents?: string | null
birthday?: string | null
gallery?: string | null
reviews?: string | null
news?: string | null
}
export interface HomePageFeature {
icon?: string | null
export interface HomePageWhyParentsItem {
title?: string | null
description?: string | null
}
export interface HomePageWhyParents {
items?: HomePageWhyParentsItem[] | null
sideGallery?: { image?: Media | string | null }[] | null
}
export interface HomePageGalleryImage {
image?: Media | string | null
alt?: string | null
}
export interface HomePageGallery {
images?: HomePageGalleryImage[] | null
}
export interface HomePageVideo {
poster?: Media | string | null
src?: string | null
}
export interface HomePageBirthdayIntro {
text?: string | null
}
export interface HomePageNews {
title?: string | null
limit?: number | null
}
export interface HomePageNewsletter {
title?: string | null
subtitle?: string | null
ctaLabel?: string | null
}
export interface HomePageGlobal {
id?: string
hero?: HomePageHero | null
locations?: HomePageLocation[] | null
features?: HomePageFeature[] | null
sectionTitles?: HomePageSectionTitles | null
whyParents?: HomePageWhyParents | null
gallery?: HomePageGallery | null
video?: HomePageVideo | null
birthdayIntro?: HomePageBirthdayIntro | null
news?: HomePageNews | null
newsletter?: HomePageNewsletter | null
updatedAt?: string
createdAt?: string
}
// Collection: Locations
export interface LocationCMS {
id: string
name: string
slug: string
tagline?: string | null
shortDesc?: string | null
description?: unknown
image?: Media | string | null
gallery?: { image?: Media | string | null }[] | null
href?: string | null
showInMenu?: boolean | null
showOnHome?: boolean | null
sort?: number | null
}
// Collection: Reviews
export interface ReviewCMS {
id: string
name: string
initial?: string | null
avatarBg?: Media | string | null
ago?: string | null
rating?: number | null
text: string
source?: 'google' | 'facebook' | 'instagram' | 'manual' | null
showOnHome?: boolean | null
sort?: number | null
}
// Collection: BirthdayPackages
export interface BirthdayPackageCMS {
id: string
name: string
slug: string
price: number
currency?: string | null
priceLabel?: string | null
features?: { text: string }[] | null
featured?: boolean | null
badge?: string | null
ctaLabel?: string | null
ctaHref?: string | null
sort?: number | null
}