From 7d65237ee94eeaf64820a400e6f14d2b8a0f9d5e Mon Sep 17 00:00:00 2001 From: Marc Mintel Date: Thu, 26 Feb 2026 01:32:22 +0100 Subject: [PATCH] feat: payload cms --- .gitea/workflows/deploy.yml | 23 + .npmrc | 1 - app/(payload)/admin/importMap.js | 4 + app/(payload)/custom.scss | 152 +++++- app/[locale]/[slug]/page.tsx | 21 +- app/[locale]/api/og/product/route.tsx | 2 +- app/[locale]/blog/page.tsx | 14 +- app/[locale]/products/[...slug]/page.tsx | 2 +- app/actions/contact.ts | 20 + app/sitemap.ts | 2 +- components/Header.tsx | 8 +- components/PayloadRichText.tsx | 408 ++++++++++++++++- components/RelatedProducts.tsx | 2 +- components/blog/PostNavigation.tsx | 14 +- components/home/CTA.tsx | 12 +- components/home/Experience.tsx | 20 +- components/home/GallerySection.tsx | 6 +- components/home/Hero.tsx | 42 +- components/home/MeetTheTeam.tsx | 14 +- components/home/ProductCategories.tsx | 10 +- components/home/RecentPosts.tsx | 19 +- components/home/VideoSection.tsx | 28 +- components/home/WhatWeDo.tsx | 21 +- components/home/WhyChooseUs.tsx | 21 +- debug-sitemap.ts | 47 -- lib/blog.ts | 14 +- lib/mail/mailer.ts | 12 +- lib/pages.ts | 113 +++-- lib/{mdx.ts => products.ts} | 155 +++---- messages/en.json | 8 +- next.config.mjs | 10 +- package.json | 6 +- payload-types.ts | 263 ++++++++++- payload.config.ts | 20 + scripts/check-broken-assets.ts | 5 +- scripts/check-pages.ts | 14 + scripts/check-start.ts | 14 + scripts/check-team.ts | 14 + scripts/cms-sync.sh | 39 +- scripts/create-home-blocks.js | 33 ++ scripts/merge-locale-duplicates.ts | 260 +++++++++++ scripts/migrate-mdx.ts | 152 ------ scripts/migrate-products.ts | 156 ------- scripts/pagespeed-sitemap.ts | 2 +- scripts/seed-home.ts | 131 ++++++ scripts/seed-pages.ts | 242 ++++++++++ scripts/sql/drop_version_cols.sql | 18 + scripts/test-rich-text.js | 14 + .../20260225_175000_native_localization.ts | 285 ++++++++++++ src/migrations/index.ts | 6 + src/payload-generated-schema.ts | 431 +++++++++++++++--- src/payload/blocks/CategoryGrid.ts | 48 ++ src/payload/blocks/CompanyHeritage.ts | 54 +++ src/payload/blocks/ContactSection.ts | 26 ++ src/payload/blocks/HeroSection.ts | 48 ++ src/payload/blocks/HomeBlocks.ts | 141 ++++++ src/payload/blocks/ImageGallery.ts | 27 ++ src/payload/blocks/ManifestoGrid.ts | 41 ++ src/payload/blocks/SupportCTA.ts | 28 ++ src/payload/blocks/TeamProfile.ts | 62 +++ src/payload/blocks/allBlocks.ts | 14 + src/payload/collections/Pages.ts | 45 +- src/payload/collections/Posts.ts | 29 +- src/payload/collections/Products.ts | 20 +- src/payload/components/Icon.tsx | 12 + src/payload/components/Logo.tsx | 12 + src/payload/seed.ts | 2 +- 67 files changed, 3179 insertions(+), 760 deletions(-) delete mode 100644 debug-sitemap.ts rename lib/{mdx.ts => products.ts} (57%) create mode 100644 scripts/check-pages.ts create mode 100644 scripts/check-start.ts create mode 100644 scripts/check-team.ts create mode 100644 scripts/create-home-blocks.js create mode 100644 scripts/merge-locale-duplicates.ts delete mode 100644 scripts/migrate-mdx.ts delete mode 100644 scripts/migrate-products.ts create mode 100644 scripts/seed-home.ts create mode 100644 scripts/seed-pages.ts create mode 100644 scripts/sql/drop_version_cols.sql create mode 100644 scripts/test-rich-text.js create mode 100644 src/migrations/20260225_175000_native_localization.ts create mode 100644 src/payload/blocks/CategoryGrid.ts create mode 100644 src/payload/blocks/CompanyHeritage.ts create mode 100644 src/payload/blocks/ContactSection.ts create mode 100644 src/payload/blocks/HeroSection.ts create mode 100644 src/payload/blocks/HomeBlocks.ts create mode 100644 src/payload/blocks/ImageGallery.ts create mode 100644 src/payload/blocks/ManifestoGrid.ts create mode 100644 src/payload/blocks/SupportCTA.ts create mode 100644 src/payload/blocks/TeamProfile.ts create mode 100644 src/payload/components/Icon.tsx create mode 100644 src/payload/components/Logo.tsx diff --git a/.gitea/workflows/deploy.yml b/.gitea/workflows/deploy.yml index af7c33ed..d6a21765 100644 --- a/.gitea/workflows/deploy.yml +++ b/.gitea/workflows/deploy.yml @@ -429,6 +429,26 @@ jobs: - name: Install dependencies id: deps run: pnpm install --frozen-lockfile + - name: πŸ” Install Chromium (for Asset Scan) + run: | + rm -f /etc/apt/apt.conf.d/docker-clean + apt-get update + apt-get install -y gnupg wget ca-certificates + OS_ID=$(. /etc/os-release && echo $ID) + CODENAME=$(. /etc/os-release && echo $VERSION_CODENAME) + if [ "$OS_ID" = "debian" ]; then + apt-get install -y chromium + else + mkdir -p /etc/apt/keyrings + KEY_ID="82BB6851C64F6880" + wget -qO- "https://keyserver.ubuntu.com/pks/lookup?op=get&search=0x$KEY_ID" | gpg --dearmor > /etc/apt/keyrings/xtradeb.gpg + echo "deb [signed-by=/etc/apt/keyrings/xtradeb.gpg] http://ppa.launchpad.net/xtradeb/apps/ubuntu $CODENAME main" > /etc/apt/sources.list.d/xtradeb-ppa.list + printf "Package: *\nPin: release o=LP-PPA-xtradeb-apps\nPin-Priority: 1001\n" > /etc/apt/preferences.d/xtradeb + apt-get update + apt-get install -y --allow-downgrades chromium + fi + [ -f /usr/bin/chromium ] && ln -sf /usr/bin/chromium /usr/bin/google-chrome + [ -f /usr/bin/chromium ] && ln -sf /usr/bin/chromium /usr/bin/chromium-browser # ── Critical Smoke Tests (MUST pass) ────────────────────────────────── - name: πŸš€ OG Image Check @@ -477,6 +497,8 @@ jobs: env: NEXT_PUBLIC_BASE_URL: ${{ needs.prepare.outputs.next_public_url }} GATEKEEPER_PASSWORD: ${{ secrets.GATEKEEPER_PASSWORD || 'klz2026' }} + PUPPETEER_EXECUTABLE_PATH: /usr/bin/chromium + CHROME_PATH: /usr/bin/chromium run: pnpm check:assets # ────────────────────────────────────────────────────────────────────────────── @@ -568,6 +590,7 @@ jobs: image: catthehacker/ubuntu:act-latest steps: - name: πŸ”” Gotify + shell: bash run: | DEPLOY="${{ needs.deploy.result }}" SMOKE="${{ needs.post_deploy_checks.result }}" diff --git a/.npmrc b/.npmrc index 97b0c833..5798879c 100644 --- a/.npmrc +++ b/.npmrc @@ -1,3 +1,2 @@ @mintel:registry=https://npm.infra.mintel.me/ //npm.infra.mintel.me/:_authToken=${NPM_TOKEN} -always-auth=true diff --git a/app/(payload)/admin/importMap.js b/app/(payload)/admin/importMap.js index ca52ea68..aecd3639 100644 --- a/app/(payload)/admin/importMap.js +++ b/app/(payload)/admin/importMap.js @@ -22,6 +22,8 @@ import { StrikethroughFeatureClient as StrikethroughFeatureClient_e70f5e05f09f93 import { UnderlineFeatureClient as UnderlineFeatureClient_e70f5e05f09f93e00b997edb1ef0c864 } from '@payloadcms/richtext-lexical/client' import { BoldFeatureClient as BoldFeatureClient_e70f5e05f09f93e00b997edb1ef0c864 } from '@payloadcms/richtext-lexical/client' import { ItalicFeatureClient as ItalicFeatureClient_e70f5e05f09f93e00b997edb1ef0c864 } from '@payloadcms/richtext-lexical/client' +import { default as default_9ed509b5e5f7d08a16335393f27586cc } from '../../../src/payload/components/Icon' +import { default as default_5470ea90f7a8fd882c2fe59ff2b1c5b9 } from '../../../src/payload/components/Logo' import { CollectionCards as CollectionCards_f9c02e79a4aed9a3924487c0cd4cafb1 } from '@payloadcms/next/rsc' export const importMap = { @@ -49,5 +51,7 @@ export const importMap = { "@payloadcms/richtext-lexical/client#UnderlineFeatureClient": UnderlineFeatureClient_e70f5e05f09f93e00b997edb1ef0c864, "@payloadcms/richtext-lexical/client#BoldFeatureClient": BoldFeatureClient_e70f5e05f09f93e00b997edb1ef0c864, "@payloadcms/richtext-lexical/client#ItalicFeatureClient": ItalicFeatureClient_e70f5e05f09f93e00b997edb1ef0c864, + "/src/payload/components/Icon#default": default_9ed509b5e5f7d08a16335393f27586cc, + "/src/payload/components/Logo#default": default_5470ea90f7a8fd882c2fe59ff2b1c5b9, "@payloadcms/next/rsc#CollectionCards": CollectionCards_f9c02e79a4aed9a3924487c0cd4cafb1 } diff --git a/app/(payload)/custom.scss b/app/(payload)/custom.scss index 5793104f..29fc9e81 100644 --- a/app/(payload)/custom.scss +++ b/app/(payload)/custom.scss @@ -1 +1,151 @@ -/* Custom Payload CMS admin styles can go here. Do not import payloadcms/ui/scss/app.scss as it is handled by @payloadcms/next/css */ +/* ================================================================= + KLZ Cables – Payload Admin Theme + Strictly follows docs/STYLEGUIDE.md & tailwind.config.cjs + + IMPORTANT: We use `html` selector (not `:root`) because Payload's + own CSS defines variables on `:root` and loads AFTER this file. + `html` has higher specificity than `:root`, so our values win. + ================================================================= */ + +@import url('https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700;800&display=swap'); + +/* ================================================================= + COLOR OVERRIDES + Payload internally maps: + --theme-elevation-* β†’ --color-base-* + --theme-success-* β†’ --color-success-* + We override the SOURCE variables on `html` to beat Payload's `:root`. + ================================================================= */ + +html { + /* --------------------------------------------------------------- + KLZ Primary Blue (#011dff) β†’ Buttons, links, active states + --------------------------------------------------------------- */ + --color-success-50: #eef0ff !important; + --color-success-100: #dfe2ff !important; + --color-success-150: #cdd2ff !important; + --color-success-200: #b8bfff !important; + --color-success-250: #a0a9ff !important; + --color-success-300: #8892ff !important; + --color-success-350: #707bff !important; + --color-success-400: #5564ff !important; + --color-success-450: #3a4dff !important; + --color-success-500: #011dff !important; + /* KLZ Primary */ + --color-success-550: #0119e6 !important; + --color-success-600: #0116cc !important; + --color-success-650: #0112b3 !important; + --color-success-700: #000e99 !important; + --color-success-750: #000b80 !important; + --color-success-800: #000866 !important; + --color-success-850: #00054d !important; + --color-success-900: #000333 !important; + --color-success-950: #00011a !important; + + /* --------------------------------------------------------------- + KLZ "Foundation Neutrals" β†’ Backgrounds, cards, borders, text + Based on tailwind.config.cjs: neutral.light=#fff, + neutral.DEFAULT=#f8f9fa, neutral.dark=#263336, neutral.black=#0a0a0a + text.primary=#1a1a1a, text.secondary=#6c757d, text.light=#adb5bd + --------------------------------------------------------------- */ + --color-base-0: #ffffff !important; + --color-base-50: #f8f9fa !important; + --color-base-100: #f1f3f5 !important; + --color-base-150: #e9ecef !important; + --color-base-200: #dee2e6 !important; + --color-base-250: #ced4da !important; + --color-base-300: #adb5bd !important; + --color-base-350: #9ba3ab !important; + --color-base-400: #868e96 !important; + --color-base-450: #6c757d !important; + --color-base-500: #5c636a !important; + --color-base-550: #4d5358 !important; + --color-base-600: #3d4246 !important; + --color-base-650: #343a40 !important; + --color-base-700: #2b3035 !important; + --color-base-750: #263336 !important; + --color-base-800: #212529 !important; + --color-base-850: #1a1a1a !important; + --color-base-900: #121212 !important; + --color-base-950: #0a0a0a !important; + --color-base-1000: #000000 !important; + + /* Typography */ + --font-body: 'Inter', system-ui, -apple-system, sans-serif !important; + --font-headings: 'Inter', system-ui, -apple-system, sans-serif !important; +} + +/* Base Body Application */ +body { + font-family: var(--font-body) !important; + -webkit-font-smoothing: antialiased; + -moz-osx-font-smoothing: grayscale; +} + +/* ================================================================= + Login / Setup Page + ================================================================= */ +.template-default.template-default--has-bg { + background: radial-gradient(circle at top right, #e6ebf5 0%, #f8f9fa 60%, #f3f4f6 100%) !important; +} + +.login__wrap, +.create-first-user__wrap { + border-top: none !important; + padding: 3rem !important; + background: rgba(255, 255, 255, 0.85) !important; + backdrop-filter: blur(20px); + -webkit-backdrop-filter: blur(20px); + border: 1px solid var(--theme-elevation-150) !important; + box-shadow: 0 25px 50px -12px rgba(0, 0, 0, 0.15) !important; + border-radius: 1.5rem !important; +} + +/* ================================================================= + Buttons – override Payload's dark buttons with KLZ Blue + Payload uses .btn--style-primary { --bg-color: var(--theme-elevation-800) } + which makes all primary buttons near-black. We override to KLZ Blue. + ================================================================= */ +.btn--style-primary, +.btn--style-pill { + --bg-color: #011dff !important; + --color: #ffffff !important; + --hover-bg: #0116cc !important; + --hover-color: #ffffff !important; +} + +.btn--style-primary.btn--disabled, +.btn--style-pill.btn--disabled { + --bg-color: #b8bfff !important; + --color: #ffffff !important; + --hover-bg: #b8bfff !important; +} + +/* Sidebar Active Items */ +[class*="nav-group__link--active"], +[class*="nav__link--active"] { + --theme-elevation-800: #011dff !important; + color: #011dff !important; + border-left-color: #011dff !important; +} + +.btn--style-secondary { + --box-shadow: inset 0 0 0 1px #011dff !important; + --color: #011dff !important; + --hover-color: #0116cc !important; + --hover-box-shadow: inset 0 0 0 1px #0116cc !important; +} + +/* ================================================================= + Logo & Icon + ================================================================= */ +.klz-admin-logo, +.klz-admin-icon { + display: block !important; + visibility: visible !important; + opacity: 1 !important; + height: 32px !important; + width: auto !important; + max-width: 100% !important; + object-fit: contain !important; +} \ No newline at end of file diff --git a/app/[locale]/[slug]/page.tsx b/app/[locale]/[slug]/page.tsx index ffe2ada9..f1313581 100644 --- a/app/[locale]/[slug]/page.tsx +++ b/app/[locale]/[slug]/page.tsx @@ -3,6 +3,7 @@ import { Container, Badge, Heading } from '@/components/ui'; import { getTranslations, setRequestLocale } from 'next-intl/server'; import { Metadata } from 'next'; import { getPageBySlug, getAllPages } from '@/lib/pages'; +import { mapSlugToFileSlug, mapFileSlugToTranslated } from '@/lib/slugs'; import PayloadRichText from '@/components/PayloadRichText'; import { SITE_URL } from '@/lib/schema'; import TrackedLink from '@/components/analytics/TrackedLink'; @@ -20,15 +21,19 @@ export async function generateMetadata({ params }: PageProps): Promise if (!pageData) return {}; + const fileSlug = await mapSlugToFileSlug(slug, locale); + const deSlug = await mapFileSlugToTranslated(fileSlug, 'de'); + const enSlug = await mapFileSlugToTranslated(fileSlug, 'en'); + return { title: pageData.frontmatter.title, description: pageData.frontmatter.excerpt || '', alternates: { canonical: `${SITE_URL}/${locale}/${slug}`, languages: { - de: `${SITE_URL}/de/${slug}`, - en: `${SITE_URL}/en/${slug}`, - 'x-default': `${SITE_URL}/en/${slug}`, + de: `${SITE_URL}/de/${deSlug}`, + en: `${SITE_URL}/en/${enSlug}`, + 'x-default': `${SITE_URL}/en/${enSlug}`, }, }, openGraph: { @@ -54,6 +59,16 @@ export default async function StandardPage({ params }: PageProps) { notFound(); } + // Full-bleed pages render blocks edge-to-edge without the generic article wrapper + if (pageData.frontmatter.layout === 'fullBleed') { + return ( +
+ +
+ ); + } + + // Default article layout with hero, content, and support CTA return (
{/* Hero Section */} diff --git a/app/[locale]/api/og/product/route.tsx b/app/[locale]/api/og/product/route.tsx index a7027ea8..6ff2dbd4 100644 --- a/app/[locale]/api/og/product/route.tsx +++ b/app/[locale]/api/og/product/route.tsx @@ -1,5 +1,5 @@ import { ImageResponse } from 'next/og'; -import { getProductBySlug } from '@/lib/mdx'; +import { getProductBySlug } from '@/lib/products'; import { getTranslations } from 'next-intl/server'; import { OGImageTemplate } from '@/components/OGImageTemplate'; import { NextRequest } from 'next/server'; diff --git a/app/[locale]/blog/page.tsx b/app/[locale]/blog/page.tsx index 89186f56..d81c6959 100644 --- a/app/[locale]/blog/page.tsx +++ b/app/[locale]/blog/page.tsx @@ -67,6 +67,9 @@ export default async function BlogIndex({ params }: BlogIndexProps) { alt={featuredPost.frontmatter.title} fill className="absolute inset-0 w-full h-full object-cover opacity-40 md:opacity-60" + style={{ + objectPosition: `${featuredPost.frontmatter.focalX ?? 50}% ${featuredPost.frontmatter.focalY ?? 50}%`, + }} sizes="100vw" priority /> @@ -168,6 +171,9 @@ export default async function BlogIndex({ params }: BlogIndexProps) { alt={post.frontmatter.title} fill className="w-full h-full object-cover transition-transform duration-1000 group-hover:scale-110" + style={{ + objectPosition: `${post.frontmatter.focalX ?? 50}% ${post.frontmatter.focalY ?? 50}%`, + }} sizes="(max-width: 768px) 100vw, (max-width: 1024px) 50vw, 33vw" />
@@ -192,10 +198,10 @@ export default async function BlogIndex({ params }: BlogIndexProps) { {(new Date(post.frontmatter.date) > new Date() || post.frontmatter.public === false) && ( - - Draft - - )} + + Draft + + )}

{post.frontmatter.title} diff --git a/app/[locale]/products/[...slug]/page.tsx b/app/[locale]/products/[...slug]/page.tsx index 98199505..fe376ea9 100644 --- a/app/[locale]/products/[...slug]/page.tsx +++ b/app/[locale]/products/[...slug]/page.tsx @@ -7,7 +7,7 @@ import RelatedProducts from '@/components/RelatedProducts'; import DatasheetDownload from '@/components/DatasheetDownload'; import { Badge, Card, Container, Heading, Section } from '@/components/ui'; import { getDatasheetPath } from '@/lib/datasheets'; -import { getAllProducts, getProductBySlug } from '@/lib/mdx'; +import { getAllProducts, getProductBySlug } from '@/lib/products'; import { mapFileSlugToTranslated, mapSlugToFileSlug } from '@/lib/slugs'; import { Metadata } from 'next'; import { getTranslations, setRequestLocale } from 'next-intl/server'; diff --git a/app/actions/contact.ts b/app/actions/contact.ts index 2b582f61..2cf3c176 100644 --- a/app/actions/contact.ts +++ b/app/actions/contact.ts @@ -94,6 +94,16 @@ export async function sendContactFormAction(formData: FormData) { logger.info('Notification email sent successfully', { messageId: notificationResult.messageId, }); + } else { + logger.error('Notification email FAILED', { + error: notificationResult.error, + subject: notificationSubject, + email, + }); + services.errors.captureException( + new Error(`Notification email failed: ${notificationResult.error}`), + { action: 'sendContactFormAction_notification', email }, + ); } // 2b. Send confirmation to Customer (branded as KLZ Cables) @@ -115,6 +125,16 @@ export async function sendContactFormAction(formData: FormData) { logger.info('Confirmation email sent successfully', { messageId: confirmationResult.messageId, }); + } else { + logger.error('Confirmation email FAILED', { + error: confirmationResult.error, + subject: confirmationSubject, + to: email, + }); + services.errors.captureException( + new Error(`Confirmation email failed: ${confirmationResult.error}`), + { action: 'sendContactFormAction_confirmation', email }, + ); } // Notify via Gotify (Internal) diff --git a/app/sitemap.ts b/app/sitemap.ts index d4a455bc..97d1fc1b 100644 --- a/app/sitemap.ts +++ b/app/sitemap.ts @@ -1,6 +1,6 @@ import { config } from '@/lib/config'; import { MetadataRoute } from 'next'; -import { getAllProductsMetadata } from '@/lib/mdx'; +import { getAllProductsMetadata } from '@/lib/products'; import { getAllPostsMetadata } from '@/lib/blog'; import { getAllPagesMetadata } from '@/lib/pages'; import { mapFileSlugToTranslated } from '@/lib/slugs'; diff --git a/components/Header.tsx b/components/Header.tsx index dd0d3e02..83595e87 100644 --- a/components/Header.tsx +++ b/components/Header.tsx @@ -137,7 +137,7 @@ export default function Header() { ]; const headerClass = cn( - 'fixed top-0 left-0 right-0 z-50 transition-all duration-500 safe-area-p transform-gpu animate-in fade-in slide-in-from-top-12 fill-mode-both', + 'fixed top-0 left-0 right-0 z-50 transition-all duration-500 safe-area-p transform-gpu fill-mode-both', { 'bg-primary/95 backdrop-blur-md md:bg-transparent py-3 md:py-8 shadow-2xl md:shadow-none': isHomePage && !isScrolled && !isMobileMenuOpen, @@ -153,8 +153,7 @@ export default function Header() {
diff --git a/components/PayloadRichText.tsx b/components/PayloadRichText.tsx index a0ef0b2f..2f16fc76 100644 --- a/components/PayloadRichText.tsx +++ b/components/PayloadRichText.tsx @@ -1,6 +1,7 @@ import { defaultJSXConverters, RichText } from '@payloadcms/richtext-lexical/react'; import type { JSXConverters } from '@payloadcms/richtext-lexical/react'; import Image from 'next/image'; +import { Suspense } from 'react'; // Import all custom React components that were previously mapped via MDX import StickyNarrative from '@/components/blog/StickyNarrative'; @@ -16,6 +17,24 @@ import Stats from '@/components/blog/Stats'; import SplitHeading from '@/components/blog/SplitHeading'; import ProductTabs from '@/components/ProductTabs'; import ProductTechnicalData from '@/components/ProductTechnicalData'; +import ContactForm from '@/components/ContactForm'; +import ContactMap from '@/components/ContactMap'; +import Gallery from '@/components/team/Gallery'; +import Reveal from '@/components/Reveal'; +import { Badge, Container, Heading, Section, Card } from '@/components/ui'; +import TrackedLink from '@/components/analytics/TrackedLink'; +import { useLocale } from 'next-intl'; + +import HomeHero from '@/components/home/Hero'; +import ProductCategories from '@/components/home/ProductCategories'; +import WhatWeDo from '@/components/home/WhatWeDo'; +import RecentPosts from '@/components/home/RecentPosts'; +import Experience from '@/components/home/Experience'; +import WhyChooseUs from '@/components/home/WhyChooseUs'; +import MeetTheTeam from '@/components/home/MeetTheTeam'; +import GallerySection from '@/components/home/GallerySection'; +import VideoSection from '@/components/home/VideoSection'; +import CTA from '@/components/home/CTA'; const jsxConverters: JSXConverters = { ...defaultJSXConverters, @@ -255,6 +274,372 @@ const jsxConverters: JSXConverters = { {node.fields.content && } ), + // ─── New Page Blocks ─────────────────────────────────────────── + heroSection: ({ node }: any) => { + const f = node.fields; + const bgSrc = f.backgroundImage?.sizes?.card?.url || f.backgroundImage?.url; + return ( + +
+ {bgSrc && ( + <> +
+ {f.title} +
+
+ + )} + +
+ {f.badge && {f.badge}} + {f.title} + {f.subtitle &&

{f.subtitle}

} + {f.ctaLabel && f.ctaHref && ( + + )} +
+
+
+
+ ); + }, + 'block-heroSection': ({ node }: any) => { + const f = node.fields; + const bgSrc = f.backgroundImage?.sizes?.card?.url || f.backgroundImage?.url; + return ( + +
+ {bgSrc && ( + <> +
+ {f.title} +
+
+ + )} + +
+ {f.badge && {f.badge}} + {f.title} + {f.subtitle &&

{f.subtitle}

} + {f.ctaLabel && f.ctaHref && ( + + )} +
+
+
+
+ ); + }, + teamProfile: ({ node }: any) => { + const f = node.fields; + const imgSrc = f.image?.sizes?.card?.url || f.image?.url; + const isDark = f.colorScheme === 'dark'; + const isImageRight = f.layout === 'imageRight'; + return ( +
+
+ +
+ {f.role} + {f.name} + {f.quote && ( +
+
+

{f.quote}

+
+ )} + {f.description &&

{f.description}

} + {f.linkedinUrl && ( + + {f.linkedinLabel || 'LinkedIn'} + + + )} +
+ + + {imgSrc && ( + <> + {f.name} +
+ + )} + +
+
+ ); + }, + 'block-teamProfile': ({ node }: any) => { + const f = node.fields; + const imgSrc = f.image?.sizes?.card?.url || f.image?.url; + const isDark = f.colorScheme === 'dark'; + const isImageRight = f.layout === 'imageRight'; + return ( +
+
+ +
+ {f.role} + {f.name} + {f.quote && ( +
+
+

{f.quote}

+
+ )} + {f.description &&

{f.description}

} + {f.linkedinUrl && ( + + {f.linkedinLabel || 'LinkedIn'} + + )} +
+ + + {imgSrc && (<>{f.name}
)} + +
+
+ ); + }, + contactSection: ({ node }: any) => { + const f = node.fields; + return ( +
+ +
+ {f.showForm && ( +
+ }> + + +
+ )} +
+
+ {f.showMap && ( +
+ }> + + +
+ )} +
+ ); + }, + 'block-contactSection': ({ node }: any) => { + const f = node.fields; + return ( +
+ +
+ {f.showForm && ( +
+ }> + + +
+ )} +
+
+ {f.showMap && ( +
+ }> + + +
+ )} +
+ ); + }, + imageGallery: ({ node }: any) => , + 'block-imageGallery': ({ node }: any) => , + categoryGrid: ({ node }: any) => { + const cats = node.fields.categories || []; + return ( +
+ +
+ {cats.map((cat: any, idx: number) => { + const imgSrc = cat.image?.sizes?.card?.url || cat.image?.url; + const iconSrc = cat.icon?.url; + return ( + + + + + +
+ ); + }, + 'block-categoryGrid': ({ node }: any) => { + const cats = node.fields.categories || []; + return ( +
+ +
+ {cats.map((cat: any, idx: number) => { + const imgSrc = cat.image?.sizes?.card?.url || cat.image?.url; + const iconSrc = cat.icon?.url; + return ( + + + + + +
+ ); + }, + manifestoGrid: ({ node }: any) => { + const f = node.fields; + return ( +
+ +
+
+
+ {f.title && {f.title}} + {f.tagline &&

{f.tagline}

} +
+
+
    + {(f.items || []).map((item: any, idx: number) => ( +
  • +
    + 0{idx + 1} +
    +

    {item.title}

    +

    {item.description}

    +
  • + ))} +
+
+
+
+ ); + }, + 'block-manifestoGrid': ({ node }: any) => { + const f = node.fields; + return ( +
+ +
+
+
+ {f.title && {f.title}} + {f.tagline &&

{f.tagline}

} +
+
+
    + {(f.items || []).map((item: any, idx: number) => ( +
  • +
    + 0{idx + 1} +
    +

    {item.title}

    +

    {item.description}

    +
  • + ))} +
+
+
+
+ ); + }, + homeHero: ({ node }: any) => { + console.log('[PayloadRichText] Rendering homeHero block'); + return ; + }, + 'block-homeHero': ({ node }: any) => { + console.log('[PayloadRichText] Rendering block-homeHero block'); + return ; + }, + homeProductCategories: ({ node }: any) => , + 'block-homeProductCategories': ({ node }: any) => , + homeWhatWeDo: ({ node }: any) => , + 'block-homeWhatWeDo': ({ node }: any) => , + homeExperience: ({ node }: any) => , + 'block-homeExperience': ({ node }: any) => , + homeWhyChooseUs: ({ node }: any) => , + 'block-homeWhyChooseUs': ({ node }: any) => , + homeMeetTheTeam: ({ node }: any) => , + 'block-homeMeetTheTeam': ({ node }: any) => , + homeGallery: ({ node }: any) => , + 'block-homeGallery': ({ node }: any) => , + homeVideo: ({ node }: any) => , + 'block-homeVideo': ({ node }: any) => , + homeCTA: ({ node }: any) => , + 'block-homeCTA': ({ node }: any) => , }, // Custom converter for the Payload "upload" Lexical node (Media collection) // This natively reconstructs Next.js tags pointing to the focal-point cropped sizes @@ -297,12 +682,29 @@ const jsxConverters: JSXConverters = { }, }; -export default function PayloadRichText({ data }: { data: any }) { +export default function PayloadRichText({ + data, + className = 'article-content max-w-none', +}: { + data: any; + className?: string; +}) { + const locale = useLocale(); + if (!data) return null; + const dynamicConverters: JSXConverters = { + ...jsxConverters, + blocks: { + ...jsxConverters.blocks, + homeRecentPosts: () => , + 'block-homeRecentPosts': () => , + }, + }; + return ( -
- +
+
); } diff --git a/components/RelatedProducts.tsx b/components/RelatedProducts.tsx index 3ef4182e..f6591665 100644 --- a/components/RelatedProducts.tsx +++ b/components/RelatedProducts.tsx @@ -1,4 +1,4 @@ -import { getAllProducts } from '@/lib/mdx'; +import { getAllProducts } from '@/lib/products'; import { getTranslations } from 'next-intl/server'; import Image from 'next/image'; import { RelatedProductLink } from './RelatedProductLink'; diff --git a/components/blog/PostNavigation.tsx b/components/blog/PostNavigation.tsx index 61f82fac..391af5ce 100644 --- a/components/blog/PostNavigation.tsx +++ b/components/blog/PostNavigation.tsx @@ -30,8 +30,11 @@ export default function PostNavigation({ {/* Background Image */} {prev.frontmatter.featuredImage ? (
) : (
@@ -81,8 +84,11 @@ export default function PostNavigation({ {/* Background Image */} {next.frontmatter.featuredImage ? (
) : (
diff --git a/components/home/CTA.tsx b/components/home/CTA.tsx index ec3c679a..4cb341a7 100644 --- a/components/home/CTA.tsx +++ b/components/home/CTA.tsx @@ -2,7 +2,7 @@ import React from 'react'; import { useTranslations, useLocale } from 'next-intl'; import { Section, Container, Button, Heading } from '../../components/ui'; -export default function CTA() { +export default function CTA({ data }: { data?: any }) { const t = useTranslations('Home.cta'); const locale = useLocale(); @@ -10,20 +10,20 @@ export default function CTA() {
- +
- - {t('title')} + + {data?.title || t('title')}

- {t('description')} + {data?.description || t('description')}

diff --git a/components/home/Experience.tsx b/components/home/Experience.tsx index 4c0187ff..58d17a69 100644 --- a/components/home/Experience.tsx +++ b/components/home/Experience.tsx @@ -3,7 +3,7 @@ import Image from 'next/image'; import { useTranslations } from 'next-intl'; import { Section, Container, Heading } from '../../components/ui'; -export default function Experience() { +export default function Experience({ data }: { data?: any }) { const t = useTranslations('Home.experience'); return ( @@ -11,7 +11,7 @@ export default function Experience() {
{t('subtitle')}
- - {t('title')} + + {data?.title || t('title')}

- {t('p1')} + {data?.paragraph1 || t('p1')}

-

{t('p2')}

+

{data?.paragraph2 || t('p2')}

- {t('certifiedQuality')} + {data?.badge1 || t('certifiedQuality')}
- {t('vdeApproved')} + {data?.badge1Text || t('vdeApproved')}
- {t('fullSpectrum')} + {data?.badge2 || t('fullSpectrum')}
- {t('solutionsRange')} + {data?.badge2Text || t('solutionsRange')}
diff --git a/components/home/GallerySection.tsx b/components/home/GallerySection.tsx index ee2c6551..80919993 100644 --- a/components/home/GallerySection.tsx +++ b/components/home/GallerySection.tsx @@ -7,7 +7,7 @@ import dynamic from 'next/dynamic'; const Lightbox = dynamic(() => import('../Lightbox'), { ssr: false }); import { useSearchParams } from 'next/navigation'; -export default function GallerySection() { +export default function GallerySection({ data }: { data?: any }) { const t = useTranslations('Home.gallery'); const searchParams = useSearchParams(); const images = [ @@ -26,8 +26,8 @@ export default function GallerySection() { return (
- - {t('title')} + + {data?.title || t('title')}
diff --git a/components/home/Hero.tsx b/components/home/Hero.tsx index 087dc40b..033aa31d 100644 --- a/components/home/Hero.tsx +++ b/components/home/Hero.tsx @@ -8,7 +8,7 @@ import { useAnalytics } from '../analytics/useAnalytics'; import { AnalyticsEvents } from '../analytics/analytics-events'; const HeroIllustration = dynamic(() => import('./HeroIllustration'), { ssr: false }); -export default function Hero() { +export default function Hero({ data }: { data?: any }) { const t = useTranslations('Home.hero'); const locale = useLocale(); const { trackEvent } = useAnalytics(); @@ -22,24 +22,28 @@ export default function Hero() { level={1} className="text-center md:text-left mb-6 md:mb-8 md:max-w-none text-white text-4xl sm:text-5xl md:text-7xl font-extrabold [text-shadow:_-2px_-2px_0_#002b49,_2px_-2px_0_#002b49,_-2px_2px_0_#002b49,_2px_2px_0_#002b49,_-2px_0_0_#002b49,_2px_0_0_#002b49,_0_-2px_0_#002b49,_0_2px_0_#002b49]" > - {t.rich('title', { - green: (chunks) => ( - - {chunks} -
- -
-
- ), - })} + {data?.title ? ( + /g, '').replace(/<\/green>/g, '') }} /> + ) : ( + t.rich('title', { + green: (chunks) => ( + + {chunks} +
+ +
+
+ ), + }) + )}

- {t('subtitle')} + {data?.subtitle || t('subtitle')}

@@ -51,12 +55,12 @@ export default function Hero() { className="group w-full sm:w-auto h-14 md:h-16 px-8 md:px-10 text-base md:text-lg hover:scale-105 transition-transform" onClick={() => trackEvent(AnalyticsEvents.BUTTON_CLICK, { - label: t('cta'), + label: data?.ctaLabel || t('cta'), location: 'home_hero_primary', }) } > - {t('cta')} + {data?.ctaLabel || t('cta')} @@ -70,12 +74,12 @@ export default function Hero() { className="group w-full sm:w-auto h-14 md:h-16 px-8 md:px-10 text-base md:text-lg md:bg-white md:text-primary md:hover:bg-neutral-light md:border-none hover:scale-105 transition-transform" onClick={() => trackEvent(AnalyticsEvents.BUTTON_CLICK, { - label: t('exploreProducts'), + label: data?.secondaryCtaLabel || t('exploreProducts'), location: 'home_hero_secondary', }) } > - {t('exploreProducts')} + {data?.secondaryCtaLabel || t('exploreProducts')}
diff --git a/components/home/MeetTheTeam.tsx b/components/home/MeetTheTeam.tsx index d1b8b8f3..e0f16dd5 100644 --- a/components/home/MeetTheTeam.tsx +++ b/components/home/MeetTheTeam.tsx @@ -3,7 +3,7 @@ import Image from 'next/image'; import { useTranslations, useLocale } from 'next-intl'; import { Section, Container, Button, Heading } from '../../components/ui'; -export default function MeetTheTeam() { +export default function MeetTheTeam({ data }: { data?: any }) { const t = useTranslations('Home.meetTheTeam'); const teamT = useTranslations('Team'); const locale = useLocale(); @@ -13,7 +13,7 @@ export default function MeetTheTeam() {
{t('subtitle')}
- - {t('title')} + + {data?.title || t('title')}

- "{t('description')}" + "{data?.description || t('description')}"

@@ -61,7 +61,7 @@ export default function MeetTheTeam() {
- {t('andNetwork')} + {data?.networkLabel || t('andNetwork')}
diff --git a/components/home/ProductCategories.tsx b/components/home/ProductCategories.tsx index d29c230a..11915ed8 100644 --- a/components/home/ProductCategories.tsx +++ b/components/home/ProductCategories.tsx @@ -4,7 +4,7 @@ import Image from 'next/image'; import { useTranslations, useLocale } from 'next-intl'; import { Section } from '../../components/ui'; -export default function ProductCategories() { +export default function ProductCategories({ data }: { data?: any }) { const t = useTranslations('Products'); const locale = useLocale(); @@ -43,9 +43,13 @@ export default function ProductCategories() { return (
- {t.has('title') && ( + {(data?.title || t.has('title')) && (

- {t.rich('title', { green: (chunks: any) => {chunks} })} + {data?.title ? ( + + ) : ( + t.rich('title', { green: (chunks: any) => {chunks} }) + )}

)}
diff --git a/components/home/RecentPosts.tsx b/components/home/RecentPosts.tsx index f8aa8871..34b3f3a9 100644 --- a/components/home/RecentPosts.tsx +++ b/components/home/RecentPosts.tsx @@ -7,34 +7,38 @@ import { Section, Container, Heading, Card, Badge } from '../../components/ui'; interface RecentPostsProps { locale: string; + data?: any; } -export default async function RecentPosts({ locale }: RecentPostsProps) { +export default async function RecentPosts({ locale, data }: RecentPostsProps) { const t = await getTranslations('Blog'); const posts = await getAllPosts(locale); const recentPosts = posts.slice(0, 3); if (recentPosts.length === 0) return null; + const title = data?.title || t('allArticles'); + const subtitle = data?.subtitle || t('latestNews'); + return (
- - {t('allArticles')} + + {title} - {t('allArticles')} + {title}
    - {recentPosts.map((post) => ( -
  • + {recentPosts.map((post, idx) => ( +
  • diff --git a/components/home/VideoSection.tsx b/components/home/VideoSection.tsx index c1503329..e8c74b82 100644 --- a/components/home/VideoSection.tsx +++ b/components/home/VideoSection.tsx @@ -4,7 +4,7 @@ import React, { useState, useEffect, useRef } from 'react'; import Scribble from '@/components/Scribble'; import { useTranslations } from 'next-intl'; -export default function VideoSection() { +export default function VideoSection({ data }: { data?: any }) { const t = useTranslations('Home.video'); const [isVisible, setIsVisible] = useState(false); const sectionRef = useRef(null); @@ -40,17 +40,21 @@ export default function VideoSection() {

    - {t.rich('title', { - future: (chunks) => ( - - {chunks} - - - ), - })} + {data?.title ? ( + /g, '').replace(/<\/future>/g, '') }} /> + ) : ( + t.rich('title', { + future: (chunks) => ( + + {chunks} + + + ), + }) + )}

    diff --git a/components/home/WhatWeDo.tsx b/components/home/WhatWeDo.tsx index 87b21b12..cdf68689 100644 --- a/components/home/WhatWeDo.tsx +++ b/components/home/WhatWeDo.tsx @@ -2,30 +2,35 @@ import React from 'react'; import { useTranslations } from 'next-intl'; import { Section, Container, Heading } from '../../components/ui'; -export default function WhatWeDo() { +export default function WhatWeDo({ data }: { data?: any }) { const t = useTranslations('Home.whatWeDo'); + const items = data?.items?.length ? data.items : [0, 1, 2, 3].map(idx => ({ + title: t(`items.${idx}.title`), + description: t(`items.${idx}.description`) + })); + return (
    - - {t('title')} + + {data?.title || t('title')}

    - {t('subtitle')} + {data?.subtitle || t('subtitle')}

    - "{t('quote')}" + "{data?.quote || t('quote')}"

    - {[0, 1, 2, 3].map((idx) => ( + {items.map((item: any, idx: number) => (
    @@ -33,8 +38,8 @@ export default function WhatWeDo() {
    -

    {t(`items.${idx}.title`)}

    -

    {t(`items.${idx}.description`)}

    +

    {item.title}

    +

    {item.description}

    ))}
    diff --git a/components/home/WhyChooseUs.tsx b/components/home/WhyChooseUs.tsx index 47475b0c..72652c73 100644 --- a/components/home/WhyChooseUs.tsx +++ b/components/home/WhyChooseUs.tsx @@ -2,24 +2,27 @@ import React from 'react'; import { useTranslations } from 'next-intl'; import { Section, Container, Heading } from '../../components/ui'; -export default function WhyChooseUs() { +export default function WhyChooseUs({ data }: { data?: any }) { const t = useTranslations('Home.whyChooseUs'); + const features = data?.features?.length ? data.features.map((f: any) => f.feature) : [0, 1, 2, 3].map(i => t(`features.${i}`)); + const items = data?.items?.length ? data.items : [0, 1, 2, 3].map(idx => ({ title: t(`items.${idx}.title`), description: t(`items.${idx}.description`) })); + return (
    - - {t('title')} + + {data?.title || t('title')}

    - {t('subtitle')} + {data?.subtitle || t('subtitle')}

      - {[0, 1, 2, 3].map((i) => ( + {features.map((featureText: string, i: number) => (
    • - {t(`features.${i}`)} + {featureText}
    • ))} @@ -46,7 +49,7 @@ export default function WhyChooseUs() {
      - {[0, 1, 2, 3].map((idx) => ( + {items.map((item: any, idx: number) => (

    - {t(`items.${idx}.title`)} + {item.title}

    - {t(`items.${idx}.description`)} + {item.description}

  • ))} diff --git a/debug-sitemap.ts b/debug-sitemap.ts deleted file mode 100644 index 60a2b3d3..00000000 --- a/debug-sitemap.ts +++ /dev/null @@ -1,47 +0,0 @@ -console.log('DEBUG SCRIPT STARTING...'); - -async function debug() { - console.log('Importing dependencies...'); - try { - const { getAllProductsMetadata } = await import('./lib/mdx'); - const { getAllPostsMetadata } = await import('./lib/blog'); - const { getAllPagesMetadata } = await import('./lib/pages'); - - console.log('Dependencies imported.'); - - const locales = ['de', 'en']; - for (const locale of locales) { - console.log(`--- Locale: ${locale} ---`); - - try { - const products = await getAllProductsMetadata(locale); - console.log(`Products (${locale}): ${products.length}`); - } catch (e) { - console.error(`Failed to get products for ${locale}:`, e); - } - - try { - const posts = await getAllPostsMetadata(locale); - console.log(`Posts (${locale}): ${posts.length}`); - } catch (e) { - console.error(`Failed to get posts for ${locale}:`, e); - } - - try { - const pages = await getAllPagesMetadata(locale); - console.log(`Pages (${locale}): ${pages.length}`); - } catch (e) { - console.error(`Failed to get pages for ${locale}:`, e); - } - } - } catch (err) { - console.error('Debug failed during setup/imports:', err); - } - console.log('DEBUG SCRIPT FINISHED.'); - process.exit(0); -} - -debug().catch((err) => { - console.error('Unhandled retransmission error in debug():', err); - process.exit(1); -}); diff --git a/lib/blog.ts b/lib/blog.ts index d7bd450c..49058ca7 100644 --- a/lib/blog.ts +++ b/lib/blog.ts @@ -35,7 +35,6 @@ export interface PostFrontmatter { focalX?: number; focalY?: number; category?: string; - locale: string; public?: boolean; } @@ -65,9 +64,9 @@ export async function getPostBySlug(slug: string, locale: string): Promise { const { docs } = await payload.find({ collection: 'posts', where: { - locale: { - equals: locale, - }, ...(!isDev ? { _status: { equals: 'published' } } : {}), }, + locale: locale as any, sort: '-date', draft: isDev, limit: 100, @@ -125,7 +121,7 @@ export async function getAllPosts(locale: string): Promise { console.log(`[Payload] getAllPosts for ${locale}: Found ${docs.length} docs`); - return docs.map((doc) => { + const posts = docs.map((doc) => { return { slug: doc.slug, frontmatter: { @@ -133,7 +129,6 @@ export async function getAllPosts(locale: string): Promise { date: doc.date, excerpt: doc.excerpt || '', category: doc.category || '', - locale: doc.locale, featuredImage: typeof doc.featuredImage === 'object' && doc.featuredImage !== null ? doc.featuredImage.sizes?.card?.url || doc.featuredImage.url @@ -151,6 +146,9 @@ export async function getAllPosts(locale: string): Promise { content: doc.content as any, }; }); + + // Integrity check: only show posts with a featured image in listings/sitemap + return posts.filter((p) => !!p.frontmatter.featuredImage); } catch (error) { console.error(`[Payload] getAllPosts failed for ${locale}:`, error); return []; diff --git a/lib/mail/mailer.ts b/lib/mail/mailer.ts index 84aace10..ffe8f5d1 100644 --- a/lib/mail/mailer.ts +++ b/lib/mail/mailer.ts @@ -33,6 +33,17 @@ interface SendEmailOptions { export async function sendEmail({ to, replyTo, subject, html }: SendEmailOptions) { const recipients = to || config.mail.recipients; + const logger = getServerAppServices().logger.child({ component: 'mailer' }); + + if (!recipients) { + logger.error('No email recipients configured (MAIL_RECIPIENTS is empty and no "to" provided)', { subject }); + return { success: false as const, error: 'No recipients configured' }; + } + + if (!config.mail.from) { + logger.error('MAIL_FROM is not configured β€” cannot send email', { subject, recipients }); + return { success: false as const, error: 'MAIL_FROM is not configured' }; + } const mailOptions = { from: config.mail.from, @@ -42,7 +53,6 @@ export async function sendEmail({ to, replyTo, subject, html }: SendEmailOptions html, }; - const logger = getServerAppServices().logger.child({ component: 'mailer' }); try { const info = await getTransporter().sendMail(mailOptions); diff --git a/lib/pages.ts b/lib/pages.ts index 5adc9099..33accbe9 100644 --- a/lib/pages.ts +++ b/lib/pages.ts @@ -5,7 +5,9 @@ export interface PageFrontmatter { title: string; excerpt: string; featuredImage: string | null; - locale: string; + focalX?: number; + focalY?: number; + layout?: 'default' | 'fullBleed'; public?: boolean; } @@ -15,6 +17,30 @@ export interface PageMdx { content: any; // Lexical AST Document } +function mapDoc(doc: any): PageMdx { + return { + slug: doc.slug, + frontmatter: { + title: doc.title, + excerpt: doc.excerpt || '', + featuredImage: + typeof doc.featuredImage === 'object' && doc.featuredImage !== null + ? doc.featuredImage.sizes?.card?.url || doc.featuredImage.url + : null, + focalX: + typeof doc.featuredImage === 'object' && doc.featuredImage !== null + ? doc.featuredImage.focalX + : 50, + focalY: + typeof doc.featuredImage === 'object' && doc.featuredImage !== null + ? doc.featuredImage.focalY + : 50, + layout: doc.layout || 'default', + } as PageFrontmatter, + content: doc.content as any, + }; +} + export async function getPageBySlug(slug: string, locale: string): Promise { try { const payload = await getPayload({ config: configPromise }); @@ -23,30 +49,14 @@ export async function getPageBySlug(slug: string, locale: string): Promise { const result = await payload.find({ collection: 'pages' as any, - where: { - locale: { - equals: locale, - }, - }, + locale: locale as any, limit: 100, }); - const docs = result.docs as any[]; - - return docs.map((doc: any) => { - return { - slug: doc.slug, - frontmatter: { - title: doc.title, - excerpt: doc.excerpt || '', - locale: doc.locale, - featuredImage: - typeof doc.featuredImage === 'object' && doc.featuredImage !== null - ? doc.featuredImage.sizes?.card?.url || doc.featuredImage.url - : null, - } as PageFrontmatter, - content: doc.content as any, - }; - }); + return (result.docs as any[]).map(mapDoc); } catch (error) { console.error(`[Payload] getAllPages failed for ${locale}:`, error); return []; @@ -96,30 +86,29 @@ export async function getAllPagesMetadata(locale: string): Promise { - return { - slug: doc.slug, - frontmatter: { - title: doc.title, - excerpt: doc.excerpt || '', - locale: doc.locale, - featuredImage: - typeof doc.featuredImage === 'object' && doc.featuredImage !== null - ? doc.featuredImage.sizes?.card?.url || doc.featuredImage.url - : null, - } as PageFrontmatter, - }; - }); + return (result.docs as any[]).map((doc: any) => ({ + slug: doc.slug, + frontmatter: { + title: doc.title, + excerpt: doc.excerpt || '', + featuredImage: + typeof doc.featuredImage === 'object' && doc.featuredImage !== null + ? doc.featuredImage.sizes?.card?.url || doc.featuredImage.url + : null, + focalX: + typeof doc.featuredImage === 'object' && doc.featuredImage !== null + ? doc.featuredImage.focalX + : 50, + focalY: + typeof doc.featuredImage === 'object' && doc.featuredImage !== null + ? doc.featuredImage.focalY + : 50, + } as PageFrontmatter, + })); } catch (error) { console.error(`[Payload] getAllPagesMetadata failed for ${locale}:`, error); return []; diff --git a/lib/mdx.ts b/lib/products.ts similarity index 57% rename from lib/mdx.ts rename to lib/products.ts index 062978cd..11e99e13 100644 --- a/lib/mdx.ts +++ b/lib/products.ts @@ -8,11 +8,12 @@ export interface ProductFrontmatter { description: string; categories: string[]; images: string[]; - locale: string; + focalX?: number; + focalY?: number; isFallback?: boolean; } -export interface ProductMdx { +export interface ProductData { slug: string; frontmatter: ProductFrontmatter; content: any; // Lexical AST from Payload @@ -21,36 +22,24 @@ export interface ProductMdx { export async function getProductMetadata( slug: string, locale: string, -): Promise | null> { +): Promise | null> { const payload = await getPayload({ config: configPromise }); const fileSlug = await mapSlugToFileSlug(slug, locale); - let result = await payload.find({ + const isDev = process.env.NODE_ENV === 'development'; + const result = await payload.find({ collection: 'products', where: { - and: [{ slug: { equals: fileSlug } }, { locale: { equals: locale } }], + and: [ + { slug: { equals: fileSlug } }, + ...(!isDev ? [{ _status: { equals: 'published' } }] : []), + ], }, - depth: 1, // To auto-resolve Media relation (images array) + locale: locale as any, + depth: 1, limit: 1, }); - let isFallback = false; - - if (result.docs.length === 0 && locale !== 'en') { - // Fallback to English - result = await payload.find({ - collection: 'products', - where: { - and: [{ slug: { equals: fileSlug } }, { locale: { equals: 'en' } }], - }, - depth: 1, - limit: 1, - }); - if (result.docs.length > 0) { - isFallback = true; - } - } - if (result.docs.length > 0) { const doc = result.docs[0]; @@ -69,8 +58,6 @@ export async function getProductMetadata( description: doc.description, categories: Array.isArray(doc.categories) ? doc.categories.map((c: any) => c.category) : [], images: resolvedImages, - locale: doc.locale, - isFallback, }, }; } @@ -78,37 +65,25 @@ export async function getProductMetadata( return null; } -export async function getProductBySlug(slug: string, locale: string): Promise { +export async function getProductBySlug(slug: string, locale: string): Promise { try { const payload = await getPayload({ config: configPromise }); const fileSlug = await mapSlugToFileSlug(slug, locale); - let result = await payload.find({ + const isDev = process.env.NODE_ENV === 'development'; + const result = await payload.find({ collection: 'products', where: { - and: [{ slug: { equals: fileSlug } }, { locale: { equals: locale } }], + and: [ + { slug: { equals: fileSlug } }, + ...(!isDev ? [{ _status: { equals: 'published' } }] : []), + ], }, - depth: 1, // Auto-resolve Media logic + locale: locale as any, + depth: 1, limit: 1, }); - let isFallback = false; - - if (result.docs.length === 0 && locale !== 'en') { - // Fallback to English - result = await payload.find({ - collection: 'products', - where: { - and: [{ slug: { equals: fileSlug } }, { locale: { equals: 'en' } }], - }, - depth: 1, - limit: 1, - }); - if (result.docs.length > 0) { - isFallback = true; - } - } - if (result.docs.length > 0) { const doc = result.docs[0]; @@ -129,10 +104,16 @@ export async function getProductBySlug(slug: string, locale: string): Promise c.category) : [], images: resolvedImages, - locale: doc.locale, - isFallback, + focalX: + Array.isArray(doc.images) && doc.images.length > 0 && typeof doc.images[0] === 'object' + ? doc.images[0].focalX + : 50, + focalY: + Array.isArray(doc.images) && doc.images.length > 0 && typeof doc.images[0] === 'object' + ? doc.images[0].focalY + : 50, }, - content: doc.content, // Lexical payload instead of raw MDX String + content: doc.content, }; } @@ -146,14 +127,14 @@ export async function getProductBySlug(slug: string, locale: string): Promise { try { const payload = await getPayload({ config: configPromise }); + const isDev = process.env.NODE_ENV === 'development'; const result = await payload.find({ collection: 'products', where: { - locale: { - equals: locale, - }, + ...(!isDev ? { _status: { equals: 'published' } } : {}), }, - pagination: false, // get all docs + locale: locale as any, + pagination: false, }); return result.docs.map((doc) => doc.slug); @@ -163,7 +144,7 @@ export async function getAllProductSlugs(locale: string): Promise { } } -export async function getAllProducts(locale: string): Promise { +export async function getAllProducts(locale: string): Promise { try { const payload = await getPayload({ config: configPromise }); @@ -174,13 +155,15 @@ export async function getAllProducts(locale: string): Promise { description: true, categories: true, images: true, - locale: true, } as const; - // Get products for this locale + const isDev = process.env.NODE_ENV === 'development'; const result = await payload.find({ collection: 'products', - where: { locale: { equals: locale } }, + where: { + ...(!isDev ? { _status: { equals: 'published' } } : {}), + }, + locale: locale as any, depth: 1, pagination: false, select: selectFields, @@ -188,7 +171,7 @@ export async function getAllProducts(locale: string): Promise { console.log(`[Payload] getAllProducts for ${locale}: Found ${result.docs.length} docs`); - let products: ProductMdx[] = result.docs.map((doc) => { + let products: ProductData[] = result.docs.map((doc) => { const resolvedImages = ((doc.images as any[]) || []) .map((img) => (typeof img === 'string' ? img : img.url)) .filter(Boolean) as string[]; @@ -205,55 +188,21 @@ export async function getAllProducts(locale: string): Promise { description: doc.description ? String(doc.description) : '', categories: plainCategories, images: resolvedImages, - locale: String(doc.locale), + focalX: + Array.isArray(doc.images) && doc.images.length > 0 && typeof doc.images[0] === 'object' + ? doc.images[0].focalX + : 50, + focalY: + Array.isArray(doc.images) && doc.images.length > 0 && typeof doc.images[0] === 'object' + ? doc.images[0].focalY + : 50, }, content: null, }; }); - // Also include English fallbacks for slugs not in this locale - if (locale !== 'en') { - const localeSlugs = new Set(products.map((p) => p.slug)); - const enResult = await payload.find({ - collection: 'products', - where: { locale: { equals: 'en' } }, - depth: 1, - pagination: false, - select: selectFields, - }); - - console.log( - `[Payload] getAllProducts (en fallbacks) for ${locale}: Found ${enResult.docs.length} docs`, - ); - - const fallbacks = enResult.docs - .filter((doc) => !localeSlugs.has(doc.slug)) - .map((doc) => { - const resolvedImages = ((doc.images as any[]) || []) - .map((img) => (typeof img === 'string' ? img : img.url)) - .filter(Boolean) as string[]; - - const plainCategories = Array.isArray(doc.categories) - ? doc.categories.map((c: any) => String(c.category)) - : []; - - return { - slug: String(doc.slug), - frontmatter: { - title: String(doc.title), - sku: doc.sku ? String(doc.sku) : '', - description: doc.description ? String(doc.description) : '', - categories: plainCategories, - images: resolvedImages, - locale: String(doc.locale), - isFallback: true, - }, - content: null, - }; - }); - - products = [...products, ...fallbacks]; - } + // Filter out products with 0 images (data integrity check to prevent 404s) + products = products.filter((p) => p.frontmatter.images && p.frontmatter.images.length > 0); return products; } catch (error) { @@ -262,7 +211,7 @@ export async function getAllProducts(locale: string): Promise { } } -export async function getAllProductsMetadata(locale: string): Promise[]> { +export async function getAllProductsMetadata(locale: string): Promise[]> { const products = await getAllProducts(locale); return products.map((p) => ({ slug: p.slug, diff --git a/messages/en.json b/messages/en.json index 0d90321f..a6a7e78f 100644 --- a/messages/en.json +++ b/messages/en.json @@ -1,9 +1,9 @@ { "Slugs": { "pages": { - "legal-notice": "legal-notice", - "privacy-policy": "privacy-policy", - "terms": "terms", + "legal-notice": "impressum", + "privacy-policy": "datenschutz", + "terms": "agbs", "contact": "contact", "team": "team", "blog": "blog", @@ -396,4 +396,4 @@ "cta": "Back to Safety" } } -} +} \ No newline at end of file diff --git a/next.config.mjs b/next.config.mjs index d5cfbe25..71e293a6 100644 --- a/next.config.mjs +++ b/next.config.mjs @@ -361,23 +361,23 @@ const nextConfig = { }, { source: '/products/solar-cables/h1z2z2-k', - destination: '/en/products/h1z2z2-k', + destination: '/en/products/solar-cables/h1z2z2-k', permanent: true, }, // Product redirects (German) { - source: '/de/produkte/solarkabel/h1z2z2-k', - destination: '/de/produkte/h1z2z2-k', + source: '/de/produkte/stromkabel/solarkabel/h1z2z2-k', + destination: '/de/produkte/solarkabel/h1z2z2-k', permanent: true, }, { source: '/de/produkte/stromkabel/niederspannungskabel/naycwy-2', - destination: '/de/produkte/naycwy', + destination: '/de/produkte/niederspannungskabel/naycwy', permanent: true, }, { source: '/de/produkte/stromkabel/niederspannungskabel/ny2y-2', - destination: '/de/produkte/ny2y', + destination: '/de/produkte/niederspannungskabel/ny2y', permanent: true, }, // VCF redirects diff --git a/package.json b/package.json index 6e9eaaab..bbd2c293 100644 --- a/package.json +++ b/package.json @@ -91,7 +91,7 @@ "vitest": "^4.0.16" }, "scripts": { - "dev": "docker network create infra 2>/dev/null || true && COMPOSE_PROJECT_NAME=klz-2026 docker-compose -f docker-compose.dev.yml up klz-app klz-db --remove-orphans", + "dev": "bash -c 'trap \"COMPOSE_PROJECT_NAME=klz-2026 docker-compose -f docker-compose.dev.yml down\" EXIT INT TERM; docker network create infra 2>/dev/null || true && COMPOSE_PROJECT_NAME=klz-2026 docker-compose -f docker-compose.dev.yml down && COMPOSE_PROJECT_NAME=klz-2026 docker-compose -f docker-compose.dev.yml up klz-app klz-db --remove-orphans'", "dev:local": "bash -c 'trap \"COMPOSE_PROJECT_NAME=klz-2026 docker-compose -f docker-compose.dev.yml down\" EXIT INT TERM; COMPOSE_PROJECT_NAME=klz-2026 docker-compose -f docker-compose.dev.yml up -d klz-db klz-proxy && POSTGRES_URI=NODE_ENV=development next dev --webpack --port 3100 --hostname 0.0.0.0'", "dev:infra": "COMPOSE_PROJECT_NAME=klz-2026 docker-compose -f docker-compose.dev.yml up -d klz-db klz-proxy", "build": "next build", @@ -126,8 +126,10 @@ "backup:db": "bash ./scripts/backup-db.sh", "restore:db": "bash ./scripts/restore-db.sh", "cms:push:testing": "bash ./scripts/cms-sync.sh push testing", + "cms:push:staging": "bash ./scripts/cms-sync.sh push staging", "cms:push:prod": "bash ./scripts/cms-sync.sh push prod", "cms:pull:testing": "bash ./scripts/cms-sync.sh pull testing", + "cms:pull:staging": "bash ./scripts/cms-sync.sh pull staging", "cms:pull:prod": "bash ./scripts/cms-sync.sh pull prod", "prepare": "husky", "preinstall": "npx only-allow pnpm" @@ -154,4 +156,4 @@ "peerDependencies": { "lucide-react": "^0.563.0" } -} +} \ No newline at end of file diff --git a/payload-types.ts b/payload-types.ts index f7d15040..c58d8e40 100644 --- a/payload-types.ts +++ b/payload-types.ts @@ -94,10 +94,10 @@ export interface Config { db: { defaultIDType: number; }; - fallbackLocale: null; + fallbackLocale: ('false' | 'none' | 'null') | false | null | ('de' | 'en') | ('de' | 'en')[]; globals: {}; globalsSelect: {}; - locale: null; + locale: 'de' | 'en'; user: User; jobs: { tasks: unknown; @@ -200,6 +200,9 @@ export interface Media { export interface Post { id: number; title: string; + /** + * Unique slug per locale (e.g. same slug can exist in DE and EN). + */ slug: string; /** * A short summary for blog feed cards and SEO. @@ -213,7 +216,6 @@ export interface Post { * The primary Hero image used for headers and OpenGraph previews. */ featuredImage?: (number | null) | Media; - locale: 'en' | 'de'; /** * Used for tag bucketing (e.g. "Kabel Technologie"). */ @@ -266,7 +268,6 @@ export interface Product { sku: string; slug: string; description: string; - locale: 'en' | 'de'; categories: { category?: string | null; id?: string | null; @@ -317,8 +318,14 @@ export interface Product { export interface Page { id: number; title: string; + /** + * The URL slug for this locale (e.g. "impressum" for DE, "imprint" for EN). + */ slug: string; - locale: 'en' | 'de'; + /** + * Full Bleed pages render blocks edge-to-edge without a generic hero wrapper. + */ + layout?: ('default' | 'fullBleed') | null; excerpt?: string | null; featuredImage?: (number | null) | Media; content: { @@ -338,6 +345,7 @@ export interface Page { }; updatedAt: string; createdAt: string; + _status?: ('draft' | 'published') | null; } /** * This interface was referenced by `Config`'s JSON-Schema @@ -514,7 +522,6 @@ export interface PostsSelect { excerpt?: T; date?: T; featuredImage?: T; - locale?: T; category?: T; content?: T; updatedAt?: T; @@ -543,7 +550,6 @@ export interface ProductsSelect { sku?: T; slug?: T; description?: T; - locale?: T; categories?: | T | { @@ -565,12 +571,13 @@ export interface ProductsSelect { export interface PagesSelect { title?: T; slug?: T; - locale?: T; + layout?: T; excerpt?: T; featuredImage?: T; content?: T; updatedAt?: T; createdAt?: T; + _status?: T; } /** * This interface was referenced by `Config`'s JSON-Schema @@ -702,6 +709,246 @@ export interface ProductTabsBlock { blockName?: string | null; blockType: 'productTabs'; } +/** + * This interface was referenced by `Config`'s JSON-Schema + * via the `definition` "HomeHeroBlock". + */ +export interface HomeHeroBlock { + title?: string | null; + subtitle?: string | null; + ctaLabel?: string | null; + secondaryCtaLabel?: string | null; + id?: string | null; + blockName?: string | null; + blockType: 'homeHero'; +} +/** + * This interface was referenced by `Config`'s JSON-Schema + * via the `definition` "HomeProductCategoriesBlock". + */ +export interface HomeProductCategoriesBlock { + title?: string | null; + subtitle?: string | null; + id?: string | null; + blockName?: string | null; + blockType: 'homeProductCategories'; +} +/** + * This interface was referenced by `Config`'s JSON-Schema + * via the `definition` "HomeWhatWeDoBlock". + */ +export interface HomeWhatWeDoBlock { + title?: string | null; + subtitle?: string | null; + expertiseLabel?: string | null; + quote?: string | null; + items?: + | { + title?: string | null; + description?: string | null; + id?: string | null; + }[] + | null; + id?: string | null; + blockName?: string | null; + blockType: 'homeWhatWeDo'; +} +/** + * This interface was referenced by `Config`'s JSON-Schema + * via the `definition` "HomeRecentPostsBlock". + */ +export interface HomeRecentPostsBlock { + title?: string | null; + subtitle?: string | null; + id?: string | null; + blockName?: string | null; + blockType: 'homeRecentPosts'; +} +/** + * This interface was referenced by `Config`'s JSON-Schema + * via the `definition` "HomeExperienceBlock". + */ +export interface HomeExperienceBlock { + title?: string | null; + subtitle?: string | null; + paragraph1?: string | null; + paragraph2?: string | null; + badge1?: string | null; + badge1Text?: string | null; + badge2?: string | null; + badge2Text?: string | null; + id?: string | null; + blockName?: string | null; + blockType: 'homeExperience'; +} +/** + * This interface was referenced by `Config`'s JSON-Schema + * via the `definition` "HomeWhyChooseUsBlock". + */ +export interface HomeWhyChooseUsBlock { + title?: string | null; + subtitle?: string | null; + tagline?: string | null; + features?: + | { + feature?: string | null; + id?: string | null; + }[] + | null; + items?: + | { + title?: string | null; + description?: string | null; + id?: string | null; + }[] + | null; + id?: string | null; + blockName?: string | null; + blockType: 'homeWhyChooseUs'; +} +/** + * This interface was referenced by `Config`'s JSON-Schema + * via the `definition` "HomeMeetTheTeamBlock". + */ +export interface HomeMeetTheTeamBlock { + title?: string | null; + subtitle?: string | null; + description?: string | null; + ctaLabel?: string | null; + networkLabel?: string | null; + id?: string | null; + blockName?: string | null; + blockType: 'homeMeetTheTeam'; +} +/** + * This interface was referenced by `Config`'s JSON-Schema + * via the `definition` "HomeGalleryBlock". + */ +export interface HomeGalleryBlock { + title?: string | null; + subtitle?: string | null; + id?: string | null; + blockName?: string | null; + blockType: 'homeGallery'; +} +/** + * This interface was referenced by `Config`'s JSON-Schema + * via the `definition` "HomeVideoBlock". + */ +export interface HomeVideoBlock { + title?: string | null; + id?: string | null; + blockName?: string | null; + blockType: 'homeVideo'; +} +/** + * This interface was referenced by `Config`'s JSON-Schema + * via the `definition` "HomeCTABlock". + */ +export interface HomeCTABlock { + title?: string | null; + subtitle?: string | null; + description?: string | null; + buttonLabel?: string | null; + id?: string | null; + blockName?: string | null; + blockType: 'homeCTA'; +} +/** + * This interface was referenced by `Config`'s JSON-Schema + * via the `definition` "CategoryGridBlock". + */ +export interface CategoryGridBlock { + categories: { + title: string; + description?: string | null; + image?: (number | null) | Media; + icon?: (number | null) | Media; + href: string; + ctaLabel?: string | null; + id?: string | null; + }[]; + id?: string | null; + blockName?: string | null; + blockType: 'categoryGrid'; +} +/** + * This interface was referenced by `Config`'s JSON-Schema + * via the `definition` "ContactSectionBlock". + */ +export interface ContactSectionBlock { + showForm?: boolean | null; + showMap?: boolean | null; + showHours?: boolean | null; + id?: string | null; + blockName?: string | null; + blockType: 'contactSection'; +} +/** + * This interface was referenced by `Config`'s JSON-Schema + * via the `definition` "HeroSectionBlock". + */ +export interface HeroSectionBlock { + badge?: string | null; + title: string; + subtitle?: string | null; + backgroundImage?: (number | null) | Media; + ctaLabel?: string | null; + ctaHref?: string | null; + alignment?: ('left' | 'center') | null; + id?: string | null; + blockName?: string | null; + blockType: 'heroSection'; +} +/** + * This interface was referenced by `Config`'s JSON-Schema + * via the `definition` "ImageGalleryBlock". + */ +export interface ImageGalleryBlock { + images: { + image: number | Media; + alt?: string | null; + id?: string | null; + }[]; + id?: string | null; + blockName?: string | null; + blockType: 'imageGallery'; +} +/** + * This interface was referenced by `Config`'s JSON-Schema + * via the `definition` "ManifestoGridBlock". + */ +export interface ManifestoGridBlock { + title?: string | null; + subtitle?: string | null; + tagline?: string | null; + items: { + title: string; + description: string; + id?: string | null; + }[]; + id?: string | null; + blockName?: string | null; + blockType: 'manifestoGrid'; +} +/** + * This interface was referenced by `Config`'s JSON-Schema + * via the `definition` "TeamProfileBlock". + */ +export interface TeamProfileBlock { + name: string; + role: string; + quote?: string | null; + description?: string | null; + image?: (number | null) | Media; + linkedinUrl?: string | null; + linkedinLabel?: string | null; + layout?: ('imageRight' | 'imageLeft') | null; + colorScheme?: ('dark' | 'light') | null; + id?: string | null; + blockName?: string | null; + blockType: 'teamProfile'; +} /** * This interface was referenced by `Config`'s JSON-Schema * via the `definition` "auth". diff --git a/payload.config.ts b/payload.config.ts index 25b314e6..5e73ac1d 100644 --- a/payload.config.ts +++ b/payload.config.ts @@ -37,6 +37,26 @@ export default buildConfig({ importMap: { baseDir: path.resolve(dirname), }, + components: { + graphics: { + Logo: '/src/payload/components/Logo', + Icon: '/src/payload/components/Icon', + }, + }, + meta: { + titleSuffix: ' – KLZ Cables', + icons: [ + { rel: 'icon', type: 'image/x-icon', url: '/favicon.ico' }, + ], + }, + }, + localization: { + locales: [ + { label: 'Deutsch', code: 'de' }, + { label: 'English', code: 'en' }, + ], + defaultLocale: 'de', + fallback: true, }, collections: [Users, Media, Posts, FormSubmissions, Products, Pages], editor: lexicalEditor({ diff --git a/scripts/check-broken-assets.ts b/scripts/check-broken-assets.ts index e9bc2415..d58097ed 100644 --- a/scripts/check-broken-assets.ts +++ b/scripts/check-broken-assets.ts @@ -40,6 +40,7 @@ async function main() { console.log(`\nπŸ•·οΈ Launching Puppeteer Headless Engine...`); const browser = await puppeteer.launch({ headless: true, + executablePath: process.env.PUPPETEER_EXECUTABLE_PATH || process.env.CHROME_PATH || undefined, args: ['--no-sandbox', '--disable-setuid-sandbox', '--disable-dev-shm-usage'], }); @@ -61,7 +62,7 @@ async function main() { const consoleErrorsList: Array<{ type: string; error: string; page: string }> = []; // Listen for unhandled exceptions natively in the page - page.on('pageerror', (err) => { + page.on('pageerror', (err: any) => { consoleErrorsList.push({ type: 'PAGE_ERROR', error: err.message, @@ -73,7 +74,7 @@ async function main() { // Listen for console.error and console.warn messages (like Next.js Image warnings, hydration errors, CSP blocks) page.on('console', (msg) => { const type = msg.type(); - if (type === 'error' || type === 'warning') { + if (type === 'error' || type === 'warn') { const text = msg.text(); // Exclude common browser extension noise or third party tracker warnings diff --git a/scripts/check-pages.ts b/scripts/check-pages.ts new file mode 100644 index 00000000..4fa6960f --- /dev/null +++ b/scripts/check-pages.ts @@ -0,0 +1,14 @@ +import { getPayload } from 'payload'; +import configPromise from '@payload-config'; + +async function run() { + const payload = await getPayload({ config: configPromise }); + const result = await payload.find({ collection: 'pages', limit: 1 }); + const doc = result.docs[0] as any; + console.log('Sample page:', doc.slug); + console.log('Content structure (first 2 levels):'); + console.log(JSON.stringify(doc.content, null, 2).slice(0, 3000)); + process.exit(0); +} + +run(); diff --git a/scripts/check-start.ts b/scripts/check-start.ts new file mode 100644 index 00000000..02e6d34a --- /dev/null +++ b/scripts/check-start.ts @@ -0,0 +1,14 @@ +import { getPayload } from 'payload'; +import configPromise from '@payload-config'; + +async function run() { + const payload = await getPayload({ config: configPromise }); + const existing = await payload.find({ + collection: 'pages', + where: { slug: { equals: 'start' }, locale: { equals: 'de' } }, + limit: 1, + }); + console.log(JSON.stringify(existing.docs[0].content, null, 2).slice(0, 1500)); + process.exit(0); +} +run(); diff --git a/scripts/check-team.ts b/scripts/check-team.ts new file mode 100644 index 00000000..7b65a06e --- /dev/null +++ b/scripts/check-team.ts @@ -0,0 +1,14 @@ +import { getPayload } from 'payload'; +import configPromise from '@payload-config'; + +async function run() { + const payload = await getPayload({ config: configPromise }); + const existing = await payload.find({ + collection: 'pages', + where: { slug: { equals: 'team' }, locale: { equals: 'de' } }, + limit: 1, + }); + console.log(JSON.stringify(existing.docs[0].content, null, 2).slice(0, 500)); + process.exit(0); +} +run(); diff --git a/scripts/cms-sync.sh b/scripts/cms-sync.sh index 823de821..9ccabce9 100755 --- a/scripts/cms-sync.sh +++ b/scripts/cms-sync.sh @@ -11,6 +11,28 @@ # ──────────────────────────────────────────────────────────────────────────── set -euo pipefail +SYNC_SUCCESS="false" +LOCAL_BACKUP_FILE="" +REMOTE_BACKUP_FILE="" + +cleanup_on_exit() { + local exit_code=$? + if [ "$SYNC_SUCCESS" != "true" ] && [ $exit_code -ne 0 ]; then + echo "" + echo "❌ Sync aborted or failed! (Exit code: $exit_code)" + if [ "${DIRECTION:-}" = "push" ] && [ -n "${REMOTE_BACKUP_FILE:-}" ]; then + echo "πŸ”„ Rolling back $TARGET database..." + ssh "$SSH_HOST" "gunzip -c $REMOTE_BACKUP_FILE | docker exec -i $REMOTE_DB_CONTAINER psql -U $REMOTE_DB_USER -d $REMOTE_DB_NAME --quiet" || echo "⚠️ Rollback failed" + echo "βœ… Rollback complete." + elif [ "${DIRECTION:-}" = "pull" ] && [ -n "${LOCAL_BACKUP_FILE:-}" ]; then + echo "πŸ”„ Rolling back local database..." + gunzip -c "$LOCAL_BACKUP_FILE" | docker exec -i "$LOCAL_DB_CONTAINER" psql -U "$LOCAL_DB_USER" -d "$LOCAL_DB_NAME" --quiet || echo "⚠️ Rollback failed" + echo "βœ… Rollback complete." + fi + fi +} +trap 'cleanup_on_exit' EXIT + # Load environment variables if [ -f .env ]; then set -a; source .env; set +a @@ -48,6 +70,13 @@ resolve_target() { REMOTE_MEDIA_VOLUME="/var/lib/docker/volumes/klz-testing_klz_media_data/_data" REMOTE_SITE_DIR="/home/deploy/sites/testing.klz-cables.com" ;; + staging) + REMOTE_PROJECT="klz-staging" + REMOTE_DB_CONTAINER="klz-staging-klz-db-1" + REMOTE_APP_CONTAINER="klz-staging-klz-app-1" + REMOTE_MEDIA_VOLUME="/var/lib/docker/volumes/klz-staging_klz_media_data/_data" + REMOTE_SITE_DIR="/home/deploy/sites/staging.klz-cables.com" + ;; prod|production) REMOTE_PROJECT="klz-cablescom" REMOTE_DB_CONTAINER="klz-cablescom-klz-db-1" @@ -57,7 +86,7 @@ resolve_target() { ;; *) echo "❌ Unknown target: $TARGET" - echo " Valid targets: testing, prod" + echo " Valid targets: testing, staging, prod" exit 1 ;; esac @@ -118,6 +147,7 @@ backup_local_db() { echo "πŸ“¦ Creating safety backup of local DB β†’ $file" docker exec "$LOCAL_DB_CONTAINER" pg_dump -U "$LOCAL_DB_USER" -d "$LOCAL_DB_NAME" --clean --if-exists | gzip > "$file" echo "βœ… Backup: $file ($(du -h "$file" | cut -f1))" + LOCAL_BACKUP_FILE="$file" } backup_remote_db() { @@ -125,6 +155,7 @@ backup_remote_db() { echo "πŸ“¦ Creating safety backup of $TARGET DB β†’ $SSH_HOST:$file" ssh "$SSH_HOST" "docker exec $REMOTE_DB_CONTAINER pg_dump -U $REMOTE_DB_USER -d $REMOTE_DB_NAME --clean --if-exists | gzip > $file" echo "βœ… Remote backup: $file" + REMOTE_BACKUP_FILE="$file" } # ── PUSH: local β†’ remote ────────────────────────────────────────────────── @@ -177,6 +208,7 @@ do_push() { rm -f "$dump" ssh "$SSH_HOST" "rm -f /tmp/payload_push.sql.gz" + SYNC_SUCCESS="true" echo "" echo "βœ… Push to $TARGET complete!" } @@ -214,12 +246,13 @@ do_pull() { # 4. Sync media echo "πŸ–ΌοΈ Syncing media files..." mkdir -p "$LOCAL_MEDIA_DIR" - rsync -az --delete --info=progress2 "$SSH_HOST:$REMOTE_MEDIA_VOLUME/" "$LOCAL_MEDIA_DIR/" + rsync -az --delete --progress "$SSH_HOST:$REMOTE_MEDIA_VOLUME/" "$LOCAL_MEDIA_DIR/" # Cleanup rm -f "/tmp/payload_pull.sql.gz" ssh "$SSH_HOST" "rm -f /tmp/payload_pull.sql.gz" + SYNC_SUCCESS="true" echo "" echo "βœ… Pull from $TARGET complete! Restart dev server to see changes." } @@ -230,8 +263,10 @@ if [ -z "$DIRECTION" ] || [ -z "$TARGET" ]; then echo "" echo "Usage:" echo " pnpm cms:push:testing Push local DB + media β†’ testing" + echo " pnpm cms:push:staging Push local DB + media β†’ staging" echo " pnpm cms:push:prod Push local DB + media β†’ production" echo " pnpm cms:pull:testing Pull testing DB + media β†’ local" + echo " pnpm cms:pull:staging Pull staging DB + media β†’ local" echo " pnpm cms:pull:prod Pull production DB + media β†’ local" echo "" echo "Safety: A backup is always created before overwriting." diff --git a/scripts/create-home-blocks.js b/scripts/create-home-blocks.js new file mode 100644 index 00000000..16b24014 --- /dev/null +++ b/scripts/create-home-blocks.js @@ -0,0 +1,33 @@ +const fs = require('fs'); + +const blocks = [ + 'HomeHero', + 'HomeProductCategories', + 'HomeWhatWeDo', + 'HomeRecentPosts', + 'HomeExperience', + 'HomeWhyChooseUs', + 'HomeMeetTheTeam', + 'HomeVideo', +]; + +blocks.forEach(name => { + const content = `import { Block } from 'payload'; + +export const ${name}: Block = { + slug: '${name.charAt(0).toLowerCase() + name.slice(1)}', + interfaceName: '${name}Block', + fields: [ + { + name: 'note', + type: 'text', + admin: { + description: 'This is a dedicated layout block for the homepage wrapper. Content is managed via translation files.', + }, + }, + ], +}; +`; + fs.writeFileSync(\`src/payload/blocks/\${name}.ts\`, content); + console.log(\`Created \${name}.ts\`); +}); diff --git a/scripts/merge-locale-duplicates.ts b/scripts/merge-locale-duplicates.ts new file mode 100644 index 00000000..c02ea1e1 --- /dev/null +++ b/scripts/merge-locale-duplicates.ts @@ -0,0 +1,260 @@ +/** + * merge-locale-duplicates.ts + * + * Merges duplicate DE/EN documents into single Payload localized documents. + * + * Problem: Before native localization, DE and EN were stored as separate rows. + * Now each should be one document with locale-specific data in the *_locales tables. + * + * Strategy: + * 1. Products: Match by slug β†’ Keep DE row as canonical, copy EN data, delete EN row + * 2. Posts: Match by slug β†’ Same strategy + * 3. Pages: Match by slug map (impressum↔legal-notice, blog↔blog, etc.) β†’ Same strategy + */ + +import pg from 'pg'; +const { Pool } = pg; + +const DB_URL = + process.env.DATABASE_URI || + process.env.POSTGRES_URI || + `postgresql://payload:120in09oenaoinsd9iaidon@127.0.0.1:54322/payload`; + +const pool = new Pool({ connectionString: DB_URL }); + +async function q(query: string, values: unknown[] = []): Promise { + const result = await pool.query(query, values); + return result.rows as T[]; +} + +async function mergeProducts() { + console.log('\n── PRODUCTS ───────────────────────────────────────'); + + const pairs = await q<{ de_id: number; en_id: number; slug: string }>(` + SELECT + de.id as de_id, + en.id as en_id, + de_loc.slug as slug + FROM products de + JOIN products_locales de_loc ON de_loc._parent_id = de.id AND de_loc._locale = 'de' + JOIN products_locales en_loc ON en_loc.slug = de_loc.slug AND en_loc._locale = 'en' + JOIN products en ON en.id = en_loc._parent_id + WHERE de.id != en.id + `); + + console.log(`Found ${pairs.length} DE/EN product pairs to merge`); + + for (const { de_id, en_id, slug } of pairs) { + console.log(` Merging: ${slug} (DE id=${de_id} ← EN id=${en_id})`); + + const [enData] = await q(` + SELECT * FROM products_locales WHERE _parent_id = $1 AND _locale = 'en' + `, [en_id]); + + if (enData) { + await q(` + INSERT INTO products_locales (title, description, application, content, _locale, _parent_id) + VALUES ($1, $2, $3, $4, 'en', $5) + ON CONFLICT (_locale, _parent_id) DO UPDATE + SET title = EXCLUDED.title, + description = EXCLUDED.description, + application = EXCLUDED.application, + content = EXCLUDED.content + `, [enData.title, enData.description, enData.application, enData.content, de_id]); + } + + // Move categories from EN to DE if DE has none + await q(` + UPDATE products_categories SET _parent_id = $1 + WHERE _parent_id = $2 + AND NOT EXISTS (SELECT 1 FROM products_categories WHERE _parent_id = $1) + `, [de_id, en_id]); + + // Move images (rels) from EN to DE if DE has none + await q(` + UPDATE products_rels SET parent = $1 + WHERE parent = $2 + AND NOT EXISTS (SELECT 1 FROM products_rels WHERE parent = $1) + `, [de_id, en_id]); + + // Copy featuredImage if DE is missing one + await q(` + UPDATE products SET featured_image_id = ( + SELECT featured_image_id FROM products WHERE id = $2 + ) + WHERE id = $1 AND featured_image_id IS NULL + `, [de_id, en_id]); + + // Delete EN locale entry and EN product row + await q(`DELETE FROM products_locales WHERE _parent_id = $1`, [en_id]); + await q(`DELETE FROM _products_v WHERE parent = $1`, [en_id]); + await q(`DELETE FROM products WHERE id = $1`, [en_id]); + + console.log(` βœ“ ${slug}`); + } + + const [{ count }] = await q(`SELECT count(*) FROM products`); + console.log(`Products remaining: ${count}`); +} + +async function mergePosts() { + console.log('\n── POSTS ──────────────────────────────────────────'); + + const pairs = await q<{ de_id: number; en_id: number; slug: string }>(` + SELECT + de.id as de_id, + en.id as en_id, + de_loc.slug as slug + FROM posts de + JOIN posts_locales de_loc ON de_loc._parent_id = de.id AND de_loc._locale = 'de' + JOIN posts_locales en_loc ON en_loc.slug = de_loc.slug AND en_loc._locale = 'en' + JOIN posts en ON en.id = en_loc._parent_id + WHERE de.id != en.id + `); + + console.log(`Found ${pairs.length} DE/EN post pairs to merge`); + + for (const { de_id, en_id, slug } of pairs) { + console.log(` Merging post: ${slug} (DE id=${de_id} ← EN id=${en_id})`); + + const [enData] = await q(` + SELECT * FROM posts_locales WHERE _parent_id = $1 AND _locale = 'en' + `, [en_id]); + + if (enData) { + await q(` + INSERT INTO posts_locales (title, slug, excerpt, category, content, _locale, _parent_id) + VALUES ($1, $2, $3, $4, $5, 'en', $6) + ON CONFLICT (_locale, _parent_id) DO UPDATE + SET title = EXCLUDED.title, slug = EXCLUDED.slug, + excerpt = EXCLUDED.excerpt, category = EXCLUDED.category, + content = EXCLUDED.content + `, [enData.title, enData.slug, enData.excerpt, enData.category, enData.content, de_id]); + } + + // Copy featuredImage/date from EN if DE is missing + await q(` + UPDATE posts SET + featured_image_id = COALESCE(featured_image_id, (SELECT featured_image_id FROM posts WHERE id = $2)), + date = COALESCE(date, (SELECT date FROM posts WHERE id = $2)) + WHERE id = $1 + `, [de_id, en_id]); + + await q(`DELETE FROM posts_locales WHERE _parent_id = $1`, [en_id]); + await q(`DELETE FROM _posts_v WHERE parent = $1`, [en_id]); + await q(`DELETE FROM posts WHERE id = $1`, [en_id]); + + console.log(` βœ“ ${slug}`); + } + + const [{ count }] = await q(`SELECT count(*) FROM posts`); + console.log(`Posts remaining: ${count}`); +} + +// DE slug β†’ EN slug mapping for pages +const PAGE_SLUG_MAP: Record = { + impressum: 'legal-notice', + datenschutz: 'privacy-policy', + agbs: 'terms', + kontakt: 'contact', + produkte: 'products', + blog: 'blog', + team: 'team', + start: 'start', + danke: 'thanks', +}; + +async function mergePages() { + console.log('\n── PAGES ──────────────────────────────────────────'); + + for (const [deSlug, enSlug] of Object.entries(PAGE_SLUG_MAP)) { + const [dePage] = await q<{ id: number }>(` + SELECT p.id FROM pages p + JOIN pages_locales pl ON pl._parent_id = p.id AND pl._locale = 'de' AND pl.slug = $1 + LIMIT 1 + `, [deSlug]); + + const [enPage] = await q<{ id: number }>(` + SELECT p.id FROM pages p + JOIN pages_locales pl ON pl._parent_id = p.id AND pl._locale = 'en' AND pl.slug = $1 + LIMIT 1 + `, [enSlug]); + + if (!dePage && !enPage) { + console.log(` ⚠ No page found for ${deSlug}/${enSlug} β€” skipping`); + continue; + } + if (!dePage) { + console.log(` ⚠ No DE page for "${deSlug}" β€” EN-only page id=${enPage!.id} kept`); + continue; + } + if (!enPage) { + console.log(` ⚠ No EN page for "${enSlug}" β€” DE-only page id=${dePage.id} kept`); + continue; + } + if (dePage.id === enPage.id) { + console.log(` βœ“ ${deSlug}/${enSlug} already merged (id=${dePage.id})`); + continue; + } + + console.log(` Merging: ${deSlug}↔${enSlug} (DE id=${dePage.id} ← EN id=${enPage.id})`); + + const [enData] = await q(` + SELECT * FROM pages_locales WHERE _parent_id = $1 AND _locale = 'en' + `, [enPage.id]); + + if (enData) { + await q(` + INSERT INTO pages_locales (title, slug, excerpt, content, _locale, _parent_id) + VALUES ($1, $2, $3, $4, 'en', $5) + ON CONFLICT (_locale, _parent_id) DO UPDATE + SET title = EXCLUDED.title, slug = EXCLUDED.slug, + excerpt = EXCLUDED.excerpt, content = EXCLUDED.content + `, [enData.title, enData.slug, enData.excerpt, enData.content, dePage.id]); + } + + // Copy featuredImage/layout from EN if DE is missing + await q(` + UPDATE pages SET + featured_image_id = COALESCE(featured_image_id, (SELECT featured_image_id FROM pages WHERE id = $2)), + layout = COALESCE(layout, (SELECT layout FROM pages WHERE id = $2)) + WHERE id = $1 + `, [dePage.id, enPage.id]); + + await q(`DELETE FROM pages_locales WHERE _parent_id = $1`, [enPage.id]); + await q(`DELETE FROM _pages_v WHERE parent = $1`, [enPage.id]); + await q(`DELETE FROM pages WHERE id = $1`, [enPage.id]); + + console.log(` βœ“ ${deSlug}/${enSlug}`); + } + + const [{ count }] = await q(`SELECT count(*) FROM pages`); + console.log(`Pages remaining: ${count}`); +} + +async function main() { + console.log('πŸ”€ Merging duplicate locale documents into native Payload localization...'); + + try { + await mergeProducts(); + await mergePosts(); + await mergePages(); + + console.log('\n── Final pages state ──────────────────────────────'); + const pages = await q(` + SELECT p.id, pl._locale, pl.slug, pl.title FROM pages p + JOIN pages_locales pl ON pl._parent_id = p.id + ORDER BY p.id, pl._locale + `); + pages.forEach((r) => console.log(` [id=${r.id}] ${r._locale}: ${r.slug} β€” ${r.title}`)); + + console.log('\nβœ… Done!'); + } finally { + await pool.end(); + } +} + +main().catch((err) => { + console.error('Fatal error:', err); + process.exit(1); +}); diff --git a/scripts/migrate-mdx.ts b/scripts/migrate-mdx.ts deleted file mode 100644 index 350a15e3..00000000 --- a/scripts/migrate-mdx.ts +++ /dev/null @@ -1,152 +0,0 @@ -import { getPayload } from 'payload'; -import configPromise from '@payload-config'; -import * as dotenv from 'dotenv'; -dotenv.config(); - -import fs from 'fs'; -import path from 'path'; -import matter from 'gray-matter'; -import { parseMarkdownToLexical } from '../src/payload/utils/lexicalParser'; - -async function mapImageToMediaId(payload: any, imagePath: string): Promise { - if (!imagePath) return null; - const filename = path.basename(imagePath); - - const media = await payload.find({ - collection: 'media', - where: { - filename: { - equals: filename, - }, - }, - limit: 1, - }); - - if (media.docs.length > 0) { - return media.docs[0].id; - } - - // Auto-ingest missing images from legacy public/ directory - const cleanPath = imagePath.startsWith('/') ? imagePath.slice(1) : imagePath; - const fullPath = path.join(process.cwd(), 'public', cleanPath); - - if (fs.existsSync(fullPath)) { - try { - console.log(`[Blog Migration] πŸ“€ Ingesting missing Media into Payload: ${filename}`); - const newMedia = await payload.create({ - collection: 'media', - data: { - alt: filename.replace(/[-_]/g, ' ').replace(/\.[^/.]+$/, ''), // create a human readable alt text - }, - filePath: fullPath, - }); - return newMedia.id; - } catch (err: any) { - console.error(`[Blog Migration] ❌ Failed to ingest ${filename}:`, err); - } - } else { - console.warn(`[Blog Migration] ⚠️ Missing image entirely from disk: ${fullPath}`); - } - return null; -} - -async function migrateBlogPosts() { - console.log('[Blog Migration] πŸ” Using POSTGRES_URI:', process.env.POSTGRES_URI || 'NOT SET'); - console.log('[Blog Migration] πŸ” Using DATABASE_URI:', process.env.DATABASE_URI || 'NOT SET'); - - let payload; - try { - payload = await getPayload({ config: configPromise }); - } catch (err: any) { - console.error('[Blog Migration] ❌ Failed to initialize Payload:', err); - process.exit(1); - } - - const locales = ['en', 'de']; - for (const locale of locales) { - const postsDir = path.join(process.cwd(), 'data', 'blog', locale); - if (!fs.existsSync(postsDir)) continue; - - const files = fs.readdirSync(postsDir); - for (const file of files) { - if (!file.endsWith('.mdx')) continue; - - const slug = file.replace(/\.mdx$/, ''); - const filePath = path.join(postsDir, file); - const fileContent = fs.readFileSync(filePath, 'utf8'); - const { data, content } = matter(fileContent); - - console.log(`Migrating ${locale}/${slug}...`); - - const lexicalBlocks = parseMarkdownToLexical(content); - const lexicalAST = { - root: { - type: 'root', - format: '', - indent: 0, - version: 1, - children: lexicalBlocks, - direction: 'ltr', - }, - }; - - const publishDate = data.date ? new Date(data.date).toISOString() : new Date().toISOString(); - const status = data.public === false ? 'draft' : 'published'; - - let featuredImageId = null; - if (data.featuredImage || data.image) { - featuredImageId = await mapImageToMediaId(payload, data.featuredImage || data.image); - } - - try { - // Find existing post - const existing = await payload.find({ - collection: 'posts', - where: { slug: { equals: slug }, locale: { equals: locale } as any }, - }); - - if (slug.includes('welcome-to-the-future')) { - console.log(`\n--- AST for ${slug} ---`); - console.log(JSON.stringify(lexicalAST, null, 2)); - console.log(`-----------------------\n`); - } - - if (existing.docs.length > 0) { - await payload.update({ - collection: 'posts', - id: existing.docs[0].id, - data: { - content: lexicalAST as any, - _status: status as any, - ...(featuredImageId ? { featuredImage: featuredImageId } : {}), - }, - }); - console.log(`βœ… AST Components & Image RE-INJECTED for ${slug}`); - } else { - await payload.create({ - collection: 'posts', - data: { - title: data.title, - slug: slug, - locale: locale, - date: publishDate, - category: data.category || '', - excerpt: data.excerpt || '', - content: lexicalAST as any, - _status: status as any, - ...(featuredImageId ? { featuredImage: featuredImageId } : {}), - }, - }); - console.log(`βœ… Created ${slug}`); - } - } catch (err: any) { - console.error(`❌ Failed ${slug}`, err.message); - } - } - } - - console.log('Migration completed.'); - process.exit(0); -} - -migrateBlogPosts().catch(console.error); diff --git a/scripts/migrate-products.ts b/scripts/migrate-products.ts deleted file mode 100644 index 20784e21..00000000 --- a/scripts/migrate-products.ts +++ /dev/null @@ -1,156 +0,0 @@ -import { getPayload } from 'payload'; -import configPromise from '../payload.config'; -import * as dotenv from 'dotenv'; -dotenv.config(); - -import fs from 'fs'; -import path from 'path'; -import matter from 'gray-matter'; -import { parseMarkdownToLexical } from '../src/payload/utils/lexicalParser'; - -async function mapImageToMediaId(payload: any, imagePath: string): Promise { - if (!imagePath) return null; - const filename = path.basename(imagePath); - - // Exact match instead of substring to avoid matching "cable-black.jpg" with "cable.jpg" - const media = await payload.find({ - collection: 'media', - where: { - filename: { - equals: filename, - }, - }, - limit: 1, - }); - - if (media.docs.length > 0) { - return media.docs[0].id; - } - - const cleanPath = imagePath.startsWith('/') ? imagePath.slice(1) : imagePath; - const fullPath = path.join(process.cwd(), 'public', cleanPath); - - if (fs.existsSync(fullPath)) { - try { - console.log(`[Products Migration] πŸ“€ Ingesting missing Media into Payload: ${filename}`); - const newMedia = await payload.create({ - collection: 'media', - data: { - alt: filename.replace(/[-_]/g, ' ').replace(/\.[^/.]+$/, ''), - }, - filePath: fullPath, - }); - return newMedia.id; - } catch (err: any) { - console.error(`[Products Migration] ❌ Failed to ingest ${filename}:`, err); - } - } else { - console.warn(`[Products Migration] ⚠️ Missing image entirely from disk: ${fullPath}`); - } - return null; -} - -export async function migrateProducts() { - const payload = await getPayload({ config: configPromise }); - const productLocales = ['en', 'de']; - - for (const locale of productLocales) { - const productsDir = path.join(process.cwd(), 'data', 'products', locale); - if (!fs.existsSync(productsDir)) continue; - - // Recursive file finder - const mdFiles: string[] = []; - const walk = (dir: string) => { - const files = fs.readdirSync(dir); - for (const file of files) { - const fullPath = path.join(dir, file); - const stat = fs.statSync(fullPath); - if (stat.isDirectory()) { - walk(fullPath); - } else if (file.endsWith('.mdx')) { - mdFiles.push(fullPath); - } - } - }; - walk(productsDir); - - for (const filePath of mdFiles) { - const fileContent = fs.readFileSync(filePath, 'utf8'); - const { data, content } = matter(fileContent); - - console.log(`Processing Product: [${locale.toUpperCase()}] ${data.title}`); - - // 1. Process Images - const mediaIds = []; - if (data.images && Array.isArray(data.images)) { - for (const imgPath of data.images) { - const id = await mapImageToMediaId(payload, imgPath); - if (id) mediaIds.push(id); - } - } - - // 2. Map Lexical AST for deeply nested components (like ProductTabs + Technical data) - const lexicalContent = parseMarkdownToLexical(content); - - const wrapLexical = (blocks: any[]) => ({ - root: { - type: 'root', - format: '', - indent: 0, - version: 1, - children: blocks, - direction: 'ltr', - }, - }); - - // Payload expects category objects via the 'category' key - const formattedCategories = Array.isArray(data.categories) - ? data.categories.map((c: string) => ({ category: c })) - : []; - - const productData = { - title: data.title, - sku: data.sku || path.basename(filePath, '.mdx'), - slug: path.basename(filePath, '.mdx'), - locale: locale as 'en' | 'de', - categories: formattedCategories, - description: data.description || '', - featuredImage: mediaIds.length > 0 ? mediaIds[0] : undefined, - images: mediaIds.length > 0 ? mediaIds : undefined, - content: wrapLexical(lexicalContent) as any, - application: data.application - ? (wrapLexical(parseMarkdownToLexical(data.application)) as any) - : undefined, - _status: 'published' as any, - }; - - // Check if product exists (by sku combined with locale, since slug may differ by language) - const existing = await payload.find({ - collection: 'products', - where: { - and: [{ slug: { equals: productData.slug } }, { locale: { equals: locale } }], - }, - }); - - if (existing.docs.length > 0) { - console.log(`Updating existing product ${productData.slug} (${locale})`); - await payload.update({ - collection: 'products', - id: existing.docs[0].id, - data: productData, - }); - } else { - console.log(`Creating new product ${productData.slug} (${locale})`); - await payload.create({ - collection: 'products', - data: productData, - }); - } - } - } - - console.log(`\nβœ… Products Migration Complete!`); - process.exit(0); -} - -migrateProducts().catch(console.error); diff --git a/scripts/pagespeed-sitemap.ts b/scripts/pagespeed-sitemap.ts index 16a07a58..c537b0cd 100644 --- a/scripts/pagespeed-sitemap.ts +++ b/scripts/pagespeed-sitemap.ts @@ -86,7 +86,7 @@ async function main() { // Using a more robust way to execute and capture output // We remove 'npx lhci upload' to keep everything local and avoid Google-hosted reports - const lhciCommand = `npx lhci collect ${urlArgs} ${chromePathArg} --config=config/lighthouserc.json --collect.settings.extraHeaders='${extraHeaders}' && npx lhci assert --config=config/lighthouserc.json`; + const lhciCommand = `npx lhci collect ${urlArgs} ${chromePathArg} --config=config/lighthouserc.json --collect.settings.extraHeaders='${extraHeaders}' --collect.settings.chromeFlags="--no-sandbox --disable-setuid-sandbox --disable-dev-shm-usage" && npx lhci assert --config=config/lighthouserc.json`; console.log(`πŸ’» Executing LHCI...`); diff --git a/scripts/seed-home.ts b/scripts/seed-home.ts new file mode 100644 index 00000000..f5e54987 --- /dev/null +++ b/scripts/seed-home.ts @@ -0,0 +1,131 @@ +/** + * Migration: Seed homepage ('start') as Lexical block content into Payload CMS. + * + * Usage: + * pnpm tsx scripts/seed-home.ts + */ +import { getPayload } from 'payload'; +import configPromise from '@payload-config'; + +function lexicalBlock(blockType: string, fields: Record = {}) { + return { + type: 'block', + version: 2, + fields: { + blockType, + ...fields, + }, + }; +} + +function lexicalDoc(blocks: any[]) { + return { + root: { + type: 'root', + format: '', + indent: 0, + version: 1, + children: blocks, + direction: 'ltr', + }, + }; +} + +const PAGES = [ + // ─── Homepage (DE) ───────────────────────────────────────── + { + title: 'Startseite', + slug: 'start', + locale: 'de', + layout: 'fullBleed', + excerpt: 'Ihr Experte fΓΌr hochwertige Stromkabel, MittelspannungslΓΆsungen und Solarkabel. ZuverlΓ€ssige Infrastruktur fΓΌr eine grΓΌne Energiezukunft.', + _status: 'published', + content: lexicalDoc([ + lexicalBlock('homeHero', { note: 'Hero section with primary video and CTA.' }), + lexicalBlock('homeProductCategories', { note: 'Product categories overview based on CMS data.' }), + lexicalBlock('homeWhatWeDo', { note: 'What we do / capabilities overview.' }), + lexicalBlock('homeRecentPosts', { note: 'Latest 3 blog articles snippet.' }), + lexicalBlock('homeExperience', { note: 'Experience and history timeline snippet.' }), + lexicalBlock('homeWhyChooseUs', { note: 'Why choose KLZ Cables metrics and selling points.' }), + lexicalBlock('homeMeetTheTeam', { note: 'High-level Meet the Team teaser.' }), + lexicalBlock('homeGallery', { note: 'Image gallery from our facilities.' }), + lexicalBlock('homeVideo', { note: 'Secondary informative background video.' }), + lexicalBlock('homeCTA', { note: 'Bottom call to action linking to contact.' }), + ]), + }, + // ─── Homepage (EN) ───────────────────────────────────────── + { + title: 'Homepage', + slug: 'start', + locale: 'en', + layout: 'fullBleed', + excerpt: 'Your expert for high-quality power cables, medium voltage solutions, and solar cables. Reliable infrastructure for a green energy future.', + _status: 'published', + content: lexicalDoc([ + lexicalBlock('homeHero', { note: 'Hero section with primary video and CTA.' }), + lexicalBlock('homeProductCategories', { note: 'Product categories overview based on CMS data.' }), + lexicalBlock('homeWhatWeDo', { note: 'What we do / capabilities overview.' }), + lexicalBlock('homeRecentPosts', { note: 'Latest 3 blog articles snippet.' }), + lexicalBlock('homeExperience', { note: 'Experience and history timeline snippet.' }), + lexicalBlock('homeWhyChooseUs', { note: 'Why choose KLZ Cables metrics and selling points.' }), + lexicalBlock('homeMeetTheTeam', { note: 'High-level Meet the Team teaser.' }), + lexicalBlock('homeGallery', { note: 'Image gallery from our facilities.' }), + lexicalBlock('homeVideo', { note: 'Secondary informative background video.' }), + lexicalBlock('homeCTA', { note: 'Bottom call to action linking to contact.' }), + ]), + }, +]; + +async function seedHome() { + const payload = await getPayload({ config: configPromise }); + + for (const page of PAGES) { + const existing = await payload.find({ + collection: 'pages', + where: { + slug: { equals: page.slug }, + locale: { equals: page.locale }, + }, + limit: 1, + }); + + const docs = existing.docs as any[]; + + if (docs.length > 0) { + await payload.update({ + collection: 'pages', + id: docs[0].id, + data: { + title: page.title, + layout: page.layout as any, + excerpt: page.excerpt, + _status: page._status as any, + content: page.content as any, + }, + }); + console.log(`βœ… Updated: ${page.slug} (${page.locale})`); + } else { + await payload.create({ + collection: 'pages', + data: { + title: page.title, + slug: page.slug, + locale: page.locale, + layout: page.layout as any, + excerpt: page.excerpt, + _status: page._status as any, + content: page.content as any, + }, + }); + console.log(`βœ… Created: ${page.slug} (${page.locale})`); + } + } + + console.log('\nπŸŽ‰ Homepage seeded successfully!'); + process.exit(0); +} + +seedHome().catch((err) => { + console.error('❌ Seed failed:', err); + process.exit(1); +}); diff --git a/scripts/seed-pages.ts b/scripts/seed-pages.ts new file mode 100644 index 00000000..df60301d --- /dev/null +++ b/scripts/seed-pages.ts @@ -0,0 +1,242 @@ +/** + * Migration: Seed team, contact, and other missing pages as Lexical block content into Payload CMS. + * + * Usage: + * pnpm tsx scripts/seed-pages.ts + */ +import { getPayload } from 'payload'; +import configPromise from '@payload-config'; + +function lexicalBlock(blockType: string, fields: Record) { + return { + type: 'block', + version: 2, + fields: { + blockType, + ...fields, + }, + }; +} + +function lexicalDoc(blocks: any[]) { + return { + root: { + type: 'root', + format: '', + indent: 0, + version: 1, + children: blocks, + direction: 'ltr', + }, + }; +} + +const PAGES = [ + // ─── Team (DE) ──────────────────────────────────────────── + { + title: 'Team', + slug: 'team', + locale: 'de', + layout: 'fullBleed', + excerpt: '', + _status: 'published', + content: lexicalDoc([ + lexicalBlock('heroSection', { + badge: 'Das Team', + title: 'Tradition trifft Moderne', + subtitle: 'Zwei Generationen, eine Vision: Deutschlands zuverlΓ€ssigster Partner fΓΌr Kabel & Leitungen.', + alignment: 'center', + }), + lexicalBlock('teamProfile', { + name: 'Michael Bodemer', + role: 'GeschΓ€ftsfΓΌhrer', + quote: 'Innovation entsteht dort, wo Erfahrung auf frische Ideen trifft.', + description: 'Als GeschΓ€ftsfΓΌhrer verbindet Michael jahrzehntelange Branchenexpertise mit einem klaren Blick fΓΌr die Zukunft. Sein Fokus liegt auf nachhaltigen LΓΆsungen und modernster Technologie.', + linkedinUrl: 'https://www.linkedin.com/in/michael-bodemer-33b493122/', + layout: 'imageRight', + colorScheme: 'dark', + }), + lexicalBlock('stats', { + stats: [ + { value: '30+', label: 'Jahre Expertise' }, + { value: 'Global', label: 'Netzwerk' }, + ], + }), + lexicalBlock('teamProfile', { + name: 'Klaus Mintel', + role: 'GrΓΌnder & Berater', + quote: 'QualitΓ€t ist kein Zufall – sie ist das Ergebnis von Engagement und Erfahrung.', + description: 'Klaus grΓΌndete KLZ Cables und hat das Unternehmen zu einem der zuverlΓ€ssigsten Partner der Kabelindustrie aufgebaut. Er bringt Jahrzehnte an Expertise ein.', + linkedinUrl: 'https://www.linkedin.com/in/klaus-mintel-b80a8b193/', + layout: 'imageLeft', + colorScheme: 'light', + }), + lexicalBlock('manifestoGrid', { + title: 'Unsere Werte', + subtitle: 'Was uns antreibt', + tagline: 'Seit der GrΓΌndung leiten uns klare Prinzipien, die wir jeden Tag leben.', + items: [ + { title: 'QualitΓ€t', description: 'Wir liefern nur Produkte, die hΓΆchsten Standards entsprechen.' }, + { title: 'ZuverlΓ€ssigkeit', description: 'Termingerechte Lieferung ist fΓΌr uns selbstverstΓ€ndlich.' }, + { title: 'Partnerschaft', description: 'Langfristige Beziehungen sind die Grundlage unseres Erfolgs.' }, + { title: 'Innovation', description: 'Wir investieren in neue Technologien und nachhaltige LΓΆsungen.' }, + { title: 'Transparenz', description: 'Offene Kommunikation und faire Preise zeichnen uns aus.' }, + { title: 'Nachhaltigkeit', description: 'Verantwortung fΓΌr Umwelt und Gesellschaft ist Teil unserer DNA.' }, + ], + }), + // Removed the imageGallery since it requires at least 1 image and we don't have media upload IDs yet. + ]), + }, + // ─── Team (EN) ──────────────────────────────────────────── + { + title: 'Team', + slug: 'team', + locale: 'en', + layout: 'fullBleed', + excerpt: '', + _status: 'published', + content: lexicalDoc([ + lexicalBlock('heroSection', { + badge: 'The Team', + title: 'Tradition Meets Innovation', + subtitle: 'Two generations, one vision: Germany\'s most reliable partner for cables & wiring.', + alignment: 'center', + }), + lexicalBlock('teamProfile', { + name: 'Michael Bodemer', + role: 'Managing Director', + quote: 'Innovation happens where experience meets fresh ideas.', + description: 'As Managing Director, Michael combines decades of industry expertise with a clear vision for the future. His focus is on sustainable solutions and cutting-edge technology.', + linkedinUrl: 'https://www.linkedin.com/in/michael-bodemer-33b493122/', + layout: 'imageRight', + colorScheme: 'dark', + }), + lexicalBlock('stats', { + stats: [ + { value: '30+', label: 'Years of Expertise' }, + { value: 'Global', label: 'Network' }, + ], + }), + lexicalBlock('teamProfile', { + name: 'Klaus Mintel', + role: 'Founder & Advisor', + quote: 'Quality is no accident – it is the result of commitment and experience.', + description: 'Klaus founded KLZ Cables and built the company into one of the most reliable partners in the cable industry. He brings decades of expertise.', + linkedinUrl: 'https://www.linkedin.com/in/klaus-mintel-b80a8b193/', + layout: 'imageLeft', + colorScheme: 'light', + }), + lexicalBlock('manifestoGrid', { + title: 'Our Values', + subtitle: 'What drives us', + tagline: 'Since our founding, clear principles have guided us every day.', + items: [ + { title: 'Quality', description: 'We only deliver products that meet the highest standards.' }, + { title: 'Reliability', description: 'On-time delivery is our standard.' }, + { title: 'Partnership', description: 'Long-term relationships are the foundation of our success.' }, + { title: 'Innovation', description: 'We invest in new technologies and sustainable solutions.' }, + { title: 'Transparency', description: 'Open communication and fair pricing define us.' }, + { title: 'Sustainability', description: 'Responsibility for the environment and society is part of our DNA.' }, + ], + }), + ]), + }, + // ─── Contact (DE) ───────────────────────────────────────── + { + title: 'Kontakt', + slug: 'kontakt', + locale: 'de', + layout: 'fullBleed', + excerpt: '', + _status: 'published', + content: lexicalDoc([ + lexicalBlock('heroSection', { + badge: 'Kontakt', + title: 'Sprechen Sie mit uns', + subtitle: 'Wir sind fΓΌr Sie da. Kontaktieren Sie uns fΓΌr Beratung, Angebote oder technische Fragen.', + alignment: 'left', + }), + lexicalBlock('contactSection', { + showForm: true, + showMap: true, + showHours: true, + }), + ]), + }, + // ─── Contact (EN) ───────────────────────────────────────── + { + title: 'Contact', + slug: 'contact', + locale: 'en', + layout: 'fullBleed', + excerpt: '', + _status: 'published', + content: lexicalDoc([ + lexicalBlock('heroSection', { + badge: 'Contact', + title: 'Talk to us', + subtitle: 'We are here for you. Contact us for consulting, quotes, or technical questions.', + alignment: 'left', + }), + lexicalBlock('contactSection', { + showForm: true, + showMap: true, + showHours: true, + }), + ]), + }, +]; + +async function seedPages() { + const payload = await getPayload({ config: configPromise }); + + for (const page of PAGES) { + const existing = await payload.find({ + collection: 'pages', + where: { + slug: { equals: page.slug }, + locale: { equals: page.locale }, + }, + limit: 1, + }); + + const docs = existing.docs as any[]; + + if (docs.length > 0) { + await payload.update({ + collection: 'pages', + id: docs[0].id, + locale: page.locale, + data: { + title: page.title, + layout: page.layout as any, + _status: page._status as any, + content: page.content as any, + }, + }); + console.log(`βœ… Updated: ${page.slug} (${page.locale})`); + } else { + await payload.create({ + collection: 'pages', + locale: page.locale, + data: { + title: page.title, + slug: page.slug, + layout: page.layout as any, + excerpt: page.excerpt, + _status: page._status as any, + content: page.content as any, + }, + }); + console.log(`βœ… Created: ${page.slug} (${page.locale})`); + } + } + + console.log('\nπŸŽ‰ All pages seeded successfully!'); + process.exit(0); +} + +seedPages().catch((err) => { + console.error('❌ Seed failed:', err); + process.exit(1); +}); diff --git a/scripts/sql/drop_version_cols.sql b/scripts/sql/drop_version_cols.sql new file mode 100644 index 00000000..db46bbca --- /dev/null +++ b/scripts/sql/drop_version_cols.sql @@ -0,0 +1,18 @@ +ALTER TABLE _products_v DROP COLUMN IF EXISTS version_title; +ALTER TABLE _products_v DROP COLUMN IF EXISTS version_description; +ALTER TABLE _products_v DROP COLUMN IF EXISTS version_locale; +ALTER TABLE _products_v DROP COLUMN IF EXISTS version_application; +ALTER TABLE _products_v DROP COLUMN IF EXISTS version_content; + +ALTER TABLE _posts_v DROP COLUMN IF EXISTS version_title; +ALTER TABLE _posts_v DROP COLUMN IF EXISTS version_slug; +ALTER TABLE _posts_v DROP COLUMN IF EXISTS version_excerpt; +ALTER TABLE _posts_v DROP COLUMN IF EXISTS version_locale; +ALTER TABLE _posts_v DROP COLUMN IF EXISTS version_category; +ALTER TABLE _posts_v DROP COLUMN IF EXISTS version_content; + +ALTER TABLE _pages_v DROP COLUMN IF EXISTS version_title; +ALTER TABLE _pages_v DROP COLUMN IF EXISTS version_slug; +ALTER TABLE _pages_v DROP COLUMN IF EXISTS version_locale; +ALTER TABLE _pages_v DROP COLUMN IF EXISTS version_excerpt; +ALTER TABLE _pages_v DROP COLUMN IF EXISTS version_content; diff --git a/scripts/test-rich-text.js b/scripts/test-rich-text.js new file mode 100644 index 00000000..c8c04b7b --- /dev/null +++ b/scripts/test-rich-text.js @@ -0,0 +1,14 @@ +import { getPayload } from 'payload'; +import configPromise from '@payload-config'; + +async function run() { + const payload = await getPayload({ config: configPromise }); + const existing = await payload.find({ + collection: 'pages', + where: { slug: { equals: 'start' } }, + limit: 1, + }); + console.log('Homepage blocks found:', existing.docs[0].content?.root?.children?.length); + process.exit(0); +} +run(); diff --git a/src/migrations/20260225_175000_native_localization.ts b/src/migrations/20260225_175000_native_localization.ts new file mode 100644 index 00000000..20d81a09 --- /dev/null +++ b/src/migrations/20260225_175000_native_localization.ts @@ -0,0 +1,285 @@ +import { MigrateUpArgs, MigrateDownArgs, sql } from '@payloadcms/db-postgres'; + +/** + * Migration: native_localization + * + * Transforms the existing schema (manual `locale` select column on each row) + * into Payload's native localization join-table structure. + * + * Each statement is a separate db.execute call to avoid Drizzle multi-statement issues. + */ +export async function up({ db }: MigrateUpArgs): Promise { + // ── 1. Global locale enum ──────────────────────────────────────────────────── + await db.execute(sql` + DO $$ BEGIN + CREATE TYPE "public"."enum__locales" AS ENUM('de', 'en'); + EXCEPTION WHEN duplicate_object THEN null; END $$ + `); + await db.execute(sql` + DO $$ BEGIN + CREATE TYPE "public"."enum__posts_v_published_locale" AS ENUM('de', 'en'); + EXCEPTION WHEN duplicate_object THEN null; END $$ + `); + await db.execute(sql` + DO $$ BEGIN + CREATE TYPE "public"."enum__products_v_published_locale" AS ENUM('de', 'en'); + EXCEPTION WHEN duplicate_object THEN null; END $$ + `); + await db.execute(sql` + DO $$ BEGIN + CREATE TYPE "public"."enum__pages_v_published_locale" AS ENUM('de', 'en'); + EXCEPTION WHEN duplicate_object THEN null; END $$ + `); + await db.execute(sql` + DO $$ BEGIN + CREATE TYPE "public"."enum_pages_layout" AS ENUM('default', 'fullBleed'); + EXCEPTION WHEN duplicate_object THEN null; END $$ + `); + await db.execute(sql` + DO $$ BEGIN + CREATE TYPE "public"."enum__pages_v_version_layout" AS ENUM('default', 'fullBleed'); + EXCEPTION WHEN duplicate_object THEN null; END $$ + `); + await db.execute(sql` + DO $$ BEGIN + CREATE TYPE "public"."enum__pages_v_version_status" AS ENUM('draft', 'published'); + EXCEPTION WHEN duplicate_object THEN null; END $$ + `); + await db.execute(sql` + DO $$ BEGIN + CREATE TYPE "public"."enum__posts_v_version_status" AS ENUM('draft', 'published'); + EXCEPTION WHEN duplicate_object THEN null; END $$ + `); + await db.execute(sql` + DO $$ BEGIN + CREATE TYPE "public"."enum__products_v_version_status" AS ENUM('draft', 'published'); + EXCEPTION WHEN duplicate_object THEN null; END $$ + `); + + // ── 2. Alter pages table ───────────────────────────────────────────────────── + await db.execute(sql`ALTER TABLE "pages" ADD COLUMN IF NOT EXISTS "layout" "enum_pages_layout" DEFAULT 'default'`); + await db.execute(sql`ALTER TABLE "pages" ADD COLUMN IF NOT EXISTS "_status" "enum_pages_status" DEFAULT 'draft'`); + + // ── 3. Create pages_locales join table ─────────────────────────────────────── + await db.execute(sql` + CREATE TABLE IF NOT EXISTS "pages_locales" ( + "title" varchar, + "slug" varchar, + "excerpt" varchar, + "content" jsonb, + "id" serial PRIMARY KEY NOT NULL, + "_locale" "enum__locales" NOT NULL, + "_parent_id" integer NOT NULL + ) + `); + await db.execute(sql` + DO $$ BEGIN + ALTER TABLE "pages_locales" ADD CONSTRAINT "pages_locales_locale_parent_id_unique" UNIQUE("_locale", "_parent_id"); + EXCEPTION WHEN duplicate_object THEN null; END $$ + `); + await db.execute(sql` + DO $$ BEGIN + ALTER TABLE "pages_locales" ADD CONSTRAINT "pages_locales_parent_id_fk" + FOREIGN KEY ("_parent_id") REFERENCES "pages"("id") ON DELETE cascade; + EXCEPTION WHEN duplicate_object THEN null; END $$ + `); + + // ── 4. Backfill pages_locales from old pages rows ──────────────────────────── + await db.execute(sql` + INSERT INTO "pages_locales" ("title", "slug", "excerpt", "content", "_locale", "_parent_id") + SELECT + p.title, p.slug, p.excerpt, p.content, + CASE WHEN p.locale::text = 'de' THEN 'de'::"enum__locales" ELSE 'en'::"enum__locales" END, + p.id + FROM "pages" p + WHERE p.locale IS NOT NULL + ON CONFLICT ("_locale", "_parent_id") DO UPDATE + SET "title" = EXCLUDED."title", + "slug" = EXCLUDED."slug", + "excerpt" = EXCLUDED."excerpt", + "content" = EXCLUDED."content" + `); + + // ── 5. Drop old columns from pages ─────────────────────────────────────────── + await db.execute(sql`ALTER TABLE "pages" DROP COLUMN IF EXISTS "title"`); + await db.execute(sql`ALTER TABLE "pages" DROP COLUMN IF EXISTS "slug"`); + await db.execute(sql`ALTER TABLE "pages" DROP COLUMN IF EXISTS "excerpt"`); + await db.execute(sql`ALTER TABLE "pages" DROP COLUMN IF EXISTS "content"`); + await db.execute(sql`ALTER TABLE "pages" DROP COLUMN IF EXISTS "locale"`); + + // ── 6. Create posts_locales join table ─────────────────────────────────────── + await db.execute(sql` + CREATE TABLE IF NOT EXISTS "posts_locales" ( + "title" varchar, + "slug" varchar, + "excerpt" varchar, + "category" varchar, + "content" jsonb, + "id" serial PRIMARY KEY NOT NULL, + "_locale" "enum__locales" NOT NULL, + "_parent_id" integer NOT NULL + ) + `); + await db.execute(sql` + DO $$ BEGIN + ALTER TABLE "posts_locales" ADD CONSTRAINT "posts_locales_locale_parent_id_unique" UNIQUE("_locale", "_parent_id"); + EXCEPTION WHEN duplicate_object THEN null; END $$ + `); + await db.execute(sql` + DO $$ BEGIN + ALTER TABLE "posts_locales" ADD CONSTRAINT "posts_locales_parent_id_fk" + FOREIGN KEY ("_parent_id") REFERENCES "posts"("id") ON DELETE cascade; + EXCEPTION WHEN duplicate_object THEN null; END $$ + `); + + // ── 7. Backfill posts_locales ──────────────────────────────────────────────── + await db.execute(sql` + INSERT INTO "posts_locales" ("title", "slug", "excerpt", "category", "content", "_locale", "_parent_id") + SELECT + p.title, p.slug, p.excerpt, p.category, p.content, + CASE WHEN p.locale::text = 'de' THEN 'de'::"enum__locales" ELSE 'en'::"enum__locales" END, + p.id + FROM "posts" p + WHERE p.locale IS NOT NULL + ON CONFLICT ("_locale", "_parent_id") DO UPDATE + SET "title" = EXCLUDED."title", + "slug" = EXCLUDED."slug", + "excerpt" = EXCLUDED."excerpt", + "category" = EXCLUDED."category", + "content" = EXCLUDED."content" + `); + + // ── 8. Drop old columns from posts ─────────────────────────────────────────── + await db.execute(sql`ALTER TABLE "posts" DROP COLUMN IF EXISTS "title"`); + await db.execute(sql`ALTER TABLE "posts" DROP COLUMN IF EXISTS "slug"`); + await db.execute(sql`ALTER TABLE "posts" DROP COLUMN IF EXISTS "excerpt"`); + await db.execute(sql`ALTER TABLE "posts" DROP COLUMN IF EXISTS "category"`); + await db.execute(sql`ALTER TABLE "posts" DROP COLUMN IF EXISTS "content"`); + await db.execute(sql`ALTER TABLE "posts" DROP COLUMN IF EXISTS "locale"`); + + // ── 9. Create products_locales join table ──────────────────────────────────── + await db.execute(sql` + CREATE TABLE IF NOT EXISTS "products_locales" ( + "title" varchar, + "description" varchar, + "application" jsonb, + "content" jsonb, + "id" serial PRIMARY KEY NOT NULL, + "_locale" "enum__locales" NOT NULL, + "_parent_id" integer NOT NULL + ) + `); + await db.execute(sql` + DO $$ BEGIN + ALTER TABLE "products_locales" ADD CONSTRAINT "products_locales_locale_parent_id_unique" UNIQUE("_locale", "_parent_id"); + EXCEPTION WHEN duplicate_object THEN null; END $$ + `); + await db.execute(sql` + DO $$ BEGIN + ALTER TABLE "products_locales" ADD CONSTRAINT "products_locales_parent_id_fk" + FOREIGN KEY ("_parent_id") REFERENCES "products"("id") ON DELETE cascade; + EXCEPTION WHEN duplicate_object THEN null; END $$ + `); + + // ── 10. Backfill products_locales ──────────────────────────────────────────── + // Products were separate DE/EN rows β€” each becomes a locale entry on its own id + await db.execute(sql` + INSERT INTO "products_locales" ("title", "description", "application", "content", "_locale", "_parent_id") + SELECT + p.title, p.description, p.application, p.content, + CASE WHEN p.locale::text = 'de' THEN 'de'::"enum__locales" ELSE 'en'::"enum__locales" END, + p.id + FROM "products" p + WHERE p.locale IS NOT NULL + ON CONFLICT ("_locale", "_parent_id") DO NOTHING + `); + + // ── 11. Drop old columns from products ─────────────────────────────────────── + await db.execute(sql`ALTER TABLE "products" DROP COLUMN IF EXISTS "title"`); + await db.execute(sql`ALTER TABLE "products" DROP COLUMN IF EXISTS "description"`); + await db.execute(sql`ALTER TABLE "products" DROP COLUMN IF EXISTS "application"`); + await db.execute(sql`ALTER TABLE "products" DROP COLUMN IF EXISTS "content"`); + await db.execute(sql`ALTER TABLE "products" DROP COLUMN IF EXISTS "locale"`); + + // ── 12. Version tables (_posts_v, _products_v, _pages_v) locale columns ────── + await db.execute(sql`ALTER TABLE "_posts_v" ADD COLUMN IF NOT EXISTS "published_locale" "enum__posts_v_published_locale"`); + await db.execute(sql`ALTER TABLE "_products_v" ADD COLUMN IF NOT EXISTS "published_locale" "enum__products_v_published_locale"`); + await db.execute(sql`ALTER TABLE "_pages_v" ADD COLUMN IF NOT EXISTS "published_locale" "enum__pages_v_published_locale"`); + + // ── 13. Create _posts_v_locales ────────────────────────────────────────────── + await db.execute(sql` + CREATE TABLE IF NOT EXISTS "_posts_v_locales" ( + "version_title" varchar, "version_slug" varchar, "version_excerpt" varchar, + "version_category" varchar, "version_content" jsonb, + "id" serial PRIMARY KEY NOT NULL, + "_locale" "enum__locales" NOT NULL, "_parent_id" integer NOT NULL + ) + `); + await db.execute(sql` + DO $$ BEGIN + ALTER TABLE "_posts_v_locales" ADD CONSTRAINT "_posts_v_locales_locale_parent_id_unique" UNIQUE("_locale", "_parent_id"); + EXCEPTION WHEN duplicate_object THEN null; END $$ + `); + await db.execute(sql` + DO $$ BEGIN + ALTER TABLE "_posts_v_locales" ADD CONSTRAINT "_posts_v_locales_parent_id_fk" + FOREIGN KEY ("_parent_id") REFERENCES "_posts_v"("id") ON DELETE cascade; + EXCEPTION WHEN duplicate_object THEN null; END $$ + `); + + // ── 14. Create _products_v_locales ─────────────────────────────────────────── + await db.execute(sql` + CREATE TABLE IF NOT EXISTS "_products_v_locales" ( + "version_title" varchar, "version_description" varchar, + "version_application" jsonb, "version_content" jsonb, + "id" serial PRIMARY KEY NOT NULL, + "_locale" "enum__locales" NOT NULL, "_parent_id" integer NOT NULL + ) + `); + await db.execute(sql` + DO $$ BEGIN + ALTER TABLE "_products_v_locales" ADD CONSTRAINT "_products_v_locales_locale_parent_id_unique" UNIQUE("_locale", "_parent_id"); + EXCEPTION WHEN duplicate_object THEN null; END $$ + `); + await db.execute(sql` + DO $$ BEGIN + ALTER TABLE "_products_v_locales" ADD CONSTRAINT "_products_v_locales_parent_id_fk" + FOREIGN KEY ("_parent_id") REFERENCES "_products_v"("id") ON DELETE cascade; + EXCEPTION WHEN duplicate_object THEN null; END $$ + `); + + // ── 15. Create _pages_v_locales ────────────────────────────────────────────── + await db.execute(sql` + CREATE TABLE IF NOT EXISTS "_pages_v_locales" ( + "version_title" varchar, "version_slug" varchar, + "version_excerpt" varchar, "version_content" jsonb, + "id" serial PRIMARY KEY NOT NULL, + "_locale" "enum__locales" NOT NULL, "_parent_id" integer NOT NULL + ) + `); + await db.execute(sql` + DO $$ BEGIN + ALTER TABLE "_pages_v_locales" ADD CONSTRAINT "_pages_v_locales_locale_parent_id_unique" UNIQUE("_locale", "_parent_id"); + EXCEPTION WHEN duplicate_object THEN null; END $$ + `); + await db.execute(sql` + DO $$ BEGIN + ALTER TABLE "_pages_v_locales" ADD CONSTRAINT "_pages_v_locales_parent_id_fk" + FOREIGN KEY ("_parent_id") REFERENCES "_pages_v"("id") ON DELETE cascade; + EXCEPTION WHEN duplicate_object THEN null; END $$ + `); + + // ── 16. Drop the now-redundant old locale enum ─────────────────────────────── + await db.execute(sql`DROP TYPE IF EXISTS "public"."enum_pages_locale"`); + await db.execute(sql`DROP TYPE IF EXISTS "public"."enum_posts_locale"`); + await db.execute(sql`DROP TYPE IF EXISTS "public"."enum_products_locale"`); +} + +export async function down({ db }: MigrateDownArgs): Promise { + await db.execute(sql`DROP TABLE IF EXISTS "pages_locales" CASCADE`); + await db.execute(sql`DROP TABLE IF EXISTS "_pages_v_locales" CASCADE`); + await db.execute(sql`DROP TABLE IF EXISTS "posts_locales" CASCADE`); + await db.execute(sql`DROP TABLE IF EXISTS "_posts_v_locales" CASCADE`); + await db.execute(sql`DROP TABLE IF EXISTS "products_locales" CASCADE`); + await db.execute(sql`DROP TABLE IF EXISTS "_products_v_locales" CASCADE`); +} diff --git a/src/migrations/index.ts b/src/migrations/index.ts index 8325ff72..644c12ec 100644 --- a/src/migrations/index.ts +++ b/src/migrations/index.ts @@ -1,6 +1,7 @@ import * as migration_20260223_195005_products_collection from './20260223_195005_products_collection'; import * as migration_20260223_195151_remove_sku_unique from './20260223_195151_remove_sku_unique'; import * as migration_20260225_003500_add_pages_collection from './20260225_003500_add_pages_collection'; +import * as migration_20260225_175000_native_localization from './20260225_175000_native_localization'; export const migrations = [ { @@ -18,4 +19,9 @@ export const migrations = [ down: migration_20260225_003500_add_pages_collection.down, name: '20260225_003500_add_pages_collection', }, + { + up: migration_20260225_175000_native_localization.up, + down: migration_20260225_175000_native_localization.down, + name: '20260225_175000_native_localization', + }, ]; diff --git a/src/payload-generated-schema.ts b/src/payload-generated-schema.ts index 1fe22d26..85c90442 100644 --- a/src/payload-generated-schema.ts +++ b/src/payload-generated-schema.ts @@ -22,27 +22,43 @@ import { pgEnum, } from '@payloadcms/db-postgres/drizzle/pg-core'; import { sql, relations } from '@payloadcms/db-postgres/drizzle'; -export const enum_posts_locale = pgEnum('enum_posts_locale', ['en', 'de']); +export const enum__locales = pgEnum('enum__locales', ['de', 'en']); export const enum_posts_status = pgEnum('enum_posts_status', ['draft', 'published']); -export const enum__posts_v_version_locale = pgEnum('enum__posts_v_version_locale', ['en', 'de']); export const enum__posts_v_version_status = pgEnum('enum__posts_v_version_status', [ 'draft', 'published', ]); +export const enum__posts_v_published_locale = pgEnum('enum__posts_v_published_locale', [ + 'de', + 'en', +]); export const enum_form_submissions_type = pgEnum('enum_form_submissions_type', [ 'contact', 'product_quote', ]); -export const enum_products_locale = pgEnum('enum_products_locale', ['en', 'de']); export const enum_products_status = pgEnum('enum_products_status', ['draft', 'published']); -export const enum__products_v_version_locale = pgEnum('enum__products_v_version_locale', [ - 'en', - 'de', -]); export const enum__products_v_version_status = pgEnum('enum__products_v_version_status', [ 'draft', 'published', ]); +export const enum__products_v_published_locale = pgEnum('enum__products_v_published_locale', [ + 'de', + 'en', +]); +export const enum_pages_layout = pgEnum('enum_pages_layout', ['default', 'fullBleed']); +export const enum_pages_status = pgEnum('enum_pages_status', ['draft', 'published']); +export const enum__pages_v_version_layout = pgEnum('enum__pages_v_version_layout', [ + 'default', + 'fullBleed', +]); +export const enum__pages_v_version_status = pgEnum('enum__pages_v_version_status', [ + 'draft', + 'published', +]); +export const enum__pages_v_published_locale = pgEnum('enum__pages_v_published_locale', [ + 'de', + 'en', +]); export const users_sessions = pgTable( 'users_sessions', @@ -130,18 +146,12 @@ export const media = pgTable( sizes_card_mimeType: varchar('sizes_card_mime_type'), sizes_card_filesize: numeric('sizes_card_filesize', { mode: 'number' }), sizes_card_filename: varchar('sizes_card_filename'), - sizes_hero_url: varchar('sizes_hero_url'), - sizes_hero_width: numeric('sizes_hero_width', { mode: 'number' }), - sizes_hero_height: numeric('sizes_hero_height', { mode: 'number' }), - sizes_hero_mimeType: varchar('sizes_hero_mime_type'), - sizes_hero_filesize: numeric('sizes_hero_filesize', { mode: 'number' }), - sizes_hero_filename: varchar('sizes_hero_filename'), - sizes_hero_mobile_url: varchar('sizes_hero_mobile_url'), - sizes_hero_mobile_width: numeric('sizes_hero_mobile_width', { mode: 'number' }), - sizes_hero_mobile_height: numeric('sizes_hero_mobile_height', { mode: 'number' }), - sizes_hero_mobile_mimeType: varchar('sizes_hero_mobile_mime_type'), - sizes_hero_mobile_filesize: numeric('sizes_hero_mobile_filesize', { mode: 'number' }), - sizes_hero_mobile_filename: varchar('sizes_hero_mobile_filename'), + sizes_tablet_url: varchar('sizes_tablet_url'), + sizes_tablet_width: numeric('sizes_tablet_width', { mode: 'number' }), + sizes_tablet_height: numeric('sizes_tablet_height', { mode: 'number' }), + sizes_tablet_mimeType: varchar('sizes_tablet_mime_type'), + sizes_tablet_filesize: numeric('sizes_tablet_filesize', { mode: 'number' }), + sizes_tablet_filename: varchar('sizes_tablet_filename'), }, (columns) => [ index('media_updated_at_idx').on(columns.updatedAt), @@ -151,10 +161,7 @@ export const media = pgTable( columns.sizes_thumbnail_filename, ), index('media_sizes_card_sizes_card_filename_idx').on(columns.sizes_card_filename), - index('media_sizes_hero_sizes_hero_filename_idx').on(columns.sizes_hero_filename), - index('media_sizes_hero_mobile_sizes_hero_mobile_filename_idx').on( - columns.sizes_hero_mobile_filename, - ), + index('media_sizes_tablet_sizes_tablet_filename_idx').on(columns.sizes_tablet_filename), ], ); @@ -162,16 +169,10 @@ export const posts = pgTable( 'posts', { id: serial('id').primaryKey(), - title: varchar('title'), - slug: varchar('slug'), - excerpt: varchar('excerpt'), date: timestamp('date', { mode: 'string', withTimezone: true, precision: 3 }), featuredImage: integer('featured_image_id').references(() => media.id, { onDelete: 'set null', }), - locale: enum_posts_locale('locale').default('en'), - category: varchar('category'), - content: jsonb('content'), updatedAt: timestamp('updated_at', { mode: 'string', withTimezone: true, precision: 3 }) .defaultNow() .notNull(), @@ -181,7 +182,6 @@ export const posts = pgTable( _status: enum_posts_status('_status').default('draft'), }, (columns) => [ - uniqueIndex('posts_slug_idx').on(columns.slug), index('posts_featured_image_idx').on(columns.featuredImage), index('posts_updated_at_idx').on(columns.updatedAt), index('posts_created_at_idx').on(columns.createdAt), @@ -189,6 +189,28 @@ export const posts = pgTable( ], ); +export const posts_locales = pgTable( + 'posts_locales', + { + title: varchar('title'), + slug: varchar('slug'), + excerpt: varchar('excerpt'), + category: varchar('category'), + content: jsonb('content'), + id: serial('id').primaryKey(), + _locale: enum__locales('_locale').notNull(), + _parentID: integer('_parent_id').notNull(), + }, + (columns) => [ + uniqueIndex('posts_locales_locale_parent_id_unique').on(columns._locale, columns._parentID), + foreignKey({ + columns: [columns['_parentID']], + foreignColumns: [posts.id], + name: 'posts_locales_parent_id_fk', + }).onDelete('cascade'), + ], +); + export const _posts_v = pgTable( '_posts_v', { @@ -196,16 +218,10 @@ export const _posts_v = pgTable( parent: integer('parent_id').references(() => posts.id, { onDelete: 'set null', }), - version_title: varchar('version_title'), - version_slug: varchar('version_slug'), - version_excerpt: varchar('version_excerpt'), version_date: timestamp('version_date', { mode: 'string', withTimezone: true, precision: 3 }), version_featuredImage: integer('version_featured_image_id').references(() => media.id, { onDelete: 'set null', }), - version_locale: enum__posts_v_version_locale('version_locale').default('en'), - version_category: varchar('version_category'), - version_content: jsonb('version_content'), version_updatedAt: timestamp('version_updated_at', { mode: 'string', withTimezone: true, @@ -223,21 +239,46 @@ export const _posts_v = pgTable( updatedAt: timestamp('updated_at', { mode: 'string', withTimezone: true, precision: 3 }) .defaultNow() .notNull(), + snapshot: boolean('snapshot'), + publishedLocale: enum__posts_v_published_locale('published_locale'), latest: boolean('latest'), }, (columns) => [ index('_posts_v_parent_idx').on(columns.parent), - index('_posts_v_version_version_slug_idx').on(columns.version_slug), index('_posts_v_version_version_featured_image_idx').on(columns.version_featuredImage), index('_posts_v_version_version_updated_at_idx').on(columns.version_updatedAt), index('_posts_v_version_version_created_at_idx').on(columns.version_createdAt), index('_posts_v_version_version__status_idx').on(columns.version__status), index('_posts_v_created_at_idx').on(columns.createdAt), index('_posts_v_updated_at_idx').on(columns.updatedAt), + index('_posts_v_snapshot_idx').on(columns.snapshot), + index('_posts_v_published_locale_idx').on(columns.publishedLocale), index('_posts_v_latest_idx').on(columns.latest), ], ); +export const _posts_v_locales = pgTable( + '_posts_v_locales', + { + version_title: varchar('version_title'), + version_slug: varchar('version_slug'), + version_excerpt: varchar('version_excerpt'), + version_category: varchar('version_category'), + version_content: jsonb('version_content'), + id: serial('id').primaryKey(), + _locale: enum__locales('_locale').notNull(), + _parentID: integer('_parent_id').notNull(), + }, + (columns) => [ + uniqueIndex('_posts_v_locales_locale_parent_id_unique').on(columns._locale, columns._parentID), + foreignKey({ + columns: [columns['_parentID']], + foreignColumns: [_posts_v.id], + name: '_posts_v_locales_parent_id_fk', + }).onDelete('cascade'), + ], +); + export const form_submissions = pgTable( 'form_submissions', { @@ -283,13 +324,11 @@ export const products = pgTable( 'products', { id: serial('id').primaryKey(), - title: varchar('title'), sku: varchar('sku'), slug: varchar('slug'), - description: varchar('description'), - locale: enum_products_locale('locale').default('de'), - application: jsonb('application'), - content: jsonb('content'), + featuredImage: integer('featured_image_id').references(() => media.id, { + onDelete: 'set null', + }), updatedAt: timestamp('updated_at', { mode: 'string', withTimezone: true, precision: 3 }) .defaultNow() .notNull(), @@ -299,13 +338,34 @@ export const products = pgTable( _status: enum_products_status('_status').default('draft'), }, (columns) => [ - uniqueIndex('products_sku_idx').on(columns.sku), + index('products_featured_image_idx').on(columns.featuredImage), index('products_updated_at_idx').on(columns.updatedAt), index('products_created_at_idx').on(columns.createdAt), index('products__status_idx').on(columns._status), ], ); +export const products_locales = pgTable( + 'products_locales', + { + title: varchar('title'), + description: varchar('description'), + application: jsonb('application'), + content: jsonb('content'), + id: serial('id').primaryKey(), + _locale: enum__locales('_locale').notNull(), + _parentID: integer('_parent_id').notNull(), + }, + (columns) => [ + uniqueIndex('products_locales_locale_parent_id_unique').on(columns._locale, columns._parentID), + foreignKey({ + columns: [columns['_parentID']], + foreignColumns: [products.id], + name: 'products_locales_parent_id_fk', + }).onDelete('cascade'), + ], +); + export const products_rels = pgTable( 'products_rels', { @@ -360,13 +420,11 @@ export const _products_v = pgTable( parent: integer('parent_id').references(() => products.id, { onDelete: 'set null', }), - version_title: varchar('version_title'), version_sku: varchar('version_sku'), version_slug: varchar('version_slug'), - version_description: varchar('version_description'), - version_locale: enum__products_v_version_locale('version_locale').default('de'), - version_application: jsonb('version_application'), - version_content: jsonb('version_content'), + version_featuredImage: integer('version_featured_image_id').references(() => media.id, { + onDelete: 'set null', + }), version_updatedAt: timestamp('version_updated_at', { mode: 'string', withTimezone: true, @@ -384,20 +442,48 @@ export const _products_v = pgTable( updatedAt: timestamp('updated_at', { mode: 'string', withTimezone: true, precision: 3 }) .defaultNow() .notNull(), + snapshot: boolean('snapshot'), + publishedLocale: enum__products_v_published_locale('published_locale'), latest: boolean('latest'), }, (columns) => [ index('_products_v_parent_idx').on(columns.parent), - index('_products_v_version_version_sku_idx').on(columns.version_sku), + index('_products_v_version_version_featured_image_idx').on(columns.version_featuredImage), index('_products_v_version_version_updated_at_idx').on(columns.version_updatedAt), index('_products_v_version_version_created_at_idx').on(columns.version_createdAt), index('_products_v_version_version__status_idx').on(columns.version__status), index('_products_v_created_at_idx').on(columns.createdAt), index('_products_v_updated_at_idx').on(columns.updatedAt), + index('_products_v_snapshot_idx').on(columns.snapshot), + index('_products_v_published_locale_idx').on(columns.publishedLocale), index('_products_v_latest_idx').on(columns.latest), ], ); +export const _products_v_locales = pgTable( + '_products_v_locales', + { + version_title: varchar('version_title'), + version_description: varchar('version_description'), + version_application: jsonb('version_application'), + version_content: jsonb('version_content'), + id: serial('id').primaryKey(), + _locale: enum__locales('_locale').notNull(), + _parentID: integer('_parent_id').notNull(), + }, + (columns) => [ + uniqueIndex('_products_v_locales_locale_parent_id_unique').on( + columns._locale, + columns._parentID, + ), + foreignKey({ + columns: [columns['_parentID']], + foreignColumns: [_products_v.id], + name: '_products_v_locales_parent_id_fk', + }).onDelete('cascade'), + ], +); + export const _products_v_rels = pgTable( '_products_v_rels', { @@ -425,6 +511,118 @@ export const _products_v_rels = pgTable( ], ); +export const pages = pgTable( + 'pages', + { + id: serial('id').primaryKey(), + layout: enum_pages_layout('layout').default('default'), + featuredImage: integer('featured_image_id').references(() => media.id, { + onDelete: 'set null', + }), + updatedAt: timestamp('updated_at', { mode: 'string', withTimezone: true, precision: 3 }) + .defaultNow() + .notNull(), + createdAt: timestamp('created_at', { mode: 'string', withTimezone: true, precision: 3 }) + .defaultNow() + .notNull(), + _status: enum_pages_status('_status').default('draft'), + }, + (columns) => [ + index('pages_featured_image_idx').on(columns.featuredImage), + index('pages_updated_at_idx').on(columns.updatedAt), + index('pages_created_at_idx').on(columns.createdAt), + index('pages__status_idx').on(columns._status), + ], +); + +export const pages_locales = pgTable( + 'pages_locales', + { + title: varchar('title'), + slug: varchar('slug'), + excerpt: varchar('excerpt'), + content: jsonb('content'), + id: serial('id').primaryKey(), + _locale: enum__locales('_locale').notNull(), + _parentID: integer('_parent_id').notNull(), + }, + (columns) => [ + uniqueIndex('pages_locales_locale_parent_id_unique').on(columns._locale, columns._parentID), + foreignKey({ + columns: [columns['_parentID']], + foreignColumns: [pages.id], + name: 'pages_locales_parent_id_fk', + }).onDelete('cascade'), + ], +); + +export const _pages_v = pgTable( + '_pages_v', + { + id: serial('id').primaryKey(), + parent: integer('parent_id').references(() => pages.id, { + onDelete: 'set null', + }), + version_layout: enum__pages_v_version_layout('version_layout').default('default'), + version_featuredImage: integer('version_featured_image_id').references(() => media.id, { + onDelete: 'set null', + }), + version_updatedAt: timestamp('version_updated_at', { + mode: 'string', + withTimezone: true, + precision: 3, + }), + version_createdAt: timestamp('version_created_at', { + mode: 'string', + withTimezone: true, + precision: 3, + }), + version__status: enum__pages_v_version_status('version__status').default('draft'), + createdAt: timestamp('created_at', { mode: 'string', withTimezone: true, precision: 3 }) + .defaultNow() + .notNull(), + updatedAt: timestamp('updated_at', { mode: 'string', withTimezone: true, precision: 3 }) + .defaultNow() + .notNull(), + snapshot: boolean('snapshot'), + publishedLocale: enum__pages_v_published_locale('published_locale'), + latest: boolean('latest'), + }, + (columns) => [ + index('_pages_v_parent_idx').on(columns.parent), + index('_pages_v_version_version_featured_image_idx').on(columns.version_featuredImage), + index('_pages_v_version_version_updated_at_idx').on(columns.version_updatedAt), + index('_pages_v_version_version_created_at_idx').on(columns.version_createdAt), + index('_pages_v_version_version__status_idx').on(columns.version__status), + index('_pages_v_created_at_idx').on(columns.createdAt), + index('_pages_v_updated_at_idx').on(columns.updatedAt), + index('_pages_v_snapshot_idx').on(columns.snapshot), + index('_pages_v_published_locale_idx').on(columns.publishedLocale), + index('_pages_v_latest_idx').on(columns.latest), + ], +); + +export const _pages_v_locales = pgTable( + '_pages_v_locales', + { + version_title: varchar('version_title'), + version_slug: varchar('version_slug'), + version_excerpt: varchar('version_excerpt'), + version_content: jsonb('version_content'), + id: serial('id').primaryKey(), + _locale: enum__locales('_locale').notNull(), + _parentID: integer('_parent_id').notNull(), + }, + (columns) => [ + uniqueIndex('_pages_v_locales_locale_parent_id_unique').on(columns._locale, columns._parentID), + foreignKey({ + columns: [columns['_parentID']], + foreignColumns: [_pages_v.id], + name: '_pages_v_locales_parent_id_fk', + }).onDelete('cascade'), + ], +); + export const payload_kv = pgTable( 'payload_kv', { @@ -466,6 +664,7 @@ export const payload_locked_documents_rels = pgTable( postsID: integer('posts_id'), 'form-submissionsID': integer('form_submissions_id'), productsID: integer('products_id'), + pagesID: integer('pages_id'), }, (columns) => [ index('payload_locked_documents_rels_order_idx').on(columns.order), @@ -478,6 +677,7 @@ export const payload_locked_documents_rels = pgTable( columns['form-submissionsID'], ), index('payload_locked_documents_rels_products_id_idx').on(columns.productsID), + index('payload_locked_documents_rels_pages_id_idx').on(columns.pagesID), foreignKey({ columns: [columns['parent']], foreignColumns: [payload_locked_documents.id], @@ -508,6 +708,11 @@ export const payload_locked_documents_rels = pgTable( foreignColumns: [products.id], name: 'payload_locked_documents_rels_products_fk', }).onDelete('cascade'), + foreignKey({ + columns: [columns['pagesID']], + foreignColumns: [pages.id], + name: 'payload_locked_documents_rels_pages_fk', + }).onDelete('cascade'), ], ); @@ -590,14 +795,31 @@ export const relations_users = relations(users, ({ many }) => ({ }), })); export const relations_media = relations(media, () => ({})); -export const relations_posts = relations(posts, ({ one }) => ({ +export const relations_posts_locales = relations(posts_locales, ({ one }) => ({ + _parentID: one(posts, { + fields: [posts_locales._parentID], + references: [posts.id], + relationName: '_locales', + }), +})); +export const relations_posts = relations(posts, ({ one, many }) => ({ featuredImage: one(media, { fields: [posts.featuredImage], references: [media.id], relationName: 'featuredImage', }), + _locales: many(posts_locales, { + relationName: '_locales', + }), })); -export const relations__posts_v = relations(_posts_v, ({ one }) => ({ +export const relations__posts_v_locales = relations(_posts_v_locales, ({ one }) => ({ + _parentID: one(_posts_v, { + fields: [_posts_v_locales._parentID], + references: [_posts_v.id], + relationName: '_locales', + }), +})); +export const relations__posts_v = relations(_posts_v, ({ one, many }) => ({ parent: one(posts, { fields: [_posts_v.parent], references: [posts.id], @@ -608,6 +830,9 @@ export const relations__posts_v = relations(_posts_v, ({ one }) => ({ references: [media.id], relationName: 'version_featuredImage', }), + _locales: many(_posts_v_locales, { + relationName: '_locales', + }), })); export const relations_form_submissions = relations(form_submissions, () => ({})); export const relations_products_categories = relations(products_categories, ({ one }) => ({ @@ -617,6 +842,13 @@ export const relations_products_categories = relations(products_categories, ({ o relationName: 'categories', }), })); +export const relations_products_locales = relations(products_locales, ({ one }) => ({ + _parentID: one(products, { + fields: [products_locales._parentID], + references: [products.id], + relationName: '_locales', + }), +})); export const relations_products_rels = relations(products_rels, ({ one }) => ({ parent: one(products, { fields: [products_rels.parent], @@ -629,10 +861,18 @@ export const relations_products_rels = relations(products_rels, ({ one }) => ({ relationName: 'media', }), })); -export const relations_products = relations(products, ({ many }) => ({ +export const relations_products = relations(products, ({ one, many }) => ({ categories: many(products_categories, { relationName: 'categories', }), + featuredImage: one(media, { + fields: [products.featuredImage], + references: [media.id], + relationName: 'featuredImage', + }), + _locales: many(products_locales, { + relationName: '_locales', + }), _rels: many(products_rels, { relationName: '_rels', }), @@ -647,6 +887,13 @@ export const relations__products_v_version_categories = relations( }), }), ); +export const relations__products_v_locales = relations(_products_v_locales, ({ one }) => ({ + _parentID: one(_products_v, { + fields: [_products_v_locales._parentID], + references: [_products_v.id], + relationName: '_locales', + }), +})); export const relations__products_v_rels = relations(_products_v_rels, ({ one }) => ({ parent: one(_products_v, { fields: [_products_v_rels.parent], @@ -668,10 +915,57 @@ export const relations__products_v = relations(_products_v, ({ one, many }) => ( version_categories: many(_products_v_version_categories, { relationName: 'version_categories', }), + version_featuredImage: one(media, { + fields: [_products_v.version_featuredImage], + references: [media.id], + relationName: 'version_featuredImage', + }), + _locales: many(_products_v_locales, { + relationName: '_locales', + }), _rels: many(_products_v_rels, { relationName: '_rels', }), })); +export const relations_pages_locales = relations(pages_locales, ({ one }) => ({ + _parentID: one(pages, { + fields: [pages_locales._parentID], + references: [pages.id], + relationName: '_locales', + }), +})); +export const relations_pages = relations(pages, ({ one, many }) => ({ + featuredImage: one(media, { + fields: [pages.featuredImage], + references: [media.id], + relationName: 'featuredImage', + }), + _locales: many(pages_locales, { + relationName: '_locales', + }), +})); +export const relations__pages_v_locales = relations(_pages_v_locales, ({ one }) => ({ + _parentID: one(_pages_v, { + fields: [_pages_v_locales._parentID], + references: [_pages_v.id], + relationName: '_locales', + }), +})); +export const relations__pages_v = relations(_pages_v, ({ one, many }) => ({ + parent: one(pages, { + fields: [_pages_v.parent], + references: [pages.id], + relationName: 'parent', + }), + version_featuredImage: one(media, { + fields: [_pages_v.version_featuredImage], + references: [media.id], + relationName: 'version_featuredImage', + }), + _locales: many(_pages_v_locales, { + relationName: '_locales', + }), +})); export const relations_payload_kv = relations(payload_kv, () => ({})); export const relations_payload_locked_documents_rels = relations( payload_locked_documents_rels, @@ -706,6 +1000,11 @@ export const relations_payload_locked_documents_rels = relations( references: [products.id], relationName: 'products', }), + pagesID: one(pages, { + fields: [payload_locked_documents_rels.pagesID], + references: [pages.id], + relationName: 'pages', + }), }), ); export const relations_payload_locked_documents = relations( @@ -739,27 +1038,39 @@ export const relations_payload_preferences = relations(payload_preferences, ({ m export const relations_payload_migrations = relations(payload_migrations, () => ({})); type DatabaseSchema = { - enum_posts_locale: typeof enum_posts_locale; + enum__locales: typeof enum__locales; enum_posts_status: typeof enum_posts_status; - enum__posts_v_version_locale: typeof enum__posts_v_version_locale; enum__posts_v_version_status: typeof enum__posts_v_version_status; + enum__posts_v_published_locale: typeof enum__posts_v_published_locale; enum_form_submissions_type: typeof enum_form_submissions_type; - enum_products_locale: typeof enum_products_locale; enum_products_status: typeof enum_products_status; - enum__products_v_version_locale: typeof enum__products_v_version_locale; enum__products_v_version_status: typeof enum__products_v_version_status; + enum__products_v_published_locale: typeof enum__products_v_published_locale; + enum_pages_layout: typeof enum_pages_layout; + enum_pages_status: typeof enum_pages_status; + enum__pages_v_version_layout: typeof enum__pages_v_version_layout; + enum__pages_v_version_status: typeof enum__pages_v_version_status; + enum__pages_v_published_locale: typeof enum__pages_v_published_locale; users_sessions: typeof users_sessions; users: typeof users; media: typeof media; posts: typeof posts; + posts_locales: typeof posts_locales; _posts_v: typeof _posts_v; + _posts_v_locales: typeof _posts_v_locales; form_submissions: typeof form_submissions; products_categories: typeof products_categories; products: typeof products; + products_locales: typeof products_locales; products_rels: typeof products_rels; _products_v_version_categories: typeof _products_v_version_categories; _products_v: typeof _products_v; + _products_v_locales: typeof _products_v_locales; _products_v_rels: typeof _products_v_rels; + pages: typeof pages; + pages_locales: typeof pages_locales; + _pages_v: typeof _pages_v; + _pages_v_locales: typeof _pages_v_locales; payload_kv: typeof payload_kv; payload_locked_documents: typeof payload_locked_documents; payload_locked_documents_rels: typeof payload_locked_documents_rels; @@ -769,15 +1080,23 @@ type DatabaseSchema = { relations_users_sessions: typeof relations_users_sessions; relations_users: typeof relations_users; relations_media: typeof relations_media; + relations_posts_locales: typeof relations_posts_locales; relations_posts: typeof relations_posts; + relations__posts_v_locales: typeof relations__posts_v_locales; relations__posts_v: typeof relations__posts_v; relations_form_submissions: typeof relations_form_submissions; relations_products_categories: typeof relations_products_categories; + relations_products_locales: typeof relations_products_locales; relations_products_rels: typeof relations_products_rels; relations_products: typeof relations_products; relations__products_v_version_categories: typeof relations__products_v_version_categories; + relations__products_v_locales: typeof relations__products_v_locales; relations__products_v_rels: typeof relations__products_v_rels; relations__products_v: typeof relations__products_v; + relations_pages_locales: typeof relations_pages_locales; + relations_pages: typeof relations_pages; + relations__pages_v_locales: typeof relations__pages_v_locales; + relations__pages_v: typeof relations__pages_v; relations_payload_kv: typeof relations_payload_kv; relations_payload_locked_documents_rels: typeof relations_payload_locked_documents_rels; relations_payload_locked_documents: typeof relations_payload_locked_documents; diff --git a/src/payload/blocks/CategoryGrid.ts b/src/payload/blocks/CategoryGrid.ts new file mode 100644 index 00000000..44e118b2 --- /dev/null +++ b/src/payload/blocks/CategoryGrid.ts @@ -0,0 +1,48 @@ +import { Block } from 'payload'; + +export const CategoryGrid: Block = { + slug: 'categoryGrid', + interfaceName: 'CategoryGridBlock', + fields: [ + { + name: 'categories', + type: 'array', + required: true, + minRows: 1, + fields: [ + { + name: 'title', + type: 'text', + required: true, + }, + { + name: 'description', + type: 'textarea', + required: false, + }, + { + name: 'image', + type: 'upload', + relationTo: 'media', + required: false, + }, + { + name: 'icon', + type: 'upload', + relationTo: 'media', + required: false, + }, + { + name: 'href', + type: 'text', + required: true, + }, + { + name: 'ctaLabel', + type: 'text', + required: false, + }, + ], + }, + ], +}; diff --git a/src/payload/blocks/CompanyHeritage.ts b/src/payload/blocks/CompanyHeritage.ts new file mode 100644 index 00000000..2687e1f1 --- /dev/null +++ b/src/payload/blocks/CompanyHeritage.ts @@ -0,0 +1,54 @@ +import { Block } from 'payload'; + +export const TeamLegacySection: Block = { + slug: 'teamLegacySection', + interfaceName: 'TeamLegacySectionBlock', + fields: [ + { + name: 'title', + type: 'text', + required: true, + }, + { + name: 'subtitle', + type: 'text', + required: true, + }, + { + name: 'paragraph1', + type: 'textarea', + required: true, + }, + { + name: 'paragraph2', + type: 'textarea', + required: true, + }, + { + name: 'expertiseTitle', + type: 'text', + required: true, + }, + { + name: 'expertiseDesc', + type: 'text', + required: true, + }, + { + name: 'networkTitle', + type: 'text', + required: true, + }, + { + name: 'networkDesc', + type: 'text', + required: true, + }, + { + name: 'backgroundImage', + type: 'upload', + relationTo: 'media', + required: false, + }, + ], +}; diff --git a/src/payload/blocks/ContactSection.ts b/src/payload/blocks/ContactSection.ts new file mode 100644 index 00000000..04347f84 --- /dev/null +++ b/src/payload/blocks/ContactSection.ts @@ -0,0 +1,26 @@ +import { Block } from 'payload'; + +export const ContactSection: Block = { + slug: 'contactSection', + interfaceName: 'ContactSectionBlock', + fields: [ + { + name: 'showForm', + type: 'checkbox', + defaultValue: true, + label: 'Show Contact Form', + }, + { + name: 'showMap', + type: 'checkbox', + defaultValue: true, + label: 'Show Map', + }, + { + name: 'showHours', + type: 'checkbox', + defaultValue: true, + label: 'Show Opening Hours', + }, + ], +}; diff --git a/src/payload/blocks/HeroSection.ts b/src/payload/blocks/HeroSection.ts new file mode 100644 index 00000000..2ef13b90 --- /dev/null +++ b/src/payload/blocks/HeroSection.ts @@ -0,0 +1,48 @@ +import { Block } from 'payload'; + +export const HeroSection: Block = { + slug: 'heroSection', + interfaceName: 'HeroSectionBlock', + fields: [ + { + name: 'badge', + type: 'text', + required: false, + }, + { + name: 'title', + type: 'text', + required: true, + }, + { + name: 'subtitle', + type: 'textarea', + required: false, + }, + { + name: 'backgroundImage', + type: 'upload', + relationTo: 'media', + required: false, + }, + { + name: 'ctaLabel', + type: 'text', + required: false, + }, + { + name: 'ctaHref', + type: 'text', + required: false, + }, + { + name: 'alignment', + type: 'select', + defaultValue: 'left', + options: [ + { label: 'Left', value: 'left' }, + { label: 'Center', value: 'center' }, + ], + }, + ], +}; diff --git a/src/payload/blocks/HomeBlocks.ts b/src/payload/blocks/HomeBlocks.ts new file mode 100644 index 00000000..60efadf2 --- /dev/null +++ b/src/payload/blocks/HomeBlocks.ts @@ -0,0 +1,141 @@ +import { Block } from 'payload'; + +export const HomeHero: Block = { + slug: 'homeHero', + interfaceName: 'HomeHeroBlock', + fields: [ + { name: 'title', type: 'text', localized: true }, + { name: 'subtitle', type: 'text', localized: true }, + { name: 'ctaLabel', type: 'text', localized: true }, + { name: 'secondaryCtaLabel', type: 'text', localized: true }, + ], +}; + +export const HomeProductCategories: Block = { + slug: 'homeProductCategories', + interfaceName: 'HomeProductCategoriesBlock', + fields: [ + { name: 'title', type: 'text', localized: true }, + { name: 'subtitle', type: 'text', localized: true }, + ], +}; + +export const HomeWhatWeDo: Block = { + slug: 'homeWhatWeDo', + interfaceName: 'HomeWhatWeDoBlock', + fields: [ + { name: 'title', type: 'text', localized: true }, + { name: 'subtitle', type: 'text', localized: true }, + { name: 'expertiseLabel', type: 'text', localized: true }, + { name: 'quote', type: 'textarea', localized: true }, + { + name: 'items', + type: 'array', + localized: true, + fields: [ + { name: 'title', type: 'text' }, + { name: 'description', type: 'textarea' }, + ], + }, + ], +}; + +export const HomeRecentPosts: Block = { + slug: 'homeRecentPosts', + interfaceName: 'HomeRecentPostsBlock', + fields: [ + { name: 'title', type: 'text', localized: true }, + { name: 'subtitle', type: 'text', localized: true }, + ], +}; + +export const HomeExperience: Block = { + slug: 'homeExperience', + interfaceName: 'HomeExperienceBlock', + fields: [ + { name: 'title', type: 'text', localized: true }, + { name: 'subtitle', type: 'text', localized: true }, + { name: 'paragraph1', type: 'textarea', localized: true }, + { name: 'paragraph2', type: 'textarea', localized: true }, + { name: 'badge1', type: 'text', localized: true }, + { name: 'badge1Text', type: 'text', localized: true }, + { name: 'badge2', type: 'text', localized: true }, + { name: 'badge2Text', type: 'text', localized: true }, + ], +}; + +export const HomeWhyChooseUs: Block = { + slug: 'homeWhyChooseUs', + interfaceName: 'HomeWhyChooseUsBlock', + fields: [ + { name: 'title', type: 'text', localized: true }, + { name: 'subtitle', type: 'text', localized: true }, + { name: 'tagline', type: 'text', localized: true }, + { + name: 'features', + type: 'array', + localized: true, + fields: [{ name: 'feature', type: 'text' }], + }, + { + name: 'items', + type: 'array', + localized: true, + fields: [ + { name: 'title', type: 'text' }, + { name: 'description', type: 'textarea' }, + ], + }, + ], +}; + +export const HomeMeetTheTeam: Block = { + slug: 'homeMeetTheTeam', + interfaceName: 'HomeMeetTheTeamBlock', + fields: [ + { name: 'title', type: 'text', localized: true }, + { name: 'subtitle', type: 'text', localized: true }, + { name: 'description', type: 'textarea', localized: true }, + { name: 'ctaLabel', type: 'text', localized: true }, + { name: 'networkLabel', type: 'text', localized: true }, + ], +}; + +export const HomeGallery: Block = { + slug: 'homeGallery', + interfaceName: 'HomeGalleryBlock', + fields: [ + { name: 'title', type: 'text', localized: true }, + { name: 'subtitle', type: 'text', localized: true }, + ], +}; + +export const HomeVideo: Block = { + slug: 'homeVideo', + interfaceName: 'HomeVideoBlock', + fields: [{ name: 'title', type: 'text', localized: true }], +}; + +export const HomeCTA: Block = { + slug: 'homeCTA', + interfaceName: 'HomeCTABlock', + fields: [ + { name: 'title', type: 'text', localized: true }, + { name: 'subtitle', type: 'text', localized: true }, + { name: 'description', type: 'textarea', localized: true }, + { name: 'buttonLabel', type: 'text', localized: true }, + ], +}; + +export const homeBlocksArray = [ + HomeHero, + HomeProductCategories, + HomeWhatWeDo, + HomeRecentPosts, + HomeExperience, + HomeWhyChooseUs, + HomeMeetTheTeam, + HomeGallery, + HomeVideo, + HomeCTA, +]; diff --git a/src/payload/blocks/ImageGallery.ts b/src/payload/blocks/ImageGallery.ts new file mode 100644 index 00000000..28ec8218 --- /dev/null +++ b/src/payload/blocks/ImageGallery.ts @@ -0,0 +1,27 @@ +import { Block } from 'payload'; + +export const ImageGallery: Block = { + slug: 'imageGallery', + interfaceName: 'ImageGalleryBlock', + fields: [ + { + name: 'images', + type: 'array', + required: true, + minRows: 1, + fields: [ + { + name: 'image', + type: 'upload', + relationTo: 'media', + required: true, + }, + { + name: 'alt', + type: 'text', + required: false, + }, + ], + }, + ], +}; diff --git a/src/payload/blocks/ManifestoGrid.ts b/src/payload/blocks/ManifestoGrid.ts new file mode 100644 index 00000000..ccc458f6 --- /dev/null +++ b/src/payload/blocks/ManifestoGrid.ts @@ -0,0 +1,41 @@ +import { Block } from 'payload'; + +export const ManifestoGrid: Block = { + slug: 'manifestoGrid', + interfaceName: 'ManifestoGridBlock', + fields: [ + { + name: 'title', + type: 'text', + required: false, + }, + { + name: 'subtitle', + type: 'text', + required: false, + }, + { + name: 'tagline', + type: 'textarea', + required: false, + }, + { + name: 'items', + type: 'array', + required: true, + minRows: 1, + fields: [ + { + name: 'title', + type: 'text', + required: true, + }, + { + name: 'description', + type: 'textarea', + required: true, + }, + ], + }, + ], +}; diff --git a/src/payload/blocks/SupportCTA.ts b/src/payload/blocks/SupportCTA.ts new file mode 100644 index 00000000..370ad4d6 --- /dev/null +++ b/src/payload/blocks/SupportCTA.ts @@ -0,0 +1,28 @@ +import { Block } from 'payload'; + +export const SupportCTA: Block = { + slug: 'supportCTA', + interfaceName: 'SupportCTABlock', + fields: [ + { + name: 'title', + type: 'text', + required: true, + }, + { + name: 'description', + type: 'textarea', + required: true, + }, + { + name: 'buttonLabel', + type: 'text', + required: true, + }, + { + name: 'buttonHref', + type: 'text', + required: true, + }, + ], +}; diff --git a/src/payload/blocks/TeamProfile.ts b/src/payload/blocks/TeamProfile.ts new file mode 100644 index 00000000..a47eb757 --- /dev/null +++ b/src/payload/blocks/TeamProfile.ts @@ -0,0 +1,62 @@ +import { Block } from 'payload'; + +export const TeamProfile: Block = { + slug: 'teamProfile', + interfaceName: 'TeamProfileBlock', + fields: [ + { + name: 'name', + type: 'text', + required: true, + }, + { + name: 'role', + type: 'text', + required: true, + }, + { + name: 'quote', + type: 'textarea', + required: false, + }, + { + name: 'description', + type: 'textarea', + required: false, + }, + { + name: 'image', + type: 'upload', + relationTo: 'media', + required: false, + }, + { + name: 'linkedinUrl', + type: 'text', + required: false, + }, + { + name: 'linkedinLabel', + type: 'text', + required: false, + }, + { + name: 'layout', + type: 'select', + defaultValue: 'imageRight', + options: [ + { label: 'Image Right', value: 'imageRight' }, + { label: 'Image Left', value: 'imageLeft' }, + ], + }, + { + name: 'colorScheme', + type: 'select', + defaultValue: 'dark', + options: [ + { label: 'Dark', value: 'dark' }, + { label: 'Light', value: 'light' }, + ], + }, + ], +}; diff --git a/src/payload/blocks/allBlocks.ts b/src/payload/blocks/allBlocks.ts index 4872e3f6..d5d63066 100644 --- a/src/payload/blocks/allBlocks.ts +++ b/src/payload/blocks/allBlocks.ts @@ -1,27 +1,41 @@ import { AnimatedImage } from './AnimatedImage'; import { Callout } from './Callout'; +import { CategoryGrid } from './CategoryGrid'; import { ChatBubble } from './ChatBubble'; import { ComparisonGrid } from './ComparisonGrid'; +import { ContactSection } from './ContactSection'; +import { HeroSection } from './HeroSection'; import { HighlightBox } from './HighlightBox'; +import { ImageGallery } from './ImageGallery'; +import { ManifestoGrid } from './ManifestoGrid'; import { PowerCTA } from './PowerCTA'; import { ProductTabs } from './ProductTabs'; import { SplitHeading } from './SplitHeading'; import { Stats } from './Stats'; import { StickyNarrative } from './StickyNarrative'; +import { TeamProfile } from './TeamProfile'; import { TechnicalGrid } from './TechnicalGrid'; import { VisualLinkPreview } from './VisualLinkPreview'; +import { homeBlocksArray } from './HomeBlocks'; export const payloadBlocks = [ + ...homeBlocksArray, AnimatedImage, Callout, + CategoryGrid, ChatBubble, ComparisonGrid, + ContactSection, + HeroSection, HighlightBox, + ImageGallery, + ManifestoGrid, PowerCTA, ProductTabs, SplitHeading, Stats, StickyNarrative, + TeamProfile, TechnicalGrid, VisualLinkPreview, ]; diff --git a/src/payload/collections/Pages.ts b/src/payload/collections/Pages.ts index 8ba93b85..2baec177 100644 --- a/src/payload/collections/Pages.ts +++ b/src/payload/collections/Pages.ts @@ -1,44 +1,65 @@ import { CollectionConfig } from 'payload'; -import { lexicalEditor } from '@payloadcms/richtext-lexical'; +import { lexicalEditor, BlocksFeature } from '@payloadcms/richtext-lexical'; +import { payloadBlocks } from '../blocks/allBlocks'; export const Pages: CollectionConfig = { slug: 'pages', admin: { useAsTitle: 'title', - defaultColumns: ['title', 'slug', 'locale', 'updatedAt'], + defaultColumns: ['title', 'slug', 'layout', '_status', 'updatedAt'], + }, + versions: { + drafts: true, }, access: { - read: () => true, + read: ({ req: { user } }) => { + if (process.env.NODE_ENV === 'development') { + return true; + } + if (user) { + return true; + } + return { + _status: { + equals: 'published', + }, + }; + }, }, fields: [ { name: 'title', type: 'text', required: true, + localized: true, }, { name: 'slug', type: 'text', required: true, + localized: true, admin: { position: 'sidebar', + description: 'The URL slug for this locale (e.g. "impressum" for DE, "imprint" for EN).', }, }, { - name: 'locale', + name: 'layout', type: 'select', + defaultValue: 'default', options: [ - { label: 'English', value: 'en' }, - { label: 'German', value: 'de' }, + { label: 'Default (Article)', value: 'default' }, + { label: 'Full Bleed (Blocks Only)', value: 'fullBleed' }, ], - required: true, admin: { position: 'sidebar', + description: 'Full Bleed pages render blocks edge-to-edge without a generic hero wrapper.', }, }, { name: 'excerpt', type: 'textarea', + localized: true, admin: { position: 'sidebar', }, @@ -54,7 +75,15 @@ export const Pages: CollectionConfig = { { name: 'content', type: 'richText', - editor: lexicalEditor({}), + localized: true, + editor: lexicalEditor({ + features: ({ defaultFeatures }) => [ + ...defaultFeatures, + BlocksFeature({ + blocks: payloadBlocks, + }), + ], + }), required: true, }, ], diff --git a/src/payload/collections/Posts.ts b/src/payload/collections/Posts.ts index 41ce6dfb..eb7958e6 100644 --- a/src/payload/collections/Posts.ts +++ b/src/payload/collections/Posts.ts @@ -19,22 +19,16 @@ export const Posts: CollectionConfig = { defaultColumns: ['featuredImage', 'title', 'date', 'updatedAt', '_status'], }, versions: { - drafts: true, // Enables Draft/Published workflows + drafts: true, }, access: { read: ({ req: { user } }) => { - // In local development, always show everything (including Drafts and scheduled future posts) if (process.env.NODE_ENV === 'development') { return true; } - - // If an Admin user is logged in, they can view everything if (user) { return true; } - - // For public unauthenticated visitors in PROD/STAGING contexts: - // Only serve Posts where Status = "published" AND the publish Date is in the past! return { and: [ { @@ -56,19 +50,20 @@ export const Posts: CollectionConfig = { name: 'title', type: 'text', required: true, + localized: true, }, { name: 'slug', type: 'text', required: true, - unique: true, + localized: true, admin: { position: 'sidebar', + description: 'Unique slug per locale (e.g. same slug can exist in DE and EN).', }, hooks: { beforeValidate: [ ({ value, data }) => { - // Auto-generate slug from title if left blank if (value || !data?.title) return value; return data.title .toLowerCase() @@ -81,6 +76,7 @@ export const Posts: CollectionConfig = { { name: 'excerpt', type: 'text', + localized: true, admin: { description: 'A short summary for blog feed cards and SEO.', }, @@ -104,22 +100,10 @@ export const Posts: CollectionConfig = { description: 'The primary Hero image used for headers and OpenGraph previews.', }, }, - { - name: 'locale', - type: 'select', - required: true, - admin: { - position: 'sidebar', - }, - options: [ - { label: 'English', value: 'en' }, - { label: 'German', value: 'de' }, - ], - defaultValue: 'en', - }, { name: 'category', type: 'text', + localized: true, admin: { position: 'sidebar', description: 'Used for tag bucketing (e.g. "Kabel Technologie").', @@ -128,6 +112,7 @@ export const Posts: CollectionConfig = { { name: 'content', type: 'richText', + localized: true, editor: lexicalEditor({ features: ({ defaultFeatures }) => [ ...defaultFeatures, diff --git a/src/payload/collections/Products.ts b/src/payload/collections/Products.ts index d467aff9..b781b683 100644 --- a/src/payload/collections/Products.ts +++ b/src/payload/collections/Products.ts @@ -17,7 +17,7 @@ import { ProductTabs } from '../blocks/ProductTabs'; export const Products: CollectionConfig = { slug: 'products', admin: { - defaultColumns: ['featuredImage', 'title', 'sku', 'locale', 'updatedAt', '_status'], + defaultColumns: ['featuredImage', 'title', 'sku', 'updatedAt', '_status'], }, versions: { drafts: true, @@ -42,6 +42,7 @@ export const Products: CollectionConfig = { name: 'title', type: 'text', required: true, + localized: true, }, { name: 'sku', @@ -52,6 +53,7 @@ export const Products: CollectionConfig = { }, }, { + // slug is shared: the cable name (e.g. "n2xy") is the same in DE and EN name: 'slug', type: 'text', required: true, @@ -63,19 +65,7 @@ export const Products: CollectionConfig = { name: 'description', type: 'textarea', required: true, - }, - { - name: 'locale', - type: 'select', - required: true, - admin: { - position: 'sidebar', - }, - options: [ - { label: 'English', value: 'en' }, - { label: 'German', value: 'de' }, - ], - defaultValue: 'de', + localized: true, }, { name: 'categories', @@ -112,11 +102,13 @@ export const Products: CollectionConfig = { { name: 'application', type: 'richText', + localized: true, editor: lexicalEditor({}), }, { name: 'content', type: 'richText', + localized: true, editor: lexicalEditor({ features: ({ defaultFeatures }) => [ ...defaultFeatures, diff --git a/src/payload/components/Icon.tsx b/src/payload/components/Icon.tsx new file mode 100644 index 00000000..97300f70 --- /dev/null +++ b/src/payload/components/Icon.tsx @@ -0,0 +1,12 @@ +import React from 'react'; + +export default function Icon() { + return ( + KLZ + ); +} diff --git a/src/payload/components/Logo.tsx b/src/payload/components/Logo.tsx new file mode 100644 index 00000000..5cd93306 --- /dev/null +++ b/src/payload/components/Logo.tsx @@ -0,0 +1,12 @@ +import React from 'react'; + +export default function Logo() { + return ( + KLZ Cables + ); +} diff --git a/src/payload/seed.ts b/src/payload/seed.ts index fa23a2e9..3cbd08a3 100644 --- a/src/payload/seed.ts +++ b/src/payload/seed.ts @@ -29,12 +29,12 @@ export async function seedDatabase(payload: Payload) { payload.logger.info('πŸ“¦ No products found. Creating smoke test product (NAY2Y)...'); await payload.create({ collection: 'products', + locale: 'de', data: { title: 'NAY2Y Smoke Test', sku: 'SMOKE-TEST-001', slug: 'nay2y', description: 'A dummy product for CI/CD smoke testing and OG image verification.', - locale: 'de', categories: [{ category: 'Power Cables' }], _status: 'published', },