feat(cms): upgrade Payload 3.33→3.84, add SEO plugin, connect hardcoded pages to CMS
Some checks are pending
CI / Type Check (push) Waiting to run
CI / Lint (push) Waiting to run
CI / Unit Tests (push) Waiting to run
Deploy / Build & Push Image (push) Waiting to run
Deploy / Deploy to VPS (push) Blocked by required conditions

- Upgrade payload + all @payloadcms/* packages to 3.84.1
- Update minor/patch deps: drizzle-kit, tailwind-merge, vitest, playwright, etc.
- Fix eslint config: remove duplicate @typescript-eslint plugin registration
- Add @payloadcms/plugin-seo for pages, blog-posts, locations collections
- Remove manual meta fields from Pages (replaced by SEO plugin)
- Add DyvoLisPage global: hero, gallery quote, working hours, whyVisit items
- Add GroupVisitsPage global: hero, form texts, group types with discounts
- Connect /dni-narodzhennia to birthday-packages collection (was hardcoded)
- Connect /lokatsii/dyvolis to dyvolis-page global (was hardcoded)
- Connect /grupovi-vidviduvannia to group-visits-page global (was hardcoded)
- Remove STATIC_LOCATIONS fallback from /lokatsii (use DB or empty state)
- Refactor DyvoLisHero/Gallery/WhyVisit/Tickets to accept CMS props w/ fallbacks

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Vadym Samoilenko 2026-05-13 16:36:18 +01:00
parent 277a240359
commit d0434dda9b
23 changed files with 838 additions and 366 deletions

1
.gitignore vendored
View file

@ -59,3 +59,4 @@ agentdb.rvf.lock
/*.png
/*.jpg
/*.jpeg
.superpowers/

View file

@ -1,11 +1,9 @@
import nextConfig from 'eslint-config-next'
import tsPlugin from '@typescript-eslint/eslint-plugin'
/** @type {import('eslint').Linter.Config[]} */
const eslintConfig = [
...nextConfig,
{
plugins: { '@typescript-eslint': tsPlugin },
rules: {
'@typescript-eslint/no-unused-vars': ['error', { argsIgnorePattern: '^_' }],
'@typescript-eslint/no-explicit-any': 'warn',

View file

@ -33,48 +33,49 @@
]
},
"dependencies": {
"@payloadcms/db-postgres": "^3.33.0",
"@payloadcms/next": "^3.33.0",
"@payloadcms/richtext-lexical": "^3.33.0",
"@payloadcms/db-postgres": "^3.84.0",
"@payloadcms/next": "^3.84.0",
"@payloadcms/plugin-seo": "^3.84.1",
"@payloadcms/richtext-lexical": "^3.84.0",
"@react-email/components": "^1.0.12",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"cyrillic-to-translit-js": "^3.2.1",
"drizzle-kit": "0.31.7",
"drizzle-kit": "0.31.10",
"graphql": "^16.9.0",
"next": "^16.2.6",
"payload": "^3.33.0",
"payload": "^3.84.0",
"pino": "^10.3.1",
"react": "^19.1.0",
"react-dom": "^19.1.0",
"react-email": "^6.1.1",
"resend": "^6.12.3",
"sharp": "^0.34.5",
"tailwind-merge": "^3.5.0",
"tailwind-merge": "^3.6.0",
"zod": "^4.4.3"
},
"devDependencies": {
"@eslint/eslintrc": "^3.3.1",
"@figma/code-connect": "^1.4.4",
"@playwright/test": "^1.59.1",
"@playwright/test": "^1.60.0",
"@tailwindcss/postcss": "^4.3.0",
"@testing-library/jest-dom": "^6.9.1",
"@testing-library/react": "^16.3.2",
"@testing-library/user-event": "^14.6.1",
"@types/node": "^25.6.2",
"@types/node": "^25.7.0",
"@types/react": "^19.1.4",
"@types/react-dom": "^19.1.2",
"@types/supertest": "^7.2.0",
"@typescript-eslint/eslint-plugin": "^8.32.0",
"@typescript-eslint/parser": "^8.32.0",
"@typescript-eslint/eslint-plugin": "^8.59.3",
"@typescript-eslint/parser": "^8.59.3",
"@vitejs/plugin-react": "^6.0.1",
"@vitest/coverage-v8": "^4.1.5",
"@vitest/coverage-v8": "^4.1.6",
"dotenv": "^17.4.2",
"eslint": "^9.28.0",
"eslint-config-next": "^16.2.6",
"husky": "^9.1.7",
"jsdom": "^29.1.1",
"lint-staged": "^17.0.3",
"lint-staged": "^17.0.4",
"pino-pretty": "^13.1.3",
"postcss": "^8.5.3",
"prettier": "^3.5.3",
@ -84,6 +85,6 @@
"tsx": "^4.21.0",
"typescript": "^6.0.3",
"vite-tsconfig-paths": "^6.1.1",
"vitest": "^4.1.5"
"vitest": "^4.1.6"
}
}

View file

