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:
parent
3226789bd1
commit
1cd20291d0
31 changed files with 1711 additions and 572 deletions
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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: {
|
||||
|
|
|
|||
|
|
@ -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' }}
|
||||
>
|
||||
Статті незабаром з'являться тут.
|
||||
</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',
|
||||
|
|
|
|||
|
|
@ -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' }}
|
||||
>
|
||||
Залиште заявку і наш менеджер зв'яжеться з вами протягом 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' }}
|
||||
>
|
||||
Залишити заявку
|
||||
|
|
|
|||
|
|
@ -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' }}
|
||||
>
|
||||
Написати нам
|
||||
|
|
|
|||
|
|
@ -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' }}
|
||||
>
|
||||
Придбати
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
)
|
||||
|
|
|
|||
306
src/app/api/admin/seed/route.ts
Normal file
306
src/app/api/admin/seed/route.ts
Normal 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: 'Дитячий (3–12 років)', 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: 'Дитячий (3–12 років)', 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 })
|
||||
}
|
||||
28
src/collections/BirthdayPackages.ts
Normal file
28
src/collections/BirthdayPackages.ts
Normal 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 },
|
||||
],
|
||||
}
|
||||
28
src/collections/Locations.ts
Normal file
28
src/collections/Locations.ts
Normal 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 },
|
||||
],
|
||||
}
|
||||
32
src/collections/Reviews.ts
Normal file
32
src/collections/Reviews.ts
Normal 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 },
|
||||
],
|
||||
}
|
||||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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">
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
66
src/components/ui/ImageLightbox.tsx
Normal file
66
src/components/ui/ImageLightbox.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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' },
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
|
|
|
|||
|
|
@ -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
51
src/lib/getHomeData.ts
Normal 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 []
|
||||
}
|
||||
})()
|
||||
}
|
||||
55
src/seed.ts
55
src/seed.ts
|
|
@ -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: 'Дитячий (3–12 років)', 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: 'Дитячий (3–12 років)', 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)
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue