feat(cms): convert all textarea fields to Lexical richText across all globals and collections

- BirthdayPage: packageItems[].description, whyItems[].description → richText
- DinosaurPage: heroDescription, activitiesDescription, activities[].description, whyVisitItems[].description → richText
- DyvoLisPage: heroDescription, whyVisitItems[].description → richText
- TicketsPage: comboCards[].description, benefitsFootnote → richText
- HomePage: heroSlides[].subtitle, hero.subtitle, whyParents.items[].description, birthdayIntro.text, news.subtitle, faq.items[].answer → richText
- LegalPages: all 4 content fields → richText
- Locations: shortDesc, whyVisitItems[].description → richText
- Frontend pages: use <RichText> component from @payloadcms/richtext-lexical/react for all converted fields

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Vadym Samoilenko 2026-06-11 15:05:07 +01:00
parent 8439941afa
commit 2d6e5f2fe0
26 changed files with 798 additions and 180 deletions

View file

@ -0,0 +1,503 @@
import { MigrateUpArgs, MigrateDownArgs, sql } from '@payloadcms/db-postgres'
export async function up({ db }: MigrateUpArgs): Promise<void> {
// ── group_visits_page: price_description ──────────────────────────────────
await db.execute(sql`
DO $$
BEGIN
IF EXISTS (
SELECT 1 FROM information_schema.columns
WHERE table_name = 'group_visits_page'
AND column_name = 'price_description'
AND data_type = 'character varying'
) THEN
ALTER TABLE "group_visits_page"
ALTER COLUMN "price_description" DROP DEFAULT,
ALTER COLUMN "price_description" TYPE jsonb USING NULL;
END IF;
END $$;
`)
await db.execute(sql`
DO $$
BEGIN
IF EXISTS (
SELECT 1 FROM information_schema.columns
WHERE table_name = '_group_visits_page_v'
AND column_name = 'version_price_description'
AND data_type = 'character varying'
) THEN
ALTER TABLE "_group_visits_page_v"
ALTER COLUMN "version_price_description" DROP DEFAULT,
ALTER COLUMN "version_price_description" TYPE jsonb USING NULL;
END IF;
END $$;
`)
// ── dinosaur_page: hero_description, activities_description ───────────────
await db.execute(sql`
DO $$
BEGIN
IF EXISTS (
SELECT 1 FROM information_schema.columns
WHERE table_name = 'dinosaur_page'
AND column_name = 'hero_description'
AND data_type = 'character varying'
) THEN
ALTER TABLE "dinosaur_page"
ALTER COLUMN "hero_description" DROP DEFAULT,
ALTER COLUMN "hero_description" TYPE jsonb USING NULL;
END IF;
END $$;
`)
await db.execute(sql`
DO $$
BEGIN
IF EXISTS (
SELECT 1 FROM information_schema.columns
WHERE table_name = 'dinosaur_page'
AND column_name = 'activities_description'
AND data_type = 'character varying'
) THEN
ALTER TABLE "dinosaur_page"
ALTER COLUMN "activities_description" DROP DEFAULT,
ALTER COLUMN "activities_description" TYPE jsonb USING NULL;
END IF;
END $$;
`)
// ── dyvolis_page: hero_description ────────────────────────────────────────
await db.execute(sql`
DO $$
BEGIN
IF EXISTS (
SELECT 1 FROM information_schema.columns
WHERE table_name = 'dyvolis_page'
AND column_name = 'hero_description'
AND data_type = 'character varying'
) THEN
ALTER TABLE "dyvolis_page"
ALTER COLUMN "hero_description" DROP DEFAULT,
ALTER COLUMN "hero_description" TYPE jsonb USING NULL;
END IF;
END $$;
`)
// ── tickets_page: benefits_footnote ───────────────────────────────────────
await db.execute(sql`
DO $$
BEGIN
IF EXISTS (
SELECT 1 FROM information_schema.columns
WHERE table_name = 'tickets_page'
AND column_name = 'benefits_footnote'
AND data_type = 'character varying'
) THEN
ALTER TABLE "tickets_page"
ALTER COLUMN "benefits_footnote" DROP DEFAULT,
ALTER COLUMN "benefits_footnote" TYPE jsonb USING NULL;
END IF;
END $$;
`)
await db.execute(sql`
DO $$
BEGIN
IF EXISTS (
SELECT 1 FROM information_schema.columns
WHERE table_name = '_tickets_page_v'
AND column_name = 'version_benefits_footnote'
AND data_type = 'character varying'
) THEN
ALTER TABLE "_tickets_page_v"
ALTER COLUMN "version_benefits_footnote" DROP DEFAULT,
ALTER COLUMN "version_benefits_footnote" TYPE jsonb USING NULL;
END IF;
END $$;
`)
// ── home_page: hero_subtitle, birthday_intro_text, news_subtitle ──────────
await db.execute(sql`
DO $$
BEGIN
IF EXISTS (
SELECT 1 FROM information_schema.columns
WHERE table_name = 'home_page'
AND column_name = 'hero_subtitle'
AND data_type = 'character varying'
) THEN
ALTER TABLE "home_page"
ALTER COLUMN "hero_subtitle" DROP DEFAULT,
ALTER COLUMN "hero_subtitle" TYPE jsonb USING NULL;
END IF;
END $$;
`)
await db.execute(sql`
DO $$
BEGIN
IF EXISTS (
SELECT 1 FROM information_schema.columns
WHERE table_name = 'home_page'
AND column_name = 'birthday_intro_text'
AND data_type = 'character varying'
) THEN
ALTER TABLE "home_page"
ALTER COLUMN "birthday_intro_text" DROP DEFAULT,
ALTER COLUMN "birthday_intro_text" TYPE jsonb USING NULL;
END IF;
END $$;
`)
await db.execute(sql`
DO $$
BEGIN
IF EXISTS (
SELECT 1 FROM information_schema.columns
WHERE table_name = 'home_page'
AND column_name = 'news_subtitle'
AND data_type = 'character varying'
) THEN
ALTER TABLE "home_page"
ALTER COLUMN "news_subtitle" DROP DEFAULT,
ALTER COLUMN "news_subtitle" TYPE jsonb USING NULL;
END IF;
END $$;
`)
await db.execute(sql`
DO $$
BEGIN
IF EXISTS (
SELECT 1 FROM information_schema.columns
WHERE table_name = '_home_page_v'
AND column_name = 'version_hero_subtitle'
AND data_type = 'character varying'
) THEN
ALTER TABLE "_home_page_v"
ALTER COLUMN "version_hero_subtitle" DROP DEFAULT,
ALTER COLUMN "version_hero_subtitle" TYPE jsonb USING NULL;
END IF;
END $$;
`)
await db.execute(sql`
DO $$
BEGIN
IF EXISTS (
SELECT 1 FROM information_schema.columns
WHERE table_name = '_home_page_v'
AND column_name = 'version_birthday_intro_text'
AND data_type = 'character varying'
) THEN
ALTER TABLE "_home_page_v"
ALTER COLUMN "version_birthday_intro_text" DROP DEFAULT,
ALTER COLUMN "version_birthday_intro_text" TYPE jsonb USING NULL;
END IF;
END $$;
`)
await db.execute(sql`
DO $$
BEGIN
IF EXISTS (
SELECT 1 FROM information_schema.columns
WHERE table_name = '_home_page_v'
AND column_name = 'version_news_subtitle'
AND data_type = 'character varying'
) THEN
ALTER TABLE "_home_page_v"
ALTER COLUMN "version_news_subtitle" DROP DEFAULT,
ALTER COLUMN "version_news_subtitle" TYPE jsonb USING NULL;
END IF;
END $$;
`)
// ── home_page_why_parents_items: description ──────────────────────────────
await db.execute(sql`
DO $$
BEGIN
IF EXISTS (
SELECT 1 FROM information_schema.columns
WHERE table_name = 'home_page_why_parents_items'
AND column_name = 'description'
AND data_type = 'character varying'
) THEN
ALTER TABLE "home_page_why_parents_items"
ALTER COLUMN "description" DROP DEFAULT,
ALTER COLUMN "description" TYPE jsonb USING NULL;
END IF;
END $$;
`)
await db.execute(sql`
DO $$
BEGIN
IF EXISTS (
SELECT 1 FROM information_schema.columns
WHERE table_name = '_home_page_v_version_why_parents_items'
AND column_name = 'description'
AND data_type = 'character varying'
) THEN
ALTER TABLE "_home_page_v_version_why_parents_items"
ALTER COLUMN "description" DROP DEFAULT,
ALTER COLUMN "description" TYPE jsonb USING NULL;
END IF;
END $$;
`)
// ── dinosaur_page_activities: description ─────────────────────────────────
await db.execute(sql`
DO $$
BEGIN
IF EXISTS (
SELECT 1 FROM information_schema.columns
WHERE table_name = 'dinosaur_page_activities'
AND column_name = 'description'
AND data_type = 'character varying'
) THEN
ALTER TABLE "dinosaur_page_activities"
ALTER COLUMN "description" DROP DEFAULT,
ALTER COLUMN "description" TYPE jsonb USING NULL;
END IF;
END $$;
`)
// ── dinosaur_page_why_visit_items: description ────────────────────────────
await db.execute(sql`
DO $$
BEGIN
IF EXISTS (
SELECT 1 FROM information_schema.columns
WHERE table_name = 'dinosaur_page_why_visit_items'
AND column_name = 'description'
AND data_type = 'character varying'
) THEN
ALTER TABLE "dinosaur_page_why_visit_items"
ALTER COLUMN "description" DROP DEFAULT,
ALTER COLUMN "description" TYPE jsonb USING NULL;
END IF;
END $$;
`)
// ── birthday_page_package_items: description ──────────────────────────────
await db.execute(sql`
DO $$
BEGIN
IF EXISTS (
SELECT 1 FROM information_schema.columns
WHERE table_name = 'birthday_page_package_items'
AND column_name = 'description'
AND data_type = 'character varying'
) THEN
ALTER TABLE "birthday_page_package_items"
ALTER COLUMN "description" DROP DEFAULT,
ALTER COLUMN "description" TYPE jsonb USING NULL;
END IF;
END $$;
`)
await db.execute(sql`
DO $$
BEGIN
IF EXISTS (
SELECT 1 FROM information_schema.columns
WHERE table_name = '_birthday_page_v_version_package_items'
AND column_name = 'description'
AND data_type = 'character varying'
) THEN
ALTER TABLE "_birthday_page_v_version_package_items"
ALTER COLUMN "description" DROP DEFAULT,
ALTER COLUMN "description" TYPE jsonb USING NULL;
END IF;
END $$;
`)
// ── birthday_page_why_items: description ──────────────────────────────────
await db.execute(sql`
DO $$
BEGIN
IF EXISTS (
SELECT 1 FROM information_schema.columns
WHERE table_name = 'birthday_page_why_items'
AND column_name = 'description'
AND data_type = 'character varying'
) THEN
ALTER TABLE "birthday_page_why_items"
ALTER COLUMN "description" DROP DEFAULT,
ALTER COLUMN "description" TYPE jsonb USING NULL;
END IF;
END $$;
`)
await db.execute(sql`
DO $$
BEGIN
IF EXISTS (
SELECT 1 FROM information_schema.columns
WHERE table_name = '_birthday_page_v_version_why_items'
AND column_name = 'description'
AND data_type = 'character varying'
) THEN
ALTER TABLE "_birthday_page_v_version_why_items"
ALTER COLUMN "description" DROP DEFAULT,
ALTER COLUMN "description" TYPE jsonb USING NULL;
END IF;
END $$;
`)
// ── dyvolis_page_why_visit_items: description ─────────────────────────────
await db.execute(sql`
DO $$
BEGIN
IF EXISTS (
SELECT 1 FROM information_schema.columns
WHERE table_name = 'dyvolis_page_why_visit_items'
AND column_name = 'description'
AND data_type = 'character varying'
) THEN
ALTER TABLE "dyvolis_page_why_visit_items"
ALTER COLUMN "description" DROP DEFAULT,
ALTER COLUMN "description" TYPE jsonb USING NULL;
END IF;
END $$;
`)
// ── tickets_page_combo_cards: description ─────────────────────────────────
await db.execute(sql`
DO $$
BEGIN
IF EXISTS (
SELECT 1 FROM information_schema.columns
WHERE table_name = 'tickets_page_combo_cards'
AND column_name = 'description'
AND data_type = 'character varying'
) THEN
ALTER TABLE "tickets_page_combo_cards"
ALTER COLUMN "description" DROP DEFAULT,
ALTER COLUMN "description" TYPE jsonb USING NULL;
END IF;
END $$;
`)
await db.execute(sql`
DO $$
BEGIN
IF EXISTS (
SELECT 1 FROM information_schema.columns
WHERE table_name = '_tickets_page_v_version_combo_cards'
AND column_name = 'description'
AND data_type = 'character varying'
) THEN
ALTER TABLE "_tickets_page_v_version_combo_cards"
ALTER COLUMN "description" DROP DEFAULT,
ALTER COLUMN "description" TYPE jsonb USING NULL;
END IF;
END $$;
`)
// ── locations: short_desc ─────────────────────────────────────────────────
await db.execute(sql`
DO $$
BEGIN
IF EXISTS (
SELECT 1 FROM information_schema.columns
WHERE table_name = 'locations'
AND column_name = 'short_desc'
AND data_type = 'character varying'
) THEN
ALTER TABLE "locations"
ALTER COLUMN "short_desc" DROP DEFAULT,
ALTER COLUMN "short_desc" TYPE jsonb USING NULL;
END IF;
END $$;
`)
// ── locations_why_visit_items: description ────────────────────────────────
await db.execute(sql`
DO $$
BEGIN
IF EXISTS (
SELECT 1 FROM information_schema.columns
WHERE table_name = 'locations_why_visit_items'
AND column_name = 'description'
AND data_type = 'character varying'
) THEN
ALTER TABLE "locations_why_visit_items"
ALTER COLUMN "description" DROP DEFAULT,
ALTER COLUMN "description" TYPE jsonb USING NULL;
END IF;
END $$;
`)
// ── home_page_hero_slides: subtitle (created after next drizzle push) ─────
await db.execute(sql`
DO $$
BEGIN
IF EXISTS (
SELECT 1 FROM information_schema.tables WHERE table_name = 'home_page_hero_slides'
) AND EXISTS (
SELECT 1 FROM information_schema.columns
WHERE table_name = 'home_page_hero_slides'
AND column_name = 'subtitle'
AND data_type = 'character varying'
) THEN
ALTER TABLE "home_page_hero_slides"
ALTER COLUMN "subtitle" DROP DEFAULT,
ALTER COLUMN "subtitle" TYPE jsonb USING NULL;
END IF;
END $$;
`)
// ── home_page_faq_items: answer (created after next drizzle push) ─────────
await db.execute(sql`
DO $$
BEGIN
IF EXISTS (
SELECT 1 FROM information_schema.tables WHERE table_name = 'home_page_faq_items'
) AND EXISTS (
SELECT 1 FROM information_schema.columns
WHERE table_name = 'home_page_faq_items'
AND column_name = 'answer'
AND data_type = 'character varying'
) THEN
ALTER TABLE "home_page_faq_items"
ALTER COLUMN "answer" DROP DEFAULT,
ALTER COLUMN "answer" TYPE jsonb USING NULL;
END IF;
END $$;
`)
}
export async function down({ db }: MigrateDownArgs): Promise<void> {
// Revert all jsonb columns back to varchar (data will be lost/NULL)
await db.execute(sql`
ALTER TABLE "group_visits_page" ALTER COLUMN "price_description" TYPE varchar USING NULL;
ALTER TABLE "_group_visits_page_v" ALTER COLUMN "version_price_description" TYPE varchar USING NULL;
ALTER TABLE "dinosaur_page" ALTER COLUMN "hero_description" TYPE varchar USING NULL;
ALTER TABLE "dinosaur_page" ALTER COLUMN "activities_description" TYPE varchar USING NULL;
ALTER TABLE "dyvolis_page" ALTER COLUMN "hero_description" TYPE varchar USING NULL;
ALTER TABLE "tickets_page" ALTER COLUMN "benefits_footnote" TYPE varchar USING NULL;
ALTER TABLE "_tickets_page_v" ALTER COLUMN "version_benefits_footnote" TYPE varchar USING NULL;
ALTER TABLE "home_page" ALTER COLUMN "hero_subtitle" TYPE varchar USING NULL;
ALTER TABLE "home_page" ALTER COLUMN "birthday_intro_text" TYPE varchar USING NULL;
ALTER TABLE "home_page" ALTER COLUMN "news_subtitle" TYPE varchar USING NULL;
ALTER TABLE "_home_page_v" ALTER COLUMN "version_hero_subtitle" TYPE varchar USING NULL;
ALTER TABLE "_home_page_v" ALTER COLUMN "version_birthday_intro_text" TYPE varchar USING NULL;
ALTER TABLE "_home_page_v" ALTER COLUMN "version_news_subtitle" TYPE varchar USING NULL;
ALTER TABLE "home_page_why_parents_items" ALTER COLUMN "description" TYPE varchar USING NULL;
ALTER TABLE "_home_page_v_version_why_parents_items" ALTER COLUMN "description" TYPE varchar USING NULL;
ALTER TABLE "dinosaur_page_activities" ALTER COLUMN "description" TYPE varchar USING NULL;
ALTER TABLE "dinosaur_page_why_visit_items" ALTER COLUMN "description" TYPE varchar USING NULL;
ALTER TABLE "birthday_page_package_items" ALTER COLUMN "description" TYPE varchar USING NULL;
ALTER TABLE "_birthday_page_v_version_package_items" ALTER COLUMN "description" TYPE varchar USING NULL;
ALTER TABLE "birthday_page_why_items" ALTER COLUMN "description" TYPE varchar USING NULL;
ALTER TABLE "_birthday_page_v_version_why_items" ALTER COLUMN "description" TYPE varchar USING NULL;
ALTER TABLE "dyvolis_page_why_visit_items" ALTER COLUMN "description" TYPE varchar USING NULL;
ALTER TABLE "tickets_page_combo_cards" ALTER COLUMN "description" TYPE varchar USING NULL;
ALTER TABLE "_tickets_page_v_version_combo_cards" ALTER COLUMN "description" TYPE varchar USING NULL;
ALTER TABLE "locations" ALTER COLUMN "short_desc" TYPE varchar USING NULL;
ALTER TABLE "locations_why_visit_items" ALTER COLUMN "description" TYPE varchar USING NULL;
`)
}

