feat(cms): unify SEO via plugin for all globals + fix migrate CLI

- seoPlugin extended to cover 7 globals (home, dyvolis, group-visits,
  birthday, tickets, checkout, thank-you) with tabbedUI, generateURL,
  generateTitle from heroTitle, generateDescription from heroSubtitle
- Remove manual metaTitle/metaDescription fields from 4 globals
  (BirthdayPage, GroupVisitsPage, TicketsPage, DyvoLisPage)
- Update 5 page files to read meta?.title/description from seoPlugin
- blog/[slug] now uses post.meta?.title from plugin instead of raw title
- pnpm migrate/migrate:create/migrate:status scripts fixed with
  NODE_OPTIONS='--import tsx' (resolves Node.js v26 + tsx incompatibility)
- Initial full-schema migration 20260515_153940.ts created as baseline

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Vadym Samoilenko 2026-05-15 16:43:36 +01:00
parent 2ec8393de9
commit efeeeca8f5
14 changed files with 11598 additions and 92 deletions

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

9
migrations/index.ts Normal file
View file

@ -0,0 +1,9 @@
import * as migration_20260515_153940 from './20260515_153940'
export const migrations = [
{
up: migration_20260515_153940.up,
down: migration_20260515_153940.down,
name: '20260515_153940',
},
]

View file

@ -2,6 +2,7 @@
"name": "shumiland-site",
"version": "0.1.0",
"private": true,
"type": "module",
"packageManager": "pnpm@11.0.9",
"engines": {
"node": ">=20.0.0",
@ -20,6 +21,10 @@
"test:e2e": "playwright test",
"test:e2e:ui": "playwright test --ui",
"payload": "payload",
"migrate": "NODE_OPTIONS='--import tsx' payload migrate",
"migrate:create": "NODE_OPTIONS='--import tsx' payload migrate:create",
"migrate:status": "NODE_OPTIONS='--import tsx' payload migrate:status",
"generate:types": "NODE_OPTIONS='--import tsx' payload generate:types",
"prepare": "husky",
"seed": "tsx src/seed.ts"
},
@ -83,7 +88,7 @@
"prettier-plugin-tailwindcss": "^0.8.0",
"supertest": "^7.1.0",
"tailwindcss": "^4.3.0",
"tsx": "^4.22.0",
"tsx": "^4.21.1",
"typescript": "^6.0.3",
"vite-tsconfig-paths": "^6.1.1",
"vitest": "^4.1.6"

View file

