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:
parent
2ec8393de9
commit
efeeeca8f5
14 changed files with 11598 additions and 92 deletions
10120
migrations/20260515_153940.json
Normal file
10120
migrations/20260515_153940.json
Normal file
File diff suppressed because it is too large
Load diff
1389
migrations/20260515_153940.ts
Normal file
1389
migrations/20260515_153940.ts
Normal file
File diff suppressed because it is too large
Load diff
9
migrations/index.ts
Normal file
9
migrations/index.ts
Normal 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',
|
||||
},
|
||||
]
|
||||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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 ?? ''
|
||||
},
|
||||
}),
|
||||
],
|
||||
|
||||
|
|
|
|||
|
|
@ -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 ?? '',
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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 ??
|
||||
'Організуйте групове відвідування Шуміленду. Спеціальні ціни для шкіл, дитячих садків і корпоративних груп.',
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 ??
|
||||
'Придбайте квитки до Шуміленду онлайн. Вхідні квитки на всі зони парку.',
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)}
|
||||
/>
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
'Святкуйте день народження у Шуміленді! Пакети для дітей та дорослих з розвагами, аніматорами та кейтерингом.',
|
||||
},
|
||||
],
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
'Казковий топіарний ліс у Шуміленді: фігури з безпечних матеріалів, унікальна ландшафтна композиція та повна свобода для дітей.',
|
||||
},
|
||||
],
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
'Організуйте групове відвідування Шуміленду. Спеціальні ціни для шкіл, дитячих садків і корпоративних груп.',
|
||||
},
|
||||
],
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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: 'Придбайте квитки до Шуміленду онлайн. Вхідні квитки на всі зони парку.',
|
||||
},
|
||||
],
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue