feat(cms): full CMS control — remove hardcoded content, add globals and SEO meta
Some checks are pending
CI / Type Check (push) Waiting to run
CI / Lint (push) Waiting to run
CI / Unit Tests (push) Waiting to run
Deploy / Build & Push Image (push) Waiting to run
Deploy / Deploy to VPS (push) Blocked by required conditions

- New BirthdayPage global: hero, form titles, meta SEO for /dni-narodzhennia
- New TicketsPage global: hero, section titles, meta SEO for /kvytky
- SiteSettings: add tariffCategoryLabels array (key→label mapping for ezy API categories)
- DyvoLisPage + GroupVisitsPage: add metaTitle/metaDescription + revalidate hook
- /kvytky: fetch birthday packages from CMS, groups from CMS, category labels from SiteSettings
- /grupovi-vidviduvannia: remove DEFAULT_GROUPS fallback, CMS-driven meta
- /dni-narodzhennia: connect to BirthdayPage global (hero, form titles, meta)
- /lokatsii/dyvolis: use CMS meta description from DyvoLisPage global
- pnpm override tsx→4.22.0 for Node.js v26 compatibility

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Vadym Samoilenko 2026-05-15 13:55:28 +01:00
parent 2a535671b6
commit 2ec8393de9
13 changed files with 421 additions and 215 deletions

View file

@ -83,9 +83,15 @@
"prettier-plugin-tailwindcss": "^0.8.0",
"supertest": "^7.1.0",
"tailwindcss": "^4.3.0",
"tsx": "^4.21.0",
"tsx": "^4.22.0",
"typescript": "^6.0.3",
"vite-tsconfig-paths": "^6.1.1",
"vitest": "^4.1.6"
},
"pnpm": {
"overrides": {
"tsx": "4.22.0",
"payload>tsx": "4.22.0"
}
}
}

View file