@ -7,29 +7,29 @@ import sharp from 'sharp'
import path from 'path'
import { fileURLToPath } from 'url'
import { Users } from './src/collections/Users'
import { Media } from './src/collections/Media'
import { Pages } from './src/collections/Pages'
import { BlogPosts } from './src/collections/BlogPosts'
import { Categories } from './src/collections/Categories'
import { Tags } from './src/collections/Tags'
import { Tariffs } from './src/collections/Tariffs'
import { Leads } from './src/collections/Leads'
import { Orders } from './src/collections/Orders'
import { Locations } from './src/collections/Locations'
import { Reviews } from './src/collections/Reviews'
import { BirthdayPackages } from './src/collections/BirthdayPackages'
import { Users } from './src/collections/Users.js'
import { Media } from './src/collections/Media.js'
import { Pages } from './src/collections/Pages.js'
import { BlogPosts } from './src/collections/BlogPosts.js'
import { Categories } from './src/collections/Categories.js'
import { Tags } from './src/collections/Tags.js'
import { Tariffs } from './src/collections/Tariffs.js'
import { Leads } from './src/collections/Leads.js'
import { Orders } from './src/collections/Orders.js'
import { Locations } from './src/collections/Locations.js'
import { Reviews } from './src/collections/Reviews.js'
import { BirthdayPackages } from './src/collections/BirthdayPackages.js'
import { HomePage } from './src/globals/HomePage'
import { CheckoutPage } from './src/globals/CheckoutPage'
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'
import { BirthdayPage } from './src/globals/BirthdayPage'
import { TicketsPage } from './src/globals/TicketsPage'
import { HomePage } from './src/globals/HomePage.js'
import { CheckoutPage } from './src/globals/CheckoutPage.js'
import { ThankYouPage } from './src/globals/ThankYouPage.js'
import { Header } from './src/globals/Header.js'
import { Footer } from './src/globals/Footer.js'
import { SiteSettings } from './src/globals/SiteSettings.js'
import { DyvoLisPage } from './src/globals/DyvoLisPage.js'
import { GroupVisitsPage } from './src/globals/GroupVisitsPage.js'
import { BirthdayPage } from './src/globals/BirthdayPage.js'
import { TicketsPage } from './src/globals/TicketsPage.js'
const filename = fileURLToPath(import.meta.url)
const dirname = path.dirname(filename)
@ -87,13 +87,43 @@ export default buildConfig({
plugins: [
seoPlugin({
collections: ['pages', 'blog-posts', 'locations'],
globals: [
'home-page',
'dyvolis-page',
'group-visits-page',
'birthday-page',
'tickets-page',
'checkout-page',
'thank-you-page',
],
uploadsCollection: 'media',
tabbedUI: true,
generateURL: ({ doc, collectionSlug, globalSlug }) => {
const base = 'https://shumiland.com.ua'
const d = doc as { slug?: string }
if (collectionSlug === 'blog-posts') return `${base}/blog/${d.slug ?? ''}`
if (collectionSlug === 'locations') return `${base}/lokatsii/${d.slug ?? ''}`
if (collectionSlug === 'pages') return `${base}/${d.slug ?? ''}`
const globalURLs: Record<string, string> = {
'home-page': base,
'dyvolis-page': `${base}/lokatsii/dyvolis`,
'group-visits-page': `${base}/grupovi-vidviduvannia`,
'birthday-page': `${base}/dni-narodzhennia`,
'tickets-page': `${base}/kvytky`,
'checkout-page': `${base}/kvytky/checkout`,
'thank-you-page': `${base}/kvytky/dyakuiemo`,
}
return globalURLs[globalSlug ?? ''] ?? base
},
generateTitle: ({ doc }) => {
const title =
(doc as { title?: string; name?: string }).title ?? (doc as { name?: string }).name ?? ''
const d = doc as { title?: string; name?: string; heroTitle?: string }
const title = d.title ?? d.name ?? d.heroTitle ?? ''
return title ? `${title} — Шуміленд` : 'Шуміленд'
},
generateDescription: ({ doc }) => (doc as { excerpt?: string }).excerpt ?? '',
generateDescription: ({ doc }) => {
const d = doc as { excerpt?: string; shortDesc?: string; heroSubtitle?: string }
return d.excerpt ?? d.shortDesc ?? d.heroSubtitle ?? ''
},
}),
],

View file

@ -16,7 +16,8 @@ interface BlogPost {
slug: string
excerpt?: string | null
publishedAt?: string | null
// eslint-disable-next-line @typescript-eslint/no-explicit-any
meta?: { title?: string | null; description?: string | null } | null
body?: any
hero?: { url?: string | null; alt?: string | null } | null
}
@ -41,8 +42,8 @@ export async function generateMetadata({ params }: Props): Promise<Metadata> {
const post = await getPost(slug)
if (!post) return { title: 'Не знайдено — Шуміленд' }
return {
title: `${post.title} — Шуміленд`,
description: post.excerpt ?? '',
title: post.meta?.title ?? `${post.title} — Шуміленд`,
description: post.meta?.description ?? post.excerpt ?? '',
}
}

View file

@ -36,9 +36,9 @@ function formatPrice(price: number): string {
export async function generateMetadata(): Promise<Metadata> {
const pageData = await getBirthdayPageData()
return {
title: pageData?.metaTitle ?? 'Дні народження — Шуміленд',
title: pageData?.meta?.title ?? 'Дні народження — Шуміленд',
description:
pageData?.metaDescription ??
pageData?.meta?.description ??
'Святкуйте день народження у Шуміленді! Пакети для дітей та дорослих з розвагами, аніматорами та кейтерингом.',
}
}
@ -97,7 +97,7 @@ export default async function BirthdayPage({
</p>
</div>
<ul className="flex flex-col gap-2">
{pkg.features?.map((f: { id?: string; text: string }) => (
{pkg.features?.map((f: { id?: string | null; text: string }) => (
<li
key={f.id}
className="flex items-center gap-2 text-[14px] text-white/80"

View file

@ -26,9 +26,9 @@ async function getGroupVisitsData() {
export async function generateMetadata(): Promise<Metadata> {
const data = await getGroupVisitsData()
return {
title: data?.metaTitle ?? 'Групові відвідування — Шуміленд',
title: data?.meta?.title ?? 'Групові відвідування — Шуміленд',
description:
data?.metaDescription ??
data?.meta?.description ??
'Організуйте групове відвідування Шуміленду. Спеціальні ціни для шкіл, дитячих садків і корпоративних груп.',
}
}

View file

@ -64,9 +64,9 @@ async function getGroupVisitsData() {
export async function generateMetadata(): Promise<Metadata> {
const pageData = await getPageData()
return {
title: pageData?.metaTitle ?? 'Купити квиток — Шуміленд',
title: pageData?.meta?.title ?? 'Купити квиток — Шуміленд',
description:
pageData?.metaDescription ??
pageData?.meta?.description ??
'Придбайте квитки до Шуміленду онлайн. Вхідні квитки на всі зони парку.',
}
}

View file

@ -21,9 +21,10 @@ export async function generateMetadata(): Promise<Metadata> {
const data = await getDyvoLisData()
return {
title:
data?.metaTitle ?? (data?.heroTitle ? `${data.heroTitle} — Шуміленд` : 'ДивоЛіс — Шуміленд'),
data?.meta?.title ??
(data?.heroTitle ? `${data.heroTitle} — Шуміленд` : 'ДивоЛіс — Шуміленд'),
description:
data?.metaDescription ??
data?.meta?.description ??
'Казковий топіарний ліс у Шуміленді: фігури з безпечних матеріалів, унікальна ландшафтна композиція та повна свобода для дітей.',
}
}
@ -43,9 +44,11 @@ export default async function DyvoLisPage() {
<DyvoLisGallery
quote={data?.galleryQuote ?? undefined}
images={data?.galleryImages
?.map((item: { image?: { url?: string | null } | string | null }) => {
if (!item.image || typeof item.image === 'string') return null
return item.image.url ?? null
?.map((item: any) => {
if (!item.image || typeof item.image === 'number' || typeof item.image === 'string')
return null
return (item.image as { url?: string | null }).url ?? null
})
.filter((u: string | null): u is string => u !== null)}
/>

View file

@ -29,18 +29,5 @@ export const BirthdayPage: GlobalConfig = {
type: 'text',
defaultValue: "Залиште заявку і наш менеджер зв'яжеться з вами протягом 30 хвилин",
},
{
name: 'metaTitle',
type: 'text',
label: 'SEO: Meta Title',
defaultValue: 'Дні народження — Шуміленд',
},
{
name: 'metaDescription',
type: 'textarea',
label: 'SEO: Meta Description',
defaultValue:
'Святкуйте день народження у Шуміленді! Пакети для дітей та дорослих з розвагами, аніматорами та кейтерингом.',
},
],
}

View file

@ -120,18 +120,5 @@ export const DyvoLisPage: GlobalConfig = {
defaultValue: 'Динопарк + Диволіс із казковими топіарними фігурами + Дзеркальний лабіринт',
admin: { description: 'Підзаголовок під секцією "Комбо" в блоці квитків' },
},
{
name: 'metaTitle',
type: 'text',
label: 'SEO: Meta Title',
defaultValue: 'ДивоЛіс — Шуміленд',
},
{
name: 'metaDescription',
type: 'textarea',
label: 'SEO: Meta Description',
defaultValue:
'Казковий топіарний ліс у Шуміленді: фігури з безпечних матеріалів, унікальна ландшафтна композиція та повна свобода для дітей.',
},
],
}

View file

@ -71,18 +71,5 @@ export const GroupVisitsPage: GlobalConfig = {
},
],
},
{
name: 'metaTitle',
type: 'text',
label: 'SEO: Meta Title',
defaultValue: 'Групові відвідування — Шуміленд',
},
{
name: 'metaDescription',
type: 'textarea',
label: 'SEO: Meta Description',
defaultValue:
'Організуйте групове відвідування Шуміленду. Спеціальні ціни для шкіл, дитячих садків і корпоративних груп.',
},
],
}

View file

@ -30,17 +30,5 @@ export const TicketsPage: GlobalConfig = {
label: 'Заголовок секції "Групові відвідування"',
defaultValue: 'Групові відвідування',
},
{
name: 'metaTitle',
type: 'text',
label: 'SEO: Meta Title',
defaultValue: 'Купити квиток — Шуміленд',
},
{
name: 'metaDescription',
type: 'textarea',
label: 'SEO: Meta Description',
defaultValue: 'Придбайте квитки до Шуміленду онлайн. Вхідні квитки на всі зони парку.',
},
],
}