feat(cms): upgrade Payload 3.33→3.84, add SEO plugin, connect hardcoded pages to CMS
- 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:
parent
277a240359
commit
d0434dda9b
23 changed files with 838 additions and 366 deletions
1
.gitignore
vendored
1
.gitignore
vendored
|
|
@ -59,3 +59,4 @@ agentdb.rvf.lock
|
|||
/*.png
|
||||
/*.jpg
|
||||
/*.jpeg
|
||||
.superpowers/
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
27
package.json
27
package.json
|
|
@ -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"
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
480
pnpm-lock.yaml
generated
File diff suppressed because it is too large
Load diff
|
|
@ -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 ?? '',
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
>
|
||||
Ім'я *
|
||||
</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>
|
||||
)}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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' }}
|
||||
>
|
||||
Локації незабаром з'являться тут.
|
||||
</p>
|
||||
)}
|
||||
<div className="flex flex-col gap-10">
|
||||
{locations.map((loc, i) => {
|
||||
const imgUrl =
|
||||
|
|
|
|||
|
|
@ -70,13 +70,5 @@ export const Pages: CollectionConfig = {
|
|||
CTABlock,
|
||||
],
|
||||
},
|
||||
{
|
||||
name: 'meta',
|
||||
type: 'group',
|
||||
fields: [
|
||||
{ name: 'metaTitle', type: 'text' },
|
||||
{ name: 'metaDescription', type: 'textarea' },
|
||||
],
|
||||
},
|
||||
],
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
)}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
||||
|
|
|
|||
|
|
@ -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]"
|
||||
|
|
|
|||
|
|
@ -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">
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
),
|
||||
}
|
||||
}
|
||||
|
|
|
|||
94
src/globals/DyvoLisPage.ts
Normal file
94
src/globals/DyvoLisPage.ts
Normal 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: 'Підзаголовок під секцією "Комбо" в блоці квитків' },
|
||||
},
|
||||
],
|
||||
}
|
||||
73
src/globals/GroupVisitsPage.ts
Normal file
73
src/globals/GroupVisitsPage.ts
Normal 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%',
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
}
|
||||
Loading…
Add table
Reference in a new issue