@ -1,6 +1,7 @@
import { buildConfig } from 'payload'
import { postgresAdapter } from '@payloadcms/db-postgres'
import { lexicalEditor } from '@payloadcms/richtext-lexical'
import { seoPlugin } from '@payloadcms/plugin-seo'
import sharp from 'sharp'
import path from 'path'
import { fileURLToPath } from 'url'
@ -24,6 +25,8 @@ import { ThankYouPage } from './src/globals/ThankYouPage'
import { Header } from './src/globals/Header'
import { Footer } from './src/globals/Footer'
import { SiteSettings } from './src/globals/SiteSettings'
import { DyvoLisPage } from './src/globals/DyvoLisPage'
import { GroupVisitsPage } from './src/globals/GroupVisitsPage'
const filename = fileURLToPath(import.meta.url)
const dirname = path.dirname(filename)
@ -60,7 +63,29 @@ export default buildConfig({
BirthdayPackages,
],
globals: [HomePage, CheckoutPage, ThankYouPage, Header, Footer, SiteSettings],
globals: [
HomePage,
CheckoutPage,
ThankYouPage,
Header,
Footer,
SiteSettings,
DyvoLisPage,
GroupVisitsPage,
],
plugins: [
seoPlugin({
collections: ['pages', 'blog-posts', 'locations'],
uploadsCollection: 'media',
generateTitle: ({ doc }) => {
const title =
(doc as { title?: string; name?: string }).title ?? (doc as { name?: string }).name ?? ''
return title ? `${title} — Шуміленд` : 'Шуміленд'
},
generateDescription: ({ doc }) => (doc as { excerpt?: string }).excerpt ?? '',
}),
],
admin: {
user: 'users',
@ -74,6 +99,8 @@ export default buildConfig({
'header',
'footer',
'site-settings',
'dyvolis-page',
'group-visits-page',
],
},
},

480
pnpm-lock.yaml generated

File diff suppressed because it is too large Load diff

View file

@ -29,11 +29,11 @@ export async function generateMetadata({ params }: Props): Promise<Metadata> {
if (!page) return { title: 'Не знайдено — Шуміленд' }
const p = page as unknown as {
title: string
meta?: { metaTitle?: string; metaDescription?: string }
meta?: { title?: string; description?: string }
}
return {
title: p.meta?.metaTitle ?? `${p.title} — Шуміленд`,
description: p.meta?.metaDescription ?? '',
title: p.meta?.title ?? `${p.title} — Шуміленд`,
description: p.meta?.description ?? '',
}
}

View file

@ -1,4 +1,6 @@
import type { Metadata } from 'next'
import { getPayload } from 'payload'
import configPromise from '@payload-config'
import { PageHero } from '@/components/ui/PageHero'
import { BirthdayBookingForm } from '@/components/forms/BirthdayBookingForm'
@ -8,41 +10,25 @@ export const metadata: Metadata = {
'Святкуйте день народження у Шуміленді! Пакети для дітей та дорослих з розвагами, аніматорами та кейтерингом.',
}
const PACKAGES = [
{
name: 'Стандарт',
price: '4 500',
features: ['До 10 дітей', 'Аніматор 2 год', 'Вхідні квитки', 'Торт від закладу'],
highlight: false,
},
{
name: 'Преміум',
price: '8 900',
features: [
'До 20 дітей',
'Аніматор 3 год',
'Вхідні квитки',
'Торт + солодкий стіл',
'Декор зали',
'Фотограф 1 год',
],
highlight: true,
},
{
name: 'VIP',
price: '15 000',
features: [
'До 40 гостей',
'Аніматор 4 год',
'Вхідні квитки',
'Банкетний зал',
'Повне меню',
'Фотограф + відео',
'Персональний менеджер',
],
highlight: false,
},
]
export const revalidate = 60
async function getPackages() {
try {
const payload = await getPayload({ config: configPromise })
const result = await payload.find({
collection: 'birthday-packages',
sort: 'sort',
limit: 20,
})
return result.docs
} catch {
return []
}
}
function formatPrice(price: number): string {
return price.toLocaleString('uk-UA').replace(/,/g, ' ')
}
export default async function BirthdayPage({
searchParams,
@ -51,6 +37,8 @@ export default async function BirthdayPage({
}) {
const params = await searchParams
const defaultPackage = params.package
const packages = await getPackages()
return (
<div className="min-h-screen bg-[#f1fbeb]">
<PageHero
@ -60,21 +48,21 @@ export default async function BirthdayPage({
<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) => (
{packages.map((pkg) => (
<div
key={pkg.name}
key={pkg.id}
className={`flex flex-col gap-5 rounded-[24px] p-8 ${
pkg.highlight
pkg.featured
? 'bg-[#f28b4a] shadow-[0_4px_60px_0_rgba(242,139,74,0.4)]'
: 'bg-[#396817] shadow-[0_4px_60px_0_rgba(57,104,23,0.15)]'
}`}
>
{pkg.highlight && (
{pkg.featured && pkg.badge && (
<span
className="self-start rounded-full bg-white px-3 py-1 text-[12px] font-bold text-[#f28b4a]"
style={{ fontFamily: 'var(--font-montserrat, Montserrat), sans-serif' }}
>
Найпопулярніший
{pkg.badge}
</span>
)}
<div>
@ -88,17 +76,18 @@ export default async function BirthdayPage({
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>
{pkg.priceLabel ?? formatPrice(pkg.price)}{' '}
<span className="text-[24px]">{pkg.currency ?? '₴'}</span>
</p>
</div>
<ul className="flex flex-col gap-2">
{pkg.features.map((f) => (
{pkg.features?.map((f: { id?: string; text: string }) => (
<li
key={f}
key={f.id}
className="flex items-center gap-2 text-[14px] text-white/80"
style={{ fontFamily: 'var(--font-montserrat, Montserrat), sans-serif' }}
>
<span className="text-white"></span> {f}
<span className="text-white"></span> {f.text}
</li>
))}
</ul>

View file

@ -1,14 +1,29 @@
import type { Metadata } from 'next'
import { getPayload } from 'payload'
import configPromise from '@payload-config'
import { PageHero } from '@/components/ui/PageHero'
import { GroupRequestForm } from '@/components/forms/GroupRequestForm'
export const metadata: Metadata = {
title: 'Групові відвідування — Шуміленд',
description:
'Організуйте групове відвідування Шуміленду. Спеціальні ціни для шкіл, дитячих садків і корпоративних груп.',
export const revalidate = 60
async function getGroupVisitsData() {
try {
const payload = await getPayload({ config: configPromise })
return await payload.findGlobal({ slug: 'group-visits-page', depth: 0 })
} catch {
return null
}
}
const GROUPS = [
export async function generateMetadata(): Promise<Metadata> {
return {
title: 'Групові відвідування — Шуміленд',
description:
'Організуйте групове відвідування Шуміленду. Спеціальні ціни для шкіл, дитячих садків і корпоративних груп.',
}
}
const DEFAULT_GROUPS = [
{
title: 'Шкільні екскурсії',
description:
@ -35,17 +50,27 @@ const GROUPS = [
},
]
export default function GroupVisitsPage() {
export default async function GroupVisitsPage() {
const data = await getGroupVisitsData()
const heroTitle = data?.heroTitle ?? 'Групові відвідування'
const heroSubtitle =
data?.heroSubtitle ??
'Спеціальні умови для організованих груп. Мінімум 10 осіб — максимум вражень.'
const formTitle = data?.formTitle ?? 'Подати заявку на групове відвідування'
const formSubtitle =
data?.formSubtitle ??
'Вкажіть кількість учасників та бажану дату — менеджер зателефонує і погодить деталі.'
const groups =
data?.groups && data.groups.length > 0 ? (data.groups as typeof DEFAULT_GROUPS) : DEFAULT_GROUPS
return (
<div className="min-h-screen bg-[#f1fbeb]">
<PageHero
title="Групові відвідування"
subtitle="Спеціальні умови для організованих груп. Мінімум 10 осіб — максимум вражень."
/>
<PageHero title={heroTitle} subtitle={heroSubtitle} />
<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) => (
{groups.map((g) => (
<div
key={g.title}
className="flex flex-col gap-4 rounded-[24px] bg-[#396817] p-8 shadow-[0_4px_60px_0_rgba(57,104,23,0.15)]"
@ -82,13 +107,13 @@ export default function GroupVisitsPage() {
className="mb-2 text-[28px] font-bold text-white"
style={{ fontFamily: 'var(--font-montserrat, Montserrat), sans-serif' }}
>
Подати заявку на групове відвідування
{formTitle}
</h2>
<p
className="mb-8 text-[15px] text-white/70"
style={{ fontFamily: 'var(--font-montserrat, Montserrat), sans-serif' }}
>
Вкажіть кількість учасників та бажану дату менеджер зателефонує і погодить деталі.
{formSubtitle}
</p>
<GroupRequestForm />
</div>

View file

@ -96,10 +96,7 @@ export default function KorzynaPage() {
<div className="min-h-screen bg-[#f1fbeb]">
<div className="mx-auto flex max-w-[600px] flex-col items-center px-6 py-24 text-center">
<div className="mb-6 text-[80px]">🛒</div>
<h1
className="mb-4 text-[28px] font-bold text-[#272727]"
style={FONT_MONT}
>
<h1 className="mb-4 text-[28px] font-bold text-[#272727]" style={FONT_MONT}>
Кошик порожній
</h1>
<p className="mb-8 text-[16px] text-[#4a4a4a]" style={FONT_MONT}>
@ -123,10 +120,7 @@ export default function KorzynaPage() {
return (
<div className="min-h-screen bg-[#f1fbeb]">
<div className="mx-auto max-w-[780px] px-4 py-10 md:px-6 md:py-16">
<h1
className="mb-8 text-[28px] font-bold text-[#272727] md:text-[36px]"
style={FONT_MONT}
>
<h1 className="mb-8 text-[28px] font-bold text-[#272727] md:text-[36px]" style={FONT_MONT}>
Ваш кошик
</h1>
@ -201,7 +195,8 @@ export default function KorzynaPage() {
style={{ background: '#2d5212' }}
>
<span className="text-[15px] text-white" style={FONT_MONT}>
Разом: {totalCount} {totalCount === 1 ? 'квиток' : totalCount < 5 ? 'квитки' : 'квитків'}
Разом: {totalCount}{' '}
{totalCount === 1 ? 'квиток' : totalCount < 5 ? 'квитки' : 'квитків'}
</span>
<span className="text-[24px] font-black text-[#fdcf54]" style={FONT_MONT}>
{totalPrice}
@ -219,7 +214,10 @@ export default function KorzynaPage() {
</h2>
<div className="mb-4">
<label className="mb-1 block text-[12px] font-bold uppercase tracking-wide text-[#888]" style={FONT_MONT}>
<label
className="mb-1 block text-[12px] font-bold tracking-wide text-[#888] uppercase"
style={FONT_MONT}
>
Ім&apos;я *
</label>
<input
@ -227,14 +225,17 @@ export default function KorzynaPage() {
value={name}
onChange={(e) => setName(e.target.value)}
placeholder="Іван Петренко"
className={`w-full rounded-[10px] border px-4 py-3 text-[14px] text-[#272727] outline-none transition-colors focus:border-[#f28b4a] ${errors.name ? 'border-red-400 bg-red-50' : 'border-[#e0d8ce] bg-[#fafaf8]'}`}
className={`w-full rounded-[10px] border px-4 py-3 text-[14px] text-[#272727] transition-colors outline-none focus:border-[#f28b4a] ${errors.name ? 'border-red-400 bg-red-50' : 'border-[#e0d8ce] bg-[#fafaf8]'}`}
style={FONT_MONT}
/>
{errors.name && <p className="mt-1 text-[12px] text-red-500">{errors.name}</p>}
</div>
<div className="mb-4">
<label className="mb-1 block text-[12px] font-bold uppercase tracking-wide text-[#888]" style={FONT_MONT}>
<label
className="mb-1 block text-[12px] font-bold tracking-wide text-[#888] uppercase"
style={FONT_MONT}
>
Телефон *
</label>
<input
@ -242,22 +243,26 @@ export default function KorzynaPage() {
value={phone}
onChange={(e) => setPhone(e.target.value)}
placeholder="+380 67 123 45 67"
className={`w-full rounded-[10px] border px-4 py-3 text-[14px] text-[#272727] outline-none transition-colors focus:border-[#f28b4a] ${errors.phone ? 'border-red-400 bg-red-50' : 'border-[#e0d8ce] bg-[#fafaf8]'}`}
className={`w-full rounded-[10px] border px-4 py-3 text-[14px] text-[#272727] transition-colors outline-none focus:border-[#f28b4a] ${errors.phone ? 'border-red-400 bg-red-50' : 'border-[#e0d8ce] bg-[#fafaf8]'}`}
style={FONT_MONT}
/>
{errors.phone && <p className="mt-1 text-[12px] text-red-500">{errors.phone}</p>}
</div>
<div className="mb-6">
<label className="mb-1 block text-[12px] font-bold uppercase tracking-wide text-[#888]" style={FONT_MONT}>
Email * <span className="normal-case font-normal text-[#aaa]">(квитки надійдуть сюди)</span>
<label
className="mb-1 block text-[12px] font-bold tracking-wide text-[#888] uppercase"
style={FONT_MONT}
>
Email *{' '}
<span className="font-normal text-[#aaa] normal-case">(квитки надійдуть сюди)</span>
</label>
<input
type="email"
value={email}
onChange={(e) => setEmail(e.target.value)}
placeholder="ivan@example.com"
className={`w-full rounded-[10px] border px-4 py-3 text-[14px] text-[#272727] outline-none transition-colors focus:border-[#f28b4a] ${errors.email ? 'border-red-400 bg-red-50' : 'border-[#e0d8ce] bg-[#fafaf8]'}`}
className={`w-full rounded-[10px] border px-4 py-3 text-[14px] text-[#272727] transition-colors outline-none focus:border-[#f28b4a] ${errors.email ? 'border-red-400 bg-red-50' : 'border-[#e0d8ce] bg-[#fafaf8]'}`}
style={FONT_MONT}
/>
{errors.email && <p className="mt-1 text-[12px] text-red-500">{errors.email}</p>}
@ -273,18 +278,27 @@ export default function KorzynaPage() {
/>
<span className="text-[13px] leading-relaxed text-[#555]" style={FONT_MONT}>
Я погоджуюсь з{' '}
<Link href="/umovy-vidviduvannia" className="text-[#2d5212] underline underline-offset-2">
<Link
href="/umovy-vidviduvannia"
className="text-[#2d5212] underline underline-offset-2"
>
умовами відвідування
</Link>{' '}
та{' '}
<Link href="/pravyla-povernennia" className="text-[#2d5212] underline underline-offset-2">
<Link
href="/pravyla-povernennia"
className="text-[#2d5212] underline underline-offset-2"
>
правилами повернення квитків
</Link>
</span>
</label>
{globalError && (
<div className="mb-4 rounded-[10px] bg-red-50 px-4 py-3 text-[13px] text-red-600" style={FONT_MONT}>
<div
className="mb-4 rounded-[10px] bg-red-50 px-4 py-3 text-[13px] text-red-600"
style={FONT_MONT}
>
{globalError}
</div>
)}

View file

@ -114,16 +114,31 @@ export default async function TicketsPage() {
</h2>
<div className="grid grid-cols-1 gap-5 sm:grid-cols-2 lg:grid-cols-3">
{[
{ name: 'Стандарт', desc: 'До 10 дітей · Аніматор 2 год · Торт від закладу', href: '/dni-narodzhennia#order-form' },
{ name: 'Преміум', desc: 'До 20 дітей · Аніматор 3 год · Декор + фотограф', href: '/dni-narodzhennia#order-form' },
{ name: 'VIP', desc: 'До 40 гостей · Аніматор 4 год · Повне меню + відео', href: '/dni-narodzhennia#order-form' },
{
name: 'Стандарт',
desc: 'До 10 дітей · Аніматор 2 год · Торт від закладу',
href: '/dni-narodzhennia#order-form',
},
{
name: 'Преміум',
desc: 'До 20 дітей · Аніматор 3 год · Декор + фотограф',
href: '/dni-narodzhennia#order-form',
},
{
name: 'VIP',
desc: 'До 40 гостей · Аніматор 4 год · Повне меню + відео',
href: '/dni-narodzhennia#order-form',
},
].map((pkg) => (
<div
key={pkg.name}
className="flex flex-col gap-4 rounded-[20px] bg-[#396817] p-8 shadow-[0_4px_60px_0_rgba(242,139,74,0.25)]"
>
<div>
<h3 className="text-[20px] font-bold leading-tight text-white" style={FONT_MONT}>
<h3
className="text-[20px] leading-tight font-bold text-white"
style={FONT_MONT}
>
{pkg.name}
</h3>
<p className="mt-2 text-[14px] leading-relaxed text-white/70" style={FONT_MONT}>
@ -133,7 +148,10 @@ export default async function TicketsPage() {
<Link
href={pkg.href}
className="mt-auto flex items-center justify-center rounded-[56px] py-[10px] text-[15px] font-bold text-[#1a1a1a] transition-opacity hover:opacity-90"
style={{ ...FONT_MONT, background: 'linear-gradient(90deg,#f28b4a 0%,#fdcf54 55%,#f28b4a 100%)' }}
style={{
...FONT_MONT,
background: 'linear-gradient(90deg,#f28b4a 0%,#fdcf54 55%,#f28b4a 100%)',
}}
>
Дізнатися ціну
</Link>
@ -149,16 +167,31 @@ export default async function TicketsPage() {
</h2>
<div className="grid grid-cols-1 gap-5 sm:grid-cols-2 lg:grid-cols-3">
{[
{ name: 'Шкільні екскурсії', desc: 'Від 15 осіб · Знижка 15% · Екскурсовод', href: '/grupovi-vidviduvannia#order-form' },
{ name: 'Дитячі садки', desc: 'Від 10 осіб · Знижка 20% · Безпечний формат', href: '/grupovi-vidviduvannia#order-form' },
{ name: 'Корпоративи', desc: 'Від 20 осіб · Знижка 10% · Ексклюзивні зони', href: '/grupovi-vidviduvannia#order-form' },
{
name: 'Шкільні екскурсії',
desc: 'Від 15 осіб · Знижка 15% · Екскурсовод',
href: '/grupovi-vidviduvannia#order-form',
},
{
name: 'Дитячі садки',
desc: 'Від 10 осіб · Знижка 20% · Безпечний формат',
href: '/grupovi-vidviduvannia#order-form',
},
{
name: 'Корпоративи',
desc: 'Від 20 осіб · Знижка 10% · Ексклюзивні зони',
href: '/grupovi-vidviduvannia#order-form',
},
].map((grp) => (
<div
key={grp.name}
className="flex flex-col gap-4 rounded-[20px] bg-[#396817] p-8 shadow-[0_4px_60px_0_rgba(242,139,74,0.25)]"
>
<div>
<h3 className="text-[20px] font-bold leading-tight text-white" style={FONT_MONT}>
<h3
className="text-[20px] leading-tight font-bold text-white"
style={FONT_MONT}
>
{grp.name}
</h3>
<p className="mt-2 text-[14px] leading-relaxed text-white/70" style={FONT_MONT}>
@ -168,7 +201,10 @@ export default async function TicketsPage() {
<Link
href={grp.href}
className="mt-auto flex items-center justify-center rounded-[56px] py-[10px] text-[15px] font-bold text-[#1a1a1a] transition-opacity hover:opacity-90"
style={{ ...FONT_MONT, background: 'linear-gradient(90deg,#f28b4a 0%,#fdcf54 55%,#f28b4a 100%)' }}
style={{
...FONT_MONT,
background: 'linear-gradient(90deg,#f28b4a 0%,#fdcf54 55%,#f28b4a 100%)',
}}
>
Дізнатися ціну
</Link>

View file

@ -1,22 +1,57 @@
import type { Metadata } from 'next'
import { getPayload } from 'payload'
import configPromise from '@payload-config'
import { DyvoLisHero } from '@/components/sections/DyvoLisHero'
import { DyvoLisGallery } from '@/components/sections/DyvoLisGallery'
import { DyvoLisWhyVisit } from '@/components/sections/DyvoLisWhyVisit'
import { DyvoLisTickets } from '@/components/sections/DyvoLisTickets'
export const metadata: Metadata = {
title: 'ДивоЛіс — Шуміленд',
description:
'Казковий топіарний ліс у Шуміленді: фігури з безпечних матеріалів, унікальна ландшафтна композиція та повна свобода для дітей.',
export const revalidate = 60
async function getDyvoLisData() {
try {
const payload = await getPayload({ config: configPromise })
return await payload.findGlobal({ slug: 'dyvolis-page', depth: 0 })
} catch {
return null
}
}
export default function DyvoLisPage() {
export async function generateMetadata(): Promise<Metadata> {
const data = await getDyvoLisData()
return {
title: data?.heroTitle ? `${data.heroTitle} — Шуміленд` : 'ДивоЛіс — Шуміленд',
description:
'Казковий топіарний ліс у Шуміленді: фігури з безпечних матеріалів, унікальна ландшафтна композиція та повна свобода для дітей.',
}
}
export default async function DyvoLisPage() {
const data = await getDyvoLisData()
return (
<div className="bg-[#f1fbeb]">
<DyvoLisHero />
<DyvoLisGallery />
<DyvoLisWhyVisit />
<DyvoLisTickets />
<DyvoLisHero
title={data?.heroTitle ?? undefined}
description={data?.heroDescription ?? undefined}
stat={data?.heroStat ?? undefined}
statLabel={data?.heroStatLabel ?? undefined}
tips={data?.heroTips?.map((t: { text: string }) => t.text)}
/>
<DyvoLisGallery quote={data?.galleryQuote ?? undefined} />
<DyvoLisWhyVisit
title={data?.whyVisitTitle ?? undefined}
items={
data?.whyVisitItems?.map((item: { title: string; description: string }) => ({
title: item.title,
description: item.description,
})) ?? undefined
}
/>
<DyvoLisTickets
workingHours={data?.workingHours ?? undefined}
comboDescription={data?.comboDescription ?? undefined}
/>
</div>
)
}

View file

@ -12,52 +12,6 @@ export const metadata: Metadata = {
export const revalidate = 3600
const STATIC_LOCATIONS: LocationCMS[] = [
{
id: 'dynopark',
name: 'ДиноПарк',
slug: 'dynopark',
tagline: 'портал у світ динозаврів',
shortDesc:
'Прогуляйтесь серед реалістичних динозаврів у повний зріст. Понад 20 видів доісторичних тварин у природному середовищі.',
image: '/images/figma/loc-dinopark.webp',
},
{
id: 'dyvolis',
name: 'Диво Ліс',
slug: 'dyvolis',
tagline: 'зона казкових топіарних фігур',
shortDesc:
'Чарівний ліс з інтерактивними атракціонами, мотузковими парками та пригодами для всіх вікових груп.',
image: '/images/figma/loc-divo-lis.webp',
},
{
id: 'maze',
name: 'Дзеркальний Лабіринт',
slug: 'maze',
tagline: 'справжній виклик кмітливості',
shortDesc: 'Захоплюючий лабіринт з дзеркалами, оптичними ілюзіями та таємничими кімнатами.',
image: '/images/figma/loc-maze.webp',
},
{
id: 'tir',
name: 'Тир з призами',
slug: 'tir',
tagline: 'перемога, яку ви розділите разом',
shortDesc: 'Влаштуйте дружні змагання, дайте малечі декілька уроків та виграйте класний приз.',
image: '/images/figma/loc-tir.webp',
},
{
id: 'playground',
name: 'Дитячий майданчик',
slug: 'playground',
tagline: 'територія забав і нових друзів',
shortDesc:
'Поки малеча підкорює гірки та знаходить перших друзів, ви можете нарешті зробити паузу.',
image: '/images/figma/loc-playground.webp',
},
]
function getMediaUrl(img: Media | string | null | undefined): string | null {
if (!img) return null
if (typeof img === 'string') return img
@ -87,12 +41,10 @@ async function getLocations(): Promise<LocationCMS[]> {
limit: 20,
overrideAccess: true,
})
const docs = result.docs as unknown as LocationCMS[]
if (docs.length > 0) return docs
return result.docs as unknown as LocationCMS[]
} catch {
// DB not available
return []
}
return STATIC_LOCATIONS
}
export default async function LocationsPage() {
@ -103,6 +55,14 @@ export default async function LocationsPage() {
<PageHero title="Локації" subtitle="Неповторні зони розваг для всієї родини" />
<div className="mx-auto max-w-[1204px] px-8 py-16">
{locations.length === 0 && (
<p
className="text-center text-[18px] text-[#272727]/60"
style={{ fontFamily: 'var(--font-montserrat, Montserrat), sans-serif' }}
>
Локації незабаром з&apos;являться тут.
</p>
)}
<div className="flex flex-col gap-10">
{locations.map((loc, i) => {
const imgUrl =

View file

@ -70,13 +70,5 @@ export const Pages: CollectionConfig = {
CTABlock,
],
},
{
name: 'meta',
type: 'group',
fields: [
{ name: 'metaTitle', type: 'text' },
{ name: 'metaDescription', type: 'textarea' },
],
},
],
}

View file

@ -7,7 +7,7 @@ const FONT_MONT = { fontFamily: 'var(--font-montserrat, Montserrat), sans-serif'
const FONT_INTER = { fontFamily: 'var(--font-inter, Inter), sans-serif' }
interface PricingCardClientProps {
tariffId: string // ezy_id as string — used by checkout API
tariffId: string // ezy_id as string — used by checkout API
name: string
price: number | null
categoryTag: string | null
@ -39,10 +39,7 @@ export function PricingCardClient({
<div className="flex flex-col gap-4 rounded-[20px] bg-[#396817] p-8 shadow-[0_4px_60px_0_rgba(242,139,74,0.25)]">
<div>
{categoryTag && (
<p
className="mb-2 text-[14px] font-bold text-[#f28b4a] uppercase"
style={FONT_MONT}
>
<p className="mb-2 text-[14px] font-bold text-[#f28b4a] uppercase" style={FONT_MONT}>
{categoryTag}
</p>
)}

View file

@ -3,9 +3,6 @@
import { useState, useEffect } from 'react'
const QUOTE =
'Це місце де малеча зустрічає героїв улюблених казок. Простір справжнього дитинства.'
const GALLERY = [
'/images/dyvolis/photo-01.jpg',
'/images/dyvolis/photo-02.jpg',
@ -33,7 +30,13 @@ const GALLERY = [
'/images/dyvolis/photo-24.jpg',
]
export function DyvoLisGallery() {
interface DyvoLisGalleryProps {
quote?: string
}
export function DyvoLisGallery({
quote = 'Це місце де малеча зустрічає героїв улюблених казок. Простір справжнього дитинства.',
}: DyvoLisGalleryProps) {
const [active, setActive] = useState(0)
const n = GALLERY.length
@ -58,7 +61,7 @@ export function DyvoLisGallery() {
className="relative mx-auto max-w-[900px] px-8 text-center text-[20px] leading-[1.4] font-bold text-white lg:text-[28px]"
style={{ fontFamily: 'var(--font-montserrat, Montserrat), sans-serif' }}
>
{QUOTE}
{quote}
</p>
</div>

View file

@ -4,12 +4,26 @@ import { BtnPrimary } from '@/components/ui/BtnPrimary'
const HERO_IMG = '/images/dyvolis/hero-cat.png'
const ELLIPSE_ORANGE = '/images/dyvolis/ellipse-orange.svg'
const TIPS = [
const DEFAULT_TIPS = [
'Унікальна ландшафтна композиція з місцями для відпочинку',
'Повна свобода переміщення - без заборон',
]
export function DyvoLisHero() {
interface DyvoLisHeroProps {
title?: string
description?: string
stat?: string
statLabel?: string
tips?: string[]
}
export function DyvoLisHero({
title = 'ДивоЛіс територія магії та фантазії',
description = 'Топіарні фігури зроблені з урахуванням важливих деталей, тому ви одразу впізнаєте в них улюблених казкових героїв. Тут можна бігати, стрибати, лазити по фігурках і ставати героями власної казки.',
stat = '60+',
statLabel = 'експонатів з безпечних для дітей матеріалів',
tips = DEFAULT_TIPS,
}: DyvoLisHeroProps) {
return (
<section className="relative overflow-hidden" style={{ background: '#f1fbeb' }}>
{/* Left column — contained within max-width */}
@ -22,15 +36,13 @@ export function DyvoLisHero() {
className="text-[36px] leading-[1.2] font-bold text-[#272727] uppercase lg:text-[64px]"
style={{ fontFamily: 'var(--font-montserrat, Montserrat), sans-serif' }}
>
ДивоЛіс територія магії та фантазії
{title}
</h1>
<p
className="text-[16px] leading-[1.5] font-medium text-[#272727] lg:text-[24px]"
style={{ fontFamily: 'var(--font-montserrat, Montserrat), sans-serif' }}
>
Топіарні фігури зроблені з урахуванням важливих деталей, тому ви одразу впізнаєте в
них улюблених казкових героїв. Тут можна бігати, стрибати, лазити по фігурках і
ставати героями власної казки.
{description}
</p>
<BtnPrimary href="/kvytky?category=dyvolis" className="self-start">
Купити квиток
@ -56,7 +68,7 @@ export function DyvoLisHero() {
}}
aria-hidden="true"
>
60+
{stat}
</div>
<div
className="flex-1 rounded-[20px] text-[16px] leading-[1.3] font-medium text-white lg:text-[24px]"
@ -69,13 +81,11 @@ export function DyvoLisHero() {
paddingRight: '24px',
}}
>
експонатів з безпечних
<br />
для дітей матеріалів
{statLabel}
</div>
</div>
{TIPS.map((tip, i) => (
{tips.map((tip, i) => (
<div
key={i}
className="rounded-[20px] text-[16px] leading-[1.3] font-medium text-white lg:text-[24px]"

View file

@ -58,7 +58,7 @@ function TicketCard({ tariff }: TicketCardProps) {
</div>
{/* Counter + Add */}
<div className="mt-auto pt-5 flex flex-col gap-3">
<div className="mt-auto flex flex-col gap-3 pt-5">
<div className="flex items-center justify-center gap-3">
<button
onClick={() => setCount((c) => Math.max(1, c - 1))}
@ -68,7 +68,10 @@ function TicketCard({ tariff }: TicketCardProps) {
>
</button>
<span className="min-w-[28px] text-center text-[18px] font-bold text-[#272727]" style={FONT_MONT}>
<span
className="min-w-[28px] text-center text-[18px] font-bold text-[#272727]"
style={FONT_MONT}
>
{count}
</span>
<button
@ -102,17 +105,25 @@ function TicketCard({ tariff }: TicketCardProps) {
function SkeletonCard() {
return (
<div
className="flex flex-col rounded-[20px] p-5 shadow-[0_4px_30px_rgba(242,139,74,0.25)] animate-pulse"
className="flex animate-pulse flex-col rounded-[20px] p-5 shadow-[0_4px_30px_rgba(242,139,74,0.25)]"
style={{ background: '#fdf2e8', minHeight: 180 }}
>
<div className="h-4 w-3/4 rounded bg-[#e8d8c4] mb-3 mx-auto" />
<div className="h-8 w-1/2 rounded bg-[#e8d8c4] mb-2 mx-auto" />
<div className="h-4 w-2/3 rounded bg-[#e8d8c4] mx-auto mt-auto" />
<div className="mx-auto mb-3 h-4 w-3/4 rounded bg-[#e8d8c4]" />
<div className="mx-auto mb-2 h-8 w-1/2 rounded bg-[#e8d8c4]" />
<div className="mx-auto mt-auto h-4 w-2/3 rounded bg-[#e8d8c4]" />
</div>
)
}
export function DyvoLisTickets() {
interface DyvoLisTicketsProps {
workingHours?: string
comboDescription?: string
}
export function DyvoLisTickets({
workingHours = 'щодня з 11:00 до 20:00',
comboDescription = 'Динопарк + Диволіс із казковими топіарними фігурами + Дзеркальний лабіринт',
}: DyvoLisTicketsProps) {
const [tariffs, setTariffs] = useState<Tariff[]>([])
const [loading, setLoading] = useState(true)
@ -123,12 +134,18 @@ export function DyvoLisTickets() {
const dyvolis = (data.tariffs ?? []).filter((t) => t.categoryTag === 'dyvolis')
setTariffs(dyvolis)
})
.catch(() => {/* show nothing on error — section still renders */})
.catch(() => {
/* show nothing on error — section still renders */
})
.finally(() => setLoading(false))
}, [])
const single = tariffs.filter((t) => !t.name.toLowerCase().includes('комбо') && !t.name.toLowerCase().includes('combo'))
const combo = tariffs.filter((t) => t.name.toLowerCase().includes('комбо') || t.name.toLowerCase().includes('combo'))
const single = tariffs.filter(
(t) => !t.name.toLowerCase().includes('комбо') && !t.name.toLowerCase().includes('combo')
)
const combo = tariffs.filter(
(t) => t.name.toLowerCase().includes('комбо') || t.name.toLowerCase().includes('combo')
)
return (
<section className="relative overflow-hidden">
@ -156,7 +173,7 @@ export function DyvoLisTickets() {
className="text-[16px] leading-[1.4] font-bold text-[#272727] lg:text-[22px]"
style={FONT_MONT}
>
щодня з 11:00 до 20:00
{workingHours}
</p>
</div>
@ -189,7 +206,7 @@ export function DyvoLisTickets() {
className="text-[16px] leading-[1.4] font-semibold text-white/80 lg:text-[20px]"
style={FONT_MONT}
>
Динопарк + Диволіс із казковими топіарними фігурами + Дзеркальний лабіринт
{comboDescription}
</p>
</div>
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2">

View file

@ -6,7 +6,7 @@ import { useState, useRef, useEffect } from 'react'
const FONT_MONT = { fontFamily: 'var(--font-montserrat, Montserrat), sans-serif' }
const IMG_PLAY = '/images/figma/btn-video-play.svg'
const ITEMS = [
const DEFAULT_ITEMS = [
{
title: 'Простір для спільної фантазії',
description:
@ -24,6 +24,11 @@ const ITEMS = [
},
]
interface DyvoLisWhyVisitProps {
title?: string
items?: Array<{ title: string; description: string }>
}
const VIDEO_REVIEWS = [
{ poster: '/images/dyvolis/photo-22.jpg' },
{ poster: '/images/dyvolis/photo-14.jpg' },
@ -32,7 +37,10 @@ const VIDEO_REVIEWS = [
{ poster: '/images/dyvolis/photo-03.jpg' },
]
export function DyvoLisWhyVisit() {
export function DyvoLisWhyVisit({
title = 'Чому варто відвідати ДивоЛіс',
items = DEFAULT_ITEMS,
}: DyvoLisWhyVisitProps) {
const [openIndex, setOpenIndex] = useState(0)
const [videoActive, setVideoActive] = useState(0)
const videoPausedRef = useRef(false)
@ -42,7 +50,7 @@ export function DyvoLisWhyVisit() {
useEffect(() => {
accordionTimer.current = setInterval(() => {
setOpenIndex((prev) => (prev + 1) % ITEMS.length)
setOpenIndex((prev) => (prev + 1) % items.length)
}, 4000)
return () => {
if (accordionTimer.current) clearInterval(accordionTimer.current)
@ -64,7 +72,7 @@ export function DyvoLisWhyVisit() {
setOpenIndex(i)
if (accordionTimer.current) clearInterval(accordionTimer.current)
accordionTimer.current = setInterval(() => {
setOpenIndex((prev) => (prev + 1) % ITEMS.length)
setOpenIndex((prev) => (prev + 1) % items.length)
}, 4000)
}
@ -75,7 +83,7 @@ export function DyvoLisWhyVisit() {
className="mb-[40px] text-[24px] font-bold text-[#272727] uppercase md:mb-[60px] md:text-[32px]"
style={FONT_MONT}
>
Чому варто відвідати ДивоЛіс
{title}
</h2>
<div className="flex flex-col items-start gap-16 lg:flex-row lg:items-start">
@ -84,7 +92,7 @@ export function DyvoLisWhyVisit() {
<div className="absolute top-8 left-0 hidden h-[488px] w-[333px] rounded-[30px] bg-[#396817] lg:block" />
<div className="relative flex flex-col gap-6 lg:ml-[76px] lg:min-h-[560px]">
{ITEMS.map((item, i) => {
{items.map((item, i) => {
const isOpen = openIndex === i
return (
<button

View file

@ -35,7 +35,10 @@ export function CartIcon() {
<span
aria-hidden="true"
className="absolute -top-1 -right-1 flex h-5 min-w-5 items-center justify-center rounded-full px-1 text-[10px] font-bold text-white"
style={{ background: '#f28b4a', fontFamily: 'var(--font-montserrat, Montserrat), sans-serif' }}
style={{
background: '#f28b4a',
fontFamily: 'var(--font-montserrat, Montserrat), sans-serif',
}}
>
{totalCount > 99 ? '99+' : totalCount}
</span>

View file

@ -14,7 +14,13 @@ interface TariffCardClientProps {
icon?: string | null
}
export function TariffCardClient({ tariffId, name, price, categoryTag, icon }: TariffCardClientProps) {
export function TariffCardClient({
tariffId,
name,
price,
categoryTag,
icon,
}: TariffCardClientProps) {
const { addItem } = useCart()
const [count, setCount] = useState(1)
const [added, setAdded] = useState(false)
@ -50,7 +56,10 @@ export function TariffCardClient({ tariffId, name, price, categoryTag, icon }: T
>
</button>
<span className="min-w-[28px] text-center text-[18px] font-bold text-white" style={FONT_MONT}>
<span
className="min-w-[28px] text-center text-[18px] font-bold text-white"
style={FONT_MONT}
>
{count}
</span>
<button

View file

@ -30,7 +30,7 @@ function reducer(state: CartState, action: CartAction): CartState {
return {
...state,
items: state.items.map((i) =>
i.tariffId === action.item.tariffId ? { ...i, count: i.count + 1 } : i,
i.tariffId === action.item.tariffId ? { ...i, count: i.count + 1 } : i
),
}
}
@ -47,7 +47,7 @@ function reducer(state: CartState, action: CartAction): CartState {
return {
...state,
items: state.items.map((i) =>
i.tariffId === action.tariffId ? { ...i, count: action.count } : i,
i.tariffId === action.tariffId ? { ...i, count: action.count } : i
),
}
}

View file

@ -0,0 +1,94 @@
import type { GlobalConfig } from 'payload'
import { isAdminOrEditor } from '@/access/isAdminOrEditor'
export const DyvoLisPage: GlobalConfig = {
slug: 'dyvolis-page',
label: 'Диво Ліс — сторінка',
access: { read: () => true, update: isAdminOrEditor },
fields: [
// Hero section
{
name: 'heroTitle',
type: 'text',
defaultValue: 'ДивоЛіс територія магії та фантазії',
},
{
name: 'heroDescription',
type: 'textarea',
defaultValue:
'Топіарні фігури зроблені з урахуванням важливих деталей, тому ви одразу впізнаєте в них улюблених казкових героїв. Тут можна бігати, стрибати, лазити по фігурках і ставати героями власної казки.',
},
{
name: 'heroStat',
type: 'text',
defaultValue: '60+',
admin: { description: 'Число у круглому бейджі (наприклад "60+")' },
},
{
name: 'heroStatLabel',
type: 'text',
defaultValue: 'експонатів з безпечних для дітей матеріалів',
},
{
name: 'heroTips',
type: 'array',
fields: [{ name: 'text', type: 'text', required: true }],
defaultValue: [
{ text: 'Унікальна ландшафтна композиція з місцями для відпочинку' },
{ text: 'Повна свобода переміщення - без заборон' },
],
},
// Working hours (shown in tickets section)
{
name: 'workingHours',
type: 'text',
defaultValue: 'щодня з 11:00 до 20:00',
admin: { description: 'Час роботи, наприклад "щодня з 11:00 до 20:00"' },
},
// Gallery
{
name: 'galleryQuote',
type: 'text',
defaultValue:
'Це місце де малеча зустрічає героїв улюблених казок. Простір справжнього дитинства.',
},
// Why visit section
{
name: 'whyVisitTitle',
type: 'text',
defaultValue: 'Чому варто відвідати ДивоЛіс',
},
{
name: 'whyVisitItems',
type: 'array',
fields: [
{ name: 'title', type: 'text', required: true },
{ name: 'description', type: 'textarea', required: true },
],
defaultValue: [
{
title: 'Простір для спільної фантазії',
description:
'Вигадуйте казки та пригоди разом із дітьми — кожна топіарна фігурка стає новою сторінкою вашої власної чарівної історії.',
},
{
title: 'Казковий ліс у справжньому лісі',
description:
'Ми створили локацію, в якій гармонійно поєднуються казкові фігури та жива природа. Прогулянка лісом ще не була такою захопливою.',
},
{
title: 'Магічні кадри для сімейного альбому',
description:
'Унікальні топіарні декорації та яскраві персонажі — ідеальний фон для незабутніх сімейних фотографій, які захочеться переглядати знову і знову.',
},
],
},
// Combo section description
{
name: 'comboDescription',
type: 'text',
defaultValue: 'Динопарк + Диволіс із казковими топіарними фігурами + Дзеркальний лабіринт',
admin: { description: 'Підзаголовок під секцією "Комбо" в блоці квитків' },
},
],
}

View file

@ -0,0 +1,73 @@
import type { GlobalConfig } from 'payload'
import { isAdminOrEditor } from '@/access/isAdminOrEditor'
export const GroupVisitsPage: GlobalConfig = {
slug: 'group-visits-page',
label: 'Групові відвідування — сторінка',
access: { read: () => true, update: isAdminOrEditor },
fields: [
{
name: 'heroTitle',
type: 'text',
defaultValue: 'Групові відвідування',
},
{
name: 'heroSubtitle',
type: 'text',
defaultValue: 'Спеціальні умови для організованих груп. Мінімум 10 осіб — максимум вражень.',
},
{
name: 'formTitle',
type: 'text',
defaultValue: 'Подати заявку на групове відвідування',
},
{
name: 'formSubtitle',
type: 'text',
defaultValue:
'Вкажіть кількість учасників та бажану дату — менеджер зателефонує і погодить деталі.',
},
{
name: 'groups',
type: 'array',
fields: [
{ name: 'icon', type: 'text', required: true, admin: { description: 'Emoji іконка' } },
{ name: 'title', type: 'text', required: true },
{ name: 'description', type: 'textarea', required: true },
{
name: 'minPeople',
type: 'text',
required: true,
admin: { description: 'Напр. "15 осіб"' },
},
{ name: 'discount', type: 'text', required: true, admin: { description: 'Напр. "15%"' } },
],
defaultValue: [
{
icon: '🏫',
title: 'Шкільні екскурсії',
description:
'Пізнавальні екскурсії для учнів початкової та середньої школи. Екскурсовод, адаптована програма, безпечний маршрут.',
minPeople: '15 осіб',
discount: '15%',
},
{
icon: '🎒',
title: 'Дитячі садки',
description:
'Програма для наймолодших — безпечний формат, розвивальні активності, відповідальний супровід.',
minPeople: '10 осіб',
discount: '20%',
},
{
icon: '🏢',
title: 'Корпоративи',
description:
'Тімбілдинг та корпоративний відпочинок у форматі парку розваг. Ексклюзивні зони, кейтеринг, програма на замовлення.',
minPeople: '20 осіб',
discount: '10%',
},
],
},
],
}