@ -28,6 +28,8 @@ import { Footer } from './src/globals/Footer'
import { SiteSettings } from './src/globals/SiteSettings'
import { DyvoLisPage } from './src/globals/DyvoLisPage'
import { GroupVisitsPage } from './src/globals/GroupVisitsPage'
import { BirthdayPage } from './src/globals/BirthdayPage'
import { TicketsPage } from './src/globals/TicketsPage'
const filename = fileURLToPath(import.meta.url)
const dirname = path.dirname(filename)
@ -78,6 +80,8 @@ export default buildConfig({
SiteSettings,
DyvoLisPage,
GroupVisitsPage,
BirthdayPage,
TicketsPage,
],
plugins: [
@ -107,6 +111,8 @@ export default buildConfig({
'site-settings',
'dyvolis-page',
'group-visits-page',
'birthday-page',
'tickets-page',
],
},
},

45
pnpm-lock.yaml generated
View file

@ -113,7 +113,7 @@ importers:
version: 8.59.3(eslint@9.39.4(jiti@2.7.0))(typescript@6.0.3)
'@vitejs/plugin-react':
specifier: ^6.0.1
version: 6.0.1(vite@7.3.3(@types/node@25.7.0)(jiti@2.7.0)(lightningcss@1.32.0)(sass@1.77.4)(tsx@4.21.0)(yaml@2.8.4))
version: 6.0.1(vite@7.3.3(@types/node@25.7.0)(jiti@2.7.0)(lightningcss@1.32.0)(sass@1.77.4)(tsx@4.22.0)(yaml@2.8.4))
'@vitest/coverage-v8':
specifier: ^4.1.6
version: 4.1.6(vitest@4.1.6)
@ -154,17 +154,17 @@ importers:
specifier: ^4.3.0
version: 4.3.0
tsx:
specifier: ^4.21.0
version: 4.21.0
specifier: ^4.22.0
version: 4.22.0
typescript:
specifier: ^6.0.3
version: 6.0.3
vite-tsconfig-paths:
specifier: ^6.1.1
version: 6.1.1(typescript@6.0.3)(vite@7.3.3(@types/node@25.7.0)(jiti@2.7.0)(lightningcss@1.32.0)(sass@1.77.4)(tsx@4.21.0)(yaml@2.8.4))
version: 6.1.1(typescript@6.0.3)(vite@7.3.3(@types/node@25.7.0)(jiti@2.7.0)(lightningcss@1.32.0)(sass@1.77.4)(tsx@4.22.0)(yaml@2.8.4))
vitest:
specifier: ^4.1.6
version: 4.1.6(@types/node@25.7.0)(@vitest/coverage-v8@4.1.6)(happy-dom@20.9.0)(jsdom@29.1.1(@noble/hashes@1.8.0))(vite@7.3.3(@types/node@25.7.0)(jiti@2.7.0)(lightningcss@1.32.0)(sass@1.77.4)(tsx@4.21.0)(yaml@2.8.4))
version: 4.1.6(@types/node@25.7.0)(@vitest/coverage-v8@4.1.6)(happy-dom@20.9.0)(jsdom@29.1.1(@noble/hashes@1.8.0))(vite@7.3.3(@types/node@25.7.0)(jiti@2.7.0)(lightningcss@1.32.0)(sass@1.77.4)(tsx@4.22.0)(yaml@2.8.4))
packages:
@ -5306,6 +5306,11 @@ packages:
engines: {node: '>=18.0.0'}
hasBin: true
tsx@4.22.0:
resolution: {integrity: sha512-8ccZMPD69s1AbKXx0C5ddTNZfNjwV04iIKgjZmKfKxMynEtSYcK0Lh7iQFh53fI5Yu4pb9usgAiqyPmEONaALg==}
engines: {node: '>=18.0.0'}
hasBin: true
type-check@0.4.0:
resolution: {integrity: sha512-XleUoc9uwGXqjWwXaUTZAmzMcFZ5858QA2vvx1Ur5xIcixXIP+8LnFDgRplU30us6teqdlskFfu+ae4K79Ooew==}
engines: {node: '>= 0.8.0'}
@ -7760,10 +7765,10 @@ snapshots:
'@unrs/resolver-binding-win32-x64-msvc@1.11.1':
optional: true
'@vitejs/plugin-react@6.0.1(vite@7.3.3(@types/node@25.7.0)(jiti@2.7.0)(lightningcss@1.32.0)(sass@1.77.4)(tsx@4.21.0)(yaml@2.8.4))':
'@vitejs/plugin-react@6.0.1(vite@7.3.3(@types/node@25.7.0)(jiti@2.7.0)(lightningcss@1.32.0)(sass@1.77.4)(tsx@4.22.0)(yaml@2.8.4))':
dependencies:
'@rolldown/pluginutils': 1.0.0-rc.7
vite: 7.3.3(@types/node@25.7.0)(jiti@2.7.0)(lightningcss@1.32.0)(sass@1.77.4)(tsx@4.21.0)(yaml@2.8.4)
vite: 7.3.3(@types/node@25.7.0)(jiti@2.7.0)(lightningcss@1.32.0)(sass@1.77.4)(tsx@4.22.0)(yaml@2.8.4)
'@vitest/coverage-v8@4.1.6(vitest@4.1.6)':
dependencies:
@ -7777,7 +7782,7 @@ snapshots:
obug: 2.1.1
std-env: 4.1.0
tinyrainbow: 3.1.0
vitest: 4.1.6(@types/node@25.7.0)(@vitest/coverage-v8@4.1.6)(happy-dom@20.9.0)(jsdom@29.1.1(@noble/hashes@1.8.0))(vite@7.3.3(@types/node@25.7.0)(jiti@2.7.0)(lightningcss@1.32.0)(sass@1.77.4)(tsx@4.21.0)(yaml@2.8.4))
vitest: 4.1.6(@types/node@25.7.0)(@vitest/coverage-v8@4.1.6)(happy-dom@20.9.0)(jsdom@29.1.1(@noble/hashes@1.8.0))(vite@7.3.3(@types/node@25.7.0)(jiti@2.7.0)(lightningcss@1.32.0)(sass@1.77.4)(tsx@4.22.0)(yaml@2.8.4))
'@vitest/expect@4.1.6':
dependencies:
@ -7788,13 +7793,13 @@ snapshots:
chai: 6.2.2
tinyrainbow: 3.1.0
'@vitest/mocker@4.1.6(vite@7.3.3(@types/node@25.7.0)(jiti@2.7.0)(lightningcss@1.32.0)(sass@1.77.4)(tsx@4.21.0)(yaml@2.8.4))':
'@vitest/mocker@4.1.6(vite@7.3.3(@types/node@25.7.0)(jiti@2.7.0)(lightningcss@1.32.0)(sass@1.77.4)(tsx@4.22.0)(yaml@2.8.4))':
dependencies:
'@vitest/spy': 4.1.6
estree-walker: 3.0.3
magic-string: 0.30.21
optionalDependencies:
vite: 7.3.3(@types/node@25.7.0)(jiti@2.7.0)(lightningcss@1.32.0)(sass@1.77.4)(tsx@4.21.0)(yaml@2.8.4)
vite: 7.3.3(@types/node@25.7.0)(jiti@2.7.0)(lightningcss@1.32.0)(sass@1.77.4)(tsx@4.22.0)(yaml@2.8.4)
'@vitest/pretty-format@4.1.6':
dependencies:
@ -11148,6 +11153,12 @@ snapshots:
optionalDependencies:
fsevents: 2.3.3
tsx@4.22.0:
dependencies:
esbuild: 0.28.0
optionalDependencies:
fsevents: 2.3.3
type-check@0.4.0:
dependencies:
prelude-ls: 1.2.1
@ -11314,17 +11325,17 @@ snapshots:
'@types/unist': 3.0.3
unist-util-stringify-position: 4.0.0
vite-tsconfig-paths@6.1.1(typescript@6.0.3)(vite@7.3.3(@types/node@25.7.0)(jiti@2.7.0)(lightningcss@1.32.0)(sass@1.77.4)(tsx@4.21.0)(yaml@2.8.4)):
vite-tsconfig-paths@6.1.1(typescript@6.0.3)(vite@7.3.3(@types/node@25.7.0)(jiti@2.7.0)(lightningcss@1.32.0)(sass@1.77.4)(tsx@4.22.0)(yaml@2.8.4)):
dependencies:
debug: 4.4.3
globrex: 0.1.2
tsconfck: 3.1.6(typescript@6.0.3)
vite: 7.3.3(@types/node@25.7.0)(jiti@2.7.0)(lightningcss@1.32.0)(sass@1.77.4)(tsx@4.21.0)(yaml@2.8.4)
vite: 7.3.3(@types/node@25.7.0)(jiti@2.7.0)(lightningcss@1.32.0)(sass@1.77.4)(tsx@4.22.0)(yaml@2.8.4)
transitivePeerDependencies:
- supports-color
- typescript
vite@7.3.3(@types/node@25.7.0)(jiti@2.7.0)(lightningcss@1.32.0)(sass@1.77.4)(tsx@4.21.0)(yaml@2.8.4):
vite@7.3.3(@types/node@25.7.0)(jiti@2.7.0)(lightningcss@1.32.0)(sass@1.77.4)(tsx@4.22.0)(yaml@2.8.4):
dependencies:
esbuild: 0.27.7
fdir: 6.5.0(picomatch@4.0.4)
@ -11338,13 +11349,13 @@ snapshots:
jiti: 2.7.0
lightningcss: 1.32.0
sass: 1.77.4
tsx: 4.21.0
tsx: 4.22.0
yaml: 2.8.4
vitest@4.1.6(@types/node@25.7.0)(@vitest/coverage-v8@4.1.6)(happy-dom@20.9.0)(jsdom@29.1.1(@noble/hashes@1.8.0))(vite@7.3.3(@types/node@25.7.0)(jiti@2.7.0)(lightningcss@1.32.0)(sass@1.77.4)(tsx@4.21.0)(yaml@2.8.4)):
vitest@4.1.6(@types/node@25.7.0)(@vitest/coverage-v8@4.1.6)(happy-dom@20.9.0)(jsdom@29.1.1(@noble/hashes@1.8.0))(vite@7.3.3(@types/node@25.7.0)(jiti@2.7.0)(lightningcss@1.32.0)(sass@1.77.4)(tsx@4.22.0)(yaml@2.8.4)):
dependencies:
'@vitest/expect': 4.1.6
'@vitest/mocker': 4.1.6(vite@7.3.3(@types/node@25.7.0)(jiti@2.7.0)(lightningcss@1.32.0)(sass@1.77.4)(tsx@4.21.0)(yaml@2.8.4))
'@vitest/mocker': 4.1.6(vite@7.3.3(@types/node@25.7.0)(jiti@2.7.0)(lightningcss@1.32.0)(sass@1.77.4)(tsx@4.22.0)(yaml@2.8.4))
'@vitest/pretty-format': 4.1.6
'@vitest/runner': 4.1.6
'@vitest/snapshot': 4.1.6
@ -11361,7 +11372,7 @@ snapshots:
tinyexec: 1.1.2
tinyglobby: 0.2.16
tinyrainbow: 3.1.0
vite: 7.3.3(@types/node@25.7.0)(jiti@2.7.0)(lightningcss@1.32.0)(sass@1.77.4)(tsx@4.21.0)(yaml@2.8.4)
vite: 7.3.3(@types/node@25.7.0)(jiti@2.7.0)(lightningcss@1.32.0)(sass@1.77.4)(tsx@4.22.0)(yaml@2.8.4)
why-is-node-running: 2.3.0
optionalDependencies:
'@types/node': 25.7.0

View file

@ -4,14 +4,17 @@ import configPromise from '@payload-config'
import { PageHero } from '@/components/ui/PageHero'
import { BirthdayBookingForm } from '@/components/forms/BirthdayBookingForm'
export const metadata: Metadata = {
title: 'Дні народження — Шуміленд',
description:
'Святкуйте день народження у Шуміленді! Пакети для дітей та дорослих з розвагами, аніматорами та кейтерингом.',
}
export const revalidate = 60
async function getBirthdayPageData() {
try {
const payload = await getPayload({ config: configPromise })
return await payload.findGlobal({ slug: 'birthday-page', depth: 0 })
} catch {
return null
}
}
async function getPackages() {
try {
const payload = await getPayload({ config: configPromise })
@ -30,6 +33,16 @@ function formatPrice(price: number): string {
return price.toLocaleString('uk-UA').replace(/,/g, ' ')
}
export async function generateMetadata(): Promise<Metadata> {
const pageData = await getBirthdayPageData()
return {
title: pageData?.metaTitle ?? 'Дні народження — Шуміленд',
description:
pageData?.metaDescription ??
'Святкуйте день народження у Шуміленді! Пакети для дітей та дорослих з розвагами, аніматорами та кейтерингом.',
}
}
export default async function BirthdayPage({
searchParams,
}: {
@ -37,13 +50,16 @@ export default async function BirthdayPage({
}) {
const params = await searchParams
const defaultPackage = params.package
const packages = await getPackages()
const [pageData, packages] = await Promise.all([getBirthdayPageData(), getPackages()])
return (
<div className="min-h-screen bg-[#f1fbeb]">
<PageHero
title="Дні народження"
subtitle="Зробіть свято незабутнім! Оберіть пакет і наші менеджери зв'яжуться з вами для уточнення деталей."
title={pageData?.heroTitle ?? 'Дні народження'}
subtitle={
pageData?.heroSubtitle ??
"Зробіть свято незабутнім! Оберіть пакет і наші менеджери зв'яжуться з вами для уточнення деталей."
}
/>
<div className="mx-auto max-w-[1204px] px-8 py-16">
@ -100,13 +116,14 @@ export default async function BirthdayPage({
className="mb-2 text-[28px] font-bold text-white"
style={{ fontFamily: 'var(--font-montserrat, Montserrat), sans-serif' }}
>
Замовити святкування
{pageData?.formTitle ?? 'Замовити святкування'}
</h2>
<p
className="mb-8 text-[15px] text-white/70"
style={{ fontFamily: 'var(--font-montserrat, Montserrat), sans-serif' }}
>
Залиште заявку і наш менеджер зв&apos;яжеться з вами протягом 30 хвилин
{pageData?.formSubtitle ??
"Залиште заявку і наш менеджер зв'яжеться з вами протягом 30 хвилин"}
</p>
<BirthdayBookingForm defaultPackage={defaultPackage} />
</div>

View file

@ -6,6 +6,14 @@ import { GroupRequestForm } from '@/components/forms/GroupRequestForm'
export const revalidate = 60
interface Group {
icon?: string | null
title: string
description: string
minPeople: string
discount: string
}
async function getGroupVisitsData() {
try {
const payload = await getPayload({ config: configPromise })
@ -16,40 +24,15 @@ async function getGroupVisitsData() {
}
export async function generateMetadata(): Promise<Metadata> {
const data = await getGroupVisitsData()
return {
title: 'Групові відвідування — Шуміленд',
title: data?.metaTitle ?? 'Групові відвідування — Шуміленд',
description:
data?.metaDescription ??
'Організуйте групове відвідування Шуміленду. Спеціальні ціни для шкіл, дитячих садків і корпоративних груп.',
}
}
const DEFAULT_GROUPS = [
{
title: 'Шкільні екскурсії',
description:
'Пізнавальні екскурсії для учнів початкової та середньої школи. Екскурсовод, адаптована програма, безпечний маршрут.',
minPeople: '15 осіб',
discount: '15%',
icon: '🏫',
},
{
title: 'Дитячі садки',
description:
'Програма для наймолодших — безпечний формат, розвивальні активності, відповідальний супровід.',
minPeople: '10 осіб',
discount: '20%',
icon: '🎒',
},
{
title: 'Корпоративи',
description:
'Тімбілдинг та корпоративний відпочинок у форматі парку розваг. Ексклюзивні зони, кейтеринг, програма на замовлення.',
minPeople: '20 осіб',
discount: '10%',
icon: '🏢',
},
]
export default async function GroupVisitsPage() {
const data = await getGroupVisitsData()
@ -61,46 +44,47 @@ export default async function GroupVisitsPage() {
const formSubtitle =
data?.formSubtitle ??
'Вкажіть кількість учасників та бажану дату — менеджер зателефонує і погодить деталі.'
const groups =
data?.groups && data.groups.length > 0 ? (data.groups as typeof DEFAULT_GROUPS) : DEFAULT_GROUPS
const groups = (data?.groups ?? []) as Group[]
return (
<div className="min-h-screen bg-[#f1fbeb]">
<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) => (
<div
key={g.title}
className="flex flex-col gap-4 rounded-[24px] bg-[#396817] p-8 shadow-[0_4px_60px_0_rgba(57,104,23,0.15)]"
>
<span className="text-[40px]">{g.icon}</span>
<h2
className="text-[22px] font-bold text-white"
style={{ fontFamily: 'var(--font-montserrat, Montserrat), sans-serif' }}
{groups.length > 0 && (
<div className="mb-16 grid grid-cols-1 gap-6 md:grid-cols-3">
{groups.map((g) => (
<div
key={g.title}
className="flex flex-col gap-4 rounded-[24px] bg-[#396817] p-8 shadow-[0_4px_60px_0_rgba(57,104,23,0.15)]"
>
{g.title}
</h2>
<p
className="text-[14px] leading-relaxed text-white/70"
style={{ fontFamily: 'var(--font-montserrat, Montserrat), sans-serif' }}
>
{g.description}
</p>
<div className="mt-auto flex gap-4 border-t border-white/10 pt-4">
<div>
<p className="text-[12px] text-white/50">Від</p>
<p className="text-[16px] font-bold text-white">{g.minPeople}</p>
</div>
<div>
<p className="text-[12px] text-white/50">Знижка</p>
<p className="text-[16px] font-bold text-[#f28b4a]">{g.discount}</p>
<span className="text-[40px]">{g.icon}</span>
<h2
className="text-[22px] font-bold text-white"
style={{ fontFamily: 'var(--font-montserrat, Montserrat), sans-serif' }}
>
{g.title}
</h2>
<p
className="text-[14px] leading-relaxed text-white/70"
style={{ fontFamily: 'var(--font-montserrat, Montserrat), sans-serif' }}
>
{g.description}
</p>
<div className="mt-auto flex gap-4 border-t border-white/10 pt-4">
<div>
<p className="text-[12px] text-white/50">Від</p>
<p className="text-[16px] font-bold text-white">{g.minPeople}</p>
</div>
<div>
<p className="text-[12px] text-white/50">Знижка</p>
<p className="text-[16px] font-bold text-[#f28b4a]">{g.discount}</p>
</div>
</div>
</div>
</div>
))}
</div>
))}
</div>
)}
<div id="order-form" className="rounded-[24px] bg-[#396817] p-10">
<h2

View file

@ -1,15 +1,23 @@
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 { TariffCardClient } from '@/components/ui/TariffCardClient'
export const metadata: Metadata = {
title: 'Купити квиток — Шуміленд',
description: 'Придбайте квитки до Шуміленду онлайн. Вхідні квитки на всі зони парку.',
}
import { getSiteSettings } from '@/lib/getSiteSettings'
export const revalidate = 60
interface Tariff {
id: number
name: string
price: number
categoryTag: string
sort: number
icon?: string | null
stale?: boolean
}
async function getTariffs() {
try {
const baseUrl = process.env['NEXT_PUBLIC_SITE_URL'] ?? 'http://localhost:3000'
@ -21,30 +29,60 @@ async function getTariffs() {
}
}
interface Tariff {
id: number
name: string
price: number
categoryTag: string
sort: number
icon?: string | null
stale?: boolean
async function getPageData() {
try {
const payload = await getPayload({ config: configPromise })
return await payload.findGlobal({ slug: 'tickets-page', depth: 0 })
} catch {
return null
}
}
const CATEGORY_LABELS: Record<string, string> = {
dyno: 'ДиноПарк',
dyvolis: 'Диво Ліс',
maze: 'Дзеркальний Лабіринт',
combo: 'Комбо',
family: 'Сімейний',
async function getBirthdayPackages() {
try {
const payload = await getPayload({ config: configPromise })
const result = await payload.find({
collection: 'birthday-packages',
sort: 'sort',
limit: 10,
})
return result.docs
} catch {
return []
}
}
async function getGroupVisitsData() {
try {
const payload = await getPayload({ config: configPromise })
return await payload.findGlobal({ slug: 'group-visits-page', depth: 0 })
} catch {
return null
}
}
export async function generateMetadata(): Promise<Metadata> {
const pageData = await getPageData()
return {
title: pageData?.metaTitle ?? 'Купити квиток — Шуміленд',
description:
pageData?.metaDescription ??
'Придбайте квитки до Шуміленду онлайн. Вхідні квитки на всі зони парку.',
}
}
export default async function TicketsPage() {
const data = await getTariffs()
const tariffs = data?.tariffs ?? []
const hasWarning = !!data?.warning
const [tariffData, pageData, birthdayPackages, groupVisitsData, siteSettings] = await Promise.all(
[getTariffs(), getPageData(), getBirthdayPackages(), getGroupVisitsData(), getSiteSettings()]
)
const tariffs = tariffData?.tariffs ?? []
const hasWarning = !!tariffData?.warning
const categoryLabelsMap = Object.fromEntries(
(siteSettings.tariffCategoryLabels ?? []).map(({ key, label }) => [key, label])
)
// Group by category
const grouped = tariffs.reduce<Record<string, Tariff[]>>((acc, t) => {
const key = t.categoryTag ?? 'other'
acc[key] ??= []
@ -57,8 +95,10 @@ export default async function TicketsPage() {
return (
<div className="min-h-screen bg-[#f1fbeb]">
<PageHero
title="Купити квиток"
subtitle="Оберіть квиток та придбайте онлайн — без черги на касі"
title={pageData?.heroTitle ?? 'Купити квиток'}
subtitle={
pageData?.heroSubtitle ?? 'Оберіть квиток та придбайте онлайн — без черги на касі'
}
/>
<div className="mx-auto max-w-[1204px] px-8 py-16">
@ -89,7 +129,7 @@ export default async function TicketsPage() {
className="mb-6 text-[24px] font-bold text-[#272727] uppercase"
style={FONT_MONT}
>
{CATEGORY_LABELS[category] ?? category}
{categoryLabelsMap[category] ?? category}
</h2>
<div className="grid grid-cols-1 gap-5 sm:grid-cols-2 lg:grid-cols-3">
{items.map((tariff) => (
@ -107,111 +147,99 @@ export default async function TicketsPage() {
))
)}
{/* Birthday packages */}
<div>
<h2 className="mb-6 text-[24px] font-bold text-[#272727] uppercase" style={FONT_MONT}>
Дні народження
</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',
},
].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] 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}>
{pkg.desc}
</p>
</div>
<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%)',
}}
{birthdayPackages.length > 0 && (
<div>
<h2 className="mb-6 text-[24px] font-bold text-[#272727] uppercase" style={FONT_MONT}>
{pageData?.sectionTitleBirthday ?? 'Дні народження'}
</h2>
<div className="grid grid-cols-1 gap-5 sm:grid-cols-2 lg:grid-cols-3">
{birthdayPackages.map((pkg) => (
<div
key={pkg.id}
className="flex flex-col gap-4 rounded-[20px] bg-[#396817] p-8 shadow-[0_4px_60px_0_rgba(242,139,74,0.25)]"
>
Дізнатися ціну
</Link>
</div>
))}
<div>
<h3
className="text-[20px] leading-tight font-bold text-white"
style={FONT_MONT}
>
{pkg.name}
</h3>
{pkg.features && pkg.features.length > 0 && (
<p
className="mt-2 text-[14px] leading-relaxed text-white/70"
style={FONT_MONT}
>
{pkg.features
.slice(0, 3)
.map((f: { text: string }) => f.text)
.join(' · ')}
</p>
)}
</div>
<Link
href={pkg.ctaHref ?? '/dni-narodzhennia#order-form'}
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%)',
}}
>
{pkg.ctaLabel ?? 'Дізнатися ціну'}
</Link>
</div>
))}
</div>
</div>
</div>
)}
{/* Group visits */}
<div>
<h2 className="mb-6 text-[24px] font-bold text-[#272727] uppercase" style={FONT_MONT}>
Групові відвідування
</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',
},
].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] leading-tight font-bold text-white"
style={FONT_MONT}
{groupVisitsData?.groups && groupVisitsData.groups.length > 0 && (
<div>
<h2 className="mb-6 text-[24px] font-bold text-[#272727] uppercase" style={FONT_MONT}>
{pageData?.sectionTitleGroups ?? 'Групові відвідування'}
</h2>
<div className="grid grid-cols-1 gap-5 sm:grid-cols-2 lg:grid-cols-3">
{groupVisitsData.groups.map(
(grp: {
title: string
description: string
minPeople: string
discount: string
}) => (
<div
key={grp.title}
className="flex flex-col gap-4 rounded-[20px] bg-[#396817] p-8 shadow-[0_4px_60px_0_rgba(242,139,74,0.25)]"
>
{grp.name}
</h3>
<p className="mt-2 text-[14px] leading-relaxed text-white/70" style={FONT_MONT}>
{grp.desc}
</p>
</div>
<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%)',
}}
>
Дізнатися ціну
</Link>
</div>
))}
<div>
<h3
className="text-[20px] leading-tight font-bold text-white"
style={FONT_MONT}
>
{grp.title}
</h3>
<p
className="mt-2 text-[14px] leading-relaxed text-white/70"
style={FONT_MONT}
>
Від {grp.minPeople} · Знижка {grp.discount}
</p>
</div>
<Link
href="/grupovi-vidviduvannia#order-form"
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%)',
}}
>
Дізнатися ціну
</Link>
</div>
)
)}
</div>
</div>
</div>
)}
</div>
</div>
</div>

View file

@ -20,8 +20,10 @@ async function getDyvoLisData() {
export async function generateMetadata(): Promise<Metadata> {
const data = await getDyvoLisData()
return {
title: data?.heroTitle ? `${data.heroTitle} — Шуміленд` : 'ДивоЛіс — Шуміленд',
title:
data?.metaTitle ?? (data?.heroTitle ? `${data.heroTitle} — Шуміленд` : 'ДивоЛіс — Шуміленд'),
description:
data?.metaDescription ??
'Казковий топіарний ліс у Шуміленді: фігури з безпечних матеріалів, унікальна ландшафтна композиція та повна свобода для дітей.',
}
}

View file

@ -0,0 +1,46 @@
import type { GlobalConfig } from 'payload'
import { isAdminOrEditor } from '@/access/isAdminOrEditor'
import { revalidateGlobalAfterChange } from '@/hooks/revalidatePath'
export const BirthdayPage: GlobalConfig = {
slug: 'birthday-page',
label: 'Дні народження — сторінка',
access: { read: () => true, update: isAdminOrEditor },
hooks: { afterChange: [revalidateGlobalAfterChange] },
fields: [
{
name: 'heroTitle',
type: 'text',
defaultValue: 'Дні народження',
},
{
name: 'heroSubtitle',
type: 'text',
defaultValue:
"Зробіть свято незабутнім! Оберіть пакет і наші менеджери зв'яжуться з вами для уточнення деталей.",
},
{
name: 'formTitle',
type: 'text',
defaultValue: 'Замовити святкування',
},
{
name: 'formSubtitle',
type: 'text',
defaultValue: "Залиште заявку і наш менеджер зв'яжеться з вами протягом 30 хвилин",
},
{
name: 'metaTitle',
type: 'text',
label: 'SEO: Meta Title',
defaultValue: 'Дні народження — Шуміленд',
},
{
name: 'metaDescription',
type: 'textarea',
label: 'SEO: Meta Description',
defaultValue:
'Святкуйте день народження у Шуміленді! Пакети для дітей та дорослих з розвагами, аніматорами та кейтерингом.',
},
],
}

View file

@ -1,10 +1,12 @@
import type { GlobalConfig } from 'payload'
import { isAdminOrEditor } from '@/access/isAdminOrEditor'
import { revalidateGlobalAfterChange } from '@/hooks/revalidatePath'
export const DyvoLisPage: GlobalConfig = {
slug: 'dyvolis-page',
label: 'Диво Ліс — сторінка',
access: { read: () => true, update: isAdminOrEditor },
hooks: { afterChange: [revalidateGlobalAfterChange] },
fields: [
// Hero section
{
@ -118,5 +120,18 @@ export const DyvoLisPage: GlobalConfig = {
defaultValue: 'Динопарк + Диволіс із казковими топіарними фігурами + Дзеркальний лабіринт',
admin: { description: 'Підзаголовок під секцією "Комбо" в блоці квитків' },
},
{
name: 'metaTitle',
type: 'text',
label: 'SEO: Meta Title',
defaultValue: 'ДивоЛіс — Шуміленд',
},
{
name: 'metaDescription',
type: 'textarea',
label: 'SEO: Meta Description',
defaultValue:
'Казковий топіарний ліс у Шуміленді: фігури з безпечних матеріалів, унікальна ландшафтна композиція та повна свобода для дітей.',
},
],
}

View file

@ -1,10 +1,12 @@
import type { GlobalConfig } from 'payload'
import { isAdminOrEditor } from '@/access/isAdminOrEditor'
import { revalidateGlobalAfterChange } from '@/hooks/revalidatePath'
export const GroupVisitsPage: GlobalConfig = {
slug: 'group-visits-page',
label: 'Групові відвідування — сторінка',
access: { read: () => true, update: isAdminOrEditor },
hooks: { afterChange: [revalidateGlobalAfterChange] },
fields: [
{
name: 'heroTitle',
@ -69,5 +71,18 @@ export const GroupVisitsPage: GlobalConfig = {
},
],
},
{
name: 'metaTitle',
type: 'text',
label: 'SEO: Meta Title',
defaultValue: 'Групові відвідування — Шуміленд',
},
{
name: 'metaDescription',
type: 'textarea',
label: 'SEO: Meta Description',
defaultValue:
'Організуйте групове відвідування Шуміленду. Спеціальні ціни для шкіл, дитячих садків і корпоративних груп.',
},
],
}

View file

@ -14,5 +14,34 @@ export const SiteSettings: GlobalConfig = {
{ name: 'defaultMetaTitle', type: 'text' },
{ name: 'defaultMetaDescription', type: 'textarea' },
{ name: 'defaultOgImage', type: 'upload', relationTo: 'media' },
{
name: 'tariffCategoryLabels',
type: 'array',
label: 'Назви категорій тарифів',
admin: {
description: 'Відповідність ключа з ezy API → назва для відображення на сайті',
},
fields: [
{
name: 'key',
type: 'text',
required: true,
admin: { description: 'Ключ з ezy API (напр. dyno, dyvolis, maze)' },
},
{
name: 'label',
type: 'text',
required: true,
admin: { description: 'Назва для відображення (напр. ДиноПарк)' },
},
],
defaultValue: [
{ key: 'dyno', label: 'ДиноПарк' },
{ key: 'dyvolis', label: 'Диво Ліс' },
{ key: 'maze', label: 'Дзеркальний Лабіринт' },
{ key: 'combo', label: 'Комбо' },
{ key: 'family', label: 'Сімейний' },
],
},
],
}

