feat(cms): rich text editor for group visits page, fix /kvytky → /payments links

- Change heroDescription, featureText, bottomText fields from textarea to richText (Lexical)
  in GroupVisitsPage global so admins can format paragraphs and bold text in admin UI
- Render rich text fields with <RichText> component on the frontend
- Migration: ALTER varchar → jsonb for the three fields; UPDATE header cta_href and
  birthday_page_pricing_packages cta_href from /kvytky to /payments

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Vadym Samoilenko 2026-06-11 13:57:52 +01:00
parent 8eda50b351
commit 00f8742814
5 changed files with 90 additions and 45 deletions

View file

@ -0,0 +1,55 @@
import { MigrateUpArgs, MigrateDownArgs, sql } from '@payloadcms/db-postgres'
export async function up({ db }: MigrateUpArgs): Promise<void> {
// Fix header CTA link: /kvytky → /payments
await db.execute(sql`
UPDATE "header"
SET "cta_href" = '/payments'
WHERE "cta_href" = '/kvytky';
`)
// Fix birthday page pricing package CTA links
await db.execute(sql`
UPDATE "birthday_page_pricing_packages"
SET "cta_href" = '/payments'
WHERE "cta_href" = '/kvytky';
`)
// Change hero_description, feature_text, bottom_text from varchar to jsonb
// (existing text content cannot be auto-converted; fields reset to NULL — re-enter in admin)
await db.execute(sql`
ALTER TABLE "group_visits_page"
ALTER COLUMN "hero_description" TYPE jsonb USING NULL,
ALTER COLUMN "feature_text" TYPE jsonb USING NULL,
ALTER COLUMN "bottom_text" TYPE jsonb USING NULL;
`)
await db.execute(sql`
ALTER TABLE "_group_visits_page_v"
ALTER COLUMN "version_hero_description" TYPE jsonb USING NULL,
ALTER COLUMN "version_feature_text" TYPE jsonb USING NULL,
ALTER COLUMN "version_bottom_text" TYPE jsonb USING NULL;
`)
}
export async function down({ db }: MigrateDownArgs): Promise<void> {
await db.execute(sql`
UPDATE "header"
SET "cta_href" = '/kvytky'
WHERE "cta_href" = '/payments';
`)
await db.execute(sql`
ALTER TABLE "group_visits_page"
ALTER COLUMN "hero_description" TYPE varchar USING NULL,
ALTER COLUMN "feature_text" TYPE varchar USING NULL,
ALTER COLUMN "bottom_text" TYPE varchar USING NULL;
`)
await db.execute(sql`
ALTER TABLE "_group_visits_page_v"
ALTER COLUMN "version_hero_description" TYPE varchar USING NULL,
ALTER COLUMN "version_feature_text" TYPE varchar USING NULL,
ALTER COLUMN "version_bottom_text" TYPE varchar USING NULL;
`)
}

View file

@ -3,6 +3,7 @@ import * as migration_20260515_162527 from './20260515_162527'
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'
export const migrations = [
{
@ -30,4 +31,9 @@ export const migrations = [
down: migration_20260610_140000.down,
name: '20260610_140000',
},
{
up: migration_20260611_140000.up,
down: migration_20260611_140000.down,
name: '20260611_140000',
},
]

View file

@ -1,6 +1,7 @@
import type { Metadata } from 'next'
import { getPayload } from 'payload'
import configPromise from '@payload-config'
import { RichText } from '@payloadcms/richtext-lexical/react'
import { GroupRequestForm } from '@/components/forms/GroupRequestForm'
import { FormBlock, type FormData as FormBlockData } from '@/components/forms/FormBlock'
import { RefreshRouteOnSave } from '@/components/cms/RefreshRouteOnSave'
@ -70,14 +71,11 @@ export default async function GroupVisitsPage() {
const heroTitleSize = (d?.heroTitleSize as number | undefined) ?? null
const heroSubtitleSize = (d?.heroSubtitleSize as number | undefined) ?? null
const heroFont = (d?.heroFont as string | undefined) ?? null
const heroDescription =
(d?.heroDescription as string) ??
'Шукаєте ідеальне місце для групового виїзду класу чи садочка? Або яскраву локацію для фотосесії? Хочете, щоб дитячий випускний альбом був дійсно унікальним? Запрошуємо провести цей захопливий і незабутній день на казковій локації.'
type RichTextData = Parameters<typeof RichText>[0]['data']
const heroDescription = (d?.heroDescription ?? null) as RichTextData | null
const heroCta = (d?.heroCta as string) ?? 'Забронювати пригоду'
const featureText =
(d?.featureText as string) ??
'На дітлахів чекає подорож ДиноПарком та ДивоЛісом. Це активне дозвілля на свіжому повітрі та справжні казкові пригоди, де кожен стане героєм власної історії.'
const featureText = (d?.featureText ?? null) as RichTextData | null
const featureImages: string[] = ((d?.featureImages as { image: unknown }[]) ?? [])
.map((i) => mediaUrl(i.image as never))
.filter((u): u is string => Boolean(u))
@ -145,9 +143,7 @@ export default async function GroupVisitsPage() {
(d?.priceDescription as string) ??
'У вартість входить відвідування ДиноПарку та ДивоЛісу.\nЧас перебування на локаціях необмежений.'
const bottomText =
(d?.bottomText as string) ??
'Хочете перетворити візит на справжню маленьку експедицію з розкопками або замовити екскурсію з розповідями про динозаврів? Ми залюбки це організуємо! Телефонуйте нам — і ми все підготуємо та розрахуємо індивідуально для вашої групи.'
const bottomText = (d?.bottomText ?? null) as RichTextData | null
const bottomImages: string[] = ((d?.bottomImages as { image: unknown }[]) ?? [])
.map((i) => mediaUrl(i.image as never))
.filter((u): u is string => Boolean(u))
@ -202,7 +198,9 @@ export default async function GroupVisitsPage() {
{/* 2. Green description band */}
<section className="wave-pattern relative overflow-hidden rounded-b-[20px] px-4 py-12 md:px-8 md:py-16">
<div className="relative z-10 mx-auto flex max-w-[1200px] flex-col items-stretch gap-10">
<p className="text-[18px] leading-[1.5] text-white md:text-[24px]">{heroDescription}</p>
<div className="text-[18px] leading-[1.5] text-white md:text-[24px] [&_p]:mb-3 [&_strong]:font-bold">
{heroDescription && <RichText data={heroDescription} />}
</div>
<a
href="#order-form"
className="inline-flex items-center justify-center self-center rounded-[64px] bg-[#f28b4a] px-[30px] py-[10px] text-[18px] font-bold text-white transition-opacity hover:opacity-90 md:text-[20px]"
@ -215,9 +213,9 @@ export default async function GroupVisitsPage() {
{/* 3. Feature banner — text + overlapping photos */}
<section className="mx-auto max-w-[1204px] px-4 py-12 md:px-8 md:py-16">
<div className="grid grid-cols-1 items-center gap-10 md:grid-cols-2">
<p className="order-2 text-[18px] leading-[1.5] font-medium text-[#272727] md:order-1 md:text-[24px]">
{featureText}
</p>
<div className="order-2 text-[18px] leading-[1.5] font-medium text-[#272727] md:order-1 md:text-[24px] [&_p]:mb-3 [&_strong]:font-bold">
{featureText && <RichText data={featureText} />}
</div>
<div className="relative order-1 mx-auto h-[300px] w-full max-w-[460px] md:order-2 md:h-[400px]">
<img
src={featureA}
@ -266,9 +264,9 @@ export default async function GroupVisitsPage() {
{/* Bottom banner — text + overlapping photos */}
<section className="mx-auto max-w-[1204px] px-4 py-12 md:px-8 md:py-16">
<div className="grid grid-cols-1 items-center gap-10 md:grid-cols-2">
<p className="order-2 text-[18px] leading-[1.5] font-medium text-[#272727] md:order-1 md:text-[24px]">
{bottomText}
</p>
<div className="order-2 text-[18px] leading-[1.5] font-medium text-[#272727] md:order-1 md:text-[24px] [&_p]:mb-3 [&_strong]:font-bold">
{bottomText && <RichText data={bottomText} />}
</div>
<div className="relative order-1 mx-auto h-[320px] w-full max-w-[460px] md:order-2 md:h-[420px]">
<img
src={bottomA}

View file

@ -1,4 +1,5 @@
import type { GlobalConfig } from 'payload'
import { lexicalEditor } from '@payloadcms/richtext-lexical'
import { isAdminOrEditor } from '@/access/isAdminOrEditor'
import { revalidateGlobalAfterChange } from '@/hooks/revalidatePath'
@ -61,10 +62,9 @@ export const GroupVisitsPage: GlobalConfig = {
// Green band
{
name: 'heroDescription',
type: 'textarea',
type: 'richText',
editor: lexicalEditor({}),
label: 'Текст зеленої смуги під hero',
defaultValue:
'Шукаєте ідеальне місце для групового виїзду класу чи садочка? Або яскраву локацію для фотосесії? Хочете, щоб дитячий випускний альбом був дійсно унікальним? Запрошуємо провести цей захопливий і незабутній день на казковій локації.',
},
{
name: 'heroCta',
@ -75,10 +75,9 @@ export const GroupVisitsPage: GlobalConfig = {
// Feature two-col section
{
name: 'featureText',
type: 'textarea',
type: 'richText',
editor: lexicalEditor({}),
label: 'Текст у секції з фото (ліворуч)',
defaultValue:
'На дітлах чекає подорож ДинопарКом та ДивоЛісом. Це активне дозвілля на свіжому повітрі та справжні казкові пригоди, де кожен стане героєм власної історії.',
},
{
name: 'featureImages',
@ -180,10 +179,9 @@ export const GroupVisitsPage: GlobalConfig = {
// Bottom section
{
name: 'bottomText',
type: 'textarea',
type: 'richText',
editor: lexicalEditor({}),
label: 'Текст нижньої секції (ліворуч)',
defaultValue:
'Хочете перетворити візит на справжню маленьку експедицію з розповідями або замовити екскурсію з розповідями про динозаврів? Ми залюбки це організуємо! Телефонуйте нам — і ми все підготуємо та розрахуємо індивідуально для вашої групи.',
},
{
name: 'bottomImages',

View file

@ -2889,13 +2889,9 @@ export const group_visits_page = pgTable(
heroSubtitle: varchar('hero_subtitle').default(
'Спеціальні умови для організованих груп. Мінімум 10 осіб — максимум вражень.'
),
heroDescription: varchar('hero_description').default(
'Шукаєте ідеальне місце для групового виїзду класу чи садочка? Або яскраву локацію для фотосесії? Хочете, щоб дитячий випускний альбом був дійсно унікальним? Запрошуємо провести цей захопливий і незабутній день на казковій локації.'
),
heroDescription: jsonb('hero_description'),
heroCta: varchar('hero_cta').default('Забронювати пригоду'),
featureText: varchar('feature_text').default(
'На дітлах чекає подорож ДинопарКом та ДивоЛісом. Це активне дозвілля на свіжому повітрі та справжні казкові пригоди, де кожен стане героєм власної історії.'
),
featureText: jsonb('feature_text'),
amenitiesTitle: varchar('amenities_title').default('Ми подбали про затишок і комфорт'),
workingHoursTitle: varchar('working_hours_title').default('Час роботи'),
workingHours: varchar('working_hours').default("п'ятниця-субота-неділя з 11:00 до 20:00"),
@ -2911,9 +2907,7 @@ export const group_visits_page = pgTable(
priceDescription: varchar('price_description').default(
'У вартість входить відвідування Динопарку та ДивоЛісу.\nЧас перебування на локаціях необмежений.'
),
bottomText: varchar('bottom_text').default(
'Хочете перетворити візит на справжню маленьку експедицію з розповідями або замовити екскурсію з розповідями про динозаврів? Ми залюбки це організуємо! Телефонуйте нам — і ми все підготуємо та розрахуємо індивідуально для вашої групи.'
),
bottomText: jsonb('bottom_text'),
formTitle: varchar('form_title').default('Подати заявку на групове відвідування'),
formSubtitle: varchar('form_subtitle').default(
'Вкажіть кількість учасників та бажану дату — менеджер зателефонує і погодить деталі.'
@ -3021,13 +3015,9 @@ export const _group_visits_page_v = pgTable(
version_heroSubtitle: varchar('version_hero_subtitle').default(
'Спеціальні умови для організованих груп. Мінімум 10 осіб — максимум вражень.'
),
version_heroDescription: varchar('version_hero_description').default(
'Шукаєте ідеальне місце для групового виїзду класу чи садочка? Або яскраву локацію для фотосесії? Хочете, щоб дитячий випускний альбом був дійсно унікальним? Запрошуємо провести цей захопливий і незабутній день на казковій локації.'
),
version_heroDescription: jsonb('version_hero_description'),
version_heroCta: varchar('version_hero_cta').default('Забронювати пригоду'),
version_featureText: varchar('version_feature_text').default(
'На дітлах чекає подорож ДинопарКом та ДивоЛісом. Це активне дозвілля на свіжому повітрі та справжні казкові пригоди, де кожен стане героєм власної історії.'
),
version_featureText: jsonb('version_feature_text'),
version_amenitiesTitle: varchar('version_amenities_title').default(
'Ми подбали про затишок і комфорт'
),
@ -3049,9 +3039,7 @@ export const _group_visits_page_v = pgTable(
version_priceDescription: varchar('version_price_description').default(
'У вартість входить відвідування Динопарку та ДивоЛісу.\nЧас перебування на локаціях необмежений.'
),
version_bottomText: varchar('version_bottom_text').default(
'Хочете перетворити візит на справжню маленьку експедицію з розповідями або замовити екскурсію з розповідями про динозаврів? Ми залюбки це організуємо! Телефонуйте нам — і ми все підготуємо та розрахуємо індивідуально для вашої групи.'
),
version_bottomText: jsonb('version_bottom_text'),
version_formTitle: varchar('version_form_title').default(
'Подати заявку на групове відвідування'
),
@ -3171,7 +3159,7 @@ export const birthday_page_pricing_packages = pgTable(
price: varchar('price').notNull(),
note: varchar('note'),
ctaLabel: varchar('cta_label').default('Купити квиток'),
ctaHref: varchar('cta_href').default('/kvytky'),
ctaHref: varchar('cta_href').default('/payments'),
},
(columns) => [
index('birthday_page_pricing_packages_order_idx').on(columns._order),
@ -3359,7 +3347,7 @@ export const _birthday_page_v_version_pricing_packages = pgTable(
price: varchar('price').notNull(),
note: varchar('note'),
ctaLabel: varchar('cta_label').default('Купити квиток'),
ctaHref: varchar('cta_href').default('/kvytky'),
ctaHref: varchar('cta_href').default('/payments'),
_uuid: varchar('_uuid'),
},
(columns) => [