feat: payload cms

This commit is contained in:
2026-02-26 01:32:22 +01:00
parent 1963a93123
commit 7d65237ee9
67 changed files with 3179 additions and 760 deletions

View File

@@ -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 }}"

1
.npmrc
View File

@@ -1,3 +1,2 @@
@mintel:registry=https://npm.infra.mintel.me/
//npm.infra.mintel.me/:_authToken=${NPM_TOKEN}
always-auth=true

View File

@@ -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
}

View File

@@ -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;
}

View File

@@ -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<Metadata>
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 (
<div className="flex flex-col min-h-screen">
<PayloadRichText data={pageData.content} className="" />
</div>
);
}
// Default article layout with hero, content, and support CTA
return (
<div className="flex flex-col min-h-screen bg-white">
{/* Hero Section */}

View File

@@ -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';

View File

@@ -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"
/>
<div className="absolute inset-0 image-overlay-gradient opacity-0 group-hover:opacity-100 transition-opacity duration-500" />
@@ -192,10 +198,10 @@ export default async function BlogIndex({ params }: BlogIndexProps) {
</span>
{(new Date(post.frontmatter.date) > new Date() ||
post.frontmatter.public === false) && (
<span className="px-1.5 py-0.5 border border-current rounded-sm text-[9px] md:text-xs">
Draft
</span>
)}
<span className="px-1.5 py-0.5 border border-current rounded-sm text-[9px] md:text-xs">
Draft
</span>
)}
</div>
<h3 className="text-lg md:text-2xl font-bold text-primary mb-3 md:mb-6 group-hover:text-accent-dark transition-colors line-clamp-3 md:line-clamp-4 leading-tight">
{post.frontmatter.title}

View File

@@ -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';

View File

@@ -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)

View File

@@ -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';

View File

@@ -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() {
<header className={headerClass} style={{ animationDuration: '800ms' }}>
<div className="container mx-auto px-4 md:px-12 lg:px-16 max-w-7xl flex items-center justify-between">
<div
className="flex-shrink-0 group touch-target animate-in fade-in zoom-in-90 fill-mode-both"
style={{ animationDuration: '600ms', animationDelay: '100ms' }}
className="flex-shrink-0 group touch-target fill-mode-both"
>
<Link
href={`/${currentLocale}`}
@@ -173,6 +172,9 @@ export default function Header() {
style={{ width: 'auto' }}
className="h-10 md:h-14 w-auto transition-all duration-500 group-hover:scale-110"
priority
fetchPriority="high"
loading="eager"
decoding="sync"
/>
</Link>
</div>

View File

@@ -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 && <RichText data={node.fields.content} converters={jsxConverters} />}
</ProductTabs>
),
// ─── New Page Blocks ───────────────────────────────────────────
heroSection: ({ node }: any) => {
const f = node.fields;
const bgSrc = f.backgroundImage?.sizes?.card?.url || f.backgroundImage?.url;
return (
<Reveal>
<section className={`relative min-h-[50vh] md:min-h-[70vh] flex items-center pt-32 pb-20 md:pt-40 md:pb-32 overflow-hidden bg-primary-dark ${f.alignment === 'center' ? 'justify-center text-center' : ''}`}>
{bgSrc && (
<>
<div className="absolute inset-0 z-0">
<Image
src={bgSrc}
alt={f.title}
fill
className="object-cover opacity-30 md:opacity-40"
style={{ objectPosition: `${f.backgroundImage?.focalX ?? 50}% ${f.backgroundImage?.focalY ?? 50}%` }}
sizes="100vw"
priority
/>
<div className="absolute inset-0 bg-gradient-to-b from-primary-dark/80 via-primary-dark/40 to-primary-dark/80" />
</div>
</>
)}
<Container className={`relative z-10 ${f.alignment === 'center' ? 'max-w-5xl' : ''}`}>
<div className={`max-w-4xl ${f.alignment === 'center' ? 'mx-auto' : ''}`}>
{f.badge && <Badge variant="saturated" className="mb-4 md:mb-8 shadow-lg">{f.badge}</Badge>}
<Heading level={1} className="text-white mb-4 md:mb-8">{f.title}</Heading>
{f.subtitle && <p className="text-lg md:text-2xl text-white/70 font-medium leading-relaxed max-w-2xl">{f.subtitle}</p>}
{f.ctaLabel && f.ctaHref && (
<div className="mt-8">
<a href={f.ctaHref} className="inline-flex items-center px-8 py-4 bg-accent text-primary-dark font-bold rounded-full hover:bg-white transition-all duration-300 group">
{f.ctaLabel}
<span className="ml-3 transition-transform group-hover:translate-x-2">&rarr;</span>
</a>
</div>
)}
</div>
</Container>
</section>
</Reveal>
);
},
'block-heroSection': ({ node }: any) => {
const f = node.fields;
const bgSrc = f.backgroundImage?.sizes?.card?.url || f.backgroundImage?.url;
return (
<Reveal>
<section className={`relative min-h-[50vh] md:min-h-[70vh] flex items-center pt-32 pb-20 md:pt-40 md:pb-32 overflow-hidden bg-primary-dark ${f.alignment === 'center' ? 'justify-center text-center' : ''}`}>
{bgSrc && (
<>
<div className="absolute inset-0 z-0">
<Image src={bgSrc} alt={f.title} fill className="object-cover opacity-30 md:opacity-40" style={{ objectPosition: `${f.backgroundImage?.focalX ?? 50}% ${f.backgroundImage?.focalY ?? 50}%` }} sizes="100vw" priority />
<div className="absolute inset-0 bg-gradient-to-b from-primary-dark/80 via-primary-dark/40 to-primary-dark/80" />
</div>
</>
)}
<Container className={`relative z-10 ${f.alignment === 'center' ? 'max-w-5xl' : ''}`}>
<div className={`max-w-4xl ${f.alignment === 'center' ? 'mx-auto' : ''}`}>
{f.badge && <Badge variant="saturated" className="mb-4 md:mb-8 shadow-lg">{f.badge}</Badge>}
<Heading level={1} className="text-white mb-4 md:mb-8">{f.title}</Heading>
{f.subtitle && <p className="text-lg md:text-2xl text-white/70 font-medium leading-relaxed max-w-2xl">{f.subtitle}</p>}
{f.ctaLabel && f.ctaHref && (
<div className="mt-8">
<a href={f.ctaHref} className="inline-flex items-center px-8 py-4 bg-accent text-primary-dark font-bold rounded-full hover:bg-white transition-all duration-300 group">{f.ctaLabel}<span className="ml-3 transition-transform group-hover:translate-x-2">&rarr;</span></a>
</div>
)}
</div>
</Container>
</section>
</Reveal>
);
},
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 (
<article className="relative bg-white overflow-hidden">
<div className="flex flex-col lg:flex-row">
<Reveal className={`w-full lg:w-1/2 p-6 md:p-24 lg:p-32 flex flex-col justify-center relative ${isImageRight ? 'order-2 lg:order-1' : 'order-2'} ${isDark ? 'bg-primary-dark text-white' : 'bg-neutral-light text-saturated'}`}>
<div className="relative z-10">
<Badge variant={isDark ? 'accent' : 'saturated'} className="mb-4 md:mb-8">{f.role}</Badge>
<Heading level={2} className={`${isDark ? 'text-white' : 'text-saturated'} mb-6 md:mb-10 text-3xl md:text-5xl`}>{f.name}</Heading>
{f.quote && (
<div className="relative mb-6 md:mb-12">
<div className={`absolute -left-4 md:-left-8 top-0 bottom-0 w-1 md:w-1.5 ${isDark ? 'bg-accent' : 'bg-saturated'} rounded-full`} />
<p className={`text-lg md:text-2xl font-bold italic leading-relaxed pl-5 md:pl-8 ${isDark ? 'text-white/90' : 'text-text-secondary'}`}>{f.quote}</p>
</div>
)}
{f.description && <p className={`text-base md:text-xl leading-relaxed mb-6 md:mb-12 max-w-xl ${isDark ? 'text-white/70' : 'text-text-secondary'}`}>{f.description}</p>}
{f.linkedinUrl && (
<TrackedLink
href={f.linkedinUrl}
className={`inline-flex items-center px-8 py-4 font-bold rounded-full transition-all duration-300 group ${isDark ? 'bg-accent text-primary-dark hover:bg-white' : 'bg-saturated text-white hover:bg-primary'}`}
eventProperties={{ type: 'social_linkedin', person: f.name, location: 'team_page' }}
>
{f.linkedinLabel || 'LinkedIn'}
<span className="ml-3 transition-transform group-hover:translate-x-2">&rarr;</span>
</TrackedLink>
)}
</div>
</Reveal>
<Reveal className={`w-full lg:w-1/2 relative min-h-[400px] md:min-h-[600px] lg:min-h-screen overflow-hidden ${isImageRight ? 'order-1 lg:order-2' : 'order-1'}`}>
{imgSrc && (
<>
<Image
src={imgSrc}
alt={f.name}
fill
className="object-cover scale-105 hover:scale-100 transition-transform duration-1000"
style={{ objectPosition: `${f.image?.focalX ?? 50}% ${f.image?.focalY ?? 50}%` }}
sizes="(max-width: 1024px) 100vw, 50vw"
/>
<div className={`absolute inset-0 ${isDark ? 'bg-gradient-to-t from-primary-dark/60 lg:bg-gradient-to-r lg:from-primary-dark/20 to-transparent' : 'bg-gradient-to-t from-white/60 lg:bg-gradient-to-l lg:from-primary-dark/20 to-transparent'}`} />
</>
)}
</Reveal>
</div>
</article>
);
},
'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 (
<article className="relative bg-white overflow-hidden">
<div className="flex flex-col lg:flex-row">
<Reveal className={`w-full lg:w-1/2 p-6 md:p-24 lg:p-32 flex flex-col justify-center relative ${isImageRight ? 'order-2 lg:order-1' : 'order-2'} ${isDark ? 'bg-primary-dark text-white' : 'bg-neutral-light text-saturated'}`}>
<div className="relative z-10">
<Badge variant={isDark ? 'accent' : 'saturated'} className="mb-4 md:mb-8">{f.role}</Badge>
<Heading level={2} className={`${isDark ? 'text-white' : 'text-saturated'} mb-6 md:mb-10 text-3xl md:text-5xl`}>{f.name}</Heading>
{f.quote && (
<div className="relative mb-6 md:mb-12">
<div className={`absolute -left-4 md:-left-8 top-0 bottom-0 w-1 md:w-1.5 ${isDark ? 'bg-accent' : 'bg-saturated'} rounded-full`} />
<p className={`text-lg md:text-2xl font-bold italic leading-relaxed pl-5 md:pl-8 ${isDark ? 'text-white/90' : 'text-text-secondary'}`}>{f.quote}</p>
</div>
)}
{f.description && <p className={`text-base md:text-xl leading-relaxed mb-6 md:mb-12 max-w-xl ${isDark ? 'text-white/70' : 'text-text-secondary'}`}>{f.description}</p>}
{f.linkedinUrl && (
<TrackedLink href={f.linkedinUrl} className={`inline-flex items-center px-8 py-4 font-bold rounded-full transition-all duration-300 group ${isDark ? 'bg-accent text-primary-dark hover:bg-white' : 'bg-saturated text-white hover:bg-primary'}`} eventProperties={{ type: 'social_linkedin', person: f.name, location: 'team_page' }}>
{f.linkedinLabel || 'LinkedIn'}<span className="ml-3 transition-transform group-hover:translate-x-2">&rarr;</span>
</TrackedLink>
)}
</div>
</Reveal>
<Reveal className={`w-full lg:w-1/2 relative min-h-[400px] md:min-h-[600px] lg:min-h-screen overflow-hidden ${isImageRight ? 'order-1 lg:order-2' : 'order-1'}`}>
{imgSrc && (<><Image src={imgSrc} alt={f.name} fill className="object-cover scale-105 hover:scale-100 transition-transform duration-1000" style={{ objectPosition: `${f.image?.focalX ?? 50}% ${f.image?.focalY ?? 50}%` }} sizes="(max-width: 1024px) 100vw, 50vw" /><div className={`absolute inset-0 ${isDark ? 'bg-gradient-to-t from-primary-dark/60 lg:bg-gradient-to-r lg:from-primary-dark/20 to-transparent' : 'bg-gradient-to-t from-white/60 lg:bg-gradient-to-l lg:from-primary-dark/20 to-transparent'}`} /></>)}
</Reveal>
</div>
</article>
);
},
contactSection: ({ node }: any) => {
const f = node.fields;
return (
<Section className="bg-neutral-light py-12 md:py-28">
<Container>
<div className="grid grid-cols-1 lg:grid-cols-12 gap-8 md:gap-16">
{f.showForm && (
<div className="lg:col-span-7">
<Suspense fallback={<div className="animate-pulse bg-neutral-medium h-96 rounded-2xl md:rounded-3xl" />}>
<ContactForm />
</Suspense>
</div>
)}
</div>
</Container>
{f.showMap && (
<section className="mt-12 h-[300px] md:h-[500px] bg-neutral-medium relative overflow-hidden grayscale hover:grayscale-0 transition-all duration-1000">
<Suspense fallback={<div className="h-full w-full bg-neutral-medium animate-pulse" />}>
<ContactMap address="Raiffeisenstraße 22\n73630 Remshalden" lat={48.8144} lng={9.4144} />
</Suspense>
</section>
)}
</Section>
);
},
'block-contactSection': ({ node }: any) => {
const f = node.fields;
return (
<Section className="bg-neutral-light py-12 md:py-28">
<Container>
<div className="grid grid-cols-1 lg:grid-cols-12 gap-8 md:gap-16">
{f.showForm && (
<div className="lg:col-span-7">
<Suspense fallback={<div className="animate-pulse bg-neutral-medium h-96 rounded-2xl md:rounded-3xl" />}>
<ContactForm />
</Suspense>
</div>
)}
</div>
</Container>
{f.showMap && (
<section className="mt-12 h-[300px] md:h-[500px] bg-neutral-medium relative overflow-hidden grayscale hover:grayscale-0 transition-all duration-1000">
<Suspense fallback={<div className="h-full w-full bg-neutral-medium animate-pulse" />}>
<ContactMap address="Raiffeisenstraße 22\n73630 Remshalden" lat={48.8144} lng={9.4144} />
</Suspense>
</section>
)}
</Section>
);
},
imageGallery: ({ node }: any) => <Gallery />,
'block-imageGallery': ({ node }: any) => <Gallery />,
categoryGrid: ({ node }: any) => {
const cats = node.fields.categories || [];
return (
<Section className="bg-neutral-light relative">
<Container>
<div className="grid grid-cols-1 md:grid-cols-2 gap-6 md:gap-8 lg:gap-12">
{cats.map((cat: any, idx: number) => {
const imgSrc = cat.image?.sizes?.card?.url || cat.image?.url;
const iconSrc = cat.icon?.url;
return (
<Reveal key={idx} delay={idx * 100}>
<a href={cat.href} className="group block">
<Card className="h-full border-none shadow-sm hover:shadow-2xl transition-all duration-500 rounded-[24px] md:rounded-[48px] overflow-hidden bg-white active:scale-[0.98]">
<div className="relative h-[200px] md:h-[400px] overflow-hidden">
{imgSrc && (
<Image src={imgSrc} alt={cat.title} fill className="object-cover transition-transform duration-1000 group-hover:scale-105" style={{ objectPosition: `${cat.image?.focalX ?? 50}% ${cat.image?.focalY ?? 50}%` }} sizes="(max-width: 768px) 100vw, 50vw" />
)}
<div className="absolute inset-0 image-overlay-gradient opacity-80 group-hover:opacity-90 transition-opacity duration-500" />
{iconSrc && (
<div className="absolute top-3 right-3 md:top-8 md:right-8 w-10 h-10 md:w-20 md:h-20 bg-white/10 backdrop-blur-md rounded-xl md:rounded-[24px] flex items-center justify-center border border-white/20 shadow-2xl transition-all duration-500 group-hover:scale-110 group-hover:bg-white/20">
<Image src={iconSrc} alt="" width={24} height={24} className="w-6 h-6 md:w-12 md:h-12 brightness-0 invert opacity-80" />
</div>
)}
<div className="absolute bottom-4 left-4 md:bottom-10 md:left-10 right-4 md:right-10">
<h2 className="text-xl md:text-4xl font-bold text-white leading-tight">{cat.title}</h2>
</div>
</div>
<div className="p-5 md:p-10">
{cat.description && <p className="text-text-secondary text-sm md:text-lg leading-relaxed mb-4 md:mb-8 line-clamp-2 md:line-clamp-none">{cat.description}</p>}
<div className="flex items-center text-saturated font-bold text-base md:text-lg group-hover:text-accent-dark transition-colors">
<span className="border-b-2 border-saturated/10 group-hover:border-accent-dark transition-colors pb-1">{cat.ctaLabel || 'View Products'}</span>
<div className="ml-3 md:ml-4 w-8 h-8 md:w-10 md:h-10 rounded-full bg-primary-light flex items-center justify-center text-saturated group-hover:bg-accent group-hover:text-primary-dark transition-all duration-300 shadow-sm">
<svg className="w-4 h-4 md:w-5 md:h-5 transition-transform group-hover:translate-x-1" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M17 8l4 4m0 0l-4 4m4-4H3" /></svg>
</div>
</div>
</div>
</Card>
</a>
</Reveal>
);
})}
</div>
</Container>
</Section>
);
},
'block-categoryGrid': ({ node }: any) => {
const cats = node.fields.categories || [];
return (
<Section className="bg-neutral-light relative">
<Container>
<div className="grid grid-cols-1 md:grid-cols-2 gap-6 md:gap-8 lg:gap-12">
{cats.map((cat: any, idx: number) => {
const imgSrc = cat.image?.sizes?.card?.url || cat.image?.url;
const iconSrc = cat.icon?.url;
return (
<Reveal key={idx} delay={idx * 100}>
<a href={cat.href} className="group block">
<Card className="h-full border-none shadow-sm hover:shadow-2xl transition-all duration-500 rounded-[24px] md:rounded-[48px] overflow-hidden bg-white active:scale-[0.98]">
<div className="relative h-[200px] md:h-[400px] overflow-hidden">
{imgSrc && (<Image src={imgSrc} alt={cat.title} fill className="object-cover transition-transform duration-1000 group-hover:scale-105" style={{ objectPosition: `${cat.image?.focalX ?? 50}% ${cat.image?.focalY ?? 50}%` }} sizes="(max-width: 768px) 100vw, 50vw" />)}
<div className="absolute inset-0 image-overlay-gradient opacity-80 group-hover:opacity-90 transition-opacity duration-500" />
{iconSrc && (<div className="absolute top-3 right-3 md:top-8 md:right-8 w-10 h-10 md:w-20 md:h-20 bg-white/10 backdrop-blur-md rounded-xl md:rounded-[24px] flex items-center justify-center border border-white/20"><Image src={iconSrc} alt="" width={24} height={24} className="w-6 h-6 md:w-12 md:h-12 brightness-0 invert opacity-80" /></div>)}
<div className="absolute bottom-4 left-4 md:bottom-10 md:left-10 right-4 md:right-10"><h2 className="text-xl md:text-4xl font-bold text-white leading-tight">{cat.title}</h2></div>
</div>
<div className="p-5 md:p-10">
{cat.description && <p className="text-text-secondary text-sm md:text-lg leading-relaxed mb-4 md:mb-8">{cat.description}</p>}
<div className="flex items-center text-saturated font-bold text-base md:text-lg group-hover:text-accent-dark transition-colors"><span>{cat.ctaLabel || 'View Products'}</span></div>
</div>
</Card>
</a>
</Reveal>
);
})}
</div>
</Container>
</Section>
);
},
manifestoGrid: ({ node }: any) => {
const f = node.fields;
return (
<Section className="bg-white text-primary py-16 md:py-28">
<Container>
<div className="sticky-narrative-container">
<div className="sticky-narrative-sidebar mb-8 lg:mb-0">
<div className="lg:sticky lg:top-32">
{f.title && <Heading level={2} subtitle={f.subtitle}>{f.title}</Heading>}
{f.tagline && <p className="text-base md:text-xl text-text-secondary leading-relaxed">{f.tagline}</p>}
</div>
</div>
<ul className="sticky-narrative-content grid grid-cols-1 md:grid-cols-2 gap-4 md:gap-10 list-none p-0 m-0">
{(f.items || []).map((item: any, idx: number) => (
<li key={idx} className="p-6 md:p-10 bg-neutral-light rounded-2xl md:rounded-[40px] border border-transparent hover:border-accent hover:bg-white hover:shadow-2xl transition-all duration-500 group active:scale-[0.98]">
<div className="w-10 h-10 md:w-16 md:h-16 bg-white rounded-xl md:rounded-2xl flex items-center justify-center mb-4 md:mb-8 shadow-sm group-hover:bg-accent transition-colors duration-500">
<span className="text-primary font-extrabold text-lg md:text-2xl group-hover:text-primary-dark">0{idx + 1}</span>
</div>
<h3 className="text-lg md:text-2xl font-bold mb-2 md:mb-4 text-primary">{item.title}</h3>
<p className="text-sm md:text-lg text-text-secondary leading-relaxed">{item.description}</p>
</li>
))}
</ul>
</div>
</Container>
</Section>
);
},
'block-manifestoGrid': ({ node }: any) => {
const f = node.fields;
return (
<Section className="bg-white text-primary py-16 md:py-28">
<Container>
<div className="sticky-narrative-container">
<div className="sticky-narrative-sidebar mb-8 lg:mb-0">
<div className="lg:sticky lg:top-32">
{f.title && <Heading level={2} subtitle={f.subtitle}>{f.title}</Heading>}
{f.tagline && <p className="text-base md:text-xl text-text-secondary leading-relaxed">{f.tagline}</p>}
</div>
</div>
<ul className="sticky-narrative-content grid grid-cols-1 md:grid-cols-2 gap-4 md:gap-10 list-none p-0 m-0">
{(f.items || []).map((item: any, idx: number) => (
<li key={idx} className="p-6 md:p-10 bg-neutral-light rounded-2xl md:rounded-[40px] border border-transparent hover:border-accent hover:bg-white hover:shadow-2xl transition-all duration-500 group active:scale-[0.98]">
<div className="w-10 h-10 md:w-16 md:h-16 bg-white rounded-xl md:rounded-2xl flex items-center justify-center mb-4 md:mb-8 shadow-sm group-hover:bg-accent transition-colors duration-500">
<span className="text-primary font-extrabold text-lg md:text-2xl group-hover:text-primary-dark">0{idx + 1}</span>
</div>
<h3 className="text-lg md:text-2xl font-bold mb-2 md:mb-4 text-primary">{item.title}</h3>
<p className="text-sm md:text-lg text-text-secondary leading-relaxed">{item.description}</p>
</li>
))}
</ul>
</div>
</Container>
</Section>
);
},
homeHero: ({ node }: any) => {
console.log('[PayloadRichText] Rendering homeHero block');
return <HomeHero data={node?.fields} />;
},
'block-homeHero': ({ node }: any) => {
console.log('[PayloadRichText] Rendering block-homeHero block');
return <HomeHero data={node?.fields} />;
},
homeProductCategories: ({ node }: any) => <Reveal><ProductCategories data={node?.fields} /></Reveal>,
'block-homeProductCategories': ({ node }: any) => <Reveal><ProductCategories data={node?.fields} /></Reveal>,
homeWhatWeDo: ({ node }: any) => <Reveal><WhatWeDo data={node?.fields} /></Reveal>,
'block-homeWhatWeDo': ({ node }: any) => <Reveal><WhatWeDo data={node?.fields} /></Reveal>,
homeExperience: ({ node }: any) => <Reveal><Experience data={node?.fields} /></Reveal>,
'block-homeExperience': ({ node }: any) => <Reveal><Experience data={node?.fields} /></Reveal>,
homeWhyChooseUs: ({ node }: any) => <Reveal><WhyChooseUs data={node?.fields} /></Reveal>,
'block-homeWhyChooseUs': ({ node }: any) => <Reveal><WhyChooseUs data={node?.fields} /></Reveal>,
homeMeetTheTeam: ({ node }: any) => <Reveal><MeetTheTeam data={node?.fields} /></Reveal>,
'block-homeMeetTheTeam': ({ node }: any) => <Reveal><MeetTheTeam data={node?.fields} /></Reveal>,
homeGallery: ({ node }: any) => <Reveal><GallerySection data={node?.fields} /></Reveal>,
'block-homeGallery': ({ node }: any) => <Reveal><GallerySection data={node?.fields} /></Reveal>,
homeVideo: ({ node }: any) => <Reveal><VideoSection data={node?.fields} /></Reveal>,
'block-homeVideo': ({ node }: any) => <Reveal><VideoSection data={node?.fields} /></Reveal>,
homeCTA: ({ node }: any) => <Reveal className="content-visibility-auto"><CTA data={node?.fields} /></Reveal>,
'block-homeCTA': ({ node }: any) => <Reveal className="content-visibility-auto"><CTA data={node?.fields} /></Reveal>,
},
// Custom converter for the Payload "upload" Lexical node (Media collection)
// This natively reconstructs Next.js <Image /> 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: () => <Reveal><RecentPosts locale={locale} /></Reveal>,
'block-homeRecentPosts': () => <Reveal><RecentPosts locale={locale} /></Reveal>,
},
};
return (
<div className="article-content max-w-none">
<RichText data={data} converters={jsxConverters} />
<div className={className}>
<RichText data={data} converters={dynamicConverters} />
</div>
);
}