View file

@ -0,0 +1,46 @@
import type { GlobalConfig } from 'payload'
import { isAdminOrEditor } from '@/access/isAdminOrEditor'
import { revalidateGlobalAfterChange } from '@/hooks/revalidatePath'
export const TicketsPage: GlobalConfig = {
slug: 'tickets-page',
label: 'Квитки — сторінка',
access: { read: () => true, update: isAdminOrEditor },
hooks: { afterChange: [revalidateGlobalAfterChange] },
fields: [
{
name: 'heroTitle',
type: 'text',
defaultValue: 'Купити квиток',
},
{
name: 'heroSubtitle',
type: 'text',
defaultValue: 'Оберіть квиток та придбайте онлайн — без черги на касі',
},
{
name: 'sectionTitleBirthday',
type: 'text',
label: 'Заголовок секції "Дні народження"',
defaultValue: 'Дні народження',
},
{
name: 'sectionTitleGroups',
type: 'text',
label: 'Заголовок секції "Групові відвідування"',
defaultValue: 'Групові відвідування',
},
{
name: 'metaTitle',
type: 'text',
label: 'SEO: Meta Title',
defaultValue: 'Купити квиток — Шуміленд',
},
{
name: 'metaDescription',
type: 'textarea',
label: 'SEO: Meta Description',
defaultValue: 'Придбайте квитки до Шуміленду онлайн. Вхідні квитки на всі зони парку.',
},
],
}

View file

@ -7,6 +7,7 @@ export interface SiteSettingsData {
binotelId?: string | null
defaultMetaTitle?: string | null
defaultMetaDescription?: string | null
tariffCategoryLabels?: Array<{ key: string; label: string }> | null
}
export const getSiteSettings = cache(async (): Promise<SiteSettingsData> => {