View file

@ -4,6 +4,7 @@ import * as migration_20260518_104929 from './20260518_104929'
import * as migration_20260518_115657 from './20260518_115657'
import * as migration_20260610_140000 from './20260610_140000'
import * as migration_20260611_140000 from './20260611_140000'
import * as migration_20260611_160000 from './20260611_160000'
export const migrations = [
{
@ -36,4 +37,9 @@ export const migrations = [
down: migration_20260611_140000.down,
name: '20260611_140000',
},
{
up: migration_20260611_160000.up,
down: migration_20260611_160000.down,
name: '20260611_160000',
},
]

View file

@ -1,6 +1,8 @@
import type { Metadata } from 'next'
import React from 'react'
import { getPayload } from 'payload'
import configPromise from '@payload-config'
import { RichText } from '@payloadcms/richtext-lexical/react'
import { BirthdayBookingForm } from '@/components/forms/BirthdayBookingForm'
import { FormBlock, type FormData as FormBlockData } from '@/components/forms/FormBlock'
import { RefreshRouteOnSave } from '@/components/cms/RefreshRouteOnSave'
@ -9,6 +11,8 @@ import { JsonLd } from '@/components/seo/JsonLd'
import { birthdayPageJsonLd } from '@/lib/structuredData'
import type { Media } from '@/payload-types'
type RichTextData = Parameters<typeof RichText>[0]['data']
export const revalidate = 60
const FONT_MONT = { fontFamily: 'var(--font-montserrat, Montserrat), sans-serif' }
@ -99,7 +103,7 @@ export async function generateMetadata(): Promise<Metadata> {
interface PackageCardData {
title: string
description: string
description: React.ReactNode
imageUrl: string | null
ctaLabel: string
ctaHref?: string
@ -154,9 +158,12 @@ function PackageCard({ item }: { item: PackageCardData }) {
>
{item.title}
</h3>
<p className="text-[15px] leading-[1.5] text-[#272727] lg:text-[16px]" style={FONT_MONT}>
<div
className="text-[15px] leading-[1.5] text-[#272727] lg:text-[16px] [&_p]:mb-1 [&_strong]:font-bold"
style={FONT_MONT}
>
{item.description}
</p>
</div>
</div>
<a
href={item.ctaHref ?? '#order-form'}
@ -207,7 +214,15 @@ export default async function BirthdayPage() {
)
const toCard = (item: Record<string, unknown>): PackageCardData => ({
title: item.title as string,
description: item.description as string,
description: item.description ? (
typeof item.description === 'string' ? (
item.description
) : (
<RichText data={item.description as RichTextData} />
)
) : (
''
),
imageUrl:
typeof item.image === 'string' ? item.image : mediaUrl(item.image as Media | undefined),
ctaLabel: (item.ctaLabel as string) ?? 'Замовити',
@ -223,8 +238,21 @@ export default async function BirthdayPage() {
: FALLBACK_EXTRA_ITEMS.map(toCard)
const whyTitle = (d?.whyTitle as string) ?? 'Чому варто святкувати у Шуміленді'
const whyItems = Array.isArray(d?.whyItems)
? (d?.whyItems as typeof FALLBACK_WHY_ITEMS)
type WhyRawItem = { title: string; description?: RichTextData | string | null }
const rawWhyItems = Array.isArray(d?.whyItems) ? (d?.whyItems as WhyRawItem[]) : null
const whyItems = rawWhyItems
? rawWhyItems.map((i) => ({
title: i.title,
description: i.description ? (
typeof i.description === 'string' ? (
i.description
) : (
<RichText data={i.description as RichTextData} />
)
) : (
''
),
}))
: FALLBACK_WHY_ITEMS
const whyVideos = Array.isArray(d?.whyVideos)
? (d?.whyVideos as Array<Record<string, unknown>>)

View file

@ -1,4 +1,5 @@
import type { Metadata } from 'next'
import React from 'react'
import { getPayload } from 'payload'
import configPromise from '@payload-config'
import { RichText } from '@payloadcms/richtext-lexical/react'
@ -139,9 +140,16 @@ export default async function GroupVisitsPage() {
(d?.priceNote as string) ?? 'Вхід для двох дорослих, що супроводжують дітей, безкоштовний.'
const priceMinPeople = (d?.priceMinPeople as string) ?? 'Пропозиція діє для груп від 10 людей'
const priceCta = (d?.priceCta as string) ?? 'Купити квиток'
const priceDescription =
(d?.priceDescription as string) ??
'У вартість входить відвідування ДиноПарку та ДивоЛісу.\nЧас перебування на локаціях необмежений.'
const priceDescriptionRaw = d?.priceDescription ?? null
const priceDescription = priceDescriptionRaw ? (
<RichText data={priceDescriptionRaw as RichTextData} />
) : (
<span className="whitespace-pre-line">
{
'У вартість входить відвідування ДиноПарку та ДивоЛісу.\nЧас перебування на локаціях необмежений.'
}
</span>
)
const bottomText = (d?.bottomText ?? null) as RichTextData | null
const bottomImages: string[] = ((d?.bottomImages as { image: unknown }[]) ?? [])

View file

@ -1,11 +1,17 @@
import { RichText } from '@payloadcms/richtext-lexical/react'
const FONT = { fontFamily: 'var(--font-montserrat, Montserrat), sans-serif' }
type RichTextData = Parameters<typeof RichText>[0]['data']
interface Props {
title: string
content: string
content: string | RichTextData | null | undefined
}
export function LegalPageLayout({ title, content }: Props) {
const isRichText = content !== null && content !== undefined && typeof content === 'object'
return (
<main className="min-h-screen bg-[#f1fbeb]">
<div className="bg-[#396817] px-8 py-12">
@ -17,10 +23,14 @@ export function LegalPageLayout({ title, content }: Props) {
</div>
<div className="mx-auto max-w-[900px] px-8 py-12">
<div
className="prose prose-sm max-w-none text-[15px] leading-[1.8] whitespace-pre-line text-[#272727]/80"
className="prose prose-sm max-w-none text-[15px] leading-[1.8] text-[#272727]/80 [&_ol]:list-decimal [&_ol]:pl-5 [&_p]:mb-3 [&_strong]:font-bold [&_ul]:list-disc [&_ul]:pl-5"
style={FONT}
>
{content}
{isRichText ? (
<RichText data={content as RichTextData} />
) : (
<span className="whitespace-pre-line">{content as string}</span>
)}
</div>
</div>
</main>

View file

@ -2,6 +2,7 @@ import type { Metadata } from 'next'
import { notFound } from 'next/navigation'
import { getPayload } from 'payload'
import configPromise from '@payload-config'
import { RichText } from '@payloadcms/richtext-lexical/react'
import { DyvoLisHero } from '@/components/sections/DyvoLisHero'
import { DyvoLisGallery } from '@/components/sections/DyvoLisGallery'
import { DyvoLisWhyVisit } from '@/components/sections/DyvoLisWhyVisit'
@ -9,6 +10,21 @@ import { DyvoLisTickets } from '@/components/sections/DyvoLisTickets'
import { RefreshRouteOnSave } from '@/components/cms/RefreshRouteOnSave'
import type { Location, Media } from '@/payload-types'
type RichTextData = Parameters<typeof RichText>[0]['data']
function richTextToPlain(rt: RichTextData | null | undefined): string {
if (!rt) return ''
type LexNode = { text?: string; children?: LexNode[] }
const walk = (n: LexNode): string => {
if (typeof n.text === 'string') return n.text
if (Array.isArray(n.children)) return n.children.map(walk).join('')
return ''
}
const root = (rt as { root?: { children?: LexNode[] } }).root
if (!root?.children) return ''
return root.children.map(walk).join(' ').trim()
}
interface Props {
params: Promise<{ slug: string }>
}
@ -48,7 +64,10 @@ export async function generateMetadata({ params }: Props): Promise<Metadata> {
if (!location || !location.showDetailPage) return { title: 'Не знайдено — Шуміленд' }
return {
title: location.meta?.title ?? `${location.name} — Шуміленд`,
description: location.meta?.description ?? location.shortDesc ?? '',
description:
location.meta?.description ??
richTextToPlain(location.shortDesc as RichTextData | null) ??
'',
}
}
@ -76,7 +95,11 @@ export default async function LocationDetailPage({ params }: Props) {
const whyVisitItems = location.whyVisitItems?.map((item) => ({
title: item.title,
description: item.description,
description: item.description ? (
<RichText data={item.description as unknown as RichTextData} />
) : (
''
),
}))
const reviewVideos = location.reviewVideos?.map((v) => ({
@ -89,7 +112,11 @@ export default async function LocationDetailPage({ params }: Props) {
<div className="bg-[#f1fbeb]">
<DyvoLisHero
title={location.shortDesc ? `${location.name}` : undefined}
description={location.shortDesc ?? undefined}
description={
location.shortDesc ? (
<RichText data={location.shortDesc as unknown as RichTextData} />
) : undefined
}
stat={location.heroStat ?? undefined}
statLabel={location.heroStatLabel ?? undefined}
tips={heroTips}

View file

@ -1,15 +1,31 @@
import type { Metadata } from 'next'
import { getPayload } from 'payload'
import configPromise from '@payload-config'
import { RichText } from '@payloadcms/richtext-lexical/react'
import { DinoPageContent } from '@/components/sections/DinoPageContent'
import { RefreshRouteOnSave } from '@/components/cms/RefreshRouteOnSave'
import type { Media, Location } from '@/payload-types'
type RichTextData = Parameters<typeof RichText>[0]['data']
function richTextToPlain(rt: RichTextData | null | undefined): string {
if (!rt) return ''
type LexNode = { text?: string; children?: LexNode[] }
const walk = (n: LexNode): string => {
if (typeof n.text === 'string') return n.text
if (Array.isArray(n.children)) return n.children.map(walk).join('')
return ''
}
const root = (rt as { root?: { children?: LexNode[] } }).root
if (!root?.children) return ''
return root.children.map(walk).join(' ').trim()
}
export const revalidate = 60
interface DinoPageData {
heroTitle?: string
heroDescription?: string
heroDescription?: RichTextData | null
heroStat?: string
heroStatLabel?: string
heroImage?: number | Media | null
@ -25,17 +41,17 @@ interface DinoPageData {
}[]
galleryImages?: { image?: number | Media | null; id?: string }[]
activitiesTitle?: string
activitiesDescription?: string
activitiesDescription?: RichTextData | null
activities?: {
name: string
price?: string | null
description?: string | null
description?: RichTextData | null
image?: number | Media | null
href?: string | null
id?: string
}[]
whyVisitTitle?: string
whyVisitItems?: { title: string; description: string; id?: string }[]
whyVisitItems?: { title: string; description: RichTextData | null; id?: string }[]
reviewVideos?: { src: string; poster?: string | null; label?: string | null; id?: string }[]
workingHours?: string
comboDescription?: string
@ -54,7 +70,8 @@ export async function generateMetadata(): Promise<Metadata> {
const data = raw as unknown as DinoPageData
return {
title: data.meta?.title ?? 'Динопарк — Шуміленд',
description: data.meta?.description ?? data.heroDescription ?? 'Динопарк у Шуміленді',
description:
data.meta?.description ?? (richTextToPlain(data.heroDescription) || 'Динопарк у Шуміленді'),
}
} catch {
return { title: 'Динопарк — Шуміленд' }
@ -104,14 +121,14 @@ export default async function DinosaurPage() {
const activities = data.activities?.map((a) => ({
name: a.name,
price: a.price ?? null,
description: a.description ?? null,
description: a.description ? <RichText data={a.description} /> : null,
imageUrl: mediaUrl(a.image),
href: a.href ?? '#tickets',
}))
const whyVisitItems = data.whyVisitItems?.map((w) => ({
title: w.title,
description: w.description,
description: w.description ? <RichText data={w.description} /> : '',
}))
const reviewVideos = data.reviewVideos?.map((v) => ({
@ -123,7 +140,9 @@ export default async function DinosaurPage() {
<>
<DinoPageContent
heroTitle={data.heroTitle}
heroDescription={data.heroDescription}
heroDescription={
data.heroDescription ? <RichText data={data.heroDescription} /> : undefined
}
stat={data.heroStat}
statLabel={data.heroStatLabel}
features={features && features.length > 0 ? features : undefined}
@ -132,7 +151,9 @@ export default async function DinosaurPage() {
dinos={dinos && dinos.length >= 12 ? dinos : undefined}
galleryImages={galleryImages && galleryImages.length >= 4 ? galleryImages : undefined}
activitiesTitle={data.activitiesTitle}
activitiesDescription={data.activitiesDescription}
activitiesDescription={
data.activitiesDescription ? <RichText data={data.activitiesDescription} /> : undefined
}
activities={activities && activities.length > 0 ? activities : undefined}
whyVisitTitle={data.whyVisitTitle}
whyVisitItems={whyVisitItems && whyVisitItems.length > 0 ? whyVisitItems : undefined}

View file

@ -1,3 +1,5 @@
import React from 'react'
import { RichText } from '@payloadcms/richtext-lexical/react'
import { Hero } from '@/components/sections/Hero'
import { Locations } from '@/components/sections/Locations'
import { WhyParents } from '@/components/sections/WhyParents'
@ -14,6 +16,8 @@ import { JsonLd } from '@/components/seo/JsonLd'
import { homePageJsonLd } from '@/lib/structuredData'
import type { HomePageHero, HomePageSectionType } from '@/types/globals'
type RichTextData = Parameters<typeof RichText>[0]['data']
const DEFAULT_SECTION_ORDER: HomePageSectionType[] = [
'locations',
'whyParents',
@ -56,15 +60,28 @@ export default async function HomePage() {
title={home?.sectionTitles?.locations ?? undefined}
/>
)
case 'whyParents':
case 'whyParents': {
const rawWhyParentsItems = home?.whyParents?.items
const whyParentsItems = rawWhyParentsItems
? rawWhyParentsItems.map((item) => ({
...item,
description:
item.description && typeof item.description !== 'string' ? (
<RichText data={item.description as unknown as RichTextData} />
) : (
item.description
),
}))
: undefined
return (
<WhyParents
key={key}
items={home?.whyParents?.items ?? undefined}
items={whyParentsItems}
sideGallery={home?.whyParents?.sideGallery ?? undefined}
title={home?.sectionTitles?.whyParents ?? undefined}
/>
)
}
case 'video':
return (
<VideoSection
@ -76,12 +93,19 @@ export default async function HomePage() {
case 'birthday': {
const pg = home?.birthdayIntro?.patternGreen
const po = home?.birthdayIntro?.patternOrange
const birthdayIntroText = home?.birthdayIntro?.text
const birthdayIntro =
birthdayIntroText && typeof birthdayIntroText !== 'string' ? (
<RichText data={birthdayIntroText as unknown as RichTextData} />
) : (
((birthdayIntroText as string | undefined) ?? undefined)
)
return (
<BirthdayPricing
key={key}
packages={birthdayPackages.length > 0 ? birthdayPackages : undefined}
title={home?.sectionTitles?.birthday ?? undefined}
intro={home?.birthdayIntro?.text ?? undefined}
intro={birthdayIntro}
patternGreen={pg && typeof pg === 'object' ? (pg.url ?? undefined) : (pg ?? undefined)}
patternOrange={po && typeof po === 'object' ? (po.url ?? undefined) : (po ?? undefined)}
/>
@ -104,23 +128,38 @@ export default async function HomePage() {
googleReviewUrl={home?.sectionTitles?.googleReviewUrl}
/>
)
case 'faq':
return (
<FAQ
key={key}
items={home?.faq?.items ?? undefined}
title={home?.faq?.title ?? undefined}
/>
)
case 'news':
case 'faq': {
const rawFaqItems = home?.faq?.items
const faqItems = rawFaqItems
? rawFaqItems.map((item) => ({
...item,
answer:
item.answer && typeof item.answer !== 'string' ? (
<RichText data={item.answer as unknown as RichTextData} />
) : (
item.answer
),
}))
: undefined
return <FAQ key={key} items={faqItems} title={home?.faq?.title ?? undefined} />
}
case 'news': {
const newsSubtitleRaw = home?.news?.subtitle
const newsSubtitle =
newsSubtitleRaw && typeof newsSubtitleRaw !== 'string' ? (
<RichText data={newsSubtitleRaw as unknown as RichTextData} />
) : (
((newsSubtitleRaw as string | undefined) ?? undefined)
)
return (
<News
key={key}
title={home?.sectionTitles?.news ?? undefined}
subtitle={home?.news?.subtitle ?? undefined}
subtitle={newsSubtitle}
limit={home?.news?.limit ?? undefined}
/>
)
}
case 'map':
return (
<MapSection

View file

@ -2,11 +2,14 @@
import type { Metadata } from 'next'
import { getPayload } from 'payload'
import configPromise from '@payload-config'
import { RichText } from '@payloadcms/richtext-lexical/react'
import { RefreshRouteOnSave } from '@/components/cms/RefreshRouteOnSave'
import { KvytkyTicketsClient, type Tariff } from '@/components/sections/KvytkyTicketsClient'
import { ComboTickets, type ComboCardData } from '@/components/sections/ComboTickets'
import type { Media, TicketsPage } from '@/payload-types'
type RichTextData = Parameters<typeof RichText>[0]['data']
export const revalidate = 60
const FONT_MONT = { fontFamily: 'var(--font-montserrat, Montserrat), sans-serif' }
@ -209,6 +212,19 @@ const COMBO_CARDS_STATIC: ComboCardData[] = [
const parsePrice = (s: string): number => Number(String(s).replace(/[^\d]/g, '')) || 0
type LexicalNode = { text?: string; children?: LexicalNode[] }
function richTextToPlain(rt: RichTextData | null | undefined): string {
if (!rt) return ''
const walk = (n: LexicalNode): string => {
if (typeof n.text === 'string') return n.text
if (Array.isArray(n.children)) return n.children.map(walk).join('')
return ''
}
const root = (rt as { root?: { children?: LexicalNode[] } }).root
if (!root?.children) return ''
return root.children.map(walk).join(' ').trim()
}
function buildComboCards(pageData: TicketsPage | null, tariffs: Tariff[]): ComboCardData[] {
const apiCombos = tariffs
.filter((t) => t.categoryTag === 'combo')
@ -248,7 +264,7 @@ function buildComboCards(pageData: TicketsPage | null, tariffs: Tariff[]): Combo
name: c.name,
subtitle: c.subtitle ?? null,
price: c.price,
description: c.description ?? '',
description: richTextToPlain(c.description as RichTextData | null | undefined),
featured: !!c.featured,
badge: c.badge ?? null,
locations: (c.locations ?? []).map((l) => l.text).filter(Boolean) as string[],
@ -385,13 +401,16 @@ function BenefitsSection({ pageData }: { pageData: TicketsPage | null }) {
{/* Footnote */}
<div className="rounded-b-[20px] bg-[#d6f2c0] px-8 py-[30px]">
<p
className="text-center text-[16px] leading-[1.5] font-normal text-[#272727] lg:text-[20px]"
<div
className="text-center text-[16px] leading-[1.5] font-normal text-[#272727] lg:text-[20px] [&_p]:mb-0"
style={FONT_MONT}
>
{pageData?.benefitsFootnote ??
'*Знижки та пільги поширюються виключно на індивідуальне відвідування 3 основних локацій (Динопарк, Зона топіарних фігур, Дзеркальний лабіринт) та не сумуються з тарифами категорії «КОМБО».'}
</p>
{pageData?.benefitsFootnote ? (
<RichText data={pageData.benefitsFootnote as unknown as RichTextData} />
) : (
'*Знижки та пільги поширюються виключно на індивідуальне відвідування 3 основних локацій (Динопарк, Зона топіарних фігур, Дзеркальний лабіринт) та не сумуються з тарифами категорії «КОМБО».'
)}
</div>
</div>
</div>
</div>

View file

@ -35,7 +35,7 @@ export const Locations: CollectionConfig = {
{ name: 'name', type: 'text', required: true },
{ name: 'slug', type: 'text', required: true, unique: true },
{ name: 'tagline', type: 'text' },
{ name: 'shortDesc', type: 'textarea' },
{ name: 'shortDesc', type: 'richText', editor: standardEditor },
{ name: 'description', type: 'richText', editor: standardEditor },
{ name: 'image', type: 'upload', relationTo: 'media' },
{
@ -91,7 +91,7 @@ export const Locations: CollectionConfig = {
label: 'Чому варто відвідати',
fields: [
{ name: 'title', type: 'text', required: true },
{ name: 'description', type: 'textarea', required: true },
{ name: 'description', type: 'richText', editor: standardEditor, required: true },
],
},
{

View file

@ -1,4 +1,5 @@
/* eslint-disable @next/next/no-img-element */
import React from 'react'
import Link from 'next/link'
import type { BirthdayPackageCMS } from '@/types/globals'
@ -9,7 +10,7 @@ const DEFAULT_PATTERN_ORANGE = '/images/figma/card-pattern-dark.webp'
interface BirthdayPricingProps {
packages?: BirthdayPackageCMS[]
title?: string
intro?: string
intro?: React.ReactNode
patternGreen?: string
patternOrange?: string
}

View file

@ -1,7 +1,7 @@
'use client'
/* eslint-disable @next/next/no-img-element */
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react'
import { useCart } from '@/context/CartContext'
import { ImageLightbox } from '@/components/ui/ImageLightbox'
@ -18,14 +18,14 @@ interface DinoSpec {
interface ActivityItem {
name: string
price?: string | null
description?: string | null
description?: React.ReactNode
imageUrl?: string | null
href?: string | null
}
interface WhyVisitItem {
title: string
description: string
description: React.ReactNode
}
interface VideoItem {
@ -45,7 +45,7 @@ interface Tariff {
export interface DinoPageContentProps {
heroTitle?: string
heroDescription?: string
heroDescription?: React.ReactNode
stat?: string
statLabel?: string
features?: string[]
@ -54,7 +54,7 @@ export interface DinoPageContentProps {
dinos?: DinoSpec[]
galleryImages?: string[]
activitiesTitle?: string
activitiesDescription?: string
activitiesDescription?: React.ReactNode
activities?: ActivityItem[]
whyVisitTitle?: string
whyVisitItems?: WhyVisitItem[]

View file

@ -1,4 +1,5 @@
/* eslint-disable @next/next/no-img-element */
import React from 'react'
import { BtnPrimary } from '@/components/ui/BtnPrimary'
const HERO_IMG = '/images/dyvolis/hero-cat.webp'
@ -11,7 +12,7 @@ const DEFAULT_TIPS = [
interface DyvoLisHeroProps {
title?: string
description?: string
description?: React.ReactNode
stat?: string
statLabel?: string
tips?: string[]

View file

@ -1,6 +1,6 @@
'use client'
import { useState, useRef, useEffect } from 'react'
import React, { useState, useRef, useEffect } from 'react'
const FONT_MONT = { fontFamily: 'var(--font-montserrat, Montserrat), sans-serif' }
const IMG_WAVE_TILE = '/images/figma/wave-tile.svg'
@ -41,7 +41,7 @@ interface ReviewVideo {
interface DyvoLisWhyVisitProps {
title?: string
items?: Array<{ title: string; description: string }>
items?: Array<{ title: string; description: React.ReactNode }>
reviewVideos?: ReviewVideo[]
}

View file

@ -1,6 +1,6 @@
'use client'
import { useState } from 'react'
import React, { useState } from 'react'
import type { HomePageFaqItem } from '@/types/globals'
const STATIC_ITEMS: HomePageFaqItem[] = [

View file

@ -1,8 +1,11 @@
import { getPayload } from 'payload'
import configPromise from '@payload-config'
import { RichText } from '@payloadcms/richtext-lexical/react'
import { HeroSlider } from './HeroSlider'
import type { SlideData } from './HeroSlider'
type RichTextData = Parameters<typeof RichText>[0]['data']
interface HeroProps {
hero?: unknown
}
@ -17,7 +20,7 @@ async function getHeroSlides(): Promise<SlideData[] | null> {
backgroundImageUrl?: string | null
type?: string | null
title?: string | null
subtitle?: string | null
subtitle?: unknown
ctaLabel?: string | null
ctaHref?: string | null
}>
@ -30,7 +33,12 @@ async function getHeroSlides(): Promise<SlideData[] | null> {
img: s.backgroundImage?.url ?? s.backgroundImageUrl ?? '',
type: (s.type === 'brand' ? 'brand' : 'location') as 'brand' | 'location',
title: s.title ?? '',
subtitle: s.subtitle ?? '',
subtitle:
s.subtitle && typeof s.subtitle !== 'string' ? (
<RichText data={s.subtitle as RichTextData} />
) : (
((s.subtitle as string) ?? '')
),
ctaLabel: s.ctaLabel ?? 'Купити квиток',
ctaHref: s.ctaHref ?? '/payments',
}))

View file

@ -1,6 +1,6 @@
'use client'
import { useState, useEffect, useCallback, useRef } from 'react'
import React, { useState, useEffect, useCallback, useRef } from 'react'
import { BtnPrimary } from '@/components/ui/BtnPrimary'
type SlideType = 'brand' | 'location'
@ -9,7 +9,7 @@ export interface SlideData {
img: string
type: SlideType
title: string
subtitle: string
subtitle: React.ReactNode
ctaLabel: string
ctaHref: string
}

View file

@ -1,4 +1,5 @@
/* eslint-disable @next/next/no-img-element */
import React from 'react'
import { getPayload } from 'payload'
import configPromise from '@payload-config'
import { BtnDetails } from '@/components/ui/BtnDetails'
@ -65,7 +66,7 @@ async function getLatestPosts(limit = 3): Promise<Article[]> {
interface NewsProps {
title?: string | null
subtitle?: string | null
subtitle?: React.ReactNode
limit?: number | null
}

View file

@ -1,4 +1,5 @@
import type { GlobalConfig } from 'payload'
import { standardEditor } from '@/fields/richText'
import { isAdminOrEditor } from '@/access/isAdminOrEditor'
import { revalidateGlobalAfterChange } from '@/hooks/revalidatePath'
@ -40,7 +41,7 @@ export const BirthdayPage: GlobalConfig = {
label: 'Що входить у пакет (картки з фото)',
fields: [
{ name: 'title', type: 'text', required: true },
{ name: 'description', type: 'textarea', required: true },
{ name: 'description', type: 'richText', editor: standardEditor, required: true },
{
name: 'image',
type: 'upload',
@ -51,36 +52,12 @@ export const BirthdayPage: GlobalConfig = {
{ name: 'ctaHref', type: 'text', admin: { description: 'Посилання кнопки (опційно)' } },
],
defaultValue: [
{
title: 'ДиноПарк',
description: 'Справжні динозаври в натуральну величину',
ctaLabel: 'Замовити',
},
{
title: 'ДивоЛіс',
description: 'Казкові топіарні фігури улюблених персонажів',
ctaLabel: 'Замовити',
},
{
title: 'Дзеркальний Лабіринт',
description: 'Весела гра для дітей та дорослих',
ctaLabel: 'Замовити',
},
{
title: 'Костюмованих ведучих',
description: 'Аніматори в яскравих костюмах проведуть свято',
ctaLabel: 'Замовити',
},
{
title: 'Аквагрим',
description: 'Конкурси, ігри та розваги для всіх гостей',
ctaLabel: 'Замовити',
},
{
title: 'Затишну альтанку',
description: 'Власна зона відпочинку для вашої родини',
ctaLabel: 'Замовити',
},
{ title: 'ДиноПарк', ctaLabel: 'Замовити' },
{ title: 'ДивоЛіс', ctaLabel: 'Замовити' },
{ title: 'Дзеркальний Лабіринт', ctaLabel: 'Замовити' },
{ title: 'Костюмованих ведучих', ctaLabel: 'Замовити' },
{ title: 'Аквагрим', ctaLabel: 'Замовити' },
{ title: 'Затишну альтанку', ctaLabel: 'Замовити' },
],
},
// Why section
@ -95,24 +72,12 @@ export const BirthdayPage: GlobalConfig = {
label: 'Переваги (акордеон)',
fields: [
{ name: 'title', type: 'text', required: true },
{ name: 'description', type: 'textarea', required: true },
{ name: 'description', type: 'richText', editor: standardEditor, required: true },
],
defaultValue: [
{
title: 'Свято під ключ',
description:
'Ми беремо на себе всі деталі: аніматорів, конкурси, прикраси та окрему зону для вашої родини. Вам залишається лише насолоджуватись.',
},
{
title: 'Простір для дітей і дорослих',
description:
'Шуміленд — це 7 локацій, де кожен знайде щось для себе: від динозаврів до казкових лісів, від лабіринтів до фотозон.',
},
{
title: 'Незабутні фото та спогади',
description:
'Унікальні декорації, яскраві персонажі та щира радість дітей — ідеальний фон для фотографій, які хочеться переглядати знову і знову.',
},
{ title: 'Свято під ключ' },
{ title: 'Простір для дітей і дорослих' },
{ title: 'Незабутні фото та спогади' },
],
},
{

View file

@ -1,4 +1,5 @@
import type { GlobalConfig } from 'payload'
import { standardEditor } from '@/fields/richText'
import { isAdminOrEditor } from '@/access/isAdminOrEditor'
import { revalidateGlobalAfterChange } from '@/hooks/revalidatePath'
@ -17,9 +18,8 @@ export const DinosaurPage: GlobalConfig = {
},
{
name: 'heroDescription',
type: 'textarea',
defaultValue:
'Великі динозаври, що рухаються та гарчать, справжнє роздоволлє, цікаві екскурсії та динородео — тут є все, щоб ваша дитина не нудьгувала.',
type: 'richText',
editor: standardEditor,
},
{
name: 'heroStat',
@ -99,9 +99,8 @@ export const DinosaurPage: GlobalConfig = {
},
{
name: 'activitiesDescription',
type: 'textarea',
defaultValue:
'Хочете дізнатись ще більше про динозаврів? Замовте екскурсію з гідом, поринь у світ палеонтологічних розкопок або підкорюй справжнього динозавра!',
type: 'richText',
editor: standardEditor,
},
{
name: 'activities',
@ -110,7 +109,7 @@ export const DinosaurPage: GlobalConfig = {
fields: [
{ name: 'name', type: 'text', required: true },
{ name: 'price', type: 'text', admin: { description: 'Наприклад: 150 грн' } },
{ name: 'description', type: 'textarea' },
{ name: 'description', type: 'richText', editor: standardEditor },
{ name: 'image', type: 'upload', relationTo: 'media' },
{ name: 'href', type: 'text', defaultValue: '#tickets' },
],
@ -131,24 +130,12 @@ export const DinosaurPage: GlobalConfig = {
type: 'array',
fields: [
{ name: 'title', type: 'text', required: true },
{ name: 'description', type: 'textarea', required: true },
{ name: 'description', type: 'richText', editor: standardEditor, required: true },
],
defaultValue: [
{
title: 'Навчання через гру',
description:
'Дітки дізнаються про стародавніх тварин через захопливі ігри та інтерактивні вправи з гідом.',
},
{
title: 'Дитячі очі, що палають захватом',
description:
'Реалістичні рухи та звуки динозаврів створюють ефект повного занурення — дитина точно не забуде цього дня.',
},
{
title: 'Неймовірні фотографії',
description:
'Сфотографуйтесь поруч із улюбленим динозавром або зробіть фото з екскурсоводом — тепла згадка для всієї родини.',
},
{ title: 'Навчання через гру' },
{ title: 'Дитячі очі, що палають захватом' },
{ title: 'Неймовірні фотографії' },
],
},
{

View file

@ -1,4 +1,5 @@
import type { GlobalConfig } from 'payload'
import { standardEditor } from '@/fields/richText'
import { isAdminOrEditor } from '@/access/isAdminOrEditor'
import { revalidateGlobalAfterChange } from '@/hooks/revalidatePath'
@ -23,9 +24,8 @@ export const DyvoLisPage: GlobalConfig = {
},
{
name: 'heroDescription',
type: 'textarea',
defaultValue:
'Топіарні фігури зроблені з урахуванням важливих деталей, тому ви одразу впізнаєте в них улюблених казкових героїв. Тут можна бігати, стрибати, лазити по фігурках і ставати героями власної казки.',
type: 'richText',
editor: standardEditor,
},
{
name: 'heroStat',
@ -79,24 +79,12 @@ export const DyvoLisPage: GlobalConfig = {
type: 'array',
fields: [
{ name: 'title', type: 'text', required: true },
{ name: 'description', type: 'textarea', required: true },
{ name: 'description', type: 'richText', editor: standardEditor, required: true },
],
defaultValue: [
{
title: 'Простір для спільної фантазії',
description:
'Вигадуйте казки та пригоди разом із дітьми — кожна топіарна фігурка стає новою сторінкою вашої власної чарівної історії.',
},
{
title: 'Казковий ліс у справжньому лісі',
description:
'Ми створили локацію, в якій гармонійно поєднуються казкові фігури та жива природа. Прогулянка лісом ще не була такою захопливою.',
},
{
title: 'Магічні кадри для сімейного альбому',
description:
'Унікальні топіарні декорації та яскраві персонажі — ідеальний фон для незабутніх сімейних фотографій, які захочеться переглядати знову і знову.',
},
{ title: 'Простір для спільної фантазії' },
{ title: 'Казковий ліс у справжньому лісі' },
{ title: 'Магічні кадри для сімейного альбому' },
],
},
// Video reviews carousel (right column of "Why visit" section)

View file

@ -171,10 +171,9 @@ export const GroupVisitsPage: GlobalConfig = {
},
{
name: 'priceDescription',
type: 'textarea',
type: 'richText',
editor: standardEditor,
label: 'Текст під ціновим блоком',
defaultValue:
'У вартість входить відвідування Динопарку та ДивоЛісу.\nЧас перебування на локаціях необмежений.',
},
// Bottom section
{

View file

@ -1,4 +1,5 @@
import type { GlobalConfig } from 'payload'
import { standardEditor } from '@/fields/richText'
import { isAdminOrEditor } from '@/access/isAdminOrEditor'
import { revalidateGlobalAfterChange } from '@/hooks/revalidatePath'
@ -45,7 +46,12 @@ export const HomePage: GlobalConfig = {
],
},
{ name: 'title', type: 'text', required: true, label: 'Заголовок' },
{ name: 'subtitle', type: 'textarea', label: 'Підзаголовок / опис' },
{
name: 'subtitle',
type: 'richText',
editor: standardEditor,
label: 'Підзаголовок / опис',
},
{ name: 'ctaLabel', type: 'text', defaultValue: 'Купити квиток', label: 'Кнопка' },
{ name: 'ctaHref', type: 'text', defaultValue: '/payments', label: 'Посилання кнопки' },
],
@ -55,7 +61,7 @@ export const HomePage: GlobalConfig = {
type: 'group',
fields: [
{ name: 'title', type: 'text' },
{ name: 'subtitle', type: 'textarea' },
{ name: 'subtitle', type: 'richText', editor: standardEditor },
{ name: 'ctaLabel', type: 'text' },
{ name: 'ctaHref', type: 'text' },
{ name: 'backgroundVideo', type: 'text' },
@ -154,7 +160,7 @@ export const HomePage: GlobalConfig = {
type: 'array',
fields: [
{ name: 'title', type: 'text' },
{ name: 'description', type: 'textarea' },
{ name: 'description', type: 'richText', editor: standardEditor },
],
},
{
@ -193,7 +199,7 @@ export const HomePage: GlobalConfig = {
name: 'birthdayIntro',
type: 'group',
fields: [
{ name: 'text', type: 'textarea' },
{ name: 'text', type: 'richText', editor: standardEditor },
{
name: 'patternGreen',
type: 'upload',
@ -217,7 +223,8 @@ export const HomePage: GlobalConfig = {
{ name: 'title', type: 'text' },
{
name: 'subtitle',
type: 'textarea',
type: 'richText',
editor: standardEditor,
admin: { description: 'Підзаголовок секції новин на головній.' },
},
{ name: 'limit', type: 'number', defaultValue: 3, min: 1, max: 12 },
@ -240,7 +247,13 @@ export const HomePage: GlobalConfig = {
label: 'Запитання та відповіді',
fields: [
{ name: 'question', type: 'text', required: true, label: 'Запитання' },
{ name: 'answer', type: 'textarea', required: true, label: 'Відповідь' },
{
name: 'answer',
type: 'richText',
editor: standardEditor,
required: true,
label: 'Відповідь',
},
],
},
],

View file

@ -1,4 +1,5 @@
import type { GlobalConfig } from 'payload'
import { standardEditor } from '@/fields/richText'
import { isAdminOrEditor } from '@/access/isAdminOrEditor'
import { revalidateGlobalAfterChange } from '@/hooks/revalidatePath'
@ -15,7 +16,7 @@ export const LegalPages: GlobalConfig = {
label: 'Політика конфіденційності',
fields: [
{ name: 'title', type: 'text', defaultValue: 'Політика конфіденційності' },
{ name: 'content', type: 'textarea', label: 'Текст сторінки', admin: { rows: 20 } },
{ name: 'content', type: 'richText', editor: standardEditor, label: 'Текст сторінки' },
],
},
{
@ -24,7 +25,7 @@ export const LegalPages: GlobalConfig = {
label: 'Умови використання',
fields: [
{ name: 'title', type: 'text', defaultValue: 'Умови використання' },
{ name: 'content', type: 'textarea', label: 'Текст сторінки', admin: { rows: 20 } },
{ name: 'content', type: 'richText', editor: standardEditor, label: 'Текст сторінки' },
],
},
{
@ -33,7 +34,7 @@ export const LegalPages: GlobalConfig = {
label: 'Публічна оферта',
fields: [
{ name: 'title', type: 'text', defaultValue: 'Публічна оферта' },
{ name: 'content', type: 'textarea', label: 'Текст сторінки', admin: { rows: 20 } },
{ name: 'content', type: 'richText', editor: standardEditor, label: 'Текст сторінки' },
],
},
{
@ -42,7 +43,7 @@ export const LegalPages: GlobalConfig = {
label: 'Обробка персональних даних',
fields: [
{ name: 'title', type: 'text', defaultValue: 'Обробка персональних даних' },
{ name: 'content', type: 'textarea', label: 'Текст сторінки', admin: { rows: 20 } },
{ name: 'content', type: 'richText', editor: standardEditor, label: 'Текст сторінки' },
],
},
],

View file

@ -1,4 +1,5 @@
import type { GlobalConfig } from 'payload'
import { standardEditor } from '@/fields/richText'
import { isAdminOrEditor } from '@/access/isAdminOrEditor'
import { revalidateGlobalAfterChange } from '@/hooks/revalidatePath'
@ -56,7 +57,7 @@ export const TicketsPage: GlobalConfig = {
{ name: 'name', type: 'text', required: true },
{ name: 'subtitle', type: 'text' },
{ name: 'price', type: 'text', required: true, admin: { description: 'Напр. «600 ₴»' } },
{ name: 'description', type: 'textarea' },
{ name: 'description', type: 'richText', editor: standardEditor },
{ name: 'featured', type: 'checkbox', label: 'Виділена (помаранчева)' },
{ name: 'badge', type: 'text', admin: { description: 'Напр. «Найпопулярніший»' } },
{
@ -66,18 +67,11 @@ export const TicketsPage: GlobalConfig = {
},
],
defaultValue: [
{
name: 'Комбо 1',
price: '600 ₴',
description: 'Індивідуальний квиток для повного занурення в атмосферу парку.',
featured: false,
locations: COMBO_LOCATIONS,
},
{ name: 'Комбо 1', price: '600 ₴', featured: false, locations: COMBO_LOCATIONS },
{
name: 'Комбо Сімейний',
subtitle: '(3 ос.)',
price: '1500 ₴',
description: 'Оптимальний вибір для батьків та дитини (віком до 14 років).',
featured: true,
badge: 'Найпопулярніший',
locations: COMBO_LOCATIONS,
@ -86,7 +80,6 @@ export const TicketsPage: GlobalConfig = {
name: 'Комбо Сімейний',
subtitle: '(4 ос.)',
price: '1800 ₴',
description: 'Універсальний сімейний пакет розваг для компанії з 4 людей.',
featured: false,
locations: COMBO_LOCATIONS,
},
@ -94,7 +87,6 @@ export const TicketsPage: GlobalConfig = {
name: 'Комбо Сімейний',
subtitle: '(5 ос.)',
price: '2000 ₴',
description: 'Максимальний та найвигідніший пакет для великої родини.',
featured: false,
locations: COMBO_LOCATIONS,
},
@ -152,9 +144,8 @@ export const TicketsPage: GlobalConfig = {
},
{
name: 'benefitsFootnote',
type: 'textarea',
defaultValue:
'*Знижки та пільги поширюються виключно на індивідуальне відвідування 3 основних локацій (Динопарк, Зона топіарних фігур, Дзеркальний лабіринт) та не сумуються з тарифами категорії «КОМБО».',
type: 'richText',
editor: standardEditor,
},
],
}

View file

@ -1,3 +1,5 @@
import type React from 'react'
export interface Media {
id?: string
url?: string | null
@ -93,7 +95,7 @@ export interface HomePageSectionTitles {
export interface HomePageWhyParentsItem {
title?: string | null
description?: string | null
description?: React.ReactNode
}
export interface HomePageWhyParents {
@ -116,14 +118,14 @@ export interface HomePageVideo {
}
export interface HomePageBirthdayIntro {
text?: string | null
text?: React.ReactNode
patternGreen?: Media | string | null
patternOrange?: Media | string | null
}
export interface HomePageNews {
title?: string | null
subtitle?: string | null
subtitle?: React.ReactNode
limit?: number | null
}
@ -136,7 +138,7 @@ export interface HomePageMap {
export interface HomePageFaqItem {
question?: string | null
answer?: string | null
answer?: React.ReactNode
}
export interface HomePageFaq {