View File

@@ -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';

View File

@@ -30,8 +30,11 @@ export default function PostNavigation({
{/* Background Image */}
{prev.frontmatter.featuredImage ? (
<div
className="absolute inset-0 bg-cover bg-center transition-transform duration-700 group-hover:scale-110"
style={{ backgroundImage: `url(${prev.frontmatter.featuredImage.split('?')[0]})` }}
className="absolute inset-0 z-0 bg-cover transition-transform duration-1000 group-hover:scale-105"
style={{
backgroundImage: `url(${prev.frontmatter.featuredImage.split('?')[0]})`,
backgroundPosition: `${prev.frontmatter.focalX ?? 50}% ${prev.frontmatter.focalY ?? 50}%`,
}}
/>
) : (
<div className="absolute inset-0 bg-neutral-100" />
@@ -81,8 +84,11 @@ export default function PostNavigation({
{/* Background Image */}
{next.frontmatter.featuredImage ? (
<div
className="absolute inset-0 bg-cover bg-center transition-transform duration-700 group-hover:scale-110"
style={{ backgroundImage: `url(${next.frontmatter.featuredImage.split('?')[0]})` }}
className="absolute inset-0 z-0 bg-cover transition-transform duration-1000 group-hover:scale-105"
style={{
backgroundImage: `url(${next.frontmatter.featuredImage.split('?')[0]})`,
backgroundPosition: `${next.frontmatter.focalX ?? 50}% ${next.frontmatter.focalY ?? 50}%`,
}}
/>
) : (
<div className="absolute inset-0 bg-neutral-100" />

View File

@@ -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() {
<Section className="bg-primary text-white py-32 relative overflow-hidden">
<div className="absolute top-0 right-0 w-1/3 h-full bg-accent/5 -skew-x-12 translate-x-1/2" />
<div className="absolute bottom-0 left-0 w-1/4 h-1/2 bg-primary/10 rounded-full blur-3xl -translate-x-1/2 translate-y-1/2" />
<Container className="relative z-10">
<div className="flex flex-col lg:flex-row items-center justify-between gap-16">
<div className="max-w-3xl text-center lg:text-left">
<Heading level={2} subtitle={t('subtitle')} className="text-white mb-6">
<span className="text-white">{t('title')}</span>
<Heading level={2} subtitle={data?.subtitle || t('subtitle')} className="text-white mb-6">
<span className="text-white">{data?.title || t('title')}</span>
</Heading>
<p className="text-lg md:text-xl text-white/70 leading-relaxed">
{t('description')}
{data?.description || t('description')}
</p>
</div>
<div className="flex-shrink-0">
<Button href={`/${locale}/contact`} variant="accent" size="xl" className="group px-12">
{t('button')}
{data?.buttonLabel || t('button')}
<span className="ml-3 transition-transform group-hover:translate-x-2">&rarr;</span>
</Button>
</div>

View File

@@ -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() {
<div className="absolute inset-0 z-0">
<Image
src="/uploads/2024/12/1694273920124-copy-2.webp"
alt={t('subtitle')}
alt={data?.subtitle || t('subtitle')}
fill
className="object-cover object-center scale-105 animate-slow-zoom"
sizes="100vw"
@@ -22,31 +22,31 @@ export default function Experience() {
<Container className="relative z-10">
<div className="max-w-3xl">
<Heading level={2} subtitle={t('subtitle')} className="text-white">
<span className="text-white">{t('title')}</span>
<Heading level={2} subtitle={data?.subtitle || t('subtitle')} className="text-white">
<span className="text-white">{data?.title || t('title')}</span>
</Heading>
<div className="space-y-8 text-lg md:text-xl text-white/90 leading-relaxed font-medium">
<p className="border-l-4 border-accent pl-8 py-2 bg-white/5 backdrop-blur-sm rounded-r-xl">
{t('p1')}
{data?.paragraph1 || t('p1')}
</p>
<p className="pl-9">{t('p2')}</p>
<p className="pl-9">{data?.paragraph2 || t('p2')}</p>
</div>
<dl className="mt-16 grid grid-cols-1 md:grid-cols-2 gap-12">
<div className="animate-fade-in">
<dt className="text-2xl md:text-3xl font-extrabold text-accent mb-4">
{t('certifiedQuality')}
{data?.badge1 || t('certifiedQuality')}
</dt>
<dd className="text-base md:text-lg font-bold uppercase tracking-widest text-white/60">
{t('vdeApproved')}
{data?.badge1Text || t('vdeApproved')}
</dd>
</div>
<div className="animate-fade-in" style={{ animationDelay: '100ms' }}>
<dt className="text-2xl md:text-3xl font-extrabold text-accent mb-4">
{t('fullSpectrum')}
{data?.badge2 || t('fullSpectrum')}
</dt>
<dd className="text-base md:text-lg font-bold uppercase tracking-widest text-white/60">
{t('solutionsRange')}
{data?.badge2Text || t('solutionsRange')}
</dd>
</div>
</dl>

View File

@@ -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 (
<Section className="bg-white text-white py-32">
<Container>
<Heading level={2} subtitle={t('subtitle')} align="center">
{t('title')}
<Heading level={2} subtitle={data?.subtitle || t('subtitle')} align="center">
{data?.title || t('title')}
</Heading>
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-8">

View File

@@ -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) => (
<span className="relative inline-block">
<span className="relative z-10 text-accent italic inline-block">{chunks}</span>
<div
className="w-[140%] h-[140%] -top-[20%] -left-[20%] text-accent/30 hidden md:block absolute -z-10 animate-in fade-in zoom-in-0 duration-1000 ease-out fill-mode-both"
style={{ animationDelay: '500ms' }}
>
<Scribble variant="circle" />
</div>
</span>
),
})}
{data?.title ? (
<span dangerouslySetInnerHTML={{ __html: data.title.replace(/<green>/g, '<span class="relative inline-block"><span class="relative z-10 text-accent italic inline-block">').replace(/<\/green>/g, '</span><div class="w-[140%] h-[140%] -top-[20%] -left-[20%] text-accent/30 hidden md:block absolute -z-10 animate-in fade-in zoom-in-0 duration-1000 ease-out fill-mode-both" style="animation-delay: 500ms;"><Scribble variant="circle" /></div></span>') }} />
) : (
t.rich('title', {
green: (chunks) => (
<span className="relative inline-block">
<span className="relative z-10 text-accent italic inline-block">{chunks}</span>
<div
className="w-[140%] h-[140%] -top-[20%] -left-[20%] text-accent/30 hidden md:block absolute -z-10 animate-in fade-in zoom-in-0 duration-1000 ease-out fill-mode-both"
style={{ animationDelay: '500ms' }}
>
<Scribble variant="circle" />
</div>
</span>
),
})
)}
</Heading>
</div>
<div>
<p className="text-lg md:text-xl text-white leading-relaxed max-w-2xl mb-10 md:mb-12">
{t('subtitle')}
{data?.subtitle || t('subtitle')}
</p>
</div>
<div className="flex flex-col sm:flex-row justify-center md:justify-start gap-4 md:gap-6">
@@ -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')}
<span className="transition-transform group-hover/btn:translate-x-1 ml-2">
&rarr;
</span>
@@ -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')}
</Button>
</div>
</div>

View File

@@ -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() {
<div className="absolute inset-0 z-0">
<Image
src="/uploads/2024/12/DSC08036-Large.webp"
alt={t('subtitle')}
alt={data?.subtitle || t('subtitle')}
fill
className="object-cover scale-105 animate-slow-zoom"
sizes="100vw"
@@ -24,20 +24,20 @@ export default function MeetTheTeam() {
<Container className="relative z-10">
<div className="max-w-3xl text-white animate-slide-up">
<Heading level={2} subtitle={t('subtitle')} className="text-white mb-8">
<span className="text-white">{t('title')}</span>
<Heading level={2} subtitle={data?.subtitle || t('subtitle')} className="text-white mb-8">
<span className="text-white">{data?.title || t('title')}</span>
</Heading>
<div className="relative mb-12">
<div className="absolute -left-8 top-0 bottom-0 w-1 bg-accent rounded-full" />
<p className="text-xl md:text-2xl leading-relaxed font-medium italic text-white/90 pl-8">
"{t('description')}"
"{data?.description || t('description')}"
</p>
</div>
<div className="flex flex-wrap gap-8 items-center">
<Button href={`/${locale}/team`} variant="accent" size="xl" className="group">
{t('cta')}
{data?.ctaLabel || t('cta')}
<span className="ml-3 transition-transform group-hover:translate-x-2">&rarr;</span>
</Button>
@@ -61,7 +61,7 @@ export default function MeetTheTeam() {
</div>
</div>
<span className="text-white/60 font-bold text-xs md:text-sm uppercase tracking-widest">
{t('andNetwork')}
{data?.networkLabel || t('andNetwork')}
</span>
</div>
</div>

View File

@@ -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 (
<Section className="bg-neutral-light py-0 md:py-0 lg:py-0 -mt-px">
{t.has('title') && (
{(data?.title || t.has('title')) && (
<h2 className="sr-only">
{t.rich('title', { green: (chunks: any) => <span>{chunks}</span> })}
{data?.title ? (
<span dangerouslySetInnerHTML={{ __html: data.title }} />
) : (
t.rich('title', { green: (chunks: any) => <span>{chunks}</span> })
)}
</h2>
)}
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4">

View File

@@ -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 (
<Section className="bg-neutral py-16 md:py-24">
<Container>
<div className="flex flex-col md:flex-row items-start md:items-end justify-between mb-12 md:mb-16 gap-6">
<Heading level={2} subtitle={t('latestNews')} className="mb-0 text-primary">
{t('allArticles')}
<Heading level={2} subtitle={subtitle} className="mb-0 text-primary">
{title}
</Heading>
<Link
href={`/${locale}/blog`}
className="group flex items-center text-primary font-bold text-base md:text-lg touch-target"
>
{t('allArticles')}
{title}
<span className="ml-2 transition-transform group-hover:translate-x-2">&rarr;</span>
</Link>
</div>
<ul className="grid grid-cols-1 md:grid-cols-3 gap-8 md:gap-10 list-none p-0 m-0">
{recentPosts.map((post) => (
<li key={post.slug}>
{recentPosts.map((post, idx) => (
<li key={`${post.slug}-${idx}`}>
<Link href={`/${locale}/blog/${post.slug}`} className="group block h-full">
<Card
tag="article"
@@ -47,6 +51,9 @@ export default async function RecentPosts({ locale }: RecentPostsProps) {
alt={post.frontmatter.title}
fill
className="w-full h-full object-cover transition-transform duration-700 group-hover:scale-110"
style={{
objectPosition: `${post.frontmatter.focalX ?? 50}% ${post.frontmatter.focalY ?? 50}%`,
}}
sizes="(max-width: 768px) 100vw, 33vw"
loading="lazy"
/>

View File

@@ -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<HTMLElement>(null);
@@ -40,17 +40,21 @@ export default function VideoSection() {
<div className="absolute inset-0 bg-gradient-to-b from-primary/60 via-transparent to-primary/60 flex items-center justify-center pointer-events-none">
<div className="max-w-5xl px-6 text-center animate-slide-up pointer-events-auto">
<h2 className="text-3xl md:text-4xl lg:text-5xl font-extrabold text-white leading-[1.1]">
{t.rich('title', {
future: (chunks) => (
<span className="relative inline-block mx-2">
<span className="relative z-10 italic text-accent">{chunks}</span>
<Scribble
variant="underline"
className="w-full h-4 -bottom-2 left-0 text-accent/40"
/>
</span>
),
})}
{data?.title ? (
<span dangerouslySetInnerHTML={{ __html: data.title.replace(/<future>/g, '<span class="relative inline-block mx-2"><span class="relative z-10 italic text-accent">').replace(/<\/future>/g, '</span><Scribble variant="underline" class="w-full h-4 -bottom-2 left-0 text-accent/40" /></span>') }} />
) : (
t.rich('title', {
future: (chunks) => (
<span className="relative inline-block mx-2">
<span className="relative z-10 italic text-accent">{chunks}</span>
<Scribble
variant="underline"
className="w-full h-4 -bottom-2 left-0 text-accent/40"
/>
</span>
),
})
)}
</h2>
</div>
</div>

View File

@@ -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 (
<Section className="bg-white">
<Container>
<div className="sticky-narrative-container">
<div className="sticky-narrative-sidebar">
<div className="lg:sticky lg:top-32">
<Heading level={2} subtitle={t('expertise')} className="text-primary-dark">
{t('title')}
<Heading level={2} subtitle={data?.expertiseLabel || t('expertise')} className="text-primary-dark">
{data?.title || t('title')}
</Heading>
<p className="text-base md:text-lg text-text-secondary leading-relaxed">
{t('subtitle')}
{data?.subtitle || t('subtitle')}
</p>
<div className="mt-8 md:mt-12 p-6 md:p-8 bg-saturated/10 rounded-2xl border border-saturated/10">
<p className="text-saturated font-bold text-base md:text-base italic">
"{t('quote')}"
"{data?.quote || t('quote')}"
</p>
</div>
</div>
</div>
<div className="sticky-narrative-content grid grid-cols-1 md:grid-cols-2 gap-x-8 md:gap-x-12 gap-y-12 md:gap-y-20">
{[0, 1, 2, 3].map((idx) => (
{items.map((item: any, idx: number) => (
<div key={idx} className="group">
<div className="flex items-center gap-4 mb-4 md:mb-6">
<span className="flex items-center justify-center w-10 h-10 md:w-12 md:h-12 rounded-full bg-saturated text-white font-bold text-base md:text-lg shadow-lg shadow-saturated/20 group-hover:scale-110 transition-transform">
@@ -33,8 +38,8 @@ export default function WhatWeDo() {
</span>
<div className="h-px flex-grow bg-neutral-medium" />
</div>
<h3 className="text-lg md:text-xl font-bold mb-3 md:mb-4 text-primary-dark group-hover:text-accent-dark transition-colors">{t(`items.${idx}.title`)}</h3>
<p className="text-text-secondary text-base md:text-base leading-relaxed">{t(`items.${idx}.description`)}</p>
<h3 className="text-lg md:text-xl font-bold mb-3 md:mb-4 text-primary-dark group-hover:text-accent-dark transition-colors">{item.title}</h3>
<p className="text-text-secondary text-base md:text-base leading-relaxed">{item.description}</p>
</div>
))}
</div>

View File

@@ -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 (
<Section className="bg-neutral-light">
<Container>
<div className="grid grid-cols-1 lg:grid-cols-12 gap-16 lg:gap-24">
<div className="lg:col-span-4 order-1 lg:order-2">
<div className="sticky top-32">
<Heading level={2} subtitle={t('whyKlz')} className="text-primary-dark">
{t('title')}
<Heading level={2} subtitle={data?.tagline || t('whyKlz')} className="text-primary-dark">
{data?.title || t('title')}
</Heading>
<p className="text-base md:text-lg text-text-secondary leading-relaxed">
{t('subtitle')}
{data?.subtitle || t('subtitle')}
</p>
<ul className="mt-12 space-y-6 list-none p-0">
{[0, 1, 2, 3].map((i) => (
{features.map((featureText: string, i: number) => (
<li key={i} className="flex items-center gap-4">
<div className="flex-shrink-0 w-6 h-6 rounded-full bg-accent flex items-center justify-center">
<svg
@@ -38,7 +41,7 @@ export default function WhyChooseUs() {
</svg>
</div>
<span className="font-bold text-primary-dark text-base md:text-base">
{t(`features.${i}`)}
{featureText}
</span>
</li>
))}
@@ -46,7 +49,7 @@ export default function WhyChooseUs() {
</div>
</div>
<ul className="lg:col-span-8 grid grid-cols-1 md:grid-cols-2 gap-8 order-2 lg:order-1 list-none p-0 m-0">
{[0, 1, 2, 3].map((idx) => (
{items.map((item: any, idx: number) => (
<li
key={idx}
className="p-10 bg-white rounded-3xl border border-neutral-medium hover:border-accent transition-all duration-500 hover:shadow-xl group"
@@ -57,10 +60,10 @@ export default function WhyChooseUs() {
</span>
</div>
<h3 className="text-xl font-bold mb-4 text-primary-dark">
{t(`items.${idx}.title`)}
{item.title}
</h3>
<p className="text-text-secondary text-base md:text-base leading-relaxed">
{t(`items.${idx}.description`)}
{item.description}
</p>
</li>
))}

View File

@@ -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);
});

View File

@@ -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<PostM
collection: 'posts',
where: {
slug: { equals: slug },
locale: { equals: locale },
...(!isDev ? { _status: { equals: 'published' } } : {}),
},
locale: locale as any,
draft: isDev,
limit: 1,
});
@@ -83,7 +82,6 @@ export async function getPostBySlug(slug: string, locale: string): Promise<PostM
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
@@ -113,11 +111,9 @@ export async function getAllPosts(locale: string): Promise<PostMdx[]> {
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<PostMdx[]> {
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<PostMdx[]> {
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<PostMdx[]> {
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 [];

View File

@@ -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);

View File

@@ -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<PageMdx | null> {
try {
const payload = await getPayload({ config: configPromise });
@@ -23,30 +49,14 @@ export async function getPageBySlug(slug: string, locale: string): Promise<PageM
collection: 'pages' as any,
where: {
slug: { equals: slug },
locale: { equals: locale },
},
locale: locale as any,
limit: 1,
});
const docs = result.docs as any[];
if (!docs || docs.length === 0) return null;
const doc = docs[0];
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, // Native Lexical Editor State
};
return mapDoc(docs[0]);
} catch (error) {
console.error(`[Payload] getPageBySlug failed for ${slug}:`, error);
return null;
@@ -59,31 +69,11 @@ export async function getAllPages(locale: string): Promise<PageMdx[]> {
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<Partial<PageM
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,
};
});
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 [];

View File

@@ -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<Partial<ProductMdx> | null> {
): Promise<Partial<ProductData> | 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<ProductMdx | null> {
export async function getProductBySlug(slug: string, locale: string): Promise<ProductData | null> {
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<Pr
? doc.categories.map((c: any) => 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<Pr
export async function getAllProductSlugs(locale: string): Promise<string[]> {
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<string[]> {
}
}
export async function getAllProducts(locale: string): Promise<ProductMdx[]> {
export async function getAllProducts(locale: string): Promise<ProductData[]> {
try {
const payload = await getPayload({ config: configPromise });
@@ -174,13 +155,15 @@ export async function getAllProducts(locale: string): Promise<ProductMdx[]> {
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<ProductMdx[]> {
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<ProductMdx[]> {
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<ProductMdx[]> {
}
}
export async function getAllProductsMetadata(locale: string): Promise<Partial<ProductMdx>[]> {
export async function getAllProductsMetadata(locale: string): Promise<Partial<ProductData>[]> {
const products = await getAllProducts(locale);
return products.map((p) => ({
slug: p.slug,

View File

@@ -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"
}
}
}
}

View File

@@ -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

View File

@@ -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"
}
}
}

View File

@@ -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<T extends boolean = true> {
excerpt?: T;
date?: T;
featuredImage?: T;
locale?: T;
category?: T;
content?: T;
updatedAt?: T;
@@ -543,7 +550,6 @@ export interface ProductsSelect<T extends boolean = true> {
sku?: T;
slug?: T;
description?: T;
locale?: T;
categories?:
| T
| {
@@ -565,12 +571,13 @@ export interface ProductsSelect<T extends boolean = true> {
export interface PagesSelect<T extends boolean = true> {
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".

View File

@@ -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({

View File

@@ -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

14
scripts/check-pages.ts Normal file
View File

@@ -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();

14
scripts/check-start.ts Normal file
View File

@@ -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();

14
scripts/check-team.ts Normal file
View File

@@ -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();

View File

@@ -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."

View File

@@ -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\`);
});

View File

@@ -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<T = any>(query: string, values: unknown[] = []): Promise<T[]> {
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<string, string> = {
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);
});

View File

@@ -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<string | null> {
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);

View File

@@ -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<string | null> {
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);

View File

@@ -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...`);

131
scripts/seed-home.ts Normal file
View File

@@ -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<string, any> = {}) {
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);
});

242
scripts/seed-pages.ts Normal file
View File

@@ -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<string, any>) {
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);
});

View File

@@ -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;

14
scripts/test-rich-text.js Normal file
View File

@@ -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();

View File

@@ -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<void> {
// ── 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<void> {
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`);
}

View File

@@ -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',
},
];

View File

@@ -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;

View File

@@ -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,
},
],
},
],
};

View File

@@ -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,
},
],
};

View File

@@ -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',
},
],
};

View File

@@ -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' },
],
},
],
};

View File

@@ -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,
];

View File

@@ -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,
},
],
},
],
};

View File

@@ -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,
},
],
},
],
};

View File

@@ -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,
},
],
};

View File

@@ -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' },
],
},
],
};

View File

@@ -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,
];

View File

@@ -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,
},
],

View File

@@ -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,

View File

@@ -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,

View File

@@ -0,0 +1,12 @@
import React from 'react';
export default function Icon() {
return (
<img
src="/logo-blue.svg"
alt="KLZ"
className="klz-admin-icon"
style={{ maxWidth: '100%', height: 'auto', maxHeight: '32px', display: 'block' }}
/>
);
}

View File

@@ -0,0 +1,12 @@
import React from 'react';
export default function Logo() {
return (
<img
src="/logo-blue.svg"
alt="KLZ Cables"
className="klz-admin-logo"
style={{ maxWidth: '100%', height: 'auto', maxHeight: '40px', display: 'block' }}
/>
);
}

View File

@@ -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',
},