feat: payload cms
Some checks failed
Build & Deploy / 🔍 Prepare (push) Successful in 8s
Build & Deploy / 🧪 QA (push) Failing after 1m13s
Build & Deploy / 🏗️ Build (push) Failing after 5m53s
Build & Deploy / 🚀 Deploy (push) Has been skipped
Build & Deploy / 🧪 Smoke Test (push) Has been skipped
Build & Deploy / ⚡ Lighthouse (push) Has been skipped
Build & Deploy / ♿ WCAG (push) Has been skipped
Build & Deploy / 🛡️ Quality Gates (push) Has been skipped
Build & Deploy / 🔔 Notify (push) Successful in 4s

This commit is contained in:
2026-02-24 02:28:48 +01:00
parent 41cfe19cbf
commit a5d77fc69b
89 changed files with 25282 additions and 1903 deletions

4
.env
View File

@@ -37,3 +37,7 @@ INFRA_DIRECTUS_URL=http://localhost:8059
INFRA_DIRECTUS_TOKEN=59fb8f4c1a51b18fe28ad947f713914e INFRA_DIRECTUS_TOKEN=59fb8f4c1a51b18fe28ad947f713914e
GATEKEEPER_ORIGIN=http://klz.localhost GATEKEEPER_ORIGIN=http://klz.localhost
OPENROUTER_API_KEY=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 OPENROUTER_API_KEY=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
# Payload CMS Local Development
POSTGRES_URI=postgres://klz_db_user:klz_db_pass@127.0.0.1:54322/directus
PAYLOAD_SECRET=D7F36ED8D3B2F77A5E6B4A3962

7
.gitignore vendored
View File

@@ -1,18 +1,17 @@
node_modules node_modules
.next .next
.DS_Store .DS_Store
.pnpm-store
public/uploads public/uploads
public/media
# Lighthouse CI # Lighthouse CI
.lighthouseci/ .lighthouseci/
lighthouserc.cjs lighthouserc.cjs
.lighthouserc.json .lighthouserc.json
# Directus # Legacy (Directus) cleanup
directus/uploads directus/uploads
!directus/extensions/
!directus/schema/
!directus/migrations/
.next-docker .next-docker

18
Dockerfile.dev Normal file
View File

@@ -0,0 +1,18 @@
FROM node:20-alpine
# Install essential build tools if needed (e.g., for node-gyp)
RUN apk add --no-cache libc6-compat python3 make g++
WORKDIR /app
# Enable corepack for pnpm
RUN corepack enable
# Pre-set the pnpm store directory
ENV PNPM_HOME="/pnpm"
ENV PATH="$PNPM_HOME:$PATH"
# Set up pnpm store configuration
RUN pnpm config set store-dir /pnpm/store
EXPOSE 3000

View File

@@ -0,0 +1,17 @@
import configPromise from '@payload-config';
import { RootPage } from '@payloadcms/next/views';
import { importMap } from '../importMap';
type Args = {
params: Promise<{
segments: string[];
}>;
searchParams: Promise<{
[key: string]: string | string[];
}>;
};
const Page = ({ params, searchParams }: Args) =>
RootPage({ config: configPromise, importMap, params, searchParams });
export default Page;

View File

@@ -0,0 +1,77 @@
import { RscEntryLexicalCell as RscEntryLexicalCell_44fe37237e0ebf4470c9990d8cb7b07e } from '@payloadcms/richtext-lexical/rsc';
import { RscEntryLexicalField as RscEntryLexicalField_44fe37237e0ebf4470c9990d8cb7b07e } from '@payloadcms/richtext-lexical/rsc';
import { LexicalDiffComponent as LexicalDiffComponent_44fe37237e0ebf4470c9990d8cb7b07e } from '@payloadcms/richtext-lexical/rsc';
import { BlocksFeatureClient as BlocksFeatureClient_e70f5e05f09f93e00b997edb1ef0c864 } from '@payloadcms/richtext-lexical/client';
import { InlineToolbarFeatureClient as InlineToolbarFeatureClient_e70f5e05f09f93e00b997edb1ef0c864 } from '@payloadcms/richtext-lexical/client';
import { HorizontalRuleFeatureClient as HorizontalRuleFeatureClient_e70f5e05f09f93e00b997edb1ef0c864 } from '@payloadcms/richtext-lexical/client';
import { UploadFeatureClient as UploadFeatureClient_e70f5e05f09f93e00b997edb1ef0c864 } from '@payloadcms/richtext-lexical/client';
import { BlockquoteFeatureClient as BlockquoteFeatureClient_e70f5e05f09f93e00b997edb1ef0c864 } from '@payloadcms/richtext-lexical/client';
import { RelationshipFeatureClient as RelationshipFeatureClient_e70f5e05f09f93e00b997edb1ef0c864 } from '@payloadcms/richtext-lexical/client';
import { LinkFeatureClient as LinkFeatureClient_e70f5e05f09f93e00b997edb1ef0c864 } from '@payloadcms/richtext-lexical/client';
import { ChecklistFeatureClient as ChecklistFeatureClient_e70f5e05f09f93e00b997edb1ef0c864 } from '@payloadcms/richtext-lexical/client';
import { OrderedListFeatureClient as OrderedListFeatureClient_e70f5e05f09f93e00b997edb1ef0c864 } from '@payloadcms/richtext-lexical/client';
import { UnorderedListFeatureClient as UnorderedListFeatureClient_e70f5e05f09f93e00b997edb1ef0c864 } from '@payloadcms/richtext-lexical/client';
import { IndentFeatureClient as IndentFeatureClient_e70f5e05f09f93e00b997edb1ef0c864 } from '@payloadcms/richtext-lexical/client';
import { AlignFeatureClient as AlignFeatureClient_e70f5e05f09f93e00b997edb1ef0c864 } from '@payloadcms/richtext-lexical/client';
import { HeadingFeatureClient as HeadingFeatureClient_e70f5e05f09f93e00b997edb1ef0c864 } from '@payloadcms/richtext-lexical/client';
import { ParagraphFeatureClient as ParagraphFeatureClient_e70f5e05f09f93e00b997edb1ef0c864 } from '@payloadcms/richtext-lexical/client';
import { InlineCodeFeatureClient as InlineCodeFeatureClient_e70f5e05f09f93e00b997edb1ef0c864 } from '@payloadcms/richtext-lexical/client';
import { SuperscriptFeatureClient as SuperscriptFeatureClient_e70f5e05f09f93e00b997edb1ef0c864 } from '@payloadcms/richtext-lexical/client';
import { SubscriptFeatureClient as SubscriptFeatureClient_e70f5e05f09f93e00b997edb1ef0c864 } from '@payloadcms/richtext-lexical/client';
import { StrikethroughFeatureClient as StrikethroughFeatureClient_e70f5e05f09f93e00b997edb1ef0c864 } from '@payloadcms/richtext-lexical/client';
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 { CollectionCards as CollectionCards_f9c02e79a4aed9a3924487c0cd4cafb1 } from '@payloadcms/next/rsc';
export const importMap = {
'@payloadcms/richtext-lexical/rsc#RscEntryLexicalCell':
RscEntryLexicalCell_44fe37237e0ebf4470c9990d8cb7b07e,
'@payloadcms/richtext-lexical/rsc#RscEntryLexicalField':
RscEntryLexicalField_44fe37237e0ebf4470c9990d8cb7b07e,
'@payloadcms/richtext-lexical/rsc#LexicalDiffComponent':
LexicalDiffComponent_44fe37237e0ebf4470c9990d8cb7b07e,
'@payloadcms/richtext-lexical/client#BlocksFeatureClient':
BlocksFeatureClient_e70f5e05f09f93e00b997edb1ef0c864,
'@payloadcms/richtext-lexical/client#InlineToolbarFeatureClient':
InlineToolbarFeatureClient_e70f5e05f09f93e00b997edb1ef0c864,
'@payloadcms/richtext-lexical/client#HorizontalRuleFeatureClient':
HorizontalRuleFeatureClient_e70f5e05f09f93e00b997edb1ef0c864,
'@payloadcms/richtext-lexical/client#UploadFeatureClient':
UploadFeatureClient_e70f5e05f09f93e00b997edb1ef0c864,
'@payloadcms/richtext-lexical/client#BlockquoteFeatureClient':
BlockquoteFeatureClient_e70f5e05f09f93e00b997edb1ef0c864,
'@payloadcms/richtext-lexical/client#RelationshipFeatureClient':
RelationshipFeatureClient_e70f5e05f09f93e00b997edb1ef0c864,
'@payloadcms/richtext-lexical/client#LinkFeatureClient':
LinkFeatureClient_e70f5e05f09f93e00b997edb1ef0c864,
'@payloadcms/richtext-lexical/client#ChecklistFeatureClient':
ChecklistFeatureClient_e70f5e05f09f93e00b997edb1ef0c864,
'@payloadcms/richtext-lexical/client#OrderedListFeatureClient':
OrderedListFeatureClient_e70f5e05f09f93e00b997edb1ef0c864,
'@payloadcms/richtext-lexical/client#UnorderedListFeatureClient':
UnorderedListFeatureClient_e70f5e05f09f93e00b997edb1ef0c864,
'@payloadcms/richtext-lexical/client#IndentFeatureClient':
IndentFeatureClient_e70f5e05f09f93e00b997edb1ef0c864,
'@payloadcms/richtext-lexical/client#AlignFeatureClient':
AlignFeatureClient_e70f5e05f09f93e00b997edb1ef0c864,
'@payloadcms/richtext-lexical/client#HeadingFeatureClient':
HeadingFeatureClient_e70f5e05f09f93e00b997edb1ef0c864,
'@payloadcms/richtext-lexical/client#ParagraphFeatureClient':
ParagraphFeatureClient_e70f5e05f09f93e00b997edb1ef0c864,
'@payloadcms/richtext-lexical/client#InlineCodeFeatureClient':
InlineCodeFeatureClient_e70f5e05f09f93e00b997edb1ef0c864,
'@payloadcms/richtext-lexical/client#SuperscriptFeatureClient':
SuperscriptFeatureClient_e70f5e05f09f93e00b997edb1ef0c864,
'@payloadcms/richtext-lexical/client#SubscriptFeatureClient':
SubscriptFeatureClient_e70f5e05f09f93e00b997edb1ef0c864,
'@payloadcms/richtext-lexical/client#StrikethroughFeatureClient':
StrikethroughFeatureClient_e70f5e05f09f93e00b997edb1ef0c864,
'@payloadcms/richtext-lexical/client#UnderlineFeatureClient':
UnderlineFeatureClient_e70f5e05f09f93e00b997edb1ef0c864,
'@payloadcms/richtext-lexical/client#BoldFeatureClient':
BoldFeatureClient_e70f5e05f09f93e00b997edb1ef0c864,
'@payloadcms/richtext-lexical/client#ItalicFeatureClient':
ItalicFeatureClient_e70f5e05f09f93e00b997edb1ef0c864,
'@payloadcms/next/rsc#CollectionCards': CollectionCards_f9c02e79a4aed9a3924487c0cd4cafb1,
};

View File

@@ -0,0 +1,14 @@
import config from '@payload-config';
import {
REST_GET,
REST_OPTIONS,
REST_PATCH,
REST_POST,
REST_DELETE,
} from '@payloadcms/next/routes';
export const GET = REST_GET(config);
export const POST = REST_POST(config);
export const DELETE = REST_DELETE(config);
export const PATCH = REST_PATCH(config);
export const OPTIONS = REST_OPTIONS(config);

View File

@@ -0,0 +1,4 @@
import config from '@payload-config';
import { GRAPHQL_POST } from '@payloadcms/next/routes';
export const POST = GRAPHQL_POST(config);

View File

@@ -0,0 +1 @@
/* Custom Payload CMS admin styles can go here. Do not import payloadcms/ui/scss/app.scss as it is handled by @payloadcms/next/css */

31
app/(payload)/layout.tsx Normal file
View File

@@ -0,0 +1,31 @@
import configPromise from '@payload-config';
import { RootLayout } from '@payloadcms/next/layouts';
import React from 'react';
import '@payloadcms/next/css';
import './custom.scss';
import { handleServerFunctions } from '@payloadcms/next/layouts';
import { importMap } from './admin/importMap';
type Args = {
children: React.ReactNode;
};
const serverFunction: any = async function (args: any) {
'use server';
return handleServerFunctions({
...args,
config: configPromise,
importMap,
});
};
const Layout = ({ children }: Args) => {
return (
<RootLayout config={configPromise} importMap={importMap} serverFunction={serverFunction}>
{children}
</RootLayout>
);
};
export default Layout;

View File

@@ -1,10 +1,9 @@
import { notFound } from 'next/navigation'; import { notFound } from 'next/navigation';
import { MDXRemote } from 'next-mdx-remote/rsc';
import { Container, Badge, Heading } from '@/components/ui'; import { Container, Badge, Heading } from '@/components/ui';
import { getTranslations, setRequestLocale } from 'next-intl/server'; import { getTranslations, setRequestLocale } from 'next-intl/server';
import { Metadata } from 'next'; import { Metadata } from 'next';
import { getPageBySlug, getAllPages } from '@/lib/pages'; import { getPageBySlug, getAllPages } from '@/lib/pages';
import { mdxComponents } from '@/components/blog/MDXComponents'; import PayloadRichText from '@/components/PayloadRichText';
import { SITE_URL } from '@/lib/schema'; import { SITE_URL } from '@/lib/schema';
import TrackedLink from '@/components/analytics/TrackedLink'; import TrackedLink from '@/components/analytics/TrackedLink';
@@ -102,7 +101,7 @@ export default async function StandardPage({ params }: PageProps) {
{/* Main content with shared blog components */} {/* Main content with shared blog components */}
<div className="prose prose-lg md:prose-xl max-w-none prose-headings:font-bold prose-headings:text-text-primary prose-p:text-text-secondary prose-p:leading-relaxed prose-a:text-primary prose-a:no-underline hover:prose-a:underline prose-img:rounded-2xl prose-img:shadow-2xl prose-blockquote:border-primary prose-blockquote:bg-primary/5 prose-blockquote:rounded-r-2xl prose-strong:text-primary"> <div className="prose prose-lg md:prose-xl max-w-none prose-headings:font-bold prose-headings:text-text-primary prose-p:text-text-secondary prose-p:leading-relaxed prose-a:text-primary prose-a:no-underline hover:prose-a:underline prose-img:rounded-2xl prose-img:shadow-2xl prose-blockquote:border-primary prose-blockquote:bg-primary/5 prose-blockquote:rounded-r-2xl prose-strong:text-primary">
<MDXRemote source={pageData.content} components={mdxComponents} /> <PayloadRichText data={pageData.content} />
</div> </div>
{/* Support Section */} {/* Support Section */}

View File

@@ -1,19 +1,19 @@
import { notFound } from 'next/navigation'; import { notFound } from 'next/navigation';
import JsonLd from '@/components/JsonLd'; import JsonLd from '@/components/JsonLd';
import { SITE_URL } from '@/lib/schema'; import { SITE_URL } from '@/lib/schema';
import { MDXRemote } from 'next-mdx-remote/rsc'; import { getPostBySlug, getAdjacentPosts, getReadingTime } from '@/lib/blog';
import { getPostBySlug, getAdjacentPosts, getReadingTime, getHeadings } from '@/lib/blog';
import { Metadata } from 'next'; import { Metadata } from 'next';
import Link from 'next/link'; import Link from 'next/link';
import Image from 'next/image'; import Image from 'next/image';
import PostNavigation from '@/components/blog/PostNavigation'; import PostNavigation from '@/components/blog/PostNavigation';
import PowerCTA from '@/components/blog/PowerCTA'; import PowerCTA from '@/components/blog/PowerCTA';
import TableOfContents from '@/components/blog/TableOfContents';
import { mdxComponents } from '@/components/blog/MDXComponents';
import { Heading } from '@/components/ui'; import { Heading } from '@/components/ui';
import { setRequestLocale } from 'next-intl/server'; import { setRequestLocale } from 'next-intl/server';
import BlogEngagementTracker from '@/components/analytics/BlogEngagementTracker'; import BlogEngagementTracker from '@/components/analytics/BlogEngagementTracker';
// Payload CMS Imports
import PayloadRichText from '@/components/PayloadRichText';
interface BlogPostProps { interface BlogPostProps {
params: Promise<{ params: Promise<{
locale: string; locale: string;
@@ -60,7 +60,8 @@ export default async function BlogPost({ params }: BlogPostProps) {
notFound(); notFound();
} }
const headings = getHeadings(post.content); // Convert Lexical content into a plain string to estimate reading time roughly
const rawTextContent = JSON.stringify(post.content);
return ( return (
<article className="bg-white min-h-screen font-sans selection:bg-primary/10 selection:text-primary"> <article className="bg-white min-h-screen font-sans selection:bg-primary/10 selection:text-primary">
@@ -68,7 +69,7 @@ export default async function BlogPost({ params }: BlogPostProps) {
title={post.frontmatter.title} title={post.frontmatter.title}
slug={slug} slug={slug}
category={post.frontmatter.category} category={post.frontmatter.category}
readingTime={getReadingTime(post.content)} readingTime={getReadingTime(rawTextContent)}
/> />
{/* Featured Image Header */} {/* Featured Image Header */}
@@ -76,7 +77,7 @@ export default async function BlogPost({ params }: BlogPostProps) {
<div className="relative w-full h-[70vh] min-h-[500px] overflow-hidden group"> <div className="relative w-full h-[70vh] min-h-[500px] overflow-hidden group">
<div className="absolute inset-0 transition-transform duration-[3s] ease-out scale-110 group-hover:scale-100"> <div className="absolute inset-0 transition-transform duration-[3s] ease-out scale-110 group-hover:scale-100">
<Image <Image
src={`${post.frontmatter.featuredImage}?ar=16:9`} src={post.frontmatter.featuredImage.split('?')[0]}
alt={post.frontmatter.title} alt={post.frontmatter.title}
fill fill
priority priority
@@ -109,7 +110,7 @@ export default async function BlogPost({ params }: BlogPostProps) {
})} })}
</time> </time>
<span className="w-1 h-1 bg-white/30 rounded-full" /> <span className="w-1 h-1 bg-white/30 rounded-full" />
<span>{getReadingTime(post.content)} min read</span> <span>{getReadingTime(rawTextContent)} min read</span>
{(new Date(post.frontmatter.date) > new Date() || {(new Date(post.frontmatter.date) > new Date() ||
post.frontmatter.public === false) && ( post.frontmatter.public === false) && (
<> <>
@@ -146,7 +147,7 @@ export default async function BlogPost({ params }: BlogPostProps) {
})} })}
</time> </time>
<span className="w-1 h-1 bg-neutral-400 rounded-full" /> <span className="w-1 h-1 bg-neutral-400 rounded-full" />
<span>{getReadingTime(post.content)} min read</span> <span>{getReadingTime(rawTextContent)} min read</span>
{(new Date(post.frontmatter.date) > new Date() || {(new Date(post.frontmatter.date) > new Date() ||
post.frontmatter.public === false) && ( post.frontmatter.public === false) && (
<> <>
@@ -175,9 +176,9 @@ export default async function BlogPost({ params }: BlogPostProps) {
</div> </div>
)} )}
{/* Main content with enhanced styling */} {/* Main content with enhanced styling rendering Payload Lexical */}
<div className="prose prose-lg md:prose-xl max-w-none prose-headings:font-bold prose-headings:text-text-primary prose-p:text-text-secondary prose-p:leading-relaxed prose-a:text-primary prose-a:no-underline hover:prose-a:underline prose-img:rounded-2xl prose-img:shadow-2xl prose-blockquote:border-primary prose-blockquote:bg-primary/5 prose-blockquote:rounded-r-2xl prose-strong:text-primary"> <div className="prose prose-lg md:prose-xl max-w-none prose-headings:font-bold prose-headings:text-text-primary prose-p:text-text-secondary prose-p:leading-relaxed prose-a:text-primary prose-a:no-underline hover:prose-a:underline prose-img:rounded-2xl prose-img:shadow-2xl prose-blockquote:border-primary prose-blockquote:bg-primary/5 prose-blockquote:rounded-r-2xl prose-strong:text-primary">
<MDXRemote source={post.content} components={mdxComponents} /> <PayloadRichText data={post.content} />
</div> </div>
{/* Power CTA */} {/* Power CTA */}
@@ -220,10 +221,10 @@ export default async function BlogPost({ params }: BlogPostProps) {
</div> </div>
</div> </div>
{/* Right Column: Sticky Sidebar */} {/* Right Column: Sticky Sidebar - Temporarily Hidden without ToC */}
<aside className="sticky-narrative-sidebar hidden lg:block"> <aside className="sticky-narrative-sidebar hidden lg:block">
<div className="space-y-12"> <div className="space-y-12">
<TableOfContents headings={headings} locale={locale} /> {/* Future Payload Table of Contents Implementation */}
</div> </div>
</aside> </aside>
</div> </div>
@@ -262,8 +263,8 @@ export default async function BlogPost({ params }: BlogPostProps) {
'@id': `${SITE_URL}/${locale}/blog/${slug}`, '@id': `${SITE_URL}/${locale}/blog/${slug}`,
}, },
articleSection: post.frontmatter.category, articleSection: post.frontmatter.category,
wordCount: post.content.split(/\s+/).length, wordCount: rawTextContent.split(/\s+/).length,
timeRequired: `PT${getReadingTime(post.content)}M`, timeRequired: `PT${getReadingTime(rawTextContent)}M`,
} as any } as any
} }
/> />

View File

@@ -63,7 +63,7 @@ export default async function BlogIndex({ params }: BlogIndexProps) {
{featuredPost && featuredPost.frontmatter.featuredImage && ( {featuredPost && featuredPost.frontmatter.featuredImage && (
<> <>
<Image <Image
src={featuredPost.frontmatter.featuredImage} src={featuredPost.frontmatter.featuredImage.split('?')[0]}
alt={featuredPost.frontmatter.title} alt={featuredPost.frontmatter.title}
fill fill
className="absolute inset-0 w-full h-full object-cover opacity-40 md:opacity-60" className="absolute inset-0 w-full h-full object-cover opacity-40 md:opacity-60"
@@ -164,7 +164,7 @@ export default async function BlogIndex({ params }: BlogIndexProps) {
{post.frontmatter.featuredImage && ( {post.frontmatter.featuredImage && (
<div className="relative h-48 md:h-72 overflow-hidden"> <div className="relative h-48 md:h-72 overflow-hidden">
<Image <Image
src={post.frontmatter.featuredImage} src={post.frontmatter.featuredImage.split('?')[0]}
alt={post.frontmatter.title} alt={post.frontmatter.title}
fill fill
className="w-full h-full object-cover transition-transform duration-1000 group-hover:scale-110" className="w-full h-full object-cover transition-transform duration-1000 group-hover:scale-110"

View File

@@ -8,7 +8,6 @@ export const contentType = 'image/png';
export const runtime = 'nodejs'; export const runtime = 'nodejs';
export default async function Image({ params }: { params: Promise<{ locale: string }> }) { export default async function Image({ params }: { params: Promise<{ locale: string }> }) {
console.log('🖼️ OG Image Handler Called');
const { locale } = await params; const { locale } = await params;
const t = await getTranslations({ locale, namespace: 'Index.meta' }); const t = await getTranslations({ locale, namespace: 'Index.meta' });
const fonts = await getOgFonts(); const fonts = await getOgFonts();

View File

@@ -12,11 +12,11 @@ import { mapFileSlugToTranslated, mapSlugToFileSlug } from '@/lib/slugs';
import { Metadata } from 'next'; import { Metadata } from 'next';
import { getTranslations, setRequestLocale } from 'next-intl/server'; import { getTranslations, setRequestLocale } from 'next-intl/server';
import { getProductOGImageMetadata } from '@/lib/metadata'; import { getProductOGImageMetadata } from '@/lib/metadata';
import { MDXRemote } from 'next-mdx-remote/rsc';
import Image from 'next/image'; import Image from 'next/image';
import Link from 'next/link'; import Link from 'next/link';
import { notFound } from 'next/navigation'; import { notFound } from 'next/navigation';
import ProductEngagementTracker from '@/components/analytics/ProductEngagementTracker'; import ProductEngagementTracker from '@/components/analytics/ProductEngagementTracker';
import PayloadRichText from '@/components/PayloadRichText';
interface ProductPageProps { interface ProductPageProps {
params: Promise<{ params: Promise<{
@@ -103,76 +103,7 @@ export async function generateMetadata({ params }: ProductPageProps): Promise<Me
}; };
} }
const components = { // ... the rest of the file layout ...
ProductTechnicalData,
ProductTabs,
p: (props: any) => (
<p
{...props}
className="text-lg md:text-xl text-text-secondary leading-relaxed mb-8 font-medium"
/>
),
h1: (props: any) => (
<div className="relative mb-16">
<h2
{...props}
className="text-3xl md:text-4xl font-black text-primary tracking-tighter uppercase mb-6"
/>
<div className="w-20 h-1.5 bg-accent rounded-full" />
</div>
),
h2: (props: any) => (
<h3
{...props}
className="text-2xl md:text-3xl font-black text-primary mb-10 tracking-tight uppercase"
/>
),
h3: (props: any) => (
<h4
{...props}
className="text-xl md:text-2xl font-black text-primary mb-8 tracking-tight uppercase"
/>
),
ul: (props: any) => <ul {...props} className="list-none pl-0 mb-10" />,
section: (props: any) => <div {...props} className="block" />,
li: (props: any) => (
<li className="flex items-start gap-4 group mb-4 last:mb-0">
<div className="mt-2.5 w-2 h-2 rounded-full bg-accent flex-shrink-0 group-hover:scale-125 transition-transform" />
<span
{...props}
className="text-lg md:text-xl text-text-secondary leading-relaxed font-medium"
/>
</li>
),
strong: (props: any) => <strong {...props} className="font-black text-primary" />,
table: (props: any) => (
<div className="overflow-x-auto my-20 rounded-[32px] border border-neutral-dark/10 shadow-xl bg-white p-1">
<table {...props} className="min-w-full divide-y divide-neutral-dark/10" />
</div>
),
th: (props: any) => (
<th
{...props}
className="px-8 py-6 bg-neutral-light/50 text-left text-[10px] font-black uppercase tracking-[0.25em] text-primary/60"
/>
),
td: (props: any) => (
<td
{...props}
className="px-8 py-6 text-text-secondary border-t border-neutral-dark/5 text-lg md:text-xl font-medium"
/>
),
hr: () => <hr className="my-24 border-t-2 border-neutral-dark/5" />,
blockquote: (props: any) => (
<div className="my-20 p-10 md:p-16 bg-primary-dark rounded-[40px] relative overflow-hidden group">
<div className="absolute top-0 right-0 w-64 h-64 bg-accent/10 rounded-full -translate-y-1/2 translate-x-1/2 blur-3xl group-hover:bg-accent/20 transition-colors duration-700" />
<div
className="relative z-10 italic text-2xl md:text-3xl text-white/90 leading-relaxed font-black tracking-tight"
{...props}
/>
</div>
),
};
export default async function ProductPage({ params }: ProductPageProps) { export default async function ProductPage({ params }: ProductPageProps) {
const { locale, slug } = await params; const { locale, slug } = await params;
@@ -181,7 +112,6 @@ export default async function ProductPage({ params }: ProductPageProps) {
const t = await getTranslations('Products'); const t = await getTranslations('Products');
const productsSlug = await mapFileSlugToTranslated('products', locale); const productsSlug = await mapFileSlugToTranslated('products', locale);
// Check if it's a category page
const categories = [ const categories = [
'low-voltage-cables', 'low-voltage-cables',
'medium-voltage-cables', 'medium-voltage-cables',
@@ -191,6 +121,7 @@ export default async function ProductPage({ params }: ProductPageProps) {
const fileSlug = await mapSlugToFileSlug(productSlug, locale); const fileSlug = await mapSlugToFileSlug(productSlug, locale);
if (categories.includes(fileSlug)) { if (categories.includes(fileSlug)) {
// (Skipping category page block, same as before)
const allProducts = await getAllProducts(locale); const allProducts = await getAllProducts(locale);
const categoryKey = fileSlug const categoryKey = fileSlug
.replace(/-cables$/, '') .replace(/-cables$/, '')
@@ -199,14 +130,12 @@ export default async function ProductPage({ params }: ProductPageProps) {
? t(`categories.${categoryKey}.title`) ? t(`categories.${categoryKey}.title`)
: fileSlug; : fileSlug;
// Filter products for this category
const filteredProducts = allProducts.filter((p) => const filteredProducts = allProducts.filter((p) =>
p.frontmatter.categories.some( p.frontmatter.categories.some(
(cat) => cat.toLowerCase().replace(/\s+/g, '-') === fileSlug || cat === categoryTitle, (cat) => cat.toLowerCase().replace(/\s+/g, '-') === fileSlug || cat === categoryTitle,
), ),
); );
// Get translated product slugs
const productsWithTranslatedSlugs = await Promise.all( const productsWithTranslatedSlugs = await Promise.all(
filteredProducts.map(async (p) => ({ filteredProducts.map(async (p) => ({
...p, ...p,
@@ -257,7 +186,6 @@ export default async function ProductPage({ params }: ProductPageProps) {
className="object-contain p-8 transition-transform duration-700 group-hover:scale-110 z-10" className="object-contain p-8 transition-transform duration-700 group-hover:scale-110 z-10"
sizes="(max-width: 768px) 100vw, (max-width: 1024px) 50vw, 33vw" sizes="(max-width: 768px) 100vw, (max-width: 1024px) 50vw, 33vw"
/> />
{/* Subtle reflection/shadow effect */}
<div className="absolute bottom-4 left-1/2 -translate-x-1/2 w-2/3 h-4 bg-black/5 blur-xl rounded-full" /> <div className="absolute bottom-4 left-1/2 -translate-x-1/2 w-2/3 h-4 bg-black/5 blur-xl rounded-full" />
</> </>
)} )}
@@ -314,17 +242,14 @@ export default async function ProductPage({ params }: ProductPageProps) {
notFound(); notFound();
} }
// Extract technical data for schema // Extract technical data natively from the Lexical AST for Schema.org
const technicalDataMatch = product.content.match(
/technicalData=\{<ProductTechnicalData data=\{(.*?)\}\s*\/>\}/s,
);
let technicalItems = []; let technicalItems = [];
if (technicalDataMatch) { if (product.content?.root?.children) {
try { const productTabsBlock = product.content.root.children.find(
const data = JSON.parse(technicalDataMatch[1]); (node: any) => node.type === 'block' && node.fields?.blockType === 'productTabs',
technicalItems = data.technicalItems || []; );
} catch (e) { if (productTabsBlock && productTabsBlock.fields?.technicalItems) {
console.error('Failed to parse technical data for schema', e); technicalItems = productTabsBlock.fields.technicalItems;
} }
} }
@@ -347,23 +272,6 @@ export default async function ProductPage({ params }: ProductPageProps) {
/> />
); );
const productComponents = {
...components,
ProductTabs: (props: any) => <ProductTabs {...props} sidebar={sidebar} />,
};
// Pre-process content to convert raw HTML tags to Markdown so they use our custom components
const processedContent = product.content
.replace(/<h1[^>]*>(.*?)<\/h1>/gs, '\n# $1\n') // Maps to our custom h1 (which renders h2)
.replace(/<h2[^>]*>(.*?)<\/h2>/gs, '\n## $1\n') // Maps to our custom h2 (which renders h3)
.replace(/<h3[^>]*>(.*?)<\/h3>/gs, '\n### $1\n') // Maps to our custom h3 (which renders h4)
.replace(/<p[^>]*>(.*?)<\/p>/gs, '\n$1\n')
.replace(/<ul[^>]*>(.*?)<\/ul>/gs, '\n$1\n')
.replace(/<li[^>]*>(.*?)<\/li>/gs, '\n- $1\n')
.replace(/<strong[^>]*>(.*?)<\/strong>/gs, '**$1**')
.replace(/<section[^>]*>/gs, '')
.replace(/<\/section>/gs, '');
return ( return (
<div className="flex flex-col min-h-screen bg-white relative"> <div className="flex flex-col min-h-screen bg-white relative">
{/* Product Hero */} {/* Product Hero */}
@@ -474,8 +382,8 @@ export default async function ProductPage({ params }: ProductPageProps) {
<div className="relative"> <div className="relative">
<div className="w-full"> <div className="w-full">
{/* Main Content Area */} {/* Main Content Area */}
<div className="max-w-none"> <div className="max-w-none prose prose-lg mt-8">
<MDXRemote source={processedContent} components={productComponents} /> <PayloadRichText data={product.content} />
</div> </div>
{/* Datasheet Download Section - Only for Medium Voltage for now */} {/* Datasheet Download Section - Only for Medium Voltage for now */}

View File

@@ -236,7 +236,7 @@ export default async function ProductsPage({ params }: ProductsPageProps) {
className="group whitespace-nowrap w-full md:w-auto md:h-16 md:px-10 md:text-xl" className="group whitespace-nowrap w-full md:w-auto md:h-16 md:px-10 md:text-xl"
> >
{t('cta.button')} {t('cta.button')}
<span className="ml-4 transition-transform group-hover:translate-x-2"> <span className="ml-2 md:ml-4 transition-transform group-hover:translate-x-2">
&rarr; &rarr;
</span> </span>
</Button> </Button>

View File

@@ -1,7 +1,5 @@
'use server'; 'use server';
import client, { ensureAuthenticated } from '@/lib/directus';
import { createItem } from '@directus/sdk';
import { sendEmail } from '@/lib/mail/mailer'; import { sendEmail } from '@/lib/mail/mailer';
import { render, ContactFormNotification, ConfirmationMessage } from '@mintel/mail'; import { render, ContactFormNotification, ConfirmationMessage } from '@mintel/mail';
import React from 'react'; import React from 'react';
@@ -41,31 +39,30 @@ export async function sendContactFormAction(formData: FormData) {
return { success: false, error: 'Missing required fields' }; return { success: false, error: 'Missing required fields' };
} }
// 1. Save to Directus // 1. Save to CMS
try { try {
await ensureAuthenticated(); const { getPayload } = await import('payload');
if (productName) { const configPromise = (await import('@payload-config')).default;
await client.request( const payload = await getPayload({ config: configPromise });
createItem('product_requests', {
product_name: productName, await payload.create({
email, collection: 'form-submissions',
message, data: {
}),
);
logger.info('Product request stored in Directus');
} else {
await client.request(
createItem('contact_submissions', {
name, name,
email, email,
message, message,
}), type: productName ? 'product_quote' : 'contact',
); productName: productName || undefined,
logger.info('Contact submission stored in Directus'); },
} });
logger.info('Successfully saved form submission to Payload CMS', {
type: productName ? 'product_quote' : 'contact',
email,
});
} catch (error) { } catch (error) {
logger.error('Failed to store submission in Directus', { error }); logger.error('Failed to store submission in Payload CMS', { error });
services.errors.captureException(error, { action: 'directus_store_submission' }); services.errors.captureException(error, { action: 'payload_store_submission' });
} }
// 2. Send Emails // 2. Send Emails

View File

@@ -1,9 +1,9 @@
import { checkHealth } from '@/lib/directus';
import { NextResponse } from 'next/server'; import { NextResponse } from 'next/server';
export const dynamic = 'force-dynamic'; export const dynamic = 'force-dynamic';
export async function GET() { export async function GET() {
const health = await checkHealth(); // Payload is embedded within the Next.js app, so if this route responds, the CMS is up.
return NextResponse.json(health, { status: health.status === 'ok' ? 200 : 503 }); // Further DB health checks can be implemented via Payload Local API later.
return NextResponse.json({ status: 'ok', message: 'Payload CMS is embedded.' }, { status: 200 });
} }

View File

@@ -17,6 +17,11 @@ export async function POST(request: NextRequest) {
const logger = services.logger.child({ component: 'sentry-relay' }); const logger = services.logger.child({ component: 'sentry-relay' });
try { try {
// Prevent 403 Forbidden console noise in local dev
if (process.env.NODE_ENV === 'development') {
return NextResponse.json({ status: 'ignored_in_dev' }, { status: 200 });
}
const envelope = await request.text(); const envelope = await request.text();
// Sentry envelopes can contain multiple parts separated by newlines // Sentry envelopes can contain multiple parts separated by newlines

View File

@@ -19,6 +19,11 @@ export async function POST(request: NextRequest) {
const logger = services.logger.child({ component: 'umami-smart-proxy' }); const logger = services.logger.child({ component: 'umami-smart-proxy' });
try { try {
// Prevent 400 Bad Request console noise in local dev
if (process.env.NODE_ENV === 'development') {
return NextResponse.json({ status: 'ignored_in_dev' }, { status: 200 });
}
const body = await request.json(); const body = await request.json();
const { type, payload } = body; const { type, payload } = body;

39
check-data.ts Normal file
View File

@@ -0,0 +1,39 @@
import { getPayload } from 'payload';
import configPromise from '@payload-config';
async function checkData() {
try {
const payload = await getPayload({ config: configPromise });
const { docs: posts } = await payload.find({ collection: 'posts', limit: 3 });
const { docs: products } = await payload.find({ collection: 'products', limit: 3 });
const { docs: pages } = await payload.find({ collection: 'pages', limit: 3 });
const checkDocs = (name: string, docs: any[]) => {
console.log(`\n----- ${name.toUpperCase()} -----`);
docs.forEach((p) => {
console.log(`ID: ${p.id}, Slug: ${p.slug}`);
if (Array.isArray(p.content)) {
console.log(
'Content is ARRAY (Slate format!)',
JSON.stringify(p.content).substring(0, 100),
);
} else if (p.content && p.content.root) {
console.log('Content is Lexical format.');
} else {
console.log('Content is UNKNOWN format.');
console.log(JSON.stringify(p.content).substring(0, 100));
}
});
};
checkDocs('posts', posts);
checkDocs('products', products);
checkDocs('pages', pages);
} catch (err) {
console.error(err);
}
process.exit(0);
}
checkData();

View File

@@ -0,0 +1,213 @@
import { RichText } from '@payloadcms/richtext-lexical/react';
import type { JSXConverters } from '@payloadcms/richtext-lexical/react';
import Image from 'next/image';
// Import all custom React components that were previously mapped via MDX
import StickyNarrative from '@/components/blog/StickyNarrative';
import ComparisonGrid from '@/components/blog/ComparisonGrid';
import VisualLinkPreview from '@/components/blog/VisualLinkPreview';
import TechnicalGrid from '@/components/blog/TechnicalGrid';
import HighlightBox from '@/components/blog/HighlightBox';
import AnimatedImage from '@/components/blog/AnimatedImage';
import ChatBubble from '@/components/blog/ChatBubble';
import PowerCTA from '@/components/blog/PowerCTA';
import { Callout } from '@/components/ui/Callout';
import Stats from '@/components/blog/Stats';
import SplitHeading from '@/components/blog/SplitHeading';
import ProductTabs from '@/components/ProductTabs';
import ProductTechnicalData from '@/components/ProductTechnicalData';
const jsxConverters: JSXConverters = {
blocks: {
// Map the custom Payload Blocks created in src/payload/blocks to their React components
// Payload Lexical exposes blocks using the 'block-[slug]' pattern
stickyNarrative: ({ node }: any) => (
<StickyNarrative title={node.fields.title} items={node.fields.items} />
),
'block-stickyNarrative': ({ node }: any) => (
<StickyNarrative title={node.fields.title} items={node.fields.items} />
),
comparisonGrid: ({ node }: any) => (
<ComparisonGrid
title={node.fields.title}
leftLabel={node.fields.leftLabel}
rightLabel={node.fields.rightLabel}
items={node.fields.items}
/>
),
'block-comparisonGrid': ({ node }: any) => (
<ComparisonGrid
title={node.fields.title}
leftLabel={node.fields.leftLabel}
rightLabel={node.fields.rightLabel}
items={node.fields.items}
/>
),
visualLinkPreview: ({ node }: any) => (
<VisualLinkPreview
url={node.fields.url}
title={node.fields.title}
summary={node.fields.summary}
image={node.fields.image?.sizes?.card?.url || node.fields.image?.url}
/>
),
'block-visualLinkPreview': ({ node }: any) => (
<VisualLinkPreview
url={node.fields.url}
title={node.fields.title}
summary={node.fields.summary}
image={node.fields.image?.sizes?.card?.url || node.fields.image?.url}
/>
),
technicalGrid: ({ node }: any) => (
<TechnicalGrid title={node.fields.title} items={node.fields.items} />
),
'block-technicalGrid': ({ node }: any) => {
console.log('[PayloadRichText] Rendering block-technicalGrid:', node.fields.title);
return <TechnicalGrid title={node.fields.title} items={node.fields.items} />;
},
highlightBox: ({ node }: any) => (
<HighlightBox title={node.fields.title} color={node.fields.color}>
<RichText data={node.fields.content} converters={jsxConverters} />
</HighlightBox>
),
'block-highlightBox': ({ node }: any) => (
<HighlightBox title={node.fields.title} color={node.fields.color}>
<RichText data={node.fields.content} converters={jsxConverters} />
</HighlightBox>
),
animatedImage: ({ node }: any) => (
<AnimatedImage
src={node.fields.src}
alt={node.fields.alt}
width={node.fields.width}
height={node.fields.height}
/>
),
'block-animatedImage': ({ node }: any) => (
<AnimatedImage
src={node.fields.src}
alt={node.fields.alt}
width={node.fields.width}
height={node.fields.height}
/>
),
chatBubble: ({ node }: any) => (
<ChatBubble
author={node.fields.author}
avatar={node.fields.avatar}
role={node.fields.role}
align={node.fields.align}
>
<RichText data={node.fields.content} converters={jsxConverters} />
</ChatBubble>
),
'block-chatBubble': ({ node }: any) => (
<ChatBubble
author={node.fields.author}
avatar={node.fields.avatar}
role={node.fields.role}
align={node.fields.align}
>
<RichText data={node.fields.content} converters={jsxConverters} />
</ChatBubble>
),
powerCTA: ({ node }: any) => <PowerCTA locale={node.fields.locale} />,
'block-powerCTA': ({ node }: any) => <PowerCTA locale={node.fields.locale} />,
callout: ({ node }: any) => (
<Callout type={node.fields.type} title={node.fields.title}>
<RichText data={node.fields.content} converters={jsxConverters} />
</Callout>
),
'block-callout': ({ node }: any) => (
<Callout type={node.fields.type} title={node.fields.title}>
<RichText data={node.fields.content} converters={jsxConverters} />
</Callout>
),
stats: ({ node }: any) => <Stats stats={node.fields.stats} />,
'block-stats': ({ node }: any) => <Stats stats={node.fields.stats} />,
splitHeading: ({ node }: any) => (
<SplitHeading id={node.fields.id} level={node.fields.level}>
{node.fields.title}
</SplitHeading>
),
'block-splitHeading': ({ node }: any) => (
<SplitHeading id={node.fields.id} level={node.fields.level}>
{node.fields.title}
</SplitHeading>
),
productTabs: ({ node }: any) => (
<ProductTabs
technicalData={
<ProductTechnicalData
data={{
technicalItems: node.fields.technicalItems,
voltageTables: node.fields.voltageTables,
}}
/>
}
>
<></>
</ProductTabs>
),
'block-productTabs': ({ node }: any) => (
<ProductTabs
technicalData={
<ProductTechnicalData
data={{
technicalItems: node.fields.technicalItems,
voltageTables: node.fields.voltageTables,
}}
/>
}
>
<></>
</ProductTabs>
),
},
// Custom converter for the Payload "upload" Lexical node (Media collection)
// This natively reconstructs Next.js <Image /> tags pointing to the focal-point cropped sizes
upload: ({ node }: any) => {
// Attempt to extract the highly optimized 'card' generated size from Payload, fallback to raw url
let src = node?.value?.sizes?.card?.url || node?.value?.url;
const alt = node?.value?.alt || 'Blog Post Media';
if (!src) return null;
// Strip legacy imgproxy query parameters (e.g. ?ar=16:9) that crash Next.js 14+ localPatterns
if (src.includes('?')) {
src = src.split('?')[0];
}
// Fallback dimensions if unmapped or loading from raw
const width = node?.value?.sizes?.card?.width || 800;
const height = node?.value?.sizes?.card?.height || 600;
return (
<figure className="my-8 md:my-12 relative w-full rounded-2xl md:rounded-[32px] overflow-hidden shadow-xl md:shadow-2xl">
<Image
src={src}
alt={alt}
width={width}
height={height}
className="w-full object-cover transition-transform duration-700 hover:scale-105"
sizes="(max-width: 768px) 100vw, (max-width: 1200px) 75vw, 60vw"
/>
{node?.value?.caption && (
<figcaption className="p-4 bg-neutral-dark text-white/80 text-sm text-center italic border-t border-white/10">
{node.value.caption}
</figcaption>
)}
</figure>
);
},
};
export default function PayloadRichText({ data }: { data: any }) {
if (!data) return null;
return (
<div className="article-content max-w-none">
<RichText data={data} converters={jsxConverters} />
</div>
);
}

View File

@@ -35,7 +35,7 @@ export default function ProductSidebar({
<div className="relative w-full aspect-[16/10] mb-6 rounded-2xl overflow-hidden bg-white/5 backdrop-blur-md p-4 border border-white/10 z-10 group"> <div className="relative w-full aspect-[16/10] mb-6 rounded-2xl overflow-hidden bg-white/5 backdrop-blur-md p-4 border border-white/10 z-10 group">
<div className="relative w-full h-full transition-transform duration-1000 ease-out group-hover:scale-105"> <div className="relative w-full h-full transition-transform duration-1000 ease-out group-hover:scale-105">
<Image <Image
src={productImage} src={productImage.split('?')[0]}
alt={productName} alt={productName}
fill fill
className="object-contain p-2 drop-shadow-[0_20px_30px_rgba(0,0,0,0.4)]" className="object-contain p-2 drop-shadow-[0_20px_30px_rgba(0,0,0,0.4)]"

View File

@@ -82,7 +82,7 @@ export default async function RelatedProducts({
{product.frontmatter.images?.[0] ? ( {product.frontmatter.images?.[0] ? (
<> <>
<Image <Image
src={product.frontmatter.images[0]} src={product.frontmatter.images[0].split('?')[0]}
alt={product.frontmatter.title} alt={product.frontmatter.title}
fill fill
className="object-contain p-4 transition-transform duration-700 group-hover:scale-110 z-10" className="object-contain p-4 transition-transform duration-700 group-hover:scale-110 z-10"

View File

@@ -29,10 +29,14 @@ export default function TrackedLink({
const { trackEvent } = useAnalytics(); const { trackEvent } = useAnalytics();
const handleClick = (e: React.MouseEvent) => { const handleClick = (e: React.MouseEvent) => {
try {
trackEvent(eventName, { trackEvent(eventName, {
href, href,
...eventProperties, ...eventProperties,
}); });
} catch (_e) {
// Analytics tracking should not block navigation, so we catch and ignore errors.
}
if (onClick) onClick(); if (onClick) onClick();
}; };

View File

@@ -31,7 +31,7 @@ export default function PostNavigation({
{prev.frontmatter.featuredImage ? ( {prev.frontmatter.featuredImage ? (
<div <div
className="absolute inset-0 bg-cover bg-center transition-transform duration-700 group-hover:scale-110" className="absolute inset-0 bg-cover bg-center transition-transform duration-700 group-hover:scale-110"
style={{ backgroundImage: `url(${prev.frontmatter.featuredImage}?ar=16:9)` }} style={{ backgroundImage: `url(${prev.frontmatter.featuredImage.split('?')[0]})` }}
/> />
) : ( ) : (
<div className="absolute inset-0 bg-neutral-100" /> <div className="absolute inset-0 bg-neutral-100" />
@@ -82,7 +82,7 @@ export default function PostNavigation({
{next.frontmatter.featuredImage ? ( {next.frontmatter.featuredImage ? (
<div <div
className="absolute inset-0 bg-cover bg-center transition-transform duration-700 group-hover:scale-110" className="absolute inset-0 bg-cover bg-center transition-transform duration-700 group-hover:scale-110"
style={{ backgroundImage: `url(${next.frontmatter.featuredImage}?ar=16:9)` }} style={{ backgroundImage: `url(${next.frontmatter.featuredImage.split('?')[0]})` }}
/> />
) : ( ) : (
<div className="absolute inset-0 bg-neutral-100" /> <div className="absolute inset-0 bg-neutral-100" />

View File

@@ -29,7 +29,7 @@ export default function VisualLinkPreview({ url, title, summary, image }: Visual
<div className="relative w-full md:w-64 h-48 md:h-auto flex-shrink-0 bg-neutral-50 overflow-hidden"> <div className="relative w-full md:w-64 h-48 md:h-auto flex-shrink-0 bg-neutral-50 overflow-hidden">
{image ? ( {image ? (
<Image <Image
src={image} src={image.split('?')[0]}
alt={title} alt={title}
fill fill
unoptimized unoptimized

View File

@@ -43,7 +43,7 @@ export default async function RecentPosts({ locale }: RecentPostsProps) {
{post.frontmatter.featuredImage && ( {post.frontmatter.featuredImage && (
<div className="relative h-64 overflow-hidden"> <div className="relative h-64 overflow-hidden">
<Image <Image
src={`${post.frontmatter.featuredImage}?ar=16:9`} src={post.frontmatter.featuredImage.split('?')[0]}
alt={post.frontmatter.title} alt={post.frontmatter.title}
fill fill
className="w-full h-full object-cover transition-transform duration-700 group-hover:scale-110" className="w-full h-full object-cover transition-transform duration-700 group-hover:scale-110"

View File

@@ -26,7 +26,7 @@ export function Button({
...props ...props
}: ButtonProps) { }: ButtonProps) {
const baseStyles = const baseStyles =
'inline-flex items-center justify-center rounded-full font-semibold transition-all duration-500 ease-out focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary focus-visible:ring-offset-2 disabled:opacity-50 disabled:pointer-events-none active:scale-95 hover:-translate-y-1 hover:scale-[1.02] relative overflow-hidden group/btn isolate'; 'inline-flex items-center justify-center whitespace-nowrap rounded-full font-semibold transition-all duration-500 ease-out focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary focus-visible:ring-offset-2 disabled:opacity-50 disabled:pointer-events-none active:scale-95 hover:-translate-y-1 hover:scale-[1.02] relative overflow-hidden group/btn isolate';
const variants = { const variants = {
primary: 'bg-primary text-white shadow-md hover:shadow-primary/30 hover:shadow-2xl', primary: 'bg-primary text-white shadow-md hover:shadow-primary/30 hover:shadow-2xl',
@@ -45,8 +45,8 @@ export function Button({
const sizes = { const sizes = {
sm: 'h-9 px-4 text-sm md:text-base', sm: 'h-9 px-4 text-sm md:text-base',
md: 'h-11 px-6 text-base md:text-lg', md: 'h-11 px-6 text-base md:text-lg',
lg: 'h-14 px-8 text-base md:text-lg', lg: 'h-14 px-5 md:px-8 text-base md:text-lg',
xl: 'h-16 px-10 text-lg md:text-xl', xl: 'h-16 px-6 md:px-10 text-lg md:text-xl',
}; };
const styles = cn(baseStyles, variants[variant], sizes[size], className); const styles = cn(baseStyles, variants[variant], sizes[size], className);

View File

@@ -1,83 +0,0 @@
version: 1
directus: 11.14.1
vendor: postgres
collections:
- collection: contact_submissions
meta:
accountability: all
archive_app_filter: true
collapse: open
collection: contact_submissions
color: '#002b49'
display_template: '{{name}} | {{email}}'
hidden: false
icon: contact_mail
singleton: false
schema:
name: contact_submissions
fields:
- collection: contact_submissions
field: id
type: uuid
meta:
collection: contact_submissions
field: id
hidden: true
sort: 1
schema:
name: id
table: contact_submissions
data_type: uuid
is_nullable: false
is_primary_key: true
- collection: contact_submissions
field: name
type: string
meta:
collection: contact_submissions
field: name
interface: input
sort: 2
schema:
name: name
table: contact_submissions
data_type: character varying
- collection: contact_submissions
field: email
type: string
meta:
collection: contact_submissions
field: email
interface: input
sort: 3
schema:
name: email
table: contact_submissions
data_type: character varying
- collection: contact_submissions
field: message
type: text
meta:
collection: contact_submissions
field: message
interface: textarea
sort: 4
schema:
name: message
table: contact_submissions
data_type: text
- collection: contact_submissions
field: date_created
type: timestamp
meta:
collection: contact_submissions
field: date_created
interface: datetime
readonly: true
sort: 5
schema:
name: date_created
table: contact_submissions
data_type: timestamp with time zone
default_value: CURRENT_TIMESTAMP
relations: []

View File

@@ -1,229 +0,0 @@
version: 1
directus: 11.14.1
vendor: postgres
collections:
- collection: contact_submissions
meta:
accountability: all
archive_app_filter: true
collapse: open
collection: contact_submissions
color: '#002b49'
display_template: '{{name}} | {{email}}'
hidden: false
icon: contact_mail
singleton: false
schema:
name: contact_submissions
- collection: product_requests
meta:
accountability: all
archive_app_filter: true
collapse: open
collection: product_requests
color: '#002b49'
display_template: '{{product_name}} | {{email}}'
hidden: false
icon: inventory
singleton: false
schema:
name: product_requests
- collection: products
meta:
accountability: all
collection: products
icon: inventory_2
singleton: false
schema:
name: products
- collection: products_translations
meta:
accountability: all
collection: products_translations
hidden: true
schema:
name: products_translations
fields:
# contact_submissions
- collection: contact_submissions
field: id
type: uuid
meta:
collection: contact_submissions
field: id
hidden: true
sort: 1
schema:
name: id
table: contact_submissions
data_type: uuid
is_nullable: false
is_primary_key: true
- collection: contact_submissions
field: name
type: string
meta:
collection: contact_submissions
field: name
interface: input
sort: 2
schema:
name: name
table: contact_submissions
data_type: character varying
- collection: contact_submissions
field: email
type: string
meta:
collection: contact_submissions
field: email
interface: input
sort: 3
schema:
name: email
table: contact_submissions
data_type: character varying
- collection: contact_submissions
field: message
type: text
meta:
collection: contact_submissions
field: message
interface: textarea
sort: 4
schema:
name: message
table: contact_submissions
data_type: text
- collection: contact_submissions
field: date_created
type: timestamp
meta:
collection: contact_submissions
field: date_created
interface: datetime
readonly: true
sort: 5
schema:
name: date_created
table: contact_submissions
data_type: timestamp with time zone
default_value: CURRENT_TIMESTAMP
# product_requests
- collection: product_requests
field: id
type: uuid
meta:
collection: product_requests
field: id
hidden: true
sort: 1
schema:
name: id
table: product_requests
data_type: uuid
is_nullable: false
is_primary_key: true
- collection: product_requests
field: product_name
type: string
meta:
collection: product_requests
field: product_name
interface: input
sort: 2
schema:
name: product_name
table: product_requests
data_type: character varying
- collection: product_requests
field: email
type: string
meta:
collection: product_requests
field: email
interface: input
sort: 3
schema:
name: email
table: product_requests
data_type: character varying
- collection: product_requests
field: message
type: text
meta:
collection: product_requests
field: message
interface: textarea
sort: 4
schema:
name: message
table: product_requests
data_type: text
- collection: product_requests
field: date_created
type: timestamp
meta:
collection: product_requests
field: date_created
interface: datetime
readonly: true
sort: 5
schema:
name: date_created
table: product_requests
data_type: timestamp with time zone
default_value: CURRENT_TIMESTAMP
# products
- collection: products
field: id
type: uuid
meta:
collection: products
field: id
hidden: true
sort: 1
schema:
name: id
table: products
data_type: uuid
is_nullable: false
is_primary_key: true
# products_translations
- collection: products_translations
field: id
type: integer
meta:
collection: products_translations
field: id
hidden: true
schema:
name: id
table: products_translations
data_type: integer
is_primary_key: true
has_auto_increment: true
systemFields:
- collection: directus_activity
field: timestamp
schema:
is_indexed: true
- collection: directus_revisions
field: activity
schema:
is_indexed: true
- collection: directus_revisions
field: parent
schema:
is_indexed: true
relations: []

65
docker-compose.dev.yml Normal file
View File

@@ -0,0 +1,65 @@
services:
klz-app:
build:
context: .
dockerfile: Dockerfile.dev
working_dir: /app
restart: unless-stopped
networks:
default:
infra:
aliases:
- klz.localhost
env_file:
- ${ENV_FILE:-.env}
environment:
NODE_ENV: development
NEXT_TELEMETRY_DISABLED: "1"
POSTGRES_URI: postgres://${DIRECTUS_DB_USER:-payload}:${DIRECTUS_DB_PASSWORD:-120in09oenaoinsd9iaidon}@klz-db:5432/${DIRECTUS_DB_NAME:-payload}
PAYLOAD_SECRET: ${DIRECTUS_SECRET:-fallback-secret-for-dev}
NODE_OPTIONS: "--max-old-space-size=4096"
CI: "true" # Prevents some interactive prompts during install
volumes:
- .:/app
# Named volumes for persistence & performance (mintel.me standard)
- klz_node_modules:/app/node_modules
- klz_next_cache:/app/.next
- klz_pnpm_store:/pnpm
- ~/.npmrc:/root/.npmrc:ro
command: >
sh -c "pnpm install && pnpm next dev --turbo --hostname 0.0.0.0"
labels:
- "traefik.enable=true"
- "traefik.http.services.${PROJECT_NAME:-klz}-app-svc.loadbalancer.server.port=3000"
- "traefik.docker.network=infra"
- "caddy=http://${TRAEFIK_HOST:-klz.localhost}"
- "caddy.reverse_proxy={{upstreams 3000}}"
klz-db:
image: postgres:15-alpine
restart: unless-stopped
env_file:
- ${ENV_FILE:-.env}
environment:
POSTGRES_DB: ${DIRECTUS_DB_NAME:-payload}
POSTGRES_USER: ${DIRECTUS_DB_USER:-payload}
POSTGRES_PASSWORD: ${DIRECTUS_DB_PASSWORD:-120in09oenaoinsd9iaidon}
volumes:
- klz_db_data:/var/lib/postgresql/data
networks:
- default
ports:
- "54322:5432"
networks:
default:
name: ${PROJECT_NAME:-klz-cables}-internal
infra:
external: true
volumes:
klz_db_data:
external: false
klz_node_modules:
klz_next_cache:
klz_pnpm_store:

View File

@@ -1,45 +0,0 @@
services:
klz-app:
build:
context: .
dockerfile: Dockerfile
target: development
volumes:
- .:/app
- /app/node_modules
- /app/.next
environment:
- WATCHPACK_POLLING=true # Useful for Docker volume mounting issues on some systems
restart: "no"
container_name: klz-app-dev
labels:
- "traefik.enable=true"
# Clear any production middlewares/headers redirect
- "traefik.http.routers.${PROJECT_NAME:-klz-cables}-web.middlewares="
- "traefik.http.routers.${PROJECT_NAME:-klz-cables}-web.rule=Host(`${TRAEFIK_HOST:-klz.localhost}`)"
- "traefik.http.routers.${PROJECT_NAME:-klz-cables}-web.entrypoints=web"
# Configure main router for local HTTP without auth
- "traefik.http.routers.${PROJECT_NAME:-klz-cables}.rule=Host(`${TRAEFIK_HOST:-klz.localhost}`)"
- "traefik.http.routers.${PROJECT_NAME:-klz-cables}.entrypoints=web"
- "traefik.http.routers.${PROJECT_NAME:-klz-cables}.middlewares="
- "traefik.http.routers.${PROJECT_NAME:-klz-cables}.tls=false"
- "traefik.docker.network=infra"
klz-cms:
container_name: klz-cms-dev
restart: "no"
ports:
- "8055:8055"
labels:
- "traefik.enable=true"
- "traefik.http.routers.${PROJECT_NAME:-klz-cables}-cms.rule=Host(`${DIRECTUS_HOST:-cms.klz.localhost}`)"
- "traefik.http.routers.${PROJECT_NAME:-klz-cables}-cms.entrypoints=${TRAEFIK_ENTRYPOINT:-web}"
- "traefik.http.routers.${PROJECT_NAME:-klz-cables}-cms.service=${PROJECT_NAME:-klz-cables}-cms"
- "traefik.http.services.${PROJECT_NAME:-klz-cables}-cms.loadbalancer.server.port=8055"
- "traefik.docker.network=infra"
klz-db:
restart: "no"
klz-gatekeeper:
restart: "no"

View File

@@ -1,12 +1,6 @@
services: services:
klz-app: klz-app:
build: image: registry.infra.mintel.me/mintel/klz-2026:${IMAGE_TAG:-latest}
context: .
dockerfile: Dockerfile
args:
NEXT_PUBLIC_BASE_URL: ${NEXT_PUBLIC_BASE_URL}
DIRECTUS_URL: "${DIRECTUS_URL}"
image: registry.infra.mintel.me/mintel/klz-cables.com:${IMAGE_TAG:-latest}
restart: unless-stopped restart: unless-stopped
networks: networks:
default: default:
@@ -15,8 +9,6 @@ services:
- klz.localhost - klz.localhost
env_file: env_file:
- ${ENV_FILE:-.env} - ${ENV_FILE:-.env}
environment:
IMGPROXY_URL: ${IMGPROXY_URL:-http://klz-imgproxy:8080}
labels: labels:
- "traefik.enable=true" - "traefik.enable=true"
# HTTP ⇒ HTTPS redirect # HTTP ⇒ HTTPS redirect
@@ -31,43 +23,23 @@ services:
- "traefik.http.routers.${PROJECT_NAME:-klz}.service=${PROJECT_NAME:-klz}-app-svc" - "traefik.http.routers.${PROJECT_NAME:-klz}.service=${PROJECT_NAME:-klz}-app-svc"
- "traefik.http.routers.${PROJECT_NAME:-klz}.middlewares=${AUTH_MIDDLEWARE:-klz-ratelimit,klz-forward,klz-compress}" - "traefik.http.routers.${PROJECT_NAME:-klz}.middlewares=${AUTH_MIDDLEWARE:-klz-ratelimit,klz-forward,klz-compress}"
# Public Router (Whitelist for OG Images, Sitemaps, Health) # Public Router (Whitelist)
- "traefik.http.routers.${PROJECT_NAME:-klz}-public.rule=(${TRAEFIK_HOST_RULE:-Host(`${TRAEFIK_HOST:-klz-cables.com}`)}) && (PathPrefix(`/health`) || PathPrefix(`/sitemap.xml`) || PathPrefix(`/robots.txt`) || PathPrefix(`/manifest.webmanifest`) || PathPrefix(`/api/og`) || PathPrefix(`/de/api/og`) || PathPrefix(`/en/api/og`) || PathPrefix(`/logo-white.svg`) || PathPrefix(`/icon-white.svg`) || PathPrefix(`/opengraph-image`) || PathPrefix(`/de/opengraph-image`) || PathPrefix(`/en/opengraph-image`) || PathPrefix(`/blog/opengraph-image`) || PathPrefix(`/de/blog/opengraph-image`) || PathPrefix(`/en/blog/opengraph-image`) || PathRegexp(`^/sitemap(-[0-9]+)?\\.xml$`) || PathRegexp(`.*\\.(svg|png|jpg|jpeg|gif|webp|ico|webm|mp4|map)$`))" - "traefik.http.routers.${PROJECT_NAME:-klz}-public.rule=(${TRAEFIK_HOST_RULE:-Host(`${TRAEFIK_HOST:-klz-cables.com}`)}) && (PathPrefix(`/health`) || PathPrefix(`/sitemap.xml`) || PathPrefix(`/robots.txt`) || PathPrefix(`/manifest.webmanifest`) || PathPrefix(`/api/og`) || PathRegexp(`.*opengraph-image.*`) || PathRegexp(`^/sitemap(-[0-9]+)?\\.xml$`))"
- "traefik.http.routers.${PROJECT_NAME:-klz}-public.entrypoints=${TRAEFIK_ENTRYPOINT:-web}" - "traefik.http.routers.${PROJECT_NAME:-klz}-public.entrypoints=${TRAEFIK_ENTRYPOINT:-web}"
- "traefik.http.routers.${PROJECT_NAME:-klz}-public.tls.certresolver=${TRAEFIK_CERT_RESOLVER:-}" - "traefik.http.routers.${PROJECT_NAME:-klz}-public.tls.certresolver=${TRAEFIK_CERT_RESOLVER:-}"
- "traefik.http.routers.${PROJECT_NAME:-klz}-public.tls=${TRAEFIK_TLS:-false}" - "traefik.http.routers.${PROJECT_NAME:-klz}-public.tls=${TRAEFIK_TLS:-false}"
- "traefik.http.routers.${PROJECT_NAME:-klz}-public.service=${PROJECT_NAME:-klz}-app-svc" - "traefik.http.routers.${PROJECT_NAME:-klz}-public.service=${PROJECT_NAME:-klz}-app-svc"
- "traefik.http.routers.${PROJECT_NAME:-klz}-public.middlewares=${AUTH_MIDDLEWARE_UNPROTECTED:-klz-ratelimit,klz-forward,klz-compress}"
- "traefik.http.routers.${PROJECT_NAME:-klz}-public.priority=2000" - "traefik.http.routers.${PROJECT_NAME:-klz}-public.priority=2000"
- "traefik.http.services.${PROJECT_NAME:-klz}-app-svc.loadbalancer.server.scheme=http"
- "traefik.http.services.${PROJECT_NAME:-klz}-app-svc.loadbalancer.server.port=3000" - "traefik.http.services.${PROJECT_NAME:-klz}-app-svc.loadbalancer.server.port=3000"
- "traefik.docker.network=infra" - "traefik.docker.network=infra"
- "caddy=http://${TRAEFIK_HOST:-klz.localhost}"
- "caddy.reverse_proxy={{upstreams 3000}}"
# Middleware Definitions # Middlewares
- "traefik.http.middlewares.${PROJECT_NAME:-klz}-compress.compress=true" - "traefik.http.middlewares.${PROJECT_NAME:-klz}-compress.compress=true"
# Forwarded Headers
- "traefik.http.middlewares.${PROJECT_NAME:-klz}-forward.headers.customrequestheaders.X-Forwarded-Proto=https"
- "traefik.http.middlewares.${PROJECT_NAME:-klz}-forward.headers.customrequestheaders.X-Forwarded-Ssl=on"
# Authentication Middleware (ForwardAuth)
- "traefik.http.middlewares.${PROJECT_NAME:-klz}-auth.forwardauth.address=http://${PROJECT_NAME:-klz}-gatekeeper:3000/gatekeeper/api/verify"
- "traefik.http.middlewares.${PROJECT_NAME:-klz}-auth.forwardauth.trustForwardHeader=true"
- "traefik.http.middlewares.${PROJECT_NAME:-klz}-auth.forwardauth.authRequestHeaders=X-Forwarded-Host,X-Forwarded-Proto,X-Forwarded-For,Cookie"
- "traefik.http.middlewares.${PROJECT_NAME:-klz}-auth.forwardauth.authResponseHeaders=X-Auth-User"
# Rate Limit Middleware
- "traefik.http.middlewares.${PROJECT_NAME:-klz}-ratelimit.ratelimit.average=100" - "traefik.http.middlewares.${PROJECT_NAME:-klz}-ratelimit.ratelimit.average=100"
- "traefik.http.middlewares.${PROJECT_NAME:-klz}-ratelimit.ratelimit.burst=50" - "traefik.http.middlewares.${PROJECT_NAME:-klz}-ratelimit.ratelimit.burst=50"
healthcheck: - "traefik.http.middlewares.${PROJECT_NAME:-klz}-forward.headers.customrequestheaders.X-Forwarded-Proto=https"
test: [ "CMD", "curl", "-f", "http://127.0.0.1:3000/health" ] - "traefik.http.middlewares.${PROJECT_NAME:-klz}-forward.headers.customrequestheaders.X-Forwarded-Ssl=on"
interval: 15s
timeout: 10s
retries: 3
start_period: 45s
klz-gatekeeper: klz-gatekeeper:
profiles: [ "gatekeeper" ] profiles: [ "gatekeeper" ]
@@ -81,125 +53,26 @@ services:
- ${ENV_FILE:-.env} - ${ENV_FILE:-.env}
environment: environment:
PORT: 3000 PORT: 3000
PROJECT_NAME: ${PROJECT_NAME:-KLZ Cables}
PROJECT_COLOR: "#82ed20"
COOKIE_DOMAIN: ${COOKIE_DOMAIN}
AUTH_COOKIE_NAME: ${AUTH_COOKIE_NAME:-klz_gatekeeper_session}
GATEKEEPER_PASSWORD: ${GATEKEEPER_PASSWORD}
NEXT_PUBLIC_BASE_URL: ${GATEKEEPER_ORIGIN}
labels: labels:
- "traefik.enable=true" - "traefik.enable=true"
- "traefik.docker.network=infra"
- "traefik.http.routers.${PROJECT_NAME:-klz}-gatekeeper.rule=(Host(`${TRAEFIK_HOST:-testing.klz-cables.com}`) && PathPrefix(`/gatekeeper`))"
- "traefik.http.routers.${PROJECT_NAME:-klz}-gatekeeper.entrypoints=${TRAEFIK_ENTRYPOINT:-web}"
- "traefik.http.routers.${PROJECT_NAME:-klz}-gatekeeper.tls.certresolver=${TRAEFIK_CERT_RESOLVER:-}"
- "traefik.http.routers.${PROJECT_NAME:-klz}-gatekeeper.tls=${TRAEFIK_TLS:-false}"
- "traefik.http.routers.${PROJECT_NAME:-klz}-gatekeeper.service=${PROJECT_NAME:-klz}-gatekeeper-svc"
- "traefik.http.services.${PROJECT_NAME:-klz}-gatekeeper-svc.loadbalancer.server.port=3000" - "traefik.http.services.${PROJECT_NAME:-klz}-gatekeeper-svc.loadbalancer.server.port=3000"
- "traefik.docker.network=infra" - "traefik.docker.network=infra"
klz-cms:
image: registry.infra.mintel.me/mintel/directus:latest
restart: unless-stopped
command: [ "node", "cli.js", "start" ]
env_file:
- ${ENV_FILE:-.env}
environment:
KEY: ${DIRECTUS_KEY}
SECRET: ${DIRECTUS_SECRET}
ADMIN_EMAIL: ${DIRECTUS_ADMIN_EMAIL}
ADMIN_PASSWORD: ${DIRECTUS_ADMIN_PASSWORD}
DB_CLIENT: 'pg'
DB_HOST: 'klz-db'
DB_PORT: '5432'
DB_DATABASE: ${DIRECTUS_DB_NAME:-directus}
DB_USER: ${DIRECTUS_DB_USER:-directus}
DB_PASSWORD: ${DIRECTUS_DB_PASSWORD:-120in09oenaoinsd9iaidon}
WEBSOCKETS_ENABLED: 'true'
PUBLIC_URL: ${DIRECTUS_URL:-https://cms.klz-cables.com}
HOST: '0.0.0.0'
networks:
- default
- infra
volumes:
- ./directus/uploads:/directus/uploads
- ./directus/extensions:/directus/extensions
- ./directus/schema:/directus/schema
- ./directus/migrations:/directus/migrations
healthcheck:
disable: true
labels:
- "traefik.enable=true"
- "traefik.http.routers.${PROJECT_NAME:-klz}-cms.rule=Host(`${DIRECTUS_HOST:-cms.klz-cables.com}`)"
- "traefik.http.routers.${PROJECT_NAME:-klz}-cms.entrypoints=websecure"
- "traefik.http.routers.${PROJECT_NAME:-klz}-cms.priority=5000"
- "traefik.http.routers.${PROJECT_NAME:-klz}-cms.tls=true"
- "traefik.http.routers.${PROJECT_NAME:-klz}-cms.tls.certresolver=le"
- "traefik.http.routers.${PROJECT_NAME:-klz}-cms.service=${PROJECT_NAME:-klz}-cms-svc"
- "traefik.http.services.${PROJECT_NAME:-klz}-cms-svc.loadbalancer.server.port=8055"
- "traefik.docker.network=infra"
- "caddy=http://${DIRECTUS_HOST:-cms.klz-cables.com}"
- "caddy.reverse_proxy={{upstreams 8055}}"
klz-db: klz-db:
image: postgres:15-alpine image: postgres:15-alpine
restart: unless-stopped restart: unless-stopped
env_file: env_file:
- ${ENV_FILE:-.env} - ${ENV_FILE:-.env}
environment: environment:
POSTGRES_DB: ${DIRECTUS_DB_NAME:-directus} POSTGRES_DB: ${DIRECTUS_DB_NAME:-payload}
POSTGRES_USER: ${DIRECTUS_DB_USER:-directus} POSTGRES_USER: ${DIRECTUS_DB_USER:-payload}
POSTGRES_PASSWORD: ${DIRECTUS_DB_PASSWORD:-120in09oenaoinsd9iaidon} POSTGRES_PASSWORD: ${DIRECTUS_DB_PASSWORD:-120in09oenaoinsd9iaidon}
volumes: volumes:
- directus-db-data:/var/lib/postgresql/data - klz_db_data:/var/lib/postgresql/data
networks: networks:
- default - default
ports:
klz-imgproxy: - "54322:5432"
build:
context: ../at-mintel
dockerfile: apps/image-service/Dockerfile
restart: unless-stopped
networks:
- default
- infra
extra_hosts:
- "klz.localhost:host-gateway"
- "cms.klz.localhost:host-gateway"
- "host.docker.internal:host-gateway"
environment:
OPENROUTER_API_KEY: ${OPENROUTER_API_KEY}
# explicitly map localhost, production and staging to bypass gatekeeper
IMGPROXY_URL_MAPPING: "https://staging.klz-cables.com:http://klz-app:3000,https://klz-cables.com:http://klz-app:3000,https://${TRAEFIK_HOST:-klz.localhost}:http://klz-app:3000,${DIRECTUS_URL}:http://klz-cms:8055,https://cms.klz-cables.com:http://klz-cms:8055"
IMGPROXY_LOG_LEVEL: debug
labels:
- "traefik.enable=true"
# Existing Local HTTP Router
- "traefik.http.routers.${PROJECT_NAME:-klz}-imgproxy.rule=Host(`img.${TRAEFIK_HOST:-klz.localhost}`)"
- "traefik.http.routers.${PROJECT_NAME:-klz}-imgproxy.entrypoints=web"
- "traefik.http.routers.${PROJECT_NAME:-klz}-imgproxy.service=${PROJECT_NAME:-klz}-imgproxy-svc"
# NEW: Direct Public Staging Router for /_img (Bypasses Next.js rewrites)
# This fixes the Next.js URL-decoding bug on dynamic image proxy paths
- "traefik.http.routers.${PROJECT_NAME:-klz}-img.rule=(Host(`${TRAEFIK_HOST:-klz.localhost}`) || Host(`staging.klz-cables.com`) || Host(`testing.klz-cables.com`)) && PathPrefix(`/_img`)"
- "traefik.http.routers.${PROJECT_NAME:-klz}-img.priority=99999"
- "traefik.http.routers.${PROJECT_NAME:-klz}-img.entrypoints=${TRAEFIK_ENTRYPOINT:-web}"
- "traefik.http.routers.${PROJECT_NAME:-klz}-img.tls=${TRAEFIK_TLS:-false}"
- "traefik.http.routers.${PROJECT_NAME:-klz}-img.tls.certresolver=${TRAEFIK_CERT_RESOLVER:-}"
- "traefik.http.routers.${PROJECT_NAME:-klz}-img.service=${PROJECT_NAME:-klz}-imgproxy-svc"
- "traefik.http.services.${PROJECT_NAME:-klz}-imgproxy-svc.loadbalancer.server.port=8080"
- "traefik.http.routers.${PROJECT_NAME:-klz}-img.middlewares=${PROJECT_NAME:-klz}-img-strip"
- "traefik.http.middlewares.${PROJECT_NAME:-klz}-img-strip.stripprefix.prefixes=/_img"
# HTTPS router (staging/prod)
- "traefik.http.routers.${PROJECT_NAME:-klz}-imgproxy-secure.rule=Host(`img.${TRAEFIK_HOST:-klz.localhost}`)"
- "traefik.http.routers.${PROJECT_NAME:-klz}-imgproxy-secure.entrypoints=${TRAEFIK_ENTRYPOINT:-web}"
- "traefik.http.routers.${PROJECT_NAME:-klz}-imgproxy-secure.tls=${TRAEFIK_TLS:-false}"
- "traefik.http.routers.${PROJECT_NAME:-klz}-imgproxy-secure.tls.certresolver=${TRAEFIK_CERT_RESOLVER:-}"
- "traefik.http.routers.${PROJECT_NAME:-klz}-imgproxy-secure.service=${PROJECT_NAME:-klz}-imgproxy-svc"
- "traefik.http.services.${PROJECT_NAME:-klz}-imgproxy-svc.loadbalancer.server.port=8080"
- "traefik.docker.network=infra"
- "caddy=http://img.${TRAEFIK_HOST:-klz.localhost}"
- "caddy.reverse_proxy={{upstreams 8080}}"
networks: networks:
default: default:
@@ -208,6 +81,5 @@ networks:
external: true external: true
volumes: volumes:
directus-db-data: klz_db_data:
external: true external: false
name: klz-cablescom_directus-db-data

View File

@@ -1,7 +1,5 @@
import fs from 'fs'; import { getPayload } from 'payload';
import path from 'path'; import configPromise from '@payload-config';
import matter from 'gray-matter';
import { mapSlugToFileSlug } from './slugs';
import { config } from '@/lib/config'; import { config } from '@/lib/config';
export function extractExcerpt(content: string): string { export function extractExcerpt(content: string): string {
@@ -42,7 +40,7 @@ export interface PostFrontmatter {
export interface PostMdx { export interface PostMdx {
slug: string; slug: string;
frontmatter: PostFrontmatter; frontmatter: PostFrontmatter;
content: string; content: any; // Mapped to Lexical SerializedEditorState
} }
export function isPostVisible(post: { frontmatter: { date: string; public?: boolean } }) { export function isPostVisible(post: { frontmatter: { date: string; public?: boolean } }) {
@@ -57,87 +55,81 @@ export function isPostVisible(post: { frontmatter: { date: string; public?: bool
} }
export async function getPostBySlug(slug: string, locale: string): Promise<PostMdx | null> { export async function getPostBySlug(slug: string, locale: string): Promise<PostMdx | null> {
// Map translated slug to file slug const payload = await getPayload({ config: configPromise });
const fileSlug = await mapSlugToFileSlug(slug, locale);
const postsDir = path.join(process.cwd(), 'data', 'blog', locale);
const filePath = path.join(postsDir, `${fileSlug}.mdx`);
if (!fs.existsSync(filePath)) { const { docs } = await payload.find({
return null; collection: 'posts',
} where: {
slug: { equals: slug },
locale: { equals: locale },
},
draft: process.env.NODE_ENV === 'development',
limit: 1,
});
const fileContent = fs.readFileSync(filePath, 'utf8'); if (!docs || docs.length === 0) return null;
const { data, content } = matter(fileContent);
const postInfo = { const doc = docs[0];
slug: fileSlug,
return {
slug: doc.slug,
frontmatter: { frontmatter: {
...data, title: doc.title,
excerpt: data.excerpt || extractExcerpt(content), 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
: null,
public: doc._status === 'published',
} as PostFrontmatter, } as PostFrontmatter,
content, content: doc.content as any, // Native Lexical Editor State
}; };
if (!isPostVisible(postInfo)) {
return null;
}
return postInfo;
} }
export async function getAllPosts(locale: string): Promise<PostMdx[]> { export async function getAllPosts(locale: string): Promise<PostMdx[]> {
const postsDir = path.join(process.cwd(), 'data', 'blog', locale); const payload = await getPayload({ config: configPromise });
if (!fs.existsSync(postsDir)) return []; // Query only published posts (access checks applied automatically by Payload!)
const { docs } = await payload.find({
collection: 'posts',
where: {
locale: {
equals: locale,
},
},
sort: '-date',
draft: process.env.NODE_ENV === 'development', // Includes Drafts if running locally
limit: 100,
});
const files = fs.readdirSync(postsDir); return docs.map((doc) => {
const posts = files
.filter((file) => file.endsWith('.mdx'))
.map((file) => {
const filePath = path.join(postsDir, file);
const fileContent = fs.readFileSync(filePath, 'utf8');
const { data, content } = matter(fileContent);
return { return {
slug: file.replace(/\.mdx$/, ''), slug: doc.slug,
frontmatter: { frontmatter: {
...data, title: doc.title,
excerpt: data.excerpt || extractExcerpt(content), 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
: null,
} as PostFrontmatter, } as PostFrontmatter,
content, // Pass the Lexical content object rather than raw markdown string
content: doc.content as any,
}; };
}) });
.filter(isPostVisible)
.sort(
(a, b) => new Date(b.frontmatter.date).getTime() - new Date(a.frontmatter.date).getTime(),
);
return posts;
} }
export async function getAllPostsMetadata(locale: string): Promise<Partial<PostMdx>[]> { export async function getAllPostsMetadata(locale: string): Promise<Partial<PostMdx>[]> {
const postsDir = path.join(process.cwd(), 'data', 'blog', locale); const posts = await getAllPosts(locale);
if (!fs.existsSync(postsDir)) return []; return posts.map((p) => ({
slug: p.slug,
const files = fs.readdirSync(postsDir); frontmatter: p.frontmatter,
return files }));
.filter((file) => file.endsWith('.mdx'))
.map((file) => {
const filePath = path.join(postsDir, file);
const fileContent = fs.readFileSync(filePath, 'utf8');
const { data } = matter(fileContent);
return {
slug: file.replace(/\.mdx$/, ''),
frontmatter: {
...data,
excerpt: data.excerpt || extractExcerpt(fileContent.replace(/^---[\s\S]*?---/, '')),
} as PostFrontmatter,
};
})
.filter(isPostVisible)
.sort(
(a, b) =>
new Date(b.frontmatter.date as string).getTime() -
new Date(a.frontmatter.date as string).getTime(),
);
} }
export async function getAdjacentPosts( export async function getAdjacentPosts(

View File

@@ -13,7 +13,10 @@ let memoizedConfig: ReturnType<typeof createConfig> | undefined;
function createConfig() { function createConfig() {
const env = getRawEnv(); const env = getRawEnv();
const target = env.NEXT_PUBLIC_TARGET || env.TARGET; const target =
env.NEXT_PUBLIC_TARGET ||
env.TARGET ||
(env.NODE_ENV === 'development' ? 'development' : 'production');
console.log('[Config] Initializing Toggles:', { console.log('[Config] Initializing Toggles:', {
feedbackEnabled: env.NEXT_PUBLIC_FEEDBACK_ENABLED, feedbackEnabled: env.NEXT_PUBLIC_FEEDBACK_ENABLED,
@@ -65,17 +68,9 @@ function createConfig() {
from: env.MAIL_FROM, from: env.MAIL_FROM,
recipients: env.MAIL_RECIPIENTS, recipients: env.MAIL_RECIPIENTS,
}, },
directus: {
url: env.DIRECTUS_URL,
adminEmail: env.DIRECTUS_ADMIN_EMAIL,
password: env.DIRECTUS_ADMIN_PASSWORD,
token: env.DIRECTUS_API_TOKEN,
internalUrl: env.INTERNAL_DIRECTUS_URL,
proxyPath: '/cms',
},
infraCMS: { infraCMS: {
url: env.INFRA_DIRECTUS_URL || env.DIRECTUS_URL, url: env.INFRA_DIRECTUS_URL,
token: env.INFRA_DIRECTUS_TOKEN || env.DIRECTUS_API_TOKEN, token: env.INFRA_DIRECTUS_TOKEN,
}, },
notifications: { notifications: {
gotify: { gotify: {
@@ -139,9 +134,6 @@ export const config = {
get mail() { get mail() {
return getConfig().mail; return getConfig().mail;
}, },
get directus() {
return getConfig().directus;
},
get notifications() { get notifications() {
return getConfig().notifications; return getConfig().notifications;
}, },
@@ -192,12 +184,6 @@ export function getMaskedConfig() {
from: c.mail.from, from: c.mail.from,
recipients: c.mail.recipients, recipients: c.mail.recipients,
}, },
directus: {
url: c.directus.url,
adminEmail: mask(c.directus.adminEmail),
password: mask(c.directus.password),
token: mask(c.directus.token),
},
notifications: { notifications: {
gotify: { gotify: {
url: c.notifications.gotify.url, url: c.notifications.gotify.url,

View File

@@ -1,192 +0,0 @@
import { readItems, readCollections } from '@directus/sdk';
import { createMintelDirectusClient, ensureDirectusAuthenticated } from '@mintel/next-utils';
import { config } from './config';
import { getServerAppServices } from './services/create-services.server';
/**
* Directus Schema Definitions
*/
export interface Schema {
products: any[];
categories: any[];
contact_submissions: any[];
product_requests: any[];
translations: any[];
categories_link: any[];
}
// Initialize client using Mintel standards (environment-aware)
const client = createMintelDirectusClient<Schema>();
/**
* Helper to determine if we should show detailed errors
*/
const shouldShowDevErrors = config.isTesting || config.isDevelopment;
/**
* Genericizes error messages for production/staging
*/
function formatError(error: any) {
if (shouldShowDevErrors) {
return error.errors?.[0]?.message || error.message || 'An unexpected error occurred.';
}
return 'A system error occurred. Our team has been notified.';
}
export async function ensureAuthenticated() {
try {
await ensureDirectusAuthenticated(client);
} catch (e: any) {
if (typeof window === 'undefined') {
getServerAppServices().errors.captureException(e, { part: 'directus_auth' });
}
console.error(`Failed to authenticate with Directus:`, e.message);
throw e;
}
}
/**
* Maps the new translation-based schema back to the application's Product interface
*/
function mapDirectusProduct(item: any, locale: string): any {
const langCode = locale === 'en' ? 'en-US' : 'de-DE';
const translation =
item.translations?.find((t: any) => t.languages_code === langCode) ||
item.translations?.[0] ||
{};
return {
id: item.id,
sku: item.sku,
title: translation.name || '',
description: translation.description || '',
content: translation.content || '',
technicalData: {
technicalItems: translation.technical_items || [],
voltageTables: translation.voltage_tables || [],
},
locale: locale,
// Use standardized proxy path for assets to avoid CORS
data_sheet_url: item.data_sheet ? `/api/directus/assets/${item.data_sheet}` : null,
categories: (item.categories_link || [])
.map((c: any) => c.categories_id?.translations?.[0]?.name)
.filter(Boolean),
};
}
export async function getProducts(locale: string = 'de') {
await ensureAuthenticated();
try {
const items = await client.request(
readItems('products', {
fields: ['*', 'translations.*', 'categories_link.categories_id.translations.name'],
}),
);
return items.map((item) => mapDirectusProduct(item, locale));
} catch (error) {
if (typeof window === 'undefined') {
getServerAppServices().errors.captureException(error, { part: 'directus_get_products' });
}
console.error('Error fetching products:', error);
return [];
}
}
export async function getProductBySlug(slug: string, locale: string = 'de') {
await ensureAuthenticated();
const langCode = locale === 'en' ? 'en-US' : 'de-DE';
try {
const items = await client.request(
readItems('products', {
filter: {
translations: {
slug: { _eq: slug },
languages_code: { _eq: langCode },
},
},
fields: ['*', 'translations.*', 'categories_link.categories_id.translations.name'],
limit: 1,
}),
);
if (!items || items.length === 0) return null;
return mapDirectusProduct(items[0], locale);
} catch (error) {
if (typeof window === 'undefined') {
getServerAppServices().errors.captureException(error, {
part: 'directus_get_product_by_slug',
slug,
});
}
console.error(`Error fetching product ${slug}:`, error);
return null;
}
}
export async function checkHealth() {
try {
// 1. Connectivity & Auth Check
try {
await ensureAuthenticated();
await client.request(readCollections());
} catch (e: any) {
if (typeof window === 'undefined') {
getServerAppServices().errors.captureException(e, { part: 'directus_health_auth' });
}
console.error('Directus authentication or collection-read failed during health check:', e);
return {
status: 'error',
message: shouldShowDevErrors
? `Directus Health Error: ${e.message || 'Unknown'}`
: 'CMS is currently unavailable due to an internal authentication or connection error.',
code: e.code || 'HEALTH_AUTH_FAILED',
details: shouldShowDevErrors
? { message: e.message, code: e.code, errors: e.errors }
: undefined,
};
}
// 2. Schema check (does the contact_submissions table exist?)
try {
await client.request(readItems('contact_submissions', { limit: 1 }));
} catch (e: any) {
if (typeof window === 'undefined') {
getServerAppServices().errors.captureException(e, { part: 'directus_health_schema' });
}
if (
e.message?.includes('does not exist') ||
e.code === 'INVALID_PAYLOAD' ||
e.status === 404
) {
return {
status: 'error',
message: shouldShowDevErrors
? `The "contact_submissions" collection is missing or inaccessible. Error: ${e.message || 'Unknown'}`
: 'Required data structures are currently unavailable.',
code: 'SCHEMA_MISSING',
};
}
return {
status: 'error',
message: shouldShowDevErrors
? `Schema error: ${e.errors?.[0]?.message || e.message || 'Unknown error'}`
: 'The data schema is currently misconfigured.',
code: 'SCHEMA_ERROR',
};
}
return { status: 'ok', message: 'Directus is reachable and responding.' };
} catch (error: any) {
if (typeof window === 'undefined') {
getServerAppServices().errors.captureException(error, { part: 'directus_health_critical' });
}
console.error('Directus health check failed with unexpected error:', error);
return {
status: 'error',
message: formatError(error),
code: error.code || 'UNKNOWN',
};
}
}
export default client;

View File

@@ -43,12 +43,6 @@ const envExtension = {
MAIL_PASSWORD: z.string().optional(), MAIL_PASSWORD: z.string().optional(),
MAIL_FROM: z.string().optional(), MAIL_FROM: z.string().optional(),
MAIL_RECIPIENTS: z.string().optional(), MAIL_RECIPIENTS: z.string().optional(),
// Directus Authentication
DIRECTUS_URL: z.string().url().optional(),
DIRECTUS_ADMIN_EMAIL: z.string().email().optional(),
DIRECTUS_ADMIN_PASSWORD: z.string().optional(),
DIRECTUS_API_TOKEN: z.string().optional(),
}; };
/** /**

View File

@@ -1,71 +0,0 @@
import { getImgproxyUrl } from './imgproxy';
/**
* Next.js Image Loader for imgproxy
*
* @param {Object} props - properties from Next.js Image component
* @param {string} props.src - The source image URL
* @param {number} props.width - The desired image width
* @param {number} props.quality - The desired image quality (ignored for now as imgproxy handles it)
*/
export default function imgproxyLoader({
src,
width,
_quality,
}: {
src: string;
width: number;
_quality?: number;
}) {
// Skip imgproxy for SVGs as they are vectors and don't benefit from resizing,
// and often cause 404s if the source is not correctly resolvable by imgproxy.
if (src.toLowerCase().endsWith('.svg')) {
return src;
}
// Check if src contains custom gravity or aspect ratio query parameters
let gravity = 'sm'; // Use smart gravity (content-aware) by default
let cleanSrc = src;
let calculatedHeight = 0;
let resizingType: 'fit' | 'fill' = 'fit';
try {
// Dummy base needed for relative URLs
const url = new URL(src, 'http://localhost');
const customGravity = url.searchParams.get('gravity');
const aspectRatio = url.searchParams.get('ar'); // e.g. "16:9"
if (customGravity) {
gravity = customGravity;
url.searchParams.delete('gravity');
}
if (aspectRatio) {
const parts = aspectRatio.split(':');
if (parts.length === 2) {
const arW = parseFloat(parts[0]);
const arH = parseFloat(parts[1]);
if (!isNaN(arW) && !isNaN(arH) && arW > 0) {
calculatedHeight = Math.round(width * (arH / arW));
resizingType = 'fill'; // Must use fill to allow imgproxy to crop
}
}
url.searchParams.delete('ar');
}
if (customGravity || aspectRatio) {
cleanSrc = src.startsWith('http') ? url.href : url.pathname + url.search;
}
} catch (e) {
// Fallback if parsing fails
}
// We use the width provided by Next.js for responsive images
// Height is calculated from aspect ratio if provided, otherwise 0 to maintain aspect ratio
return getImgproxyUrl(cleanSrc, {
width,
height: calculatedHeight,
resizing_type: resizingType,
gravity,
});
}

View File

@@ -1,59 +0,0 @@
/**
* Generates an imgproxy URL for a given source image and options.
*
* Documentation: https://docs.imgproxy.net/usage/processing
*/
interface ImgproxyOptions {
width?: number;
height?: number;
resizing_type?: 'fit' | 'fill' | 'fill-down' | 'force' | 'auto';
gravity?: string;
enlarge?: boolean;
extension?: string;
}
export function getImgproxyUrl(src: string, options: ImgproxyOptions = {}): string {
// Use local proxy path which is rewritten in next.config.mjs
const baseUrl = '/_img';
// Handle local paths or relative URLs
let absoluteSrc = src;
if (src.startsWith('/')) {
const baseUrlForSrc =
process.env.NEXT_PUBLIC_BASE_URL ||
(typeof window !== 'undefined' ? window.location.origin : 'https://klz-cables.com');
if (baseUrlForSrc) {
absoluteSrc = `${baseUrlForSrc.replace(/\/$/, '')}${src}`;
}
}
// Development mapping: Map local domains to internal Docker hostnames
// so imgproxy can fetch images without SSL issues or external routing
if (process.env.NODE_ENV === 'development') {
if (absoluteSrc.includes('klz.localhost')) {
absoluteSrc = absoluteSrc.replace(/^https?:\/\/klz\.localhost/, 'http://klz-app:3000');
} else if (absoluteSrc.includes('cms.klz.localhost')) {
absoluteSrc = absoluteSrc.replace(/^https?:\/\/cms\.klz\.localhost/, 'http://klz-cms:8055');
}
// Also handle direct container names if needed
}
const { width = 0, height = 0, enlarge = false, extension = '' } = options;
let quality = 80;
if (extension) quality = 90;
// Re-map imgproxy URL to our new parameter structure
// e.g. /process?url=...&w=...&h=...&q=...&format=...
const queryParams = new URLSearchParams({
url: absoluteSrc,
});
if (width > 0) queryParams.set('w', width.toString());
if (height > 0) queryParams.set('h', height.toString());
if (extension) queryParams.set('format', extension.replace('.', ''));
if (quality) queryParams.set('q', quality.toString());
return `${baseUrl}/process?${queryParams.toString()}`;
}

View File

@@ -1,6 +1,5 @@
import fs from 'fs'; import { getPayload } from 'payload';
import path from 'path'; import configPromise from '@payload-config';
import matter from 'gray-matter';
import { mapSlugToFileSlug } from './slugs'; import { mapSlugToFileSlug } from './slugs';
export interface ProductFrontmatter { export interface ProductFrontmatter {
@@ -10,65 +9,69 @@ export interface ProductFrontmatter {
categories: string[]; categories: string[];
images: string[]; images: string[];
locale: string; locale: string;
isFallback?: boolean;
} }
export interface ProductMdx { export interface ProductMdx {
slug: string; slug: string;
frontmatter: ProductFrontmatter; frontmatter: ProductFrontmatter;
content: string; content: any; // Lexical AST from Payload
} }
export async function getProductMetadata( export async function getProductMetadata(
slug: string, slug: string,
locale: string, locale: string,
): Promise<Partial<ProductMdx> | null> { ): Promise<Partial<ProductMdx> | null> {
// Map translated slug to file slug const payload = await getPayload({ config: configPromise });
const fileSlug = await mapSlugToFileSlug(slug, locale); const fileSlug = await mapSlugToFileSlug(slug, locale);
const productsDir = path.join(process.cwd(), 'data', 'products', locale);
if (!fs.existsSync(productsDir)) return null; let result = await payload.find({
collection: 'products',
where: {
and: [{ slug: { equals: fileSlug } }, { locale: { equals: locale } }],
},
depth: 1, // To auto-resolve Media relation (images array)
limit: 1,
});
// Recursive search for the file let isFallback = false;
const findFile = (dir: string): string | null => {
const files = fs.readdirSync(dir);
for (const file of files) {
const fullPath = path.join(dir, file);
const stat = fs.statSync(fullPath);
if (stat.isDirectory()) {
const found = findFile(fullPath);
if (found) return found;
} else if (file === `${fileSlug}.mdx` || file === `${fileSlug}-2.mdx`) {
return fullPath;
}
}
return null;
};
let filePath = findFile(productsDir); if (result.docs.length === 0 && locale !== 'en') {
if (!filePath && locale !== 'en') {
// Fallback to English // Fallback to English
const enProductsDir = path.join(process.cwd(), 'data', 'products', 'en'); result = await payload.find({
if (fs.existsSync(enProductsDir)) { collection: 'products',
filePath = findFile(enProductsDir); where: {
and: [{ slug: { equals: fileSlug } }, { locale: { equals: 'en' } }],
},
depth: 1,
limit: 1,
});
if (result.docs.length > 0) {
isFallback = true;
} }
} }
if (filePath && fs.existsSync(filePath)) { if (result.docs.length > 0) {
const fileContent = fs.readFileSync(filePath, 'utf8'); const doc = result.docs[0];
const { data } = matter(fileContent);
// Filter out products without images to match getProductBySlug behavior // Process Images
if (!data.images || data.images.length === 0 || !data.images[0]) { const resolvedImages = ((doc.images as any[]) || [])
return null; .map((img) => (typeof img === 'string' ? img : img.url))
} .filter(Boolean);
if (resolvedImages.length === 0) return null; // Original logic skipped docs without images
return { return {
slug: fileSlug, slug: doc.slug,
frontmatter: { frontmatter: {
...data, title: doc.title,
isFallback: filePath.includes('/en/'), sku: doc.sku,
} as any, description: doc.description,
categories: Array.isArray(doc.categories) ? doc.categories.map((c: any) => c.category) : [],
images: resolvedImages,
locale: doc.locale,
isFallback,
},
}; };
} }
@@ -76,111 +79,159 @@ export async function getProductMetadata(
} }
export async function getProductBySlug(slug: string, locale: string): Promise<ProductMdx | null> { export async function getProductBySlug(slug: string, locale: string): Promise<ProductMdx | null> {
// Map translated slug to file slug const payload = await getPayload({ config: configPromise });
const fileSlug = await mapSlugToFileSlug(slug, locale); const fileSlug = await mapSlugToFileSlug(slug, locale);
const productsDir = path.join(process.cwd(), 'data', 'products', locale);
if (!fs.existsSync(productsDir)) return null; let result = await payload.find({
collection: 'products',
where: {
and: [{ slug: { equals: fileSlug } }, { locale: { equals: locale } }],
},
depth: 1, // Auto-resolve Media logic
limit: 1,
});
// Recursive search for the file
const findFile = (dir: string): string | null => {
const files = fs.readdirSync(dir);
for (const file of files) {
const fullPath = path.join(dir, file);
const stat = fs.statSync(fullPath);
if (stat.isDirectory()) {
const found = findFile(fullPath);
if (found) return found;
} else if (file === `${fileSlug}.mdx` || file === `${fileSlug}-2.mdx`) {
return fullPath;
}
}
return null;
};
let filePath = findFile(productsDir);
let isFallback = false; let isFallback = false;
if (!filePath && locale !== 'en') { if (result.docs.length === 0 && locale !== 'en') {
// Fallback to English // Fallback to English
const enProductsDir = path.join(process.cwd(), 'data', 'products', 'en'); result = await payload.find({
if (fs.existsSync(enProductsDir)) { collection: 'products',
filePath = findFile(enProductsDir); where: {
if (filePath) isFallback = true; and: [{ slug: { equals: fileSlug } }, { locale: { equals: 'en' } }],
},
depth: 1,
limit: 1,
});
if (result.docs.length > 0) {
isFallback = true;
} }
} }
if (filePath && fs.existsSync(filePath)) { if (result.docs.length > 0) {
const fileContent = fs.readFileSync(filePath, 'utf8'); const doc = result.docs[0];
const { data, content } = matter(fileContent);
const product = { // Map Images correctly from resolved Media docs
slug: fileSlug, const resolvedImages = ((doc.images as any[]) || [])
.map((img) => (typeof img === 'string' ? img : img.url))
.filter(Boolean);
if (resolvedImages.length === 0) return null;
return {
slug: doc.slug,
frontmatter: { frontmatter: {
...data, title: doc.title,
sku: doc.sku,
description: doc.description,
categories: Array.isArray(doc.categories) ? doc.categories.map((c: any) => c.category) : [],
images: resolvedImages,
locale: doc.locale,
isFallback, isFallback,
} as any, },
content, content: doc.content, // Lexical payload instead of raw MDX String
}; };
// Filter out products without images
if (
!product.frontmatter.images ||
product.frontmatter.images.length === 0 ||
!product.frontmatter.images[0]
) {
return null;
}
return product;
} }
return null; return null;
} }
export async function getAllProductSlugs(locale: string): Promise<string[]> { export async function getAllProductSlugs(locale: string): Promise<string[]> {
const productsDir = path.join(process.cwd(), 'data', 'products', locale); const payload = await getPayload({ config: configPromise });
if (!fs.existsSync(productsDir)) return []; const result = await payload.find({
collection: 'products',
where: {
locale: {
equals: locale,
},
},
pagination: false, // get all docs
});
const slugs: string[] = []; return result.docs.map((doc) => doc.slug);
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')) {
slugs.push(file.replace(/\.mdx$/, ''));
}
}
};
walk(productsDir);
return slugs;
} }
export async function getAllProducts(locale: string): Promise<ProductMdx[]> { export async function getAllProducts(locale: string): Promise<ProductMdx[]> {
const slugs = await getAllProductSlugs(locale); // Fetch ALL products in a single query to avoid N+1 getPayload() calls
let allSlugs = slugs; const payload = await getPayload({ config: configPromise });
// Get products for this locale
const result = await payload.find({
collection: 'products',
where: { locale: { equals: locale } },
depth: 1,
pagination: false,
});
let products: ProductMdx[] = result.docs
.filter((doc) => {
const resolvedImages = ((doc.images as any[]) || [])
.map((img) => (typeof img === 'string' ? img : img.url))
.filter(Boolean);
return resolvedImages.length > 0;
})
.map((doc) => ({
slug: doc.slug,
frontmatter: {
title: doc.title,
sku: doc.sku,
description: doc.description,
categories: Array.isArray(doc.categories) ? doc.categories.map((c: any) => c.category) : [],
images: ((doc.images as any[]) || [])
.map((img) => (typeof img === 'string' ? img : img.url))
.filter(Boolean),
locale: doc.locale,
},
content: doc.content,
}));
// Also include English fallbacks for slugs not in this locale
if (locale !== 'en') { if (locale !== 'en') {
const enSlugs = await getAllProductSlugs('en'); const localeSlugs = new Set(products.map((p) => p.slug));
allSlugs = Array.from(new Set([...slugs, ...enSlugs])); const enResult = await payload.find({
collection: 'products',
where: { locale: { equals: 'en' } },
depth: 1,
pagination: false,
});
const fallbacks = enResult.docs
.filter((doc) => !localeSlugs.has(doc.slug))
.filter((doc) => {
const resolvedImages = ((doc.images as any[]) || [])
.map((img) => (typeof img === 'string' ? img : img.url))
.filter(Boolean);
return resolvedImages.length > 0;
})
.map((doc) => ({
slug: doc.slug,
frontmatter: {
title: doc.title,
sku: doc.sku,
description: doc.description,
categories: Array.isArray(doc.categories)
? doc.categories.map((c: any) => c.category)
: [],
images: ((doc.images as any[]) || [])
.map((img) => (typeof img === 'string' ? img : img.url))
.filter(Boolean),
locale: doc.locale,
isFallback: true,
},
content: doc.content,
}));
products = [...products, ...fallbacks];
} }
const products = await Promise.all(allSlugs.map((slug) => getProductBySlug(slug, locale))); return products;
return products.filter((p): p is ProductMdx => p !== null);
} }
export async function getAllProductsMetadata(locale: string): Promise<Partial<ProductMdx>[]> { export async function getAllProductsMetadata(locale: string): Promise<Partial<ProductMdx>[]> {
const slugs = await getAllProductSlugs(locale); // Reuse getAllProducts to avoid separate N+1 queries
let allSlugs = slugs; const products = await getAllProducts(locale);
return products.map((p) => ({
if (locale !== 'en') { slug: p.slug,
const enSlugs = await getAllProductSlugs('en'); frontmatter: p.frontmatter,
allSlugs = Array.from(new Set([...slugs, ...enSlugs])); }));
}
const metadata = await Promise.all(allSlugs.map((slug) => getProductMetadata(slug, locale)));
return metadata.filter((m): m is Partial<ProductMdx> => m !== null);
} }

View File

@@ -1,80 +1,112 @@
import fs from 'fs'; import { getPayload } from 'payload';
import path from 'path'; import configPromise from '@payload-config';
import matter from 'gray-matter';
import { mapSlugToFileSlug } from './slugs';
export interface PageFrontmatter { export interface PageFrontmatter {
title: string; title: string;
excerpt: string; excerpt: string;
featuredImage: string | null; featuredImage: string | null;
locale: string; locale: string;
public?: boolean;
} }
export interface PageMdx { export interface PageMdx {
slug: string; slug: string;
frontmatter: PageFrontmatter; frontmatter: PageFrontmatter;
content: string; content: any; // Lexical AST Document
} }
export async function getPageBySlug(slug: string, locale: string): Promise<PageMdx | null> { export async function getPageBySlug(slug: string, locale: string): Promise<PageMdx | null> {
// Map translated slug to file slug const payload = await getPayload({ config: configPromise });
const fileSlug = await mapSlugToFileSlug(slug, locale);
const pagesDir = path.join(process.cwd(), 'data', 'pages', locale);
const filePath = path.join(pagesDir, `${fileSlug}.mdx`);
if (!fs.existsSync(filePath)) { const result = await payload.find({
return null; collection: 'pages' as any,
} where: {
slug: { equals: slug },
locale: { equals: locale },
},
limit: 1,
});
const fileContent = fs.readFileSync(filePath, 'utf8'); const docs = result.docs as any[];
const { data, content } = matter(fileContent);
if (!docs || docs.length === 0) return null;
const doc = docs[0];
return { return {
slug: fileSlug, slug: doc.slug,
frontmatter: data as PageFrontmatter, frontmatter: {
content, 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
}; };
} }
export async function getAllPages(locale: string): Promise<PageMdx[]> { export async function getAllPages(locale: string): Promise<PageMdx[]> {
const pagesDir = path.join(process.cwd(), 'data', 'pages', locale); const payload = await getPayload({ config: configPromise });
if (!fs.existsSync(pagesDir)) return [];
const files = fs.readdirSync(pagesDir); const result = await payload.find({
const pages = await Promise.all( collection: 'pages' as any,
files where: {
.filter((file) => file.endsWith('.mdx')) locale: {
.map((file) => { equals: locale,
const fileSlug = file.replace(/\.mdx$/, ''); },
const filePath = path.join(pagesDir, file); },
const fileContent = fs.readFileSync(filePath, 'utf8'); limit: 100,
const { data, content } = matter(fileContent); });
const docs = result.docs as any[];
return docs.map((doc: any) => {
return { return {
slug: fileSlug, slug: doc.slug,
frontmatter: data as PageFrontmatter, frontmatter: {
content, title: doc.title,
}; excerpt: doc.excerpt || '',
}), locale: doc.locale,
); featuredImage:
typeof doc.featuredImage === 'object' && doc.featuredImage !== null
return pages.filter((p): p is PageMdx => p !== null); ? doc.featuredImage.sizes?.card?.url || doc.featuredImage.url
} : null,
} as PageFrontmatter,
export async function getAllPagesMetadata(locale: string): Promise<Partial<PageMdx>[]> { content: doc.content as any,
const pagesDir = path.join(process.cwd(), 'data', 'pages', locale); };
if (!fs.existsSync(pagesDir)) return []; });
}
const files = fs.readdirSync(pagesDir);
return files export async function getAllPagesMetadata(locale: string): Promise<Partial<PageMdx>[]> {
.filter((file) => file.endsWith('.mdx')) const payload = await getPayload({ config: configPromise });
.map((file) => {
const fileSlug = file.replace(/\.mdx$/, ''); const result = await payload.find({
const filePath = path.join(pagesDir, file); collection: 'pages' as any,
const fileContent = fs.readFileSync(filePath, 'utf8'); where: {
const { data } = matter(fileContent); locale: {
return { equals: locale,
slug: fileSlug, },
frontmatter: data as PageFrontmatter, },
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,
}; };
}); });
} }

View File

@@ -11,9 +11,19 @@ import {
import { PinoLoggerService } from './logging/pino-logger-service'; import { PinoLoggerService } from './logging/pino-logger-service';
import { config, getMaskedConfig } from '../config'; import { config, getMaskedConfig } from '../config';
let singleton: AppServices | undefined; declare global {
var __appServices: AppServices | undefined;
}
// Add a local cache to prevent re-looking up globalThis frequently
let serverSingleton: AppServices | undefined;
export function getServerAppServices(): AppServices { export function getServerAppServices(): AppServices {
if (singleton) return singleton; if (serverSingleton) return serverSingleton;
if (globalThis.__appServices) {
serverSingleton = globalThis.__appServices;
return serverSingleton;
}
// Create logger first to log initialization // Create logger first to log initialization
const logger = new PinoLoggerService('server'); const logger = new PinoLoggerService('server');
@@ -74,9 +84,9 @@ export function getServerAppServices(): AppServices {
level: config.logging.level, level: config.logging.level,
}); });
singleton = new AppServices(analytics, errors, cache, logger, notifications); globalThis.__appServices = new AppServices(analytics, errors, cache, logger, notifications);
logger.info('All application services initialized successfully'); logger.info('All application services initialized successfully');
return singleton; return globalThis.__appServices;
} }

View File

@@ -9,65 +9,18 @@ import { PinoLoggerService } from './logging/pino-logger-service';
import { NoopNotificationService } from './notifications/gotify-notification-service'; import { NoopNotificationService } from './notifications/gotify-notification-service';
import { config, getMaskedConfig } from '../config'; import { config, getMaskedConfig } from '../config';
/** declare global {
* Singleton instance of AppServices. var __appServices: AppServices | undefined;
* }
* In Next.js, module singletons are per-process (server) and per-tab (client).
* This is sufficient for a small service layer and provides better performance
* than creating new instances on every request.
*
* @private
*/
let singleton: AppServices | undefined; let singleton: AppServices | undefined;
/** /**
* Get the application services singleton. * Get the application services singleton.
*
* This function creates and caches the application services, including:
* - Analytics service (Umami or no-op)
* - Error reporting service (GlitchTip/Sentry or no-op)
* - Cache service (in-memory)
*
* The services are configured based on environment variables:
* - `UMAMI_WEBSITE_ID` - Enables Umami analytics
* - `SENTRY_DSN` - Enables error reporting (server-side direct, client-side via relay)
*
* @returns {AppServices} The application services singleton
*
* @example
* ```typescript
* // Get services in a client component
* import { getAppServices } from '@/lib/services/create-services';
*
* const services = getAppServices();
* services.analytics.track('button_click', { button_id: 'cta' });
* ```
*
* @example
* ```typescript
* // Get services in a server component or API route
* import { getAppServices } from '@/lib/services/create-services';
*
* const services = getAppServices();
* await services.cache.set('key', 'value');
* ```
*
* @example
* ```typescript
* // Automatic service selection based on environment
* // If UMAMI_WEBSITE_ID is set:
* // services.analytics = UmamiAnalyticsService
* // If not set:
* // services.analytics = NoopAnalyticsService (safe no-op)
* ```
*
* @see {@link UmamiAnalyticsService} for analytics implementation
* @see {@link NoopAnalyticsService} for no-op fallback
* @see {@link GlitchtipErrorReportingService} for error reporting
* @see {@link MemoryCacheService} for caching
*/ */
export function getAppServices(): AppServices { export function getAppServices(): AppServices {
// Return cached instance if available // Return cached instance if available
if (typeof window === 'undefined' && globalThis.__appServices) return globalThis.__appServices;
if (singleton) return singleton; if (singleton) return singleton;
// Create logger first to log initialization // Create logger first to log initialization
@@ -127,9 +80,6 @@ export function getAppServices(): AppServices {
logger.info('Noop error reporting service initialized (error reporting disabled)'); logger.info('Noop error reporting service initialized (error reporting disabled)');
} }
// IMPORTANT: This module is imported by client components.
// Do not import Node-only modules (like the `redis` client) here.
// Use [`getServerAppServices()`](lib/services/create-services.server.ts:1) on the server.
const cache = new MemoryCacheService(); const cache = new MemoryCacheService();
logger.info('Memory cache service initialized'); logger.info('Memory cache service initialized');
@@ -139,6 +89,11 @@ export function getAppServices(): AppServices {
}); });
// Create and cache the singleton // Create and cache the singleton
if (typeof window === 'undefined') {
globalThis.__appServices = new AppServices(analytics, errors, cache, logger, notifications);
return globalThis.__appServices;
}
singleton = new AppServices(analytics, errors, cache, logger, notifications); singleton = new AppServices(analytics, errors, cache, logger, notifications);
logger.info('All application services initialized successfully'); logger.info('All application services initialized successfully');

View File

@@ -97,7 +97,7 @@ export class GlitchtipErrorReportingService implements ErrorReportingService {
this.getSentry().then((Sentry) => Sentry.setTag(key, value)); this.getSentry().then((Sentry) => Sentry.setTag(key, value));
} }
withScope<T>(fn: () => T, context?: Record<string, unknown>): T { withScope<T>(fn: () => T, _context?: Record<string, unknown>): T {
if (!this.options.enabled) return fn(); if (!this.options.enabled) return fn();
// Since withScope mandates executing fn() synchronously to return T, // Since withScope mandates executing fn() synchronously to return T,

View File

@@ -10,40 +10,14 @@ const intlMiddleware = createMiddleware({
defaultLocale: 'en', defaultLocale: 'en',
}); });
const imgproxyStatus = { isDown: false, lastCheck: 0 };
async function isImgproxyDown() {
const now = Date.now();
if (now - imgproxyStatus.lastCheck > 60000) {
try {
const imgproxyUrl = process.env.IMGPROXY_URL || 'http://klz-imgproxy:8080';
const checkUrl = imgproxyUrl.startsWith('http') ? imgproxyUrl : `https://${imgproxyUrl}`;
const res = await fetch(checkUrl, { signal: AbortSignal.timeout(2000) });
imgproxyStatus.isDown = res.status >= 500;
} catch (e) {
imgproxyStatus.isDown = true;
}
imgproxyStatus.lastCheck = now;
}
return imgproxyStatus.isDown;
}
export default async function middleware(request: NextRequest) { export default async function middleware(request: NextRequest) {
const { method, url, headers } = request; const { method, url, headers } = request;
const { pathname } = request.nextUrl; const { pathname } = request.nextUrl;
if (pathname.startsWith('/_img/')) { // Explicit bypass for infrastructure and Payload CMS routes to avoid locale redirects/interception
if (await isImgproxyDown()) {
const originalUrl = request.nextUrl.searchParams.get('url');
if (originalUrl) {
return NextResponse.redirect(originalUrl);
}
}
return NextResponse.next();
}
// Explicit bypass for infrastructure routes to avoid locale redirects/interception
if ( if (
pathname.startsWith('/admin') ||
pathname.startsWith('/api') ||
pathname.startsWith('/stats') || pathname.startsWith('/stats') ||
pathname.startsWith('/errors') || pathname.startsWith('/errors') ||
pathname.startsWith('/health') || pathname.startsWith('/health') ||
@@ -125,8 +99,7 @@ export default async function middleware(request: NextRequest) {
export const config = { export const config = {
matcher: [ matcher: [
'/((?!api|_next/static|_next/image|favicon.ico|manifest.webmanifest|.*\\.(?:svg|png|jpg|jpeg|gif|webp|pdf|txt|vcf|xml|webm|mp4|map)$).*)', '/((?!api|_next/static|_next/image|favicon.ico|admin|manifest.webmanifest|.*\\.(?:svg|png|jpg|jpeg|gif|webp|pdf|txt|vcf|xml|webm|mp4|map)$).*)',
'/(de|en)/:path*',
'/(de|en)/:path*', '/(de|en)/:path*',
], ],
}; };

2
next-env.d.ts vendored
View File

@@ -1,6 +1,6 @@
/// <reference types="next" /> /// <reference types="next" />
/// <reference types="next/image-types/global" /> /// <reference types="next/image-types/global" />
import "./.next/dev/types/routes.d.ts"; import "./.next/types/routes.d.ts";
// NOTE: This file should not be edited // NOTE: This file should not be edited
// see https://nextjs.org/docs/app/api-reference/config/typescript for more information. // see https://nextjs.org/docs/app/api-reference/config/typescript for more information.

View File

@@ -2,8 +2,9 @@ import withMintelConfig from '@mintel/next-config';
import withBundleAnalyzer from '@next/bundle-analyzer'; import withBundleAnalyzer from '@next/bundle-analyzer';
import { withSentryConfig } from '@sentry/nextjs'; import { withSentryConfig } from '@sentry/nextjs';
console.log('🚀 NEXT CONFIG LOADED FROM:', process.cwd()); import { withPayload } from '@payloadcms/next/withPayload';
console.log('🚀 NEXT CONFIG LOADED FROM:', process.cwd());
/** @type {import('next').NextConfig} */ /** @type {import('next').NextConfig} */
const nextConfig = { const nextConfig = {
onDemandEntries: { onDemandEntries: {
@@ -11,9 +12,10 @@ const nextConfig = {
maxInactiveAge: 60 * 1000, maxInactiveAge: 60 * 1000,
}, },
experimental: { experimental: {
optimizeCss: true,
optimizePackageImports: ['lucide-react', 'framer-motion', '@/components/ui'], optimizePackageImports: ['lucide-react', 'framer-motion', '@/components/ui'],
}, },
swcMinify: false,
reactStrictMode: false,
productionBrowserSourceMaps: false, productionBrowserSourceMaps: false,
logging: { logging: {
fetches: { fetches: {
@@ -22,38 +24,39 @@ const nextConfig = {
}, },
output: 'standalone', output: 'standalone',
async headers() { async headers() {
const isProd = process.env.NODE_ENV === 'production';
const umamiDomain = new URL(process.env.UMAMI_API_ENDPOINT || 'https://analytics.infra.mintel.me').origin; const umamiDomain = new URL(process.env.UMAMI_API_ENDPOINT || 'https://analytics.infra.mintel.me').origin;
const directusDomain = new URL(process.env.DIRECTUS_URL || 'https://cms.klz-cables.com').origin;
const imgproxyDomain = new URL(process.env.IMGPROXY_URL || 'http://klz-imgproxy:8080').origin;
const glitchtipDomain = new URL(process.env.SENTRY_DSN ? new URL(process.env.SENTRY_DSN).origin : 'https://errors.infra.mintel.me').origin; const glitchtipDomain = new URL(process.env.SENTRY_DSN ? new URL(process.env.SENTRY_DSN).origin : 'https://errors.infra.mintel.me').origin;
// Additional domains that need to be whitelisted for images
const extraImgDomains = [
'https://klz-cables.com',
'https://staging.klz-cables.com',
'https://testing.klz-cables.com',
'http://klz.localhost',
'https://www.gravatar.com',
'https://gravatar.com',
].join(' ');
const cspHeader = ` const cspHeader = `
default-src 'self'; default-src 'self';
script-src 'self' 'unsafe-inline' 'unsafe-eval' ${umamiDomain}; script-src 'self' 'unsafe-inline' 'unsafe-eval' ${umamiDomain};
style-src 'self' 'unsafe-inline' https://fonts.googleapis.com; style-src 'self' 'unsafe-inline' https://fonts.googleapis.com;
font-src 'self' https://fonts.gstatic.com; font-src 'self' https://fonts.gstatic.com;
img-src 'self' data: blob: ${imgproxyDomain} ${directusDomain}; img-src 'self' data: blob: ${extraImgDomains};
connect-src 'self' ${umamiDomain} ${glitchtipDomain} ${directusDomain}; connect-src 'self' ${umamiDomain} ${glitchtipDomain};
frame-src 'self'; frame-src 'self';
object-src 'none'; object-src 'none';
base-uri 'self'; base-uri 'self';
form-action 'self'; form-action 'self';
frame-ancestors 'none'; frame-ancestors 'none';
upgrade-insecure-requests; ${isProd ? 'upgrade-insecure-requests;' : ''}
`.replace(/\s{2,}/g, ' ').trim(); `.replace(/\s{2,}/g, ' ').trim();
return [ const secureHeaders = [
{
source: '/:path*',
headers: [
{ {
key: 'Content-Security-Policy', key: 'Content-Security-Policy',
value: cspHeader, value: cspHeader,
}, },
{
key: 'Strict-Transport-Security',
value: 'max-age=63072000; includeSubDomains; preload',
},
{ {
key: 'X-Frame-Options', key: 'X-Frame-Options',
value: 'DENY', value: 'DENY',
@@ -70,7 +73,19 @@ const nextConfig = {
key: 'Permissions-Policy', key: 'Permissions-Policy',
value: 'camera=(), microphone=(), geolocation=(), interest-cohort=()', value: 'camera=(), microphone=(), geolocation=(), interest-cohort=()',
}, },
], ];
if (isProd) {
secureHeaders.push({
key: 'Strict-Transport-Security',
value: 'max-age=63072000; includeSubDomains; preload',
});
}
return [
{
source: '/:path*',
headers: secureHeaders,
}, },
]; ];
}, },
@@ -373,33 +388,31 @@ const nextConfig = {
source: '/vcf/michael-bodemer', source: '/vcf/michael-bodemer',
destination: '/michael-bodemer.vcf', destination: '/michael-bodemer.vcf',
permanent: true, permanent: true,
}, }
]; ];
}, },
images: { images: {
loader: 'custom', formats: ['image/webp'],
loaderFile: './lib/imgproxy-loader.ts', remotePatterns: [
dangerouslyAllowSVG: true, {
contentDispositionType: "attachment", protocol: 'https',
contentSecurityPolicy: "default-src 'self'; script-src 'none'; sandbox;", hostname: 'klz-cables.com',
},
{
protocol: 'https',
hostname: '*.klz-cables.com',
},
{
protocol: 'http',
hostname: 'klz.localhost',
},
{
protocol: 'http',
hostname: 'localhost',
},
],
}, },
async rewrites() { async rewrites() {
const umamiUrl =
process.env.UMAMI_API_ENDPOINT ||
process.env.UMAMI_SCRIPT_URL ||
process.env.NEXT_PUBLIC_UMAMI_SCRIPT_URL ||
'https://analytics.infra.mintel.me';
const glitchtipUrl = process.env.SENTRY_DSN
? new URL(process.env.SENTRY_DSN).origin
: 'https://errors.infra.mintel.me';
const directusUrl = process.env.INTERNAL_DIRECTUS_URL || process.env.DIRECTUS_URL || 'https://cms.klz-cables.com';
let imgproxyUrl = process.env.IMGPROXY_URL || 'http://klz-imgproxy:8080';
if (!imgproxyUrl.startsWith('http')) {
imgproxyUrl = `https://${imgproxyUrl}`;
}
return [ return [
{ {
source: '/de/produkte', source: '/de/produkte',
@@ -409,14 +422,6 @@ const nextConfig = {
source: '/de/produkte/:path*', source: '/de/produkte/:path*',
destination: '/de/products/:path*', destination: '/de/products/:path*',
}, },
{
source: '/cms/:path*',
destination: `${directusUrl}/:path*`,
},
{
source: '/_img/:path*',
destination: `${imgproxyUrl}/:path*`,
},
]; ];
}, },
}; };
@@ -425,6 +430,6 @@ const withAnalyzer = withBundleAnalyzer({
enabled: process.env.ANALYZE === 'true', enabled: process.env.ANALYZE === 'true',
}); });
export default withAnalyzer(withMintelConfig(nextConfig, { export default withPayload(withAnalyzer(withMintelConfig(nextConfig, {
hideSourceMaps: true, hideSourceMaps: true,
})); })));

13328
openrouter_models.json Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -4,11 +4,15 @@
"private": true, "private": true,
"packageManager": "pnpm@10.18.3", "packageManager": "pnpm@10.18.3",
"dependencies": { "dependencies": {
"@directus/sdk": "^21.0.0",
"@mintel/mail": "1.8.3", "@mintel/mail": "1.8.3",
"@mintel/next-config": "1.8.3", "@mintel/next-config": "1.8.3",
"@mintel/next-feedback": "1.8.10", "@mintel/next-feedback": "1.8.10",
"@mintel/next-utils": "^1.7.15", "@mintel/next-utils": "^1.7.15",
"@payloadcms/db-postgres": "^3.77.0",
"@payloadcms/email-nodemailer": "^3.77.0",
"@payloadcms/next": "^3.77.0",
"@payloadcms/richtext-lexical": "^3.77.0",
"@payloadcms/ui": "^3.77.0",
"@react-email/components": "^1.0.7", "@react-email/components": "^1.0.7",
"@react-pdf/renderer": "^4.3.2", "@react-pdf/renderer": "^4.3.2",
"@sentry/nextjs": "^10.39.0", "@sentry/nextjs": "^10.39.0",
@@ -16,6 +20,7 @@
"axios": "^1.13.5", "axios": "^1.13.5",
"clsx": "^2.1.1", "clsx": "^2.1.1",
"framer-motion": "^12.34.0", "framer-motion": "^12.34.0",
"graphql": "^16.12.0",
"gray-matter": "^4.0.3", "gray-matter": "^4.0.3",
"i18next": "^25.7.3", "i18next": "^25.7.3",
"import-in-the-middle": "^1.11.0", "import-in-the-middle": "^1.11.0",
@@ -26,6 +31,7 @@
"next-intl": "^4.8.2", "next-intl": "^4.8.2",
"next-mdx-remote": "^5.0.0", "next-mdx-remote": "^5.0.0",
"nodemailer": "^7.0.12", "nodemailer": "^7.0.12",
"payload": "^3.77.0",
"pdf-lib": "^1.17.1", "pdf-lib": "^1.17.1",
"pino": "^10.3.0", "pino": "^10.3.0",
"pino-pretty": "^13.1.3", "pino-pretty": "^13.1.3",
@@ -85,9 +91,8 @@
"vitest": "^4.0.16" "vitest": "^4.0.16"
}, },
"scripts": { "scripts": {
"dev": "docker network create infra 2>/dev/null || true && echo '\\n🚀 Development Environment Starting...\\n\\n📱 App (Next.js): http://localhost:3000\\n📱 App (Traefik): http://klz.localhost\\n🗄 CMS: http://cms.klz.localhost/admin\\n🚦 Traefik: http://localhost:8080\\n\\n(Press Ctrl+C to stop)\\n' && docker-compose down --remove-orphans && docker-compose up --build klz-app klz-cms klz-db klz-gatekeeper", "dev": "docker network create infra 2>/dev/null || true && echo \"\\n🚀 Dockerized Environment Starting...\\n\" && COMPOSE_PROJECT_NAME=klz-2026 docker-compose -f docker-compose.dev.yml up klz-app klz-db",
"dev:infra": "docker network create infra 2>/dev/null || true && docker-compose up -d klz-cms klz-db klz-gatekeeper", "dev:infra": "docker-compose up klz-db klz-gatekeeper",
"dev:local": "next dev",
"build": "next build", "build": "next build",
"start": "next start", "start": "next start",
"lint": "eslint .", "lint": "eslint .",

702
payload-types.ts Normal file
View File

@@ -0,0 +1,702 @@
/* tslint:disable */
/* eslint-disable */
/**
* This file was automatically generated by Payload.
* DO NOT MODIFY IT BY HAND. Instead, modify your source Payload config,
* and re-run `payload generate:types` to regenerate this file.
*/
/**
* Supported timezones in IANA format.
*
* This interface was referenced by `Config`'s JSON-Schema
* via the `definition` "supportedTimezones".
*/
export type SupportedTimezones =
| 'Pacific/Midway'
| 'Pacific/Niue'
| 'Pacific/Honolulu'
| 'Pacific/Rarotonga'
| 'America/Anchorage'
| 'Pacific/Gambier'
| 'America/Los_Angeles'
| 'America/Tijuana'
| 'America/Denver'
| 'America/Phoenix'
| 'America/Chicago'
| 'America/Guatemala'
| 'America/New_York'
| 'America/Bogota'
| 'America/Caracas'
| 'America/Santiago'
| 'America/Buenos_Aires'
| 'America/Sao_Paulo'
| 'Atlantic/South_Georgia'
| 'Atlantic/Azores'
| 'Atlantic/Cape_Verde'
| 'Europe/London'
| 'Europe/Berlin'
| 'Africa/Lagos'
| 'Europe/Athens'
| 'Africa/Cairo'
| 'Europe/Moscow'
| 'Asia/Riyadh'
| 'Asia/Dubai'
| 'Asia/Baku'
| 'Asia/Karachi'
| 'Asia/Tashkent'
| 'Asia/Calcutta'
| 'Asia/Dhaka'
| 'Asia/Almaty'
| 'Asia/Jakarta'
| 'Asia/Bangkok'
| 'Asia/Shanghai'
| 'Asia/Singapore'
| 'Asia/Tokyo'
| 'Asia/Seoul'
| 'Australia/Brisbane'
| 'Australia/Sydney'
| 'Pacific/Guam'
| 'Pacific/Noumea'
| 'Pacific/Auckland'
| 'Pacific/Fiji';
export interface Config {
auth: {
users: UserAuthOperations;
};
blocks: {};
collections: {
users: User;
media: Media;
posts: Post;
'form-submissions': FormSubmission;
products: Product;
pages: Page;
'payload-kv': PayloadKv;
'payload-locked-documents': PayloadLockedDocument;
'payload-preferences': PayloadPreference;
'payload-migrations': PayloadMigration;
};
collectionsJoins: {};
collectionsSelect: {
users: UsersSelect<false> | UsersSelect<true>;
media: MediaSelect<false> | MediaSelect<true>;
posts: PostsSelect<false> | PostsSelect<true>;
'form-submissions': FormSubmissionsSelect<false> | FormSubmissionsSelect<true>;
products: ProductsSelect<false> | ProductsSelect<true>;
pages: PagesSelect<false> | PagesSelect<true>;
'payload-kv': PayloadKvSelect<false> | PayloadKvSelect<true>;
'payload-locked-documents':
| PayloadLockedDocumentsSelect<false>
| PayloadLockedDocumentsSelect<true>;
'payload-preferences': PayloadPreferencesSelect<false> | PayloadPreferencesSelect<true>;
'payload-migrations': PayloadMigrationsSelect<false> | PayloadMigrationsSelect<true>;
};
db: {
defaultIDType: number;
};
fallbackLocale: null;
globals: {};
globalsSelect: {};
locale: null;
user: User;
jobs: {
tasks: unknown;
workflows: unknown;
};
}
export interface UserAuthOperations {
forgotPassword: {
email: string;
password: string;
};
login: {
email: string;
password: string;
};
registerFirstUser: {
email: string;
password: string;
};
unlock: {
email: string;
password: string;
};
}
/**
* This interface was referenced by `Config`'s JSON-Schema
* via the `definition` "users".
*/
export interface User {
id: number;
updatedAt: string;
createdAt: string;
email: string;
resetPasswordToken?: string | null;
resetPasswordExpiration?: string | null;
salt?: string | null;
hash?: string | null;
loginAttempts?: number | null;
lockUntil?: string | null;
sessions?:
| {
id: string;
createdAt?: string | null;
expiresAt: string;
}[]
| null;
password?: string | null;
collection: 'users';
}
/**
* This interface was referenced by `Config`'s JSON-Schema
* via the `definition` "media".
*/
export interface Media {
id: number;
alt: string;
caption?: string | null;
updatedAt: string;
createdAt: string;
url?: string | null;
thumbnailURL?: string | null;
filename?: string | null;
mimeType?: string | null;
filesize?: number | null;
width?: number | null;
height?: number | null;
focalX?: number | null;
focalY?: number | null;
sizes?: {
thumbnail?: {
url?: string | null;
width?: number | null;
height?: number | null;
mimeType?: string | null;
filesize?: number | null;
filename?: string | null;
};
card?: {
url?: string | null;
width?: number | null;
height?: number | null;
mimeType?: string | null;
filesize?: number | null;
filename?: string | null;
};
tablet?: {
url?: string | null;
width?: number | null;
height?: number | null;
mimeType?: string | null;
filesize?: number | null;
filename?: string | null;
};
};
}
/**
* This interface was referenced by `Config`'s JSON-Schema
* via the `definition` "posts".
*/
export interface Post {
id: number;
title: string;
slug: string;
/**
* A short summary for blog feed cards and SEO.
*/
excerpt?: string | null;
/**
* Future dates will schedule the post to publish automatically.
*/
date: string;
/**
* 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").
*/
category?: string | null;
content?: {
root: {
type: string;
children: {
type: any;
version: number;
[k: string]: unknown;
}[];
direction: ('ltr' | 'rtl') | null;
format: 'left' | 'start' | 'center' | 'right' | 'end' | 'justify' | '';
indent: number;
version: number;
};
[k: string]: unknown;
} | null;
updatedAt: string;
createdAt: string;
_status?: ('draft' | 'published') | null;
}
/**
* Captured leads from Contact and Product Quote forms.
*
* This interface was referenced by `Config`'s JSON-Schema
* via the `definition` "form-submissions".
*/
export interface FormSubmission {
id: number;
name: string;
email: string;
type: 'contact' | 'product_quote';
/**
* The specific KLZ product the user requested a quote for.
*/
productName?: string | null;
message: string;
updatedAt: string;
createdAt: string;
}
/**
* This interface was referenced by `Config`'s JSON-Schema
* via the `definition` "products".
*/
export interface Product {
id: number;
title: string;
sku: string;
slug: string;
description: string;
locale: 'en' | 'de';
categories: {
category?: string | null;
id?: string | null;
}[];
/**
* The primary thumbnail used in list views.
*/
featuredImage?: (number | null) | Media;
images?: (number | Media)[] | null;
application?: {
root: {
type: string;
children: {
type: any;
version: number;
[k: string]: unknown;
}[];
direction: ('ltr' | 'rtl') | null;
format: 'left' | 'start' | 'center' | 'right' | 'end' | 'justify' | '';
indent: number;
version: number;
};
[k: string]: unknown;
} | null;
content?: {
root: {
type: string;
children: {
type: any;
version: number;
[k: string]: unknown;
}[];
direction: ('ltr' | 'rtl') | null;
format: 'left' | 'start' | 'center' | 'right' | 'end' | 'justify' | '';
indent: number;
version: number;
};
[k: string]: unknown;
} | null;
updatedAt: string;
createdAt: string;
_status?: ('draft' | 'published') | null;
}
/**
* This interface was referenced by `Config`'s JSON-Schema
* via the `definition` "pages".
*/
export interface Page {
id: number;
title: string;
slug: string;
locale: 'en' | 'de';
excerpt?: string | null;
featuredImage?: (number | null) | Media;
content: {
root: {
type: string;
children: {
type: any;
version: number;
[k: string]: unknown;
}[];
direction: ('ltr' | 'rtl') | null;
format: 'left' | 'start' | 'center' | 'right' | 'end' | 'justify' | '';
indent: number;
version: number;
};
[k: string]: unknown;
};
updatedAt: string;
createdAt: string;
}
/**
* This interface was referenced by `Config`'s JSON-Schema
* via the `definition` "payload-kv".
*/
export interface PayloadKv {
id: number;
key: string;
data:
| {
[k: string]: unknown;
}
| unknown[]
| string
| number
| boolean
| null;
}
/**
* This interface was referenced by `Config`'s JSON-Schema
* via the `definition` "payload-locked-documents".
*/
export interface PayloadLockedDocument {
id: number;
document?:
| ({
relationTo: 'users';
value: number | User;
} | null)
| ({
relationTo: 'media';
value: number | Media;
} | null)
| ({
relationTo: 'posts';
value: number | Post;
} | null)
| ({
relationTo: 'form-submissions';
value: number | FormSubmission;
} | null)
| ({
relationTo: 'products';
value: number | Product;
} | null)
| ({
relationTo: 'pages';
value: number | Page;
} | null);
globalSlug?: string | null;
user: {
relationTo: 'users';
value: number | User;
};
updatedAt: string;
createdAt: string;
}
/**
* This interface was referenced by `Config`'s JSON-Schema
* via the `definition` "payload-preferences".
*/
export interface PayloadPreference {
id: number;
user: {
relationTo: 'users';
value: number | User;
};
key?: string | null;
value?:
| {
[k: string]: unknown;
}
| unknown[]
| string
| number
| boolean
| null;
updatedAt: string;
createdAt: string;
}
/**
* This interface was referenced by `Config`'s JSON-Schema
* via the `definition` "payload-migrations".
*/
export interface PayloadMigration {
id: number;
name?: string | null;
batch?: number | null;
updatedAt: string;
createdAt: string;
}
/**
* This interface was referenced by `Config`'s JSON-Schema
* via the `definition` "users_select".
*/
export interface UsersSelect<T extends boolean = true> {
updatedAt?: T;
createdAt?: T;
email?: T;
resetPasswordToken?: T;
resetPasswordExpiration?: T;
salt?: T;
hash?: T;
loginAttempts?: T;
lockUntil?: T;
sessions?:
| T
| {
id?: T;
createdAt?: T;
expiresAt?: T;
};
}
/**
* This interface was referenced by `Config`'s JSON-Schema
* via the `definition` "media_select".
*/
export interface MediaSelect<T extends boolean = true> {
alt?: T;
caption?: T;
updatedAt?: T;
createdAt?: T;
url?: T;
thumbnailURL?: T;
filename?: T;
mimeType?: T;
filesize?: T;
width?: T;
height?: T;
focalX?: T;
focalY?: T;
sizes?:
| T
| {
thumbnail?:
| T
| {
url?: T;
width?: T;
height?: T;
mimeType?: T;
filesize?: T;
filename?: T;
};
card?:
| T
| {
url?: T;
width?: T;
height?: T;
mimeType?: T;
filesize?: T;
filename?: T;
};
tablet?:
| T
| {
url?: T;
width?: T;
height?: T;
mimeType?: T;
filesize?: T;
filename?: T;
};
};
}
/**
* This interface was referenced by `Config`'s JSON-Schema
* via the `definition` "posts_select".
*/
export interface PostsSelect<T extends boolean = true> {
title?: T;
slug?: T;
excerpt?: T;
date?: T;
featuredImage?: T;
locale?: T;
category?: T;
content?: T;
updatedAt?: T;
createdAt?: T;
_status?: T;
}
/**
* This interface was referenced by `Config`'s JSON-Schema
* via the `definition` "form-submissions_select".
*/
export interface FormSubmissionsSelect<T extends boolean = true> {
name?: T;
email?: T;
type?: T;
productName?: T;
message?: T;
updatedAt?: T;
createdAt?: T;
}
/**
* This interface was referenced by `Config`'s JSON-Schema
* via the `definition` "products_select".
*/
export interface ProductsSelect<T extends boolean = true> {
title?: T;
sku?: T;
slug?: T;
description?: T;
locale?: T;
categories?:
| T
| {
category?: T;
id?: T;
};
featuredImage?: T;
images?: T;
application?: T;
content?: T;
updatedAt?: T;
createdAt?: T;
_status?: T;
}
/**
* This interface was referenced by `Config`'s JSON-Schema
* via the `definition` "pages_select".
*/
export interface PagesSelect<T extends boolean = true> {
title?: T;
slug?: T;
locale?: T;
excerpt?: T;
featuredImage?: T;
content?: T;
updatedAt?: T;
createdAt?: T;
}
/**
* This interface was referenced by `Config`'s JSON-Schema
* via the `definition` "payload-kv_select".
*/
export interface PayloadKvSelect<T extends boolean = true> {
key?: T;
data?: T;
}
/**
* This interface was referenced by `Config`'s JSON-Schema
* via the `definition` "payload-locked-documents_select".
*/
export interface PayloadLockedDocumentsSelect<T extends boolean = true> {
document?: T;
globalSlug?: T;
user?: T;
updatedAt?: T;
createdAt?: T;
}
/**
* This interface was referenced by `Config`'s JSON-Schema
* via the `definition` "payload-preferences_select".
*/
export interface PayloadPreferencesSelect<T extends boolean = true> {
user?: T;
key?: T;
value?: T;
updatedAt?: T;
createdAt?: T;
}
/**
* This interface was referenced by `Config`'s JSON-Schema
* via the `definition` "payload-migrations_select".
*/
export interface PayloadMigrationsSelect<T extends boolean = true> {
name?: T;
batch?: T;
updatedAt?: T;
createdAt?: T;
}
/**
* This interface was referenced by `Config`'s JSON-Schema
* via the `definition` "StatsBlock".
*/
export interface StatsBlock {
stats: {
value: string;
label: string;
id?: string | null;
}[];
id?: string | null;
blockName?: string | null;
blockType: 'stats';
}
/**
* This interface was referenced by `Config`'s JSON-Schema
* via the `definition` "SplitHeadingBlock".
*/
export interface SplitHeadingBlock {
title: string;
id?: string | null;
level?: ('h1' | 'h2' | 'h3' | 'h4' | 'h5' | 'h6') | null;
blockName?: string | null;
blockType: 'splitHeading';
}
/**
* This interface was referenced by `Config`'s JSON-Schema
* via the `definition` "ProductTabsBlock".
*/
export interface ProductTabsBlock {
technicalItems?:
| {
label: string;
value: string;
unit?: string | null;
id?: string | null;
}[]
| null;
voltageTables?:
| {
voltageLabel: string;
metaItems?:
| {
label: string;
value: string;
unit?: string | null;
id?: string | null;
}[]
| null;
columns?:
| {
key: string;
label: string;
id?: string | null;
}[]
| null;
rows?:
| {
configuration: string;
cells?:
| {
value?: string | null;
id?: string | null;
}[]
| null;
id?: string | null;
}[]
| null;
id?: string | null;
}[]
| null;
id?: string | null;
blockName?: string | null;
blockType: 'productTabs';
}
/**
* This interface was referenced by `Config`'s JSON-Schema
* via the `definition` "auth".
*/
export interface Auth {
[k: string]: unknown;
}
declare module 'payload' {
export interface GeneratedTypes extends Config {}
}

66
payload.config.ts Normal file
View File

@@ -0,0 +1,66 @@
import { buildConfig } from 'payload';
import { postgresAdapter } from '@payloadcms/db-postgres';
import { lexicalEditor } from '@payloadcms/richtext-lexical';
import sharp from 'sharp';
import path from 'path';
import { fileURLToPath } from 'url';
import { nodemailerAdapter } from '@payloadcms/email-nodemailer';
import { BlocksFeature } from '@payloadcms/richtext-lexical';
import { payloadBlocks } from './src/payload/blocks/allBlocks';
// Disable sharp cache to prevent memory leaks in Docker
sharp.cache(false);
import { Users } from './src/payload/collections/Users';
import { Media } from './src/payload/collections/Media';
import { Posts } from './src/payload/collections/Posts';
import { FormSubmissions } from './src/payload/collections/FormSubmissions';
import { Products } from './src/payload/collections/Products';
import { Pages } from './src/payload/collections/Pages';
const filename = fileURLToPath(import.meta.url);
const dirname = path.dirname(filename);
export default buildConfig({
admin: {
user: Users.slug,
importMap: {
baseDir: path.resolve(dirname),
},
},
collections: [Users, Media, Posts, FormSubmissions, Products, Pages],
editor: lexicalEditor({
features: ({ defaultFeatures }) => [
...defaultFeatures,
BlocksFeature({
blocks: payloadBlocks,
}),
],
}),
secret: process.env.PAYLOAD_SECRET || 'fallback-secret-for-dev',
typescript: {
outputFile: path.resolve(dirname, 'payload-types.ts'),
},
db: postgresAdapter({
pool: {
connectionString:
process.env.DATABASE_URI ||
process.env.POSTGRES_URI ||
'postgresql://payload:120in09oenaoinsd9iaidon@localhost:5432/payload',
},
}),
email: nodemailerAdapter({
defaultFromAddress: process.env.MAIL_FROM?.replace(/.*<|>.*/g, '') || 'postmaster@mg.mintel.me',
defaultFromName: process.env.MAIL_FROM?.split('<')[0]?.trim() || 'KLZ Cables',
transportOptions: {
host: process.env.MAIL_HOST || 'smtp.eu.mailgun.org',
port: Number(process.env.MAIL_PORT) || 587,
auth: {
user: process.env.MAIL_USERNAME,
pass: process.env.MAIL_PASSWORD,
},
},
}),
sharp,
plugins: [],
});

2137
pnpm-lock.yaml generated

File diff suppressed because it is too large Load Diff

View File

@@ -1,65 +0,0 @@
#!/bin/bash
ENV=$1
REMOTE_HOST="root@alpha.mintel.me"
REMOTE_DIR="/home/deploy/sites/klz-cables.com"
if [ -z "$ENV" ]; then
echo "Usage: ./scripts/cms-apply.sh [local|testing|staging|production]"
exit 1
fi
PRJ_ID=$(jq -r .name package.json | sed 's/@mintel\///' | sed 's/\.com$//' | sed 's/-nextjs$//' | sed 's/-cables$//')
case $ENV in
local)
CONTAINER=$(docker compose ps -q klz-cms)
if [ -z "$CONTAINER" ]; then
echo "❌ Local directus container not found."
exit 1
fi
echo "🚀 Applying schema locally..."
docker exec "$CONTAINER" npx directus schema apply /directus/schema/snapshot.yaml --yes
;;
testing|staging|production)
case $ENV in
testing) PROJECT_NAME="${PRJ_ID}-testing" ;;
staging) PROJECT_NAME="${PRJ_ID}-staging" ;;
production)
PROJECT_NAME="${PRJ_ID}-production"
OLD_PROJECT_NAME="${PRJ_ID}-prod" # Fallback for previous convention
;;
esac
echo "📤 Uploading snapshot to $ENV..."
scp ./directus/schema/snapshot.yaml "$REMOTE_HOST:$REMOTE_DIR/directus/schema/snapshot.yaml"
echo "🔍 Detecting remote container..."
REMOTE_CONTAINER=$(ssh "$REMOTE_HOST" "cd $REMOTE_DIR && docker compose -p $PROJECT_NAME ps -q directus")
if [ -z "$REMOTE_CONTAINER" ] && [ -n "$OLD_PROJECT_NAME" ]; then
echo "⚠️ $PROJECT_NAME not found, trying fallback $OLD_PROJECT_NAME..."
REMOTE_CONTAINER=$(ssh "$REMOTE_HOST" "cd $REMOTE_DIR && docker compose -p $OLD_PROJECT_NAME ps -q directus")
if [ -n "$REMOTE_CONTAINER" ]; then
PROJECT_NAME=$OLD_PROJECT_NAME
fi
fi
if [ -z "$REMOTE_CONTAINER" ]; then
echo "❌ Remote container for $ENV not found (checked $PROJECT_NAME)."
exit 1
fi
echo "🚀 Applying schema to $ENV..."
ssh "$REMOTE_HOST" "docker exec $REMOTE_CONTAINER npx directus schema apply /directus/schema/snapshot.yaml --yes"
echo "🔄 Restarting Directus to clear cache..."
ssh "$REMOTE_HOST" "cd $REMOTE_DIR && docker compose -p $PROJECT_NAME restart directus"
;;
*)
echo "❌ Invalid environment."
exit 1
;;
esac
echo "✨ Schema apply complete!"

View File

@@ -1,15 +0,0 @@
#!/bin/bash
# Detect local container
LOCAL_CONTAINER=$(docker compose ps -q directus)
if [ -z "$LOCAL_CONTAINER" ]; then
echo "❌ Local directus container not found. Is it running?"
exit 1
fi
echo "📸 Creating schema snapshot..."
# Note: we save it to the mounted volume path inside the container
docker exec "$LOCAL_CONTAINER" npx directus schema snapshot /directus/schema/snapshot.yaml
echo "✅ Snapshot saved to ./directus/schema/snapshot.yaml"

140
scripts/migrate-mdx.ts Normal file
View File

@@ -0,0 +1,140 @@
import { getPayload } from 'payload';
import configPromise from '@payload-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() {
const payload = await getPayload({ config: configPromise });
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);

135
scripts/migrate-pages.ts Normal file
View File

@@ -0,0 +1,135 @@
import { getPayload } from 'payload';
import configPromise from '@payload-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 migrateBlogPages() {
const payload = await getPayload({ config: configPromise });
const locales = ['en', 'de'];
for (const locale of locales) {
const pagesDir = path.join(process.cwd(), 'data', 'pages', locale);
if (!fs.existsSync(pagesDir)) continue;
const files = fs.readdirSync(pagesDir);
for (const file of files) {
if (!file.endsWith('.mdx')) continue;
const slug = file.replace(/\.mdx$/, '');
const filePath = path.join(pagesDir, 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 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: 'pages',
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: 'pages',
id: existing.docs[0].id,
data: {
content: lexicalAST as any,
...(featuredImageId ? { featuredImage: featuredImageId } : {}),
},
});
console.log(`✅ AST Components & Image RE-INJECTED for ${slug}`);
} else {
await payload.create({
collection: 'pages',
data: {
title: data.title,
slug: slug,
locale: locale,
excerpt: data.excerpt || '',
content: lexicalAST 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);
}
migrateBlogPages().catch(console.error);

153
scripts/migrate-products.ts Normal file
View File

@@ -0,0 +1,153 @@
import { getPayload } from 'payload';
import configPromise from '../payload.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

@@ -1,217 +0,0 @@
import client, { ensureAuthenticated } from '../lib/directus';
import { updateSettings, uploadFiles } from '@directus/sdk';
import fs from 'fs';
import path from 'path';
import { fileURLToPath } from 'url';
// Helper for ESM __dirname
const __filename = fileURLToPath(import.meta.url);
const __dirname = path.dirname(__filename);
async function setupBranding() {
console.log('🎨 Refining Directus Branding for Premium Website Look...');
// 1. Authenticate
await ensureAuthenticated();
try {
// 2. Upload Assets (MIME FIXED)
console.log('📤 Re-uploading assets for clean IDs...');
const getMimeType = (filePath: string) => {
const ext = path.extname(filePath).toLowerCase();
switch (ext) {
case '.svg':
return 'image/svg+xml';
case '.png':
return 'image/png';
case '.jpg':
case '.jpeg':
return 'image/jpeg';
case '.ico':
return 'image/x-icon';
default:
return 'application/octet-stream';
}
};
const uploadAsset = async (filePath: string, title: string) => {
if (!fs.existsSync(filePath)) {
console.warn(`⚠️ File not found: ${filePath}`);
return null;
}
const mimeType = getMimeType(filePath);
const form = new FormData();
const fileBuffer = fs.readFileSync(filePath);
const blob = new Blob([fileBuffer], { type: mimeType });
form.append('file', blob, path.basename(filePath));
form.append('title', title);
const res = await client.request(uploadFiles(form));
return res.id;
};
const logoWhiteId = await uploadAsset(
path.resolve(__dirname, '../public/logo-white.svg'),
'Logo White',
);
const logoBlueId = await uploadAsset(
path.resolve(__dirname, '../public/logo-blue.svg'),
'Logo Blue',
);
const faviconId = await uploadAsset(
path.resolve(__dirname, '../public/favicon.ico'),
'Favicon',
);
// Smoother Background SVG
const bgSvgPath = path.resolve(__dirname, '../public/login-bg.svg');
fs.writeFileSync(
bgSvgPath,
`<svg width="1920" height="1080" viewBox="0 0 1920 1080" fill="none" xmlns="http://www.w3.org/2000/svg">
<rect width="1920" height="1080" fill="#001a4d"/>
<ellipse cx="960" cy="540" rx="800" ry="600" fill="url(#paint0_radial_premium)"/>
<defs>
<radialGradient id="paint0_radial_premium" cx="0" cy="0" r="1" gradientUnits="userSpaceOnUse" gradientTransform="translate(960 540) rotate(90) scale(600 800)">
<stop stop-color="#003d82" stop-opacity="0.8"/>
<stop offset="1" stop-color="#001a4d" stop-opacity="0"/>
</radialGradient>
</defs>
</svg>`,
);
const backgroundId = await uploadAsset(bgSvgPath, 'Login Bg');
if (fs.existsSync(bgSvgPath)) fs.unlinkSync(bgSvgPath);
// 3. Update Settings with "Premium Web" Theme
console.log('⚙️ Updating Directus settings...');
const COLOR_PRIMARY = '#001a4d'; // Deep Blue
const COLOR_ACCENT = '#82ed20'; // Sustainability Green
const COLOR_SECONDARY = '#003d82';
const customCss = `
@import url('https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&display=swap');
/* Global Login Styles */
body, .v-app {
font-family: 'Inter', sans-serif !important;
-webkit-font-smoothing: antialiased;
}
/* Glassmorphism Effect for Login Card */
.public-view .v-card {
background: rgba(255, 255, 255, 0.95) !important;
backdrop-filter: blur(20px);
border: 1px solid rgba(255, 255, 255, 0.3) !important;
border-radius: 32px !important;
box-shadow: 0 50px 100px -20px rgba(0, 0, 0, 0.4) !important;
padding: 40px !important;
}
.public-view .v-button {
border-radius: 9999px !important;
height: 56px !important;
font-weight: 600 !important;
letter-spacing: -0.01em !important;
transition: all 0.4s cubic-bezier(0.16, 1, 0.3, 1) !important;
}
.public-view .v-button:hover {
transform: translateY(-2px);
box-shadow: 0 15px 30px rgba(130, 237, 32, 0.2) !important;
}
.public-view .v-input {
--v-input-border-radius: 12px !important;
--v-input-background-color: #f8f9fa !important;
}
/* Inject Headline via CSS to avoid raw HTML display in public_note */
.public-view .form::before {
content: 'Sustainable Energy. Industrial Reliability.';
display: block;
text-align: center;
font-size: 18px;
font-weight: 700;
color: #ffffff;
margin-bottom: 8px;
}
.public-view .form::after {
content: 'KLZ INFRASTRUCTURE ENGINE';
display: block;
text-align: center;
font-size: 11px;
font-weight: 600;
letter-spacing: 0.1em;
color: rgba(255, 255, 255, 0.5);
margin-top: 24px;
}
`;
const publicNote = '';
await client.request(
updateSettings({
project_name: 'KLZ Cables',
project_url: 'https://klz-cables.com',
project_color: COLOR_ACCENT,
project_descriptor: 'Sustainable Energy Infrastructure',
// FIXED: Use WHITE logo for the Blue Sidebar
project_logo: logoWhiteId as any,
public_foreground: logoWhiteId as any,
public_background: backgroundId as any,
public_note: publicNote,
public_favicon: faviconId as any,
custom_css: customCss,
// DEEP PREMIUM THEME
theme_light_overrides: {
// Brands
primary: COLOR_ACCENT, // Buttons/Actions are GREEN like the website
secondary: COLOR_SECONDARY,
// Content Area
background: '#f1f3f7',
backgroundNormal: '#ffffff',
backgroundAccent: '#eef2ff',
// Sidebar Branding
navigationBackground: COLOR_PRIMARY,
navigationForeground: '#ffffff',
navigationBackgroundHover: 'rgba(255,255,255,0.05)',
navigationForegroundHover: '#ffffff',
navigationBackgroundActive: 'rgba(130, 237, 32, 0.15)', // Subtle Green highlight
navigationForegroundActive: COLOR_ACCENT, // Active item is GREEN
// Module Bar (Thin far left)
moduleBarBackground: '#000d26',
moduleBarForeground: '#ffffff',
moduleBarForegroundActive: COLOR_ACCENT,
// UI Standards
borderRadius: '16px', // Larger radius for modern feel
borderWidth: '1px',
borderColor: '#e2e8f0',
formFieldHeight: '48px', // Touch-target height
} as any,
theme_dark_overrides: {
primary: COLOR_ACCENT,
background: '#0a0a0a',
navigationBackground: '#000000',
moduleBarBackground: COLOR_PRIMARY,
borderRadius: '16px',
formFieldHeight: '48px',
} as any,
}),
);
console.log('✨ Premium Theme applied successfully!');
} catch (error: any) {
console.error('❌ Error:', JSON.stringify(error, null, 2));
}
}
setupBranding();

View File

@@ -1,132 +0,0 @@
#!/bin/bash
# Configuration
REMOTE_HOST="root@alpha.mintel.me"
REMOTE_DIR="/home/deploy/sites/klz-cables.com"
# DB Details (matching docker-compose defaults)
LOCAL_DB_USER="klz_db_user"
REMOTE_DB_USER="directus"
DB_NAME="directus"
ACTION=$1
ENV=$2
# Help
if [ -z "$ACTION" ] || [ -z "$ENV" ]; then
echo "Usage: ./scripts/sync-directus.sh [push|pull] [testing|staging|production]"
echo ""
echo "Commands:"
echo " push Sync LOCAL data -> REMOTE"
echo " pull Sync REMOTE data -> LOCAL"
echo ""
echo "Environments:"
echo " testing, staging, production"
exit 1
fi
# Map Environment to Project Name
case $ENV in
production|staging|testing)
PROJECT_NAME="klz-cablescom"
ENV_FILE=".env"
;;
*)
echo "❌ Invalid environment: $ENV. Use testing, staging, or production."
exit 1
;;
esac
# Detect local container
echo "🔍 Detecting local database..."
# Use a more robust way to find the container if multiple projects exist locally
LOCAL_DB_CONTAINER=$(docker compose ps -q klz-db)
if [ -z "$LOCAL_DB_CONTAINER" ]; then
echo "❌ Local klz-directus-db container not found. Is it running? (npm run dev)"
exit 1
fi
if [ "$ACTION" == "push" ]; then
echo "🚀 Pushing Local Data to $ENV ($PROJECT_NAME)..."
# 1. DB Dump
echo "📦 Dumping local database..."
# Note: we use --no-owner --no-privileges to ensure restore works on remote with different user setup
docker exec "$LOCAL_DB_CONTAINER" pg_dump -U "$DB_USER" --clean --if-exists --no-owner --no-privileges "$DB_NAME" > dump.sql
# 2. Upload Dump
echo "📤 Uploading dump to remote server..."
scp dump.sql "$REMOTE_HOST:$REMOTE_DIR/dump.sql"
# 3. Restore on Remote
echo "🔄 Restoring dump on $ENV..."
REMOTE_DB_CONTAINER=$(ssh "$REMOTE_HOST" "cd $REMOTE_DIR && docker compose -p $PROJECT_NAME ps -q directus-db")
if [ -z "$REMOTE_DB_CONTAINER" ] && [ -n "$OLD_PROJECT_NAME" ]; then
echo "⚠️ $PROJECT_NAME not found, trying fallback $OLD_PROJECT_NAME..."
REMOTE_DB_CONTAINER=$(ssh "$REMOTE_HOST" "cd $REMOTE_DIR && docker compose -p $OLD_PROJECT_NAME ps -q directus-db")
fi
if [ -z "$REMOTE_DB_CONTAINER" ]; then
echo "❌ Remote $ENV-db container not found!"
exit 1
fi
# Wipe remote DB clean before restore to avoid constraint errors
echo "🧹 Wiping remote database schema..."
ssh "$REMOTE_HOST" "docker exec $REMOTE_DB_CONTAINER psql -U $REMOTE_DB_USER $DB_NAME -c 'DROP SCHEMA public CASCADE; CREATE SCHEMA public;'"
echo "⚡ Restoring database..."
ssh "$REMOTE_HOST" "docker exec -i $REMOTE_DB_CONTAINER psql -U $REMOTE_DB_USER $DB_NAME < $REMOTE_DIR/dump.sql"
# 4. Sync Uploads
echo "📁 Syncing uploads (Local -> $ENV)..."
# Note: If environments share the same directory, this might overwrite others' files if not careful.
# But since they share the same host directory currently, rsync will update the shared folder.
rsync -avz --progress ./directus/uploads/ "$REMOTE_HOST:$REMOTE_DIR/directus/uploads/"
# Clean up
rm dump.sql
ssh "$REMOTE_HOST" "rm $REMOTE_DIR/dump.sql"
# 5. Restart Directus to trigger migrations and refresh schema cache
echo "🔄 Restarting remote Directus to apply migrations..."
ssh "$REMOTE_HOST" "cd $REMOTE_DIR && docker compose -p $PROJECT_NAME restart directus"
echo "✨ Push to $ENV complete!"
elif [ "$ACTION" == "pull" ]; then
echo "📥 Pulling $ENV Data to Local..."
# The remote service name is 'klz-db' according to docker compose config
REMOTE_DB_CONTAINER=$(ssh "$REMOTE_HOST" "cd $REMOTE_DIR && docker compose -p $PROJECT_NAME ps -q klz-db")
if [ -z "$REMOTE_DB_CONTAINER" ]; then
echo "❌ Remote $ENV-db container not found!"
exit 1
fi
ssh "$REMOTE_HOST" "docker exec $REMOTE_DB_CONTAINER pg_dump -U $REMOTE_DB_USER --clean --if-exists --no-owner --no-privileges $DB_NAME > $REMOTE_DIR/dump.sql"
# 2. Download Dump
echo "📥 Downloading dump..."
scp "$REMOTE_HOST:$REMOTE_DIR/dump.sql" dump.sql
# Wipe local DB clean before restore to avoid constraint errors
echo "🧹 Wiping local database schema..."
docker exec "$LOCAL_DB_CONTAINER" psql -U "$LOCAL_DB_USER" "$DB_NAME" -c 'DROP SCHEMA public CASCADE; CREATE SCHEMA public;'
echo "⚡ Restoring database locally..."
docker exec -i "$LOCAL_DB_CONTAINER" psql -U "$LOCAL_DB_USER" "$DB_NAME" < dump.sql
# 4. Sync Uploads
echo "📁 Syncing uploads ($ENV -> Local)..."
rsync -avz --progress "$REMOTE_HOST:$REMOTE_DIR/directus/uploads/" ./directus/uploads/
# Clean up
rm dump.sql
ssh "$REMOTE_HOST" "rm $REMOTE_DIR/dump.sql"
echo "✨ Pull to Local complete!"
else
echo "Invalid action: $ACTION. Use push or pull."
exit 1
fi

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,356 @@
import { MigrateUpArgs, MigrateDownArgs, sql } from '@payloadcms/db-postgres';
export async function up({ db }: MigrateUpArgs): Promise<void> {
await db.execute(sql`
CREATE TYPE "public"."enum_posts_locale" AS ENUM('en', 'de');
CREATE TYPE "public"."enum_posts_status" AS ENUM('draft', 'published');
CREATE TYPE "public"."enum__posts_v_version_locale" AS ENUM('en', 'de');
CREATE TYPE "public"."enum__posts_v_version_status" AS ENUM('draft', 'published');
CREATE TYPE "public"."enum_form_submissions_type" AS ENUM('contact', 'product_quote');
CREATE TYPE "public"."enum_products_locale" AS ENUM('en', 'de');
CREATE TYPE "public"."enum_products_status" AS ENUM('draft', 'published');
CREATE TYPE "public"."enum__products_v_version_locale" AS ENUM('en', 'de');
CREATE TYPE "public"."enum__products_v_version_status" AS ENUM('draft', 'published');
CREATE TABLE "users_sessions" (
"_order" integer NOT NULL,
"_parent_id" integer NOT NULL,
"id" varchar PRIMARY KEY NOT NULL,
"created_at" timestamp(3) with time zone,
"expires_at" timestamp(3) with time zone NOT NULL
);
CREATE TABLE "users" (
"id" serial PRIMARY KEY NOT NULL,
"updated_at" timestamp(3) with time zone DEFAULT now() NOT NULL,
"created_at" timestamp(3) with time zone DEFAULT now() NOT NULL,
"email" varchar NOT NULL,
"reset_password_token" varchar,
"reset_password_expiration" timestamp(3) with time zone,
"salt" varchar,
"hash" varchar,
"login_attempts" numeric DEFAULT 0,
"lock_until" timestamp(3) with time zone
);
CREATE TABLE "media" (
"id" serial PRIMARY KEY NOT NULL,
"alt" varchar NOT NULL,
"caption" varchar,
"updated_at" timestamp(3) with time zone DEFAULT now() NOT NULL,
"created_at" timestamp(3) with time zone DEFAULT now() NOT NULL,
"url" varchar,
"thumbnail_u_r_l" varchar,
"filename" varchar,
"mime_type" varchar,
"filesize" numeric,
"width" numeric,
"height" numeric,
"focal_x" numeric,
"focal_y" numeric,
"sizes_thumbnail_url" varchar,
"sizes_thumbnail_width" numeric,
"sizes_thumbnail_height" numeric,
"sizes_thumbnail_mime_type" varchar,
"sizes_thumbnail_filesize" numeric,
"sizes_thumbnail_filename" varchar,
"sizes_card_url" varchar,
"sizes_card_width" numeric,
"sizes_card_height" numeric,
"sizes_card_mime_type" varchar,
"sizes_card_filesize" numeric,
"sizes_card_filename" varchar,
"sizes_hero_url" varchar,
"sizes_hero_width" numeric,
"sizes_hero_height" numeric,
"sizes_hero_mime_type" varchar,
"sizes_hero_filesize" numeric,
"sizes_hero_filename" varchar,
"sizes_hero_mobile_url" varchar,
"sizes_hero_mobile_width" numeric,
"sizes_hero_mobile_height" numeric,
"sizes_hero_mobile_mime_type" varchar,
"sizes_hero_mobile_filesize" numeric,
"sizes_hero_mobile_filename" varchar
);
CREATE TABLE "posts" (
"id" serial PRIMARY KEY NOT NULL,
"title" varchar,
"slug" varchar,
"excerpt" varchar,
"date" timestamp(3) with time zone,
"featured_image_id" integer,
"locale" "enum_posts_locale" DEFAULT 'en',
"category" varchar,
"content" jsonb,
"updated_at" timestamp(3) with time zone DEFAULT now() NOT NULL,
"created_at" timestamp(3) with time zone DEFAULT now() NOT NULL,
"_status" "enum_posts_status" DEFAULT 'draft'
);
CREATE TABLE "_posts_v" (
"id" serial PRIMARY KEY NOT NULL,
"parent_id" integer,
"version_title" varchar,
"version_slug" varchar,
"version_excerpt" varchar,
"version_date" timestamp(3) with time zone,
"version_featured_image_id" integer,
"version_locale" "enum__posts_v_version_locale" DEFAULT 'en',
"version_category" varchar,
"version_content" jsonb,
"version_updated_at" timestamp(3) with time zone,
"version_created_at" timestamp(3) with time zone,
"version__status" "enum__posts_v_version_status" DEFAULT 'draft',
"created_at" timestamp(3) with time zone DEFAULT now() NOT NULL,
"updated_at" timestamp(3) with time zone DEFAULT now() NOT NULL,
"latest" boolean
);
CREATE TABLE "form_submissions" (
"id" serial PRIMARY KEY NOT NULL,
"name" varchar NOT NULL,
"email" varchar NOT NULL,
"type" "enum_form_submissions_type" NOT NULL,
"product_name" varchar,
"message" varchar NOT NULL,
"updated_at" timestamp(3) with time zone DEFAULT now() NOT NULL,
"created_at" timestamp(3) with time zone DEFAULT now() NOT NULL
);
CREATE TABLE "products_categories" (
"_order" integer NOT NULL,
"_parent_id" integer NOT NULL,
"id" varchar PRIMARY KEY NOT NULL,
"category" varchar
);
CREATE TABLE "products" (
"id" serial PRIMARY KEY NOT NULL,
"title" varchar,
"sku" varchar,
"slug" varchar,
"description" varchar,
"locale" "enum_products_locale" DEFAULT 'de',
"application" jsonb,
"content" jsonb,
"updated_at" timestamp(3) with time zone DEFAULT now() NOT NULL,
"created_at" timestamp(3) with time zone DEFAULT now() NOT NULL,
"_status" "enum_products_status" DEFAULT 'draft'
);
CREATE TABLE "products_rels" (
"id" serial PRIMARY KEY NOT NULL,
"order" integer,
"parent_id" integer NOT NULL,
"path" varchar NOT NULL,
"media_id" integer
);
CREATE TABLE "_products_v_version_categories" (
"_order" integer NOT NULL,
"_parent_id" integer NOT NULL,
"id" serial PRIMARY KEY NOT NULL,
"category" varchar,
"_uuid" varchar
);
CREATE TABLE "_products_v" (
"id" serial PRIMARY KEY NOT NULL,
"parent_id" integer,
"version_title" varchar,
"version_sku" varchar,
"version_slug" varchar,
"version_description" varchar,
"version_locale" "enum__products_v_version_locale" DEFAULT 'de',
"version_application" jsonb,
"version_content" jsonb,
"version_updated_at" timestamp(3) with time zone,
"version_created_at" timestamp(3) with time zone,
"version__status" "enum__products_v_version_status" DEFAULT 'draft',
"created_at" timestamp(3) with time zone DEFAULT now() NOT NULL,
"updated_at" timestamp(3) with time zone DEFAULT now() NOT NULL,
"latest" boolean
);
CREATE TABLE "_products_v_rels" (
"id" serial PRIMARY KEY NOT NULL,
"order" integer,
"parent_id" integer NOT NULL,
"path" varchar NOT NULL,
"media_id" integer
);
CREATE TABLE "payload_kv" (
"id" serial PRIMARY KEY NOT NULL,
"key" varchar NOT NULL,
"data" jsonb NOT NULL
);
CREATE TABLE "payload_locked_documents" (
"id" serial PRIMARY KEY NOT NULL,
"global_slug" varchar,
"updated_at" timestamp(3) with time zone DEFAULT now() NOT NULL,
"created_at" timestamp(3) with time zone DEFAULT now() NOT NULL
);
CREATE TABLE "payload_locked_documents_rels" (
"id" serial PRIMARY KEY NOT NULL,
"order" integer,
"parent_id" integer NOT NULL,
"path" varchar NOT NULL,
"users_id" integer,
"media_id" integer,
"posts_id" integer,
"form_submissions_id" integer,
"products_id" integer
);
CREATE TABLE "payload_preferences" (
"id" serial PRIMARY KEY NOT NULL,
"key" varchar,
"value" jsonb,
"updated_at" timestamp(3) with time zone DEFAULT now() NOT NULL,
"created_at" timestamp(3) with time zone DEFAULT now() NOT NULL
);
CREATE TABLE "payload_preferences_rels" (
"id" serial PRIMARY KEY NOT NULL,
"order" integer,
"parent_id" integer NOT NULL,
"path" varchar NOT NULL,
"users_id" integer
);
CREATE TABLE "payload_migrations" (
"id" serial PRIMARY KEY NOT NULL,
"name" varchar,
"batch" numeric,
"updated_at" timestamp(3) with time zone DEFAULT now() NOT NULL,
"created_at" timestamp(3) with time zone DEFAULT now() NOT NULL
);
ALTER TABLE "users_sessions" ADD CONSTRAINT "users_sessions_parent_id_fk" FOREIGN KEY ("_parent_id") REFERENCES "public"."users"("id") ON DELETE cascade ON UPDATE no action;
ALTER TABLE "posts" ADD CONSTRAINT "posts_featured_image_id_media_id_fk" FOREIGN KEY ("featured_image_id") REFERENCES "public"."media"("id") ON DELETE set null ON UPDATE no action;
ALTER TABLE "_posts_v" ADD CONSTRAINT "_posts_v_parent_id_posts_id_fk" FOREIGN KEY ("parent_id") REFERENCES "public"."posts"("id") ON DELETE set null ON UPDATE no action;
ALTER TABLE "_posts_v" ADD CONSTRAINT "_posts_v_version_featured_image_id_media_id_fk" FOREIGN KEY ("version_featured_image_id") REFERENCES "public"."media"("id") ON DELETE set null ON UPDATE no action;
ALTER TABLE "products_categories" ADD CONSTRAINT "products_categories_parent_id_fk" FOREIGN KEY ("_parent_id") REFERENCES "public"."products"("id") ON DELETE cascade ON UPDATE no action;
ALTER TABLE "products_rels" ADD CONSTRAINT "products_rels_parent_fk" FOREIGN KEY ("parent_id") REFERENCES "public"."products"("id") ON DELETE cascade ON UPDATE no action;
ALTER TABLE "products_rels" ADD CONSTRAINT "products_rels_media_fk" FOREIGN KEY ("media_id") REFERENCES "public"."media"("id") ON DELETE cascade ON UPDATE no action;
ALTER TABLE "_products_v_version_categories" ADD CONSTRAINT "_products_v_version_categories_parent_id_fk" FOREIGN KEY ("_parent_id") REFERENCES "public"."_products_v"("id") ON DELETE cascade ON UPDATE no action;
ALTER TABLE "_products_v" ADD CONSTRAINT "_products_v_parent_id_products_id_fk" FOREIGN KEY ("parent_id") REFERENCES "public"."products"("id") ON DELETE set null ON UPDATE no action;
ALTER TABLE "_products_v_rels" ADD CONSTRAINT "_products_v_rels_parent_fk" FOREIGN KEY ("parent_id") REFERENCES "public"."_products_v"("id") ON DELETE cascade ON UPDATE no action;
ALTER TABLE "_products_v_rels" ADD CONSTRAINT "_products_v_rels_media_fk" FOREIGN KEY ("media_id") REFERENCES "public"."media"("id") ON DELETE cascade ON UPDATE no action;
ALTER TABLE "payload_locked_documents_rels" ADD CONSTRAINT "payload_locked_documents_rels_parent_fk" FOREIGN KEY ("parent_id") REFERENCES "public"."payload_locked_documents"("id") ON DELETE cascade ON UPDATE no action;
ALTER TABLE "payload_locked_documents_rels" ADD CONSTRAINT "payload_locked_documents_rels_users_fk" FOREIGN KEY ("users_id") REFERENCES "public"."users"("id") ON DELETE cascade ON UPDATE no action;
ALTER TABLE "payload_locked_documents_rels" ADD CONSTRAINT "payload_locked_documents_rels_media_fk" FOREIGN KEY ("media_id") REFERENCES "public"."media"("id") ON DELETE cascade ON UPDATE no action;
ALTER TABLE "payload_locked_documents_rels" ADD CONSTRAINT "payload_locked_documents_rels_posts_fk" FOREIGN KEY ("posts_id") REFERENCES "public"."posts"("id") ON DELETE cascade ON UPDATE no action;
ALTER TABLE "payload_locked_documents_rels" ADD CONSTRAINT "payload_locked_documents_rels_form_submissions_fk" FOREIGN KEY ("form_submissions_id") REFERENCES "public"."form_submissions"("id") ON DELETE cascade ON UPDATE no action;
ALTER TABLE "payload_locked_documents_rels" ADD CONSTRAINT "payload_locked_documents_rels_products_fk" FOREIGN KEY ("products_id") REFERENCES "public"."products"("id") ON DELETE cascade ON UPDATE no action;
ALTER TABLE "payload_preferences_rels" ADD CONSTRAINT "payload_preferences_rels_parent_fk" FOREIGN KEY ("parent_id") REFERENCES "public"."payload_preferences"("id") ON DELETE cascade ON UPDATE no action;
ALTER TABLE "payload_preferences_rels" ADD CONSTRAINT "payload_preferences_rels_users_fk" FOREIGN KEY ("users_id") REFERENCES "public"."users"("id") ON DELETE cascade ON UPDATE no action;
CREATE INDEX "users_sessions_order_idx" ON "users_sessions" USING btree ("_order");
CREATE INDEX "users_sessions_parent_id_idx" ON "users_sessions" USING btree ("_parent_id");
CREATE INDEX "users_updated_at_idx" ON "users" USING btree ("updated_at");
CREATE INDEX "users_created_at_idx" ON "users" USING btree ("created_at");
CREATE UNIQUE INDEX "users_email_idx" ON "users" USING btree ("email");
CREATE INDEX "media_updated_at_idx" ON "media" USING btree ("updated_at");
CREATE INDEX "media_created_at_idx" ON "media" USING btree ("created_at");
CREATE UNIQUE INDEX "media_filename_idx" ON "media" USING btree ("filename");
CREATE INDEX "media_sizes_thumbnail_sizes_thumbnail_filename_idx" ON "media" USING btree ("sizes_thumbnail_filename");
CREATE INDEX "media_sizes_card_sizes_card_filename_idx" ON "media" USING btree ("sizes_card_filename");
CREATE INDEX "media_sizes_hero_sizes_hero_filename_idx" ON "media" USING btree ("sizes_hero_filename");
CREATE INDEX "media_sizes_hero_mobile_sizes_hero_mobile_filename_idx" ON "media" USING btree ("sizes_hero_mobile_filename");
CREATE UNIQUE INDEX "posts_slug_idx" ON "posts" USING btree ("slug");
CREATE INDEX "posts_featured_image_idx" ON "posts" USING btree ("featured_image_id");
CREATE INDEX "posts_updated_at_idx" ON "posts" USING btree ("updated_at");
CREATE INDEX "posts_created_at_idx" ON "posts" USING btree ("created_at");
CREATE INDEX "posts__status_idx" ON "posts" USING btree ("_status");
CREATE INDEX "_posts_v_parent_idx" ON "_posts_v" USING btree ("parent_id");
CREATE INDEX "_posts_v_version_version_slug_idx" ON "_posts_v" USING btree ("version_slug");
CREATE INDEX "_posts_v_version_version_featured_image_idx" ON "_posts_v" USING btree ("version_featured_image_id");
CREATE INDEX "_posts_v_version_version_updated_at_idx" ON "_posts_v" USING btree ("version_updated_at");
CREATE INDEX "_posts_v_version_version_created_at_idx" ON "_posts_v" USING btree ("version_created_at");
CREATE INDEX "_posts_v_version_version__status_idx" ON "_posts_v" USING btree ("version__status");
CREATE INDEX "_posts_v_created_at_idx" ON "_posts_v" USING btree ("created_at");
CREATE INDEX "_posts_v_updated_at_idx" ON "_posts_v" USING btree ("updated_at");
CREATE INDEX "_posts_v_latest_idx" ON "_posts_v" USING btree ("latest");
CREATE INDEX "form_submissions_updated_at_idx" ON "form_submissions" USING btree ("updated_at");
CREATE INDEX "form_submissions_created_at_idx" ON "form_submissions" USING btree ("created_at");
CREATE INDEX "products_categories_order_idx" ON "products_categories" USING btree ("_order");
CREATE INDEX "products_categories_parent_id_idx" ON "products_categories" USING btree ("_parent_id");
CREATE UNIQUE INDEX "products_sku_idx" ON "products" USING btree ("sku");
CREATE INDEX "products_updated_at_idx" ON "products" USING btree ("updated_at");
CREATE INDEX "products_created_at_idx" ON "products" USING btree ("created_at");
CREATE INDEX "products__status_idx" ON "products" USING btree ("_status");
CREATE INDEX "products_rels_order_idx" ON "products_rels" USING btree ("order");
CREATE INDEX "products_rels_parent_idx" ON "products_rels" USING btree ("parent_id");
CREATE INDEX "products_rels_path_idx" ON "products_rels" USING btree ("path");
CREATE INDEX "products_rels_media_id_idx" ON "products_rels" USING btree ("media_id");
CREATE INDEX "_products_v_version_categories_order_idx" ON "_products_v_version_categories" USING btree ("_order");
CREATE INDEX "_products_v_version_categories_parent_id_idx" ON "_products_v_version_categories" USING btree ("_parent_id");
CREATE INDEX "_products_v_parent_idx" ON "_products_v" USING btree ("parent_id");
CREATE INDEX "_products_v_version_version_sku_idx" ON "_products_v" USING btree ("version_sku");
CREATE INDEX "_products_v_version_version_updated_at_idx" ON "_products_v" USING btree ("version_updated_at");
CREATE INDEX "_products_v_version_version_created_at_idx" ON "_products_v" USING btree ("version_created_at");
CREATE INDEX "_products_v_version_version__status_idx" ON "_products_v" USING btree ("version__status");
CREATE INDEX "_products_v_created_at_idx" ON "_products_v" USING btree ("created_at");
CREATE INDEX "_products_v_updated_at_idx" ON "_products_v" USING btree ("updated_at");
CREATE INDEX "_products_v_latest_idx" ON "_products_v" USING btree ("latest");
CREATE INDEX "_products_v_rels_order_idx" ON "_products_v_rels" USING btree ("order");
CREATE INDEX "_products_v_rels_parent_idx" ON "_products_v_rels" USING btree ("parent_id");
CREATE INDEX "_products_v_rels_path_idx" ON "_products_v_rels" USING btree ("path");
CREATE INDEX "_products_v_rels_media_id_idx" ON "_products_v_rels" USING btree ("media_id");
CREATE UNIQUE INDEX "payload_kv_key_idx" ON "payload_kv" USING btree ("key");
CREATE INDEX "payload_locked_documents_global_slug_idx" ON "payload_locked_documents" USING btree ("global_slug");
CREATE INDEX "payload_locked_documents_updated_at_idx" ON "payload_locked_documents" USING btree ("updated_at");
CREATE INDEX "payload_locked_documents_created_at_idx" ON "payload_locked_documents" USING btree ("created_at");
CREATE INDEX "payload_locked_documents_rels_order_idx" ON "payload_locked_documents_rels" USING btree ("order");
CREATE INDEX "payload_locked_documents_rels_parent_idx" ON "payload_locked_documents_rels" USING btree ("parent_id");
CREATE INDEX "payload_locked_documents_rels_path_idx" ON "payload_locked_documents_rels" USING btree ("path");
CREATE INDEX "payload_locked_documents_rels_users_id_idx" ON "payload_locked_documents_rels" USING btree ("users_id");
CREATE INDEX "payload_locked_documents_rels_media_id_idx" ON "payload_locked_documents_rels" USING btree ("media_id");
CREATE INDEX "payload_locked_documents_rels_posts_id_idx" ON "payload_locked_documents_rels" USING btree ("posts_id");
CREATE INDEX "payload_locked_documents_rels_form_submissions_id_idx" ON "payload_locked_documents_rels" USING btree ("form_submissions_id");
CREATE INDEX "payload_locked_documents_rels_products_id_idx" ON "payload_locked_documents_rels" USING btree ("products_id");
CREATE INDEX "payload_preferences_key_idx" ON "payload_preferences" USING btree ("key");
CREATE INDEX "payload_preferences_updated_at_idx" ON "payload_preferences" USING btree ("updated_at");
CREATE INDEX "payload_preferences_created_at_idx" ON "payload_preferences" USING btree ("created_at");
CREATE INDEX "payload_preferences_rels_order_idx" ON "payload_preferences_rels" USING btree ("order");
CREATE INDEX "payload_preferences_rels_parent_idx" ON "payload_preferences_rels" USING btree ("parent_id");
CREATE INDEX "payload_preferences_rels_path_idx" ON "payload_preferences_rels" USING btree ("path");
CREATE INDEX "payload_preferences_rels_users_id_idx" ON "payload_preferences_rels" USING btree ("users_id");
CREATE INDEX "payload_migrations_updated_at_idx" ON "payload_migrations" USING btree ("updated_at");
CREATE INDEX "payload_migrations_created_at_idx" ON "payload_migrations" USING btree ("created_at");`);
}
export async function down({ db }: MigrateDownArgs): Promise<void> {
await db.execute(sql`
DROP TABLE "users_sessions" CASCADE;
DROP TABLE "users" CASCADE;
DROP TABLE "media" CASCADE;
DROP TABLE "posts" CASCADE;
DROP TABLE "_posts_v" CASCADE;
DROP TABLE "form_submissions" CASCADE;
DROP TABLE "products_categories" CASCADE;
DROP TABLE "products" CASCADE;
DROP TABLE "products_rels" CASCADE;
DROP TABLE "_products_v_version_categories" CASCADE;
DROP TABLE "_products_v" CASCADE;
DROP TABLE "_products_v_rels" CASCADE;
DROP TABLE "payload_kv" CASCADE;
DROP TABLE "payload_locked_documents" CASCADE;
DROP TABLE "payload_locked_documents_rels" CASCADE;
DROP TABLE "payload_preferences" CASCADE;
DROP TABLE "payload_preferences_rels" CASCADE;
DROP TABLE "payload_migrations" CASCADE;
DROP TYPE "public"."enum_posts_locale";
DROP TYPE "public"."enum_posts_status";
DROP TYPE "public"."enum__posts_v_version_locale";
DROP TYPE "public"."enum__posts_v_version_status";
DROP TYPE "public"."enum_form_submissions_type";
DROP TYPE "public"."enum_products_locale";
DROP TYPE "public"."enum_products_status";
DROP TYPE "public"."enum__products_v_version_locale";
DROP TYPE "public"."enum__products_v_version_status";`);
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,13 @@
import { MigrateUpArgs, MigrateDownArgs, sql } from '@payloadcms/db-postgres';
export async function up({ db }: MigrateUpArgs): Promise<void> {
await db.execute(sql`
DROP INDEX "products_sku_idx";
DROP INDEX "_products_v_version_version_sku_idx";`);
}
export async function down({ db }: MigrateDownArgs): Promise<void> {
await db.execute(sql`
CREATE UNIQUE INDEX "products_sku_idx" ON "products" USING btree ("sku");
CREATE INDEX "_products_v_version_version_sku_idx" ON "_products_v" USING btree ("version_sku");`);
}

15
src/migrations/index.ts Normal file
View File

@@ -0,0 +1,15 @@
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';
export const migrations = [
{
up: migration_20260223_195005_products_collection.up,
down: migration_20260223_195005_products_collection.down,
name: '20260223_195005_products_collection',
},
{
up: migration_20260223_195151_remove_sku_unique.up,
down: migration_20260223_195151_remove_sku_unique.down,
name: '20260223_195151_remove_sku_unique',
},
];

View File

@@ -0,0 +1,793 @@
/* tslint:disable */
/* eslint-disable */
/**
* This file was automatically generated by Payload.
* DO NOT MODIFY IT BY HAND. Instead, modify your source Payload config,
* and re-run `payload generate:db-schema` to regenerate this file.
*/
import type {} from '@payloadcms/db-postgres';
import {
pgTable,
index,
uniqueIndex,
foreignKey,
integer,
varchar,
timestamp,
serial,
numeric,
jsonb,
boolean,
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_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_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 users_sessions = pgTable(
'users_sessions',
{
_order: integer('_order').notNull(),
_parentID: integer('_parent_id').notNull(),
id: varchar('id').primaryKey(),
createdAt: timestamp('created_at', { mode: 'string', withTimezone: true, precision: 3 }),
expiresAt: timestamp('expires_at', {
mode: 'string',
withTimezone: true,
precision: 3,
}).notNull(),
},
(columns) => [
index('users_sessions_order_idx').on(columns._order),
index('users_sessions_parent_id_idx').on(columns._parentID),
foreignKey({
columns: [columns['_parentID']],
foreignColumns: [users.id],
name: 'users_sessions_parent_id_fk',
}).onDelete('cascade'),
],
);
export const users = pgTable(
'users',
{
id: serial('id').primaryKey(),
updatedAt: timestamp('updated_at', { mode: 'string', withTimezone: true, precision: 3 })
.defaultNow()
.notNull(),
createdAt: timestamp('created_at', { mode: 'string', withTimezone: true, precision: 3 })
.defaultNow()
.notNull(),
email: varchar('email').notNull(),
resetPasswordToken: varchar('reset_password_token'),
resetPasswordExpiration: timestamp('reset_password_expiration', {
mode: 'string',
withTimezone: true,
precision: 3,
}),
salt: varchar('salt'),
hash: varchar('hash'),
loginAttempts: numeric('login_attempts', { mode: 'number' }).default(0),
lockUntil: timestamp('lock_until', { mode: 'string', withTimezone: true, precision: 3 }),
},
(columns) => [
index('users_updated_at_idx').on(columns.updatedAt),
index('users_created_at_idx').on(columns.createdAt),
uniqueIndex('users_email_idx').on(columns.email),
],
);
export const media = pgTable(
'media',
{
id: serial('id').primaryKey(),
alt: varchar('alt').notNull(),
caption: varchar('caption'),
updatedAt: timestamp('updated_at', { mode: 'string', withTimezone: true, precision: 3 })
.defaultNow()
.notNull(),
createdAt: timestamp('created_at', { mode: 'string', withTimezone: true, precision: 3 })
.defaultNow()
.notNull(),
url: varchar('url'),
thumbnailURL: varchar('thumbnail_u_r_l'),
filename: varchar('filename'),
mimeType: varchar('mime_type'),
filesize: numeric('filesize', { mode: 'number' }),
width: numeric('width', { mode: 'number' }),
height: numeric('height', { mode: 'number' }),
focalX: numeric('focal_x', { mode: 'number' }),
focalY: numeric('focal_y', { mode: 'number' }),
sizes_thumbnail_url: varchar('sizes_thumbnail_url'),
sizes_thumbnail_width: numeric('sizes_thumbnail_width', { mode: 'number' }),
sizes_thumbnail_height: numeric('sizes_thumbnail_height', { mode: 'number' }),
sizes_thumbnail_mimeType: varchar('sizes_thumbnail_mime_type'),
sizes_thumbnail_filesize: numeric('sizes_thumbnail_filesize', { mode: 'number' }),
sizes_thumbnail_filename: varchar('sizes_thumbnail_filename'),
sizes_card_url: varchar('sizes_card_url'),
sizes_card_width: numeric('sizes_card_width', { mode: 'number' }),
sizes_card_height: numeric('sizes_card_height', { mode: 'number' }),
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'),
},
(columns) => [
index('media_updated_at_idx').on(columns.updatedAt),
index('media_created_at_idx').on(columns.createdAt),
uniqueIndex('media_filename_idx').on(columns.filename),
index('media_sizes_thumbnail_sizes_thumbnail_filename_idx').on(
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,
),
],
);
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(),
createdAt: timestamp('created_at', { mode: 'string', withTimezone: true, precision: 3 })
.defaultNow()
.notNull(),
_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),
index('posts__status_idx').on(columns._status),
],
);
export const _posts_v = pgTable(
'_posts_v',
{
id: serial('id').primaryKey(),
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,
precision: 3,
}),
version_createdAt: timestamp('version_created_at', {
mode: 'string',
withTimezone: true,
precision: 3,
}),
version__status: enum__posts_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(),
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_latest_idx').on(columns.latest),
],
);
export const form_submissions = pgTable(
'form_submissions',
{
id: serial('id').primaryKey(),
name: varchar('name').notNull(),
email: varchar('email').notNull(),
type: enum_form_submissions_type('type').notNull(),
productName: varchar('product_name'),
message: varchar('message').notNull(),
updatedAt: timestamp('updated_at', { mode: 'string', withTimezone: true, precision: 3 })
.defaultNow()
.notNull(),
createdAt: timestamp('created_at', { mode: 'string', withTimezone: true, precision: 3 })
.defaultNow()
.notNull(),
},
(columns) => [
index('form_submissions_updated_at_idx').on(columns.updatedAt),
index('form_submissions_created_at_idx').on(columns.createdAt),
],
);
export const products_categories = pgTable(
'products_categories',
{
_order: integer('_order').notNull(),
_parentID: integer('_parent_id').notNull(),
id: varchar('id').primaryKey(),
category: varchar('category'),
},
(columns) => [
index('products_categories_order_idx').on(columns._order),
index('products_categories_parent_id_idx').on(columns._parentID),
foreignKey({
columns: [columns['_parentID']],
foreignColumns: [products.id],
name: 'products_categories_parent_id_fk',
}).onDelete('cascade'),
],
);
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'),
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_products_status('_status').default('draft'),
},
(columns) => [
uniqueIndex('products_sku_idx').on(columns.sku),
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_rels = pgTable(
'products_rels',
{
id: serial('id').primaryKey(),
order: integer('order'),
parent: integer('parent_id').notNull(),
path: varchar('path').notNull(),
mediaID: integer('media_id'),
},
(columns) => [
index('products_rels_order_idx').on(columns.order),
index('products_rels_parent_idx').on(columns.parent),
index('products_rels_path_idx').on(columns.path),
index('products_rels_media_id_idx').on(columns.mediaID),
foreignKey({
columns: [columns['parent']],
foreignColumns: [products.id],
name: 'products_rels_parent_fk',
}).onDelete('cascade'),
foreignKey({
columns: [columns['mediaID']],
foreignColumns: [media.id],
name: 'products_rels_media_fk',
}).onDelete('cascade'),
],
);
export const _products_v_version_categories = pgTable(
'_products_v_version_categories',
{
_order: integer('_order').notNull(),
_parentID: integer('_parent_id').notNull(),
id: serial('id').primaryKey(),
category: varchar('category'),
_uuid: varchar('_uuid'),
},
(columns) => [
index('_products_v_version_categories_order_idx').on(columns._order),
index('_products_v_version_categories_parent_id_idx').on(columns._parentID),
foreignKey({
columns: [columns['_parentID']],
foreignColumns: [_products_v.id],
name: '_products_v_version_categories_parent_id_fk',
}).onDelete('cascade'),
],
);
export const _products_v = pgTable(
'_products_v',
{
id: serial('id').primaryKey(),
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_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__products_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(),
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_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_latest_idx').on(columns.latest),
],
);
export const _products_v_rels = pgTable(
'_products_v_rels',
{
id: serial('id').primaryKey(),
order: integer('order'),
parent: integer('parent_id').notNull(),
path: varchar('path').notNull(),
mediaID: integer('media_id'),
},
(columns) => [
index('_products_v_rels_order_idx').on(columns.order),
index('_products_v_rels_parent_idx').on(columns.parent),
index('_products_v_rels_path_idx').on(columns.path),
index('_products_v_rels_media_id_idx').on(columns.mediaID),
foreignKey({
columns: [columns['parent']],
foreignColumns: [_products_v.id],
name: '_products_v_rels_parent_fk',
}).onDelete('cascade'),
foreignKey({
columns: [columns['mediaID']],
foreignColumns: [media.id],
name: '_products_v_rels_media_fk',
}).onDelete('cascade'),
],
);
export const payload_kv = pgTable(
'payload_kv',
{
id: serial('id').primaryKey(),
key: varchar('key').notNull(),
data: jsonb('data').notNull(),
},
(columns) => [uniqueIndex('payload_kv_key_idx').on(columns.key)],
);
export const payload_locked_documents = pgTable(
'payload_locked_documents',
{
id: serial('id').primaryKey(),
globalSlug: varchar('global_slug'),
updatedAt: timestamp('updated_at', { mode: 'string', withTimezone: true, precision: 3 })
.defaultNow()
.notNull(),
createdAt: timestamp('created_at', { mode: 'string', withTimezone: true, precision: 3 })
.defaultNow()
.notNull(),
},
(columns) => [
index('payload_locked_documents_global_slug_idx').on(columns.globalSlug),
index('payload_locked_documents_updated_at_idx').on(columns.updatedAt),
index('payload_locked_documents_created_at_idx').on(columns.createdAt),
],
);
export const payload_locked_documents_rels = pgTable(
'payload_locked_documents_rels',
{
id: serial('id').primaryKey(),
order: integer('order'),
parent: integer('parent_id').notNull(),
path: varchar('path').notNull(),
usersID: integer('users_id'),
mediaID: integer('media_id'),
postsID: integer('posts_id'),
'form-submissionsID': integer('form_submissions_id'),
productsID: integer('products_id'),
},
(columns) => [
index('payload_locked_documents_rels_order_idx').on(columns.order),
index('payload_locked_documents_rels_parent_idx').on(columns.parent),
index('payload_locked_documents_rels_path_idx').on(columns.path),
index('payload_locked_documents_rels_users_id_idx').on(columns.usersID),
index('payload_locked_documents_rels_media_id_idx').on(columns.mediaID),
index('payload_locked_documents_rels_posts_id_idx').on(columns.postsID),
index('payload_locked_documents_rels_form_submissions_id_idx').on(
columns['form-submissionsID'],
),
index('payload_locked_documents_rels_products_id_idx').on(columns.productsID),
foreignKey({
columns: [columns['parent']],
foreignColumns: [payload_locked_documents.id],
name: 'payload_locked_documents_rels_parent_fk',
}).onDelete('cascade'),
foreignKey({
columns: [columns['usersID']],
foreignColumns: [users.id],
name: 'payload_locked_documents_rels_users_fk',
}).onDelete('cascade'),
foreignKey({
columns: [columns['mediaID']],
foreignColumns: [media.id],
name: 'payload_locked_documents_rels_media_fk',
}).onDelete('cascade'),
foreignKey({
columns: [columns['postsID']],
foreignColumns: [posts.id],
name: 'payload_locked_documents_rels_posts_fk',
}).onDelete('cascade'),
foreignKey({
columns: [columns['form-submissionsID']],
foreignColumns: [form_submissions.id],
name: 'payload_locked_documents_rels_form_submissions_fk',
}).onDelete('cascade'),
foreignKey({
columns: [columns['productsID']],
foreignColumns: [products.id],
name: 'payload_locked_documents_rels_products_fk',
}).onDelete('cascade'),
],
);
export const payload_preferences = pgTable(
'payload_preferences',
{
id: serial('id').primaryKey(),
key: varchar('key'),
value: jsonb('value'),
updatedAt: timestamp('updated_at', { mode: 'string', withTimezone: true, precision: 3 })
.defaultNow()
.notNull(),
createdAt: timestamp('created_at', { mode: 'string', withTimezone: true, precision: 3 })
.defaultNow()
.notNull(),
},
(columns) => [
index('payload_preferences_key_idx').on(columns.key),
index('payload_preferences_updated_at_idx').on(columns.updatedAt),
index('payload_preferences_created_at_idx').on(columns.createdAt),
],
);
export const payload_preferences_rels = pgTable(
'payload_preferences_rels',
{
id: serial('id').primaryKey(),
order: integer('order'),
parent: integer('parent_id').notNull(),
path: varchar('path').notNull(),
usersID: integer('users_id'),
},
(columns) => [
index('payload_preferences_rels_order_idx').on(columns.order),
index('payload_preferences_rels_parent_idx').on(columns.parent),
index('payload_preferences_rels_path_idx').on(columns.path),
index('payload_preferences_rels_users_id_idx').on(columns.usersID),
foreignKey({
columns: [columns['parent']],
foreignColumns: [payload_preferences.id],
name: 'payload_preferences_rels_parent_fk',
}).onDelete('cascade'),
foreignKey({
columns: [columns['usersID']],
foreignColumns: [users.id],
name: 'payload_preferences_rels_users_fk',
}).onDelete('cascade'),
],
);
export const payload_migrations = pgTable(
'payload_migrations',
{
id: serial('id').primaryKey(),
name: varchar('name'),
batch: numeric('batch', { mode: 'number' }),
updatedAt: timestamp('updated_at', { mode: 'string', withTimezone: true, precision: 3 })
.defaultNow()
.notNull(),
createdAt: timestamp('created_at', { mode: 'string', withTimezone: true, precision: 3 })
.defaultNow()
.notNull(),
},
(columns) => [
index('payload_migrations_updated_at_idx').on(columns.updatedAt),
index('payload_migrations_created_at_idx').on(columns.createdAt),
],
);
export const relations_users_sessions = relations(users_sessions, ({ one }) => ({
_parentID: one(users, {
fields: [users_sessions._parentID],
references: [users.id],
relationName: 'sessions',
}),
}));
export const relations_users = relations(users, ({ many }) => ({
sessions: many(users_sessions, {
relationName: 'sessions',
}),
}));
export const relations_media = relations(media, () => ({}));
export const relations_posts = relations(posts, ({ one }) => ({
featuredImage: one(media, {
fields: [posts.featuredImage],
references: [media.id],
relationName: 'featuredImage',
}),
}));
export const relations__posts_v = relations(_posts_v, ({ one }) => ({
parent: one(posts, {
fields: [_posts_v.parent],
references: [posts.id],
relationName: 'parent',
}),
version_featuredImage: one(media, {
fields: [_posts_v.version_featuredImage],
references: [media.id],
relationName: 'version_featuredImage',
}),
}));
export const relations_form_submissions = relations(form_submissions, () => ({}));
export const relations_products_categories = relations(products_categories, ({ one }) => ({
_parentID: one(products, {
fields: [products_categories._parentID],
references: [products.id],
relationName: 'categories',
}),
}));
export const relations_products_rels = relations(products_rels, ({ one }) => ({
parent: one(products, {
fields: [products_rels.parent],
references: [products.id],
relationName: '_rels',
}),
mediaID: one(media, {
fields: [products_rels.mediaID],
references: [media.id],
relationName: 'media',
}),
}));
export const relations_products = relations(products, ({ many }) => ({
categories: many(products_categories, {
relationName: 'categories',
}),
_rels: many(products_rels, {
relationName: '_rels',
}),
}));
export const relations__products_v_version_categories = relations(
_products_v_version_categories,
({ one }) => ({
_parentID: one(_products_v, {
fields: [_products_v_version_categories._parentID],
references: [_products_v.id],
relationName: 'version_categories',
}),
}),
);
export const relations__products_v_rels = relations(_products_v_rels, ({ one }) => ({
parent: one(_products_v, {
fields: [_products_v_rels.parent],
references: [_products_v.id],
relationName: '_rels',
}),
mediaID: one(media, {
fields: [_products_v_rels.mediaID],
references: [media.id],
relationName: 'media',
}),
}));
export const relations__products_v = relations(_products_v, ({ one, many }) => ({
parent: one(products, {
fields: [_products_v.parent],
references: [products.id],
relationName: 'parent',
}),
version_categories: many(_products_v_version_categories, {
relationName: 'version_categories',
}),
_rels: many(_products_v_rels, {
relationName: '_rels',
}),
}));
export const relations_payload_kv = relations(payload_kv, () => ({}));
export const relations_payload_locked_documents_rels = relations(
payload_locked_documents_rels,
({ one }) => ({
parent: one(payload_locked_documents, {
fields: [payload_locked_documents_rels.parent],
references: [payload_locked_documents.id],
relationName: '_rels',
}),
usersID: one(users, {
fields: [payload_locked_documents_rels.usersID],
references: [users.id],
relationName: 'users',
}),
mediaID: one(media, {
fields: [payload_locked_documents_rels.mediaID],
references: [media.id],
relationName: 'media',
}),
postsID: one(posts, {
fields: [payload_locked_documents_rels.postsID],
references: [posts.id],
relationName: 'posts',
}),
'form-submissionsID': one(form_submissions, {
fields: [payload_locked_documents_rels['form-submissionsID']],
references: [form_submissions.id],
relationName: 'form-submissions',
}),
productsID: one(products, {
fields: [payload_locked_documents_rels.productsID],
references: [products.id],
relationName: 'products',
}),
}),
);
export const relations_payload_locked_documents = relations(
payload_locked_documents,
({ many }) => ({
_rels: many(payload_locked_documents_rels, {
relationName: '_rels',
}),
}),
);
export const relations_payload_preferences_rels = relations(
payload_preferences_rels,
({ one }) => ({
parent: one(payload_preferences, {
fields: [payload_preferences_rels.parent],
references: [payload_preferences.id],
relationName: '_rels',
}),
usersID: one(users, {
fields: [payload_preferences_rels.usersID],
references: [users.id],
relationName: 'users',
}),
}),
);
export const relations_payload_preferences = relations(payload_preferences, ({ many }) => ({
_rels: many(payload_preferences_rels, {
relationName: '_rels',
}),
}));
export const relations_payload_migrations = relations(payload_migrations, () => ({}));
type DatabaseSchema = {
enum_posts_locale: typeof enum_posts_locale;
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_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;
users_sessions: typeof users_sessions;
users: typeof users;
media: typeof media;
posts: typeof posts;
_posts_v: typeof _posts_v;
form_submissions: typeof form_submissions;
products_categories: typeof products_categories;
products: typeof products;
products_rels: typeof products_rels;
_products_v_version_categories: typeof _products_v_version_categories;
_products_v: typeof _products_v;
_products_v_rels: typeof _products_v_rels;
payload_kv: typeof payload_kv;
payload_locked_documents: typeof payload_locked_documents;
payload_locked_documents_rels: typeof payload_locked_documents_rels;
payload_preferences: typeof payload_preferences;
payload_preferences_rels: typeof payload_preferences_rels;
payload_migrations: typeof payload_migrations;
relations_users_sessions: typeof relations_users_sessions;
relations_users: typeof relations_users;
relations_media: typeof relations_media;
relations_posts: typeof relations_posts;
relations__posts_v: typeof relations__posts_v;
relations_form_submissions: typeof relations_form_submissions;
relations_products_categories: typeof relations_products_categories;
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_rels: typeof relations__products_v_rels;
relations__products_v: typeof relations__products_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;
relations_payload_preferences_rels: typeof relations_payload_preferences_rels;
relations_payload_preferences: typeof relations_payload_preferences;
relations_payload_migrations: typeof relations_payload_migrations;
};
declare module '@payloadcms/db-postgres' {
export interface GeneratedDatabaseSchema {
schema: DatabaseSchema;
}
}

View File

@@ -0,0 +1,25 @@
import { Block } from 'payload';
export const AnimatedImage: Block = {
slug: 'animatedImage',
fields: [
{
name: 'src',
type: 'text',
required: true,
},
{
name: 'alt',
type: 'text',
required: true,
},
{
name: 'width',
type: 'number',
},
{
name: 'height',
type: 'number',
},
],
};

View File

@@ -0,0 +1,20 @@
import { Block } from 'payload';
import { lexicalEditor } from '@payloadcms/richtext-lexical';
export const Callout: Block = {
slug: 'callout',
fields: [
{
name: 'type',
type: 'select',
options: ['info', 'warning', 'important', 'tip', 'caution'],
defaultValue: 'info',
},
{
name: 'content',
type: 'richText',
editor: lexicalEditor({}),
required: true,
},
],
};

View File

@@ -0,0 +1,34 @@
import { Block } from 'payload';
import { lexicalEditor } from '@payloadcms/richtext-lexical';
export const ChatBubble: Block = {
slug: 'chatBubble',
fields: [
{
name: 'author',
type: 'text',
defaultValue: 'KLZ Team',
},
{
name: 'avatar',
type: 'text',
},
{
name: 'role',
type: 'text',
defaultValue: 'Assistant',
},
{
name: 'align',
type: 'select',
options: ['left', 'right'],
defaultValue: 'left',
},
{
name: 'content',
type: 'richText',
editor: lexicalEditor({}),
required: true,
},
],
};

View File

@@ -0,0 +1,47 @@
import { Block } from 'payload';
export const ComparisonGrid: Block = {
slug: 'comparisonGrid',
fields: [
{
name: 'title',
label: 'Main Heading',
type: 'text',
required: true,
},
{
name: 'leftLabel',
type: 'text',
required: true,
},
{
name: 'rightLabel',
type: 'text',
required: true,
},
{
name: 'items',
type: 'array',
required: true,
minRows: 1,
fields: [
{
name: 'label',
label: 'Row Label',
type: 'text',
required: true,
},
{
name: 'leftValue',
type: 'text',
required: true,
},
{
name: 'rightValue',
type: 'text',
required: true,
},
],
},
],
};

View File

@@ -0,0 +1,20 @@
import { Block } from 'payload';
import { lexicalEditor } from '@payloadcms/richtext-lexical';
export const HighlightBox: Block = {
slug: 'highlightBox',
fields: [
{
name: 'type',
type: 'select',
options: ['info', 'warning', 'success', 'error', 'neutral'],
defaultValue: 'neutral',
},
{
name: 'content',
type: 'richText',
editor: lexicalEditor({}),
required: true,
},
],
};

View File

@@ -0,0 +1,12 @@
import { Block } from 'payload';
export const PowerCTA: Block = {
slug: 'powerCTA',
fields: [
{
name: 'locale',
type: 'text',
required: true,
},
],
};

View File

@@ -0,0 +1,96 @@
import { Block } from 'payload';
export const ProductTabs: Block = {
slug: 'productTabs',
interfaceName: 'ProductTabsBlock',
fields: [
{
name: 'technicalItems',
type: 'array',
fields: [
{
name: 'label',
type: 'text',
required: true,
},
{
name: 'value',
type: 'text',
required: true,
},
{
name: 'unit',
type: 'text',
},
],
},
{
name: 'voltageTables',
type: 'array',
fields: [
{
name: 'voltageLabel',
type: 'text',
required: true,
},
{
name: 'metaItems',
type: 'array',
fields: [
{
name: 'label',
type: 'text',
required: true,
},
{
name: 'value',
type: 'text',
required: true,
},
{
name: 'unit',
type: 'text',
},
],
},
{
name: 'columns',
type: 'array',
fields: [
{
name: 'key',
type: 'text',
required: true,
},
{
name: 'label',
type: 'text',
required: true,
},
],
},
{
name: 'rows',
type: 'array',
fields: [
{
name: 'configuration',
type: 'text',
required: true,
},
{
name: 'cells',
type: 'array',
fields: [
{
name: 'value',
type: 'text',
},
],
},
],
},
],
},
],
};

View File

@@ -0,0 +1,23 @@
import { Block } from 'payload';
export const SplitHeading: Block = {
slug: 'splitHeading',
interfaceName: 'SplitHeadingBlock',
fields: [
{
name: 'title',
type: 'text',
required: true,
},
{
name: 'id',
type: 'text',
},
{
name: 'level',
type: 'select',
options: ['h1', 'h2', 'h3', 'h4', 'h5', 'h6'],
defaultValue: 'h2',
},
],
};

View File

@@ -0,0 +1,25 @@
import { Block } from 'payload';
export const Stats: Block = {
slug: 'stats',
interfaceName: 'StatsBlock',
fields: [
{
name: 'stats',
type: 'array',
required: true,
fields: [
{
name: 'value',
type: 'text',
required: true,
},
{
name: 'label',
type: 'text',
required: true,
},
],
},
],
};

View File

@@ -0,0 +1,31 @@
import { Block } from 'payload';
export const StickyNarrative: Block = {
slug: 'stickyNarrative',
fields: [
{
name: 'title',
label: 'Main Heading',
type: 'text',
required: true,
},
{
name: 'items',
type: 'array',
required: true,
minRows: 1,
fields: [
{
name: 'title',
type: 'text',
required: true,
},
{
name: 'content',
type: 'textarea',
required: true,
},
],
},
],
};

View File

@@ -0,0 +1,30 @@
import { Block } from 'payload';
export const TechnicalGrid: Block = {
slug: 'technicalGrid',
fields: [
{
name: 'title',
label: 'Main Heading',
type: 'text',
},
{
name: 'items',
type: 'array',
required: true,
minRows: 1,
fields: [
{
name: 'label',
type: 'text',
required: true,
},
{
name: 'value',
type: 'text',
required: true,
},
],
},
],
};

View File

@@ -0,0 +1,30 @@
import { Block } from 'payload';
export const VisualLinkPreview: Block = {
slug: 'visualLinkPreview',
fields: [
{
name: 'url',
type: 'text',
required: true,
},
{
name: 'title',
type: 'text',
required: true,
},
{
name: 'summary',
type: 'textarea',
required: true,
},
{
name: 'image',
type: 'text',
admin: {
description: 'Legacy HTTP string from the old hardcoded images.',
},
required: false,
},
],
};

View File

@@ -0,0 +1,27 @@
import { AnimatedImage } from './AnimatedImage';
import { Callout } from './Callout';
import { ChatBubble } from './ChatBubble';
import { ComparisonGrid } from './ComparisonGrid';
import { HighlightBox } from './HighlightBox';
import { PowerCTA } from './PowerCTA';
import { ProductTabs } from './ProductTabs';
import { SplitHeading } from './SplitHeading';
import { Stats } from './Stats';
import { StickyNarrative } from './StickyNarrative';
import { TechnicalGrid } from './TechnicalGrid';
import { VisualLinkPreview } from './VisualLinkPreview';
export const payloadBlocks = [
AnimatedImage,
Callout,
ChatBubble,
ComparisonGrid,
HighlightBox,
PowerCTA,
ProductTabs,
SplitHeading,
Stats,
StickyNarrative,
TechnicalGrid,
VisualLinkPreview,
];

View File

@@ -0,0 +1,67 @@
import type { CollectionConfig } from 'payload';
export const FormSubmissions: CollectionConfig = {
slug: 'form-submissions',
admin: {
useAsTitle: 'name',
defaultColumns: ['name', 'email', 'type', 'createdAt'],
description: 'Captured leads from Contact and Product Quote forms.',
},
access: {
// Only Admins can view and delete leads via dashboard.
read: ({ req: { user } }) => Boolean(user) || process.env.NODE_ENV === 'development',
update: ({ req: { user } }) => Boolean(user) || process.env.NODE_ENV === 'development',
delete: ({ req: { user } }) => Boolean(user) || process.env.NODE_ENV === 'development',
// Next.js server actions handle secure inserts natively. No public client create access.
create: () => false,
},
fields: [
{
name: 'name',
type: 'text',
required: true,
admin: {
readOnly: true,
},
},
{
name: 'email',
type: 'email',
required: true,
admin: {
readOnly: true,
},
},
{
name: 'type',
type: 'select',
options: [
{ label: 'General Contact', value: 'contact' },
{ label: 'Product Quote', value: 'product_quote' },
],
required: true,
admin: {
position: 'sidebar',
readOnly: true,
},
},
{
name: 'productName',
type: 'text',
admin: {
position: 'sidebar',
readOnly: true,
condition: (data) => data.type === 'product_quote',
description: 'The specific KLZ product the user requested a quote for.',
},
},
{
name: 'message',
type: 'textarea',
required: true,
admin: {
readOnly: true,
},
},
],
};

View File

@@ -0,0 +1,48 @@
import type { CollectionConfig } from 'payload';
export const Media: CollectionConfig = {
slug: 'media',
access: {
read: () => true,
},
admin: {
useAsTitle: 'alt',
defaultColumns: ['filename', 'alt', 'updatedAt'],
},
upload: {
staticDir: 'public/media',
adminThumbnail: 'thumbnail',
imageSizes: [
{
name: 'thumbnail',
width: 600,
// height: undefined allows wide 5:1 aspect ratios to be preserved without cropping
height: undefined,
position: 'centre',
},
{
name: 'card',
width: 768,
height: undefined,
position: 'centre',
},
{
name: 'tablet',
width: 1024,
height: undefined,
position: 'centre',
},
],
},
fields: [
{
name: 'alt',
type: 'text',
required: true,
},
{
name: 'caption',
type: 'text',
},
],
};

View File

@@ -0,0 +1,61 @@
import { CollectionConfig } from 'payload';
import { lexicalEditor } from '@payloadcms/richtext-lexical';
export const Pages: CollectionConfig = {
slug: 'pages',
admin: {
useAsTitle: 'title',
defaultColumns: ['title', 'slug', 'locale', 'updatedAt'],
},
access: {
read: () => true,
},
fields: [
{
name: 'title',
type: 'text',
required: true,
},
{
name: 'slug',
type: 'text',
required: true,
admin: {
position: 'sidebar',
},
},
{
name: 'locale',
type: 'select',
options: [
{ label: 'English', value: 'en' },
{ label: 'German', value: 'de' },
],
required: true,
admin: {
position: 'sidebar',
},
},
{
name: 'excerpt',
type: 'textarea',
admin: {
position: 'sidebar',
},
},
{
name: 'featuredImage',
type: 'upload',
relationTo: 'media',
admin: {
position: 'sidebar',
},
},
{
name: 'content',
type: 'richText',
editor: lexicalEditor({}),
required: true,
},
],
};

View File

@@ -0,0 +1,154 @@
import type { CollectionConfig } from 'payload';
import { lexicalEditor, BlocksFeature } from '@payloadcms/richtext-lexical';
import { StickyNarrative } from '../blocks/StickyNarrative';
import { ComparisonGrid } from '../blocks/ComparisonGrid';
import { VisualLinkPreview } from '../blocks/VisualLinkPreview';
import { TechnicalGrid } from '../blocks/TechnicalGrid';
import { HighlightBox } from '../blocks/HighlightBox';
import { AnimatedImage } from '../blocks/AnimatedImage';
import { ChatBubble } from '../blocks/ChatBubble';
import { PowerCTA } from '../blocks/PowerCTA';
import { Callout } from '../blocks/Callout';
import { Stats } from '../blocks/Stats';
import { SplitHeading } from '../blocks/SplitHeading';
export const Posts: CollectionConfig = {
slug: 'posts',
admin: {
useAsTitle: 'title',
defaultColumns: ['featuredImage', 'title', 'date', 'updatedAt', '_status'],
},
versions: {
drafts: true, // Enables Draft/Published workflows
},
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: [
{
_status: {
equals: 'published',
},
},
{
date: {
less_than_equal: new Date().toISOString(),
},
},
],
};
},
},
fields: [
{
name: 'title',
type: 'text',
required: true,
},
{
name: 'slug',
type: 'text',
required: true,
unique: true,
admin: {
position: 'sidebar',
},
hooks: {
beforeValidate: [
({ value, data }) => {
// Auto-generate slug from title if left blank
if (value || !data?.title) return value;
return data.title
.toLowerCase()
.replace(/ /g, '-')
.replace(/[^\w-]+/g, '');
},
],
},
},
{
name: 'excerpt',
type: 'text',
admin: {
description: 'A short summary for blog feed cards and SEO.',
},
},
{
name: 'date',
type: 'date',
required: true,
admin: {
position: 'sidebar',
description: 'Future dates will schedule the post to publish automatically.',
},
defaultValue: () => new Date().toISOString(),
},
{
name: 'featuredImage',
type: 'upload',
relationTo: 'media',
admin: {
position: 'sidebar',
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',
admin: {
position: 'sidebar',
description: 'Used for tag bucketing (e.g. "Kabel Technologie").',
},
},
{
name: 'content',
type: 'richText',
editor: lexicalEditor({
features: ({ defaultFeatures }) => [
...defaultFeatures,
BlocksFeature({
blocks: [
StickyNarrative,
ComparisonGrid,
VisualLinkPreview,
TechnicalGrid,
HighlightBox,
AnimatedImage,
ChatBubble,
PowerCTA,
Callout,
Stats,
SplitHeading,
],
}),
],
}),
},
],
};

View File

@@ -0,0 +1,144 @@
import type { CollectionConfig } from 'payload';
import { lexicalEditor, BlocksFeature } from '@payloadcms/richtext-lexical';
import { StickyNarrative } from '../blocks/StickyNarrative';
import { ComparisonGrid } from '../blocks/ComparisonGrid';
import { VisualLinkPreview } from '../blocks/VisualLinkPreview';
import { TechnicalGrid } from '../blocks/TechnicalGrid';
import { HighlightBox } from '../blocks/HighlightBox';
import { AnimatedImage } from '../blocks/AnimatedImage';
import { ChatBubble } from '../blocks/ChatBubble';
import { PowerCTA } from '../blocks/PowerCTA';
import { Callout } from '../blocks/Callout';
import { Stats } from '../blocks/Stats';
import { SplitHeading } from '../blocks/SplitHeading';
import { ProductTabs } from '../blocks/ProductTabs';
export const Products: CollectionConfig = {
slug: 'products',
admin: {
useAsTitle: 'title',
defaultColumns: ['featuredImage', 'title', 'sku', 'locale', 'updatedAt', '_status'],
},
versions: {
drafts: true,
},
access: {
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,
},
{
name: 'sku',
type: 'text',
required: true,
admin: {
position: 'sidebar',
},
},
{
name: 'slug',
type: 'text',
required: true,
admin: {
position: 'sidebar',
},
},
{
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',
},
{
name: 'categories',
type: 'array',
required: true,
fields: [
{
name: 'category',
type: 'text',
},
],
admin: {
position: 'sidebar',
},
},
{
name: 'featuredImage',
type: 'upload',
relationTo: 'media',
admin: {
position: 'sidebar',
description: 'The primary thumbnail used in list views.',
},
},
{
name: 'images',
type: 'upload',
relationTo: 'media',
hasMany: true,
admin: {
position: 'sidebar',
},
},
{
name: 'application',
type: 'richText',
editor: lexicalEditor({}),
},
{
name: 'content',
type: 'richText',
editor: lexicalEditor({
features: ({ defaultFeatures }) => [
...defaultFeatures,
BlocksFeature({
blocks: [
StickyNarrative,
ComparisonGrid,
VisualLinkPreview,
TechnicalGrid,
HighlightBox,
AnimatedImage,
ChatBubble,
PowerCTA,
Callout,
Stats,
SplitHeading,
ProductTabs,
],
}),
],
}),
},
],
};

View File

@@ -0,0 +1,12 @@
import type { CollectionConfig } from 'payload';
export const Users: CollectionConfig = {
slug: 'users',
admin: {
useAsTitle: 'email',
},
auth: true,
fields: [
// Email added by default
],
};

View File

@@ -0,0 +1,296 @@
/**
* Converts a Markdown+JSX string into a Lexical AST node array.
* Specifically adapted for klz-cables.com custom Component Blocks.
*/
function propValue(chunk: string, prop: string): string {
// Match prop="value" or prop='value' or prop={value}
// and also multiline props like prop={\n [\n {...}\n ]\n}
// For arrays or complex objects passed as props, basic regex might fail,
// but the MDX in klz-cables usually uses simpler props or children.
const match =
chunk.match(new RegExp(`${prop}=["']([^"']+)["']`)) ||
chunk.match(new RegExp(`${prop}=\\{([^}]+)\\}`));
return match ? match[1] : '';
}
function extractItemsProp(chunk: string, startTag: string): any[] {
// Match items={ [ ... ] } robustly without stopping at inner object braces
const itemsMatch = chunk.match(/items=\{\s*(\[[\s\S]*?\])\s*\}/);
if (itemsMatch) {
try {
const arrayString = itemsMatch[1].trim();
// Since klz-cables MDX passes pure JS object arrays like `items={[{title: 'A', content: 'B'}]}`,
// parsing it via Regex to JSON is extremely brittle due to unquoted keys and trailing commas.
// Using `new Function` safely evaluates the array AST directly in this Node script environment.
const fn = new Function(`return ${arrayString};`);
return fn();
} catch (_e: any) {
console.warn(`Could not parse items array for block ${startTag}:`, _e.message);
return [];
}
}
return [];
}
function blockNode(blockType: string, fields: Record<string, any>) {
return { type: 'block', format: '', version: 2, fields: { blockType, ...fields } };
}
function ensureChildren(parsedNodes: any[]): any[] {
// Lexical root nodes require at least one child node, or validation fails
if (parsedNodes.length === 0) {
return [
{
type: 'paragraph',
format: '',
indent: 0,
version: 1,
children: [{ mode: 'normal', type: 'text', text: ' ', version: 1 }],
},
];
}
return parsedNodes;
}
export function parseMarkdownToLexical(markdown: string): any[] {
const textNode = (text: string) => ({
type: 'paragraph',
format: '',
indent: 0,
version: 1,
children: [{ mode: 'normal', type: 'text', text, version: 1 }],
});
const nodes: any[] = [];
let content = markdown;
// Strip frontmatter
const fm = content.match(/^---\s*\n[\s\S]*?\n---/);
if (fm) content = content.replace(fm[0], '').trim();
// 1. EXTRACT MULTILINE WRAPPERS BEFORE CHUNKING
// This allows nested newlines inside components without breaking them.
const extractBlocks = [
{
tag: 'HighlightBox',
regex: /<HighlightBox([^>]*)>([\s\S]*?)<\/HighlightBox>/g,
build: (props: string, inner: string) =>
blockNode('highlightBox', {
title: propValue(`<Tag ${props}>`, 'title'),
color: propValue(`<Tag ${props}>`, 'color') || 'primary',
content: {
root: {
type: 'root',
format: '',
indent: 0,
version: 1,
direction: 'ltr',
children: ensureChildren(parseMarkdownToLexical(inner.trim())),
},
},
}),
},
{
tag: 'ChatBubble',
regex: /<ChatBubble([^>]*)>([\s\S]*?)<\/ChatBubble>/g,
build: (props: string, inner: string) =>
blockNode('chatBubble', {
author: propValue(`<Tag ${props}>`, 'author') || 'KLZ Team',
avatar: propValue(`<Tag ${props}>`, 'avatar'),
role: propValue(`<Tag ${props}>`, 'role') || 'Assistant',
align: propValue(`<Tag ${props}>`, 'align') || 'left',
content: {
root: {
type: 'root',
format: '',
indent: 0,
version: 1,
direction: 'ltr',
children: ensureChildren(parseMarkdownToLexical(inner.trim())),
},
},
}),
},
{
tag: 'Callout',
regex: /<Callout([^>]*)>([\s\S]*?)<\/Callout>/g,
build: (props: string, inner: string) =>
blockNode('callout', {
type: propValue(`<Tag ${props}>`, 'type') || 'info',
title: propValue(`<Tag ${props}>`, 'title'),
content: {
root: {
type: 'root',
format: '',
indent: 0,
version: 1,
direction: 'ltr',
children: ensureChildren(parseMarkdownToLexical(inner.trim())),
},
},
}),
},
];
// Placeholder map to temporarily store extracted multi-line blocks
const placeholders = new Map<string, any>();
let placeholderIdx = 0;
for (const block of extractBlocks) {
content = content.replace(block.regex, (match, propsMatch, innerMatch) => {
const id = `__BLOCK_PLACEHOLDER_${placeholderIdx++}__`;
placeholders.set(id, block.build(propsMatch, innerMatch));
return `\n\n${id}\n\n`; // Pad with newlines so it becomes its own chunk
});
}
// 2. CHUNK THE REST (Paragraphs, Single-line Components)
const rawChunks = content.split(/\n\s*\n/);
for (let chunk of rawChunks) {
chunk = chunk.trim();
if (!chunk) continue;
// Has Placeholder?
if (chunk.startsWith('__BLOCK_PLACEHOLDER_')) {
nodes.push(placeholders.get(chunk));
continue;
}
// --- Custom Component: ProductTabs ---
if (chunk.includes('<ProductTabs')) {
const dataMatch = chunk.match(/data=\{({[\s\S]*?})\}\s*\/>/);
if (dataMatch) {
try {
const parsedData = JSON.parse(dataMatch[1]);
// Normalize String Arrays to Payload Object Arrays { value: "string" }
if (parsedData.voltageTables) {
parsedData.voltageTables.forEach((vt: any) => {
if (vt.rows) {
vt.rows.forEach((row: any) => {
if (row.cells && Array.isArray(row.cells)) {
row.cells = row.cells.map((cell: any) =>
typeof cell !== 'object' || cell === null ? { value: String(cell) } : cell,
);
}
});
}
});
}
nodes.push(
blockNode('productTabs', {
technicalItems: parsedData.technicalItems || [],
voltageTables: parsedData.voltageTables || [],
}),
);
} catch (e: any) {
console.warn(`Could not parse JSON payload for ProductTabs:`, e.message);
}
}
continue;
}
// --- Custom Component: StickyNarrative ---
if (chunk.includes('<StickyNarrative')) {
nodes.push(
blockNode('stickyNarrative', {
title: propValue(chunk, 'title'),
items: extractItemsProp(chunk, 'StickyNarrative'),
}),
);
continue;
}
// --- Custom Component: ComparisonGrid ---
if (chunk.includes('<ComparisonGrid')) {
nodes.push(
blockNode('comparisonGrid', {
title: propValue(chunk, 'title'),
leftLabel: propValue(chunk, 'leftLabel'),
rightLabel: propValue(chunk, 'rightLabel'),
items: extractItemsProp(chunk, 'ComparisonGrid'),
}),
);
continue;
}
// --- Custom Component: VisualLinkPreview ---
if (chunk.includes('<VisualLinkPreview')) {
nodes.push(
blockNode('visualLinkPreview', {
url: propValue(chunk, 'url'),
title: propValue(chunk, 'title'),
summary: propValue(chunk, 'summary'),
image: propValue(chunk, 'image'),
}),
);
continue;
}
// --- Custom Component: TechnicalGrid ---
if (chunk.includes('<TechnicalGrid')) {
nodes.push(
blockNode('technicalGrid', {
title: propValue(chunk, 'title'),
items: extractItemsProp(chunk, 'TechnicalGrid'),
}),
);
continue;
}
// --- Custom Component: AnimatedImage ---
if (chunk.includes('<AnimatedImage')) {
const widthMatch = chunk.match(/width=\{?(\d+)\}?/);
const heightMatch = chunk.match(/height=\{?(\d+)\}?/);
nodes.push(
blockNode('animatedImage', {
src: propValue(chunk, 'src'),
alt: propValue(chunk, 'alt'),
width: widthMatch ? parseInt(widthMatch[1], 10) : undefined,
height: heightMatch ? parseInt(heightMatch[1], 10) : undefined,
}),
);
continue;
}
// --- Custom Component: PowerCTA ---
if (chunk.includes('<PowerCTA')) {
nodes.push(
blockNode('powerCTA', {
locale: propValue(chunk, 'locale') || 'de',
}),
);
continue;
}
// --- Standard Markdown: Headings ---
const headingMatch = chunk.match(/^(#{1,6})\s+(.*)/);
if (headingMatch) {
nodes.push({
type: 'heading',
tag: `h${headingMatch[1].length}`,
format: '',
indent: 0,
version: 1,
direction: 'ltr',
children: [{ mode: 'normal', type: 'text', text: headingMatch[2], version: 1 }],
});
continue;
}
// --- Standard Markdown: Images ---
const imageMatch = chunk.match(/^!\[([^\]]*)\]\(([^)]+)\)$/);
if (imageMatch) {
nodes.push(textNode(chunk));
continue;
}
// Default: plain text paragraph
nodes.push(textNode(chunk));
}
return nodes;
}

View File

@@ -54,6 +54,7 @@
stroke-dasharray: 1; stroke-dasharray: 1;
stroke-dashoffset: 1; stroke-dashoffset: 1;
} }
to { to {
stroke-dasharray: 1; stroke-dasharray: 1;
stroke-dashoffset: 0; stroke-dashoffset: 0;
@@ -174,6 +175,18 @@
} }
@layer base { @layer base {
html {
/* Scale text down slightly on mobile (87.5% of 16px = 14px) */
font-size: 87.5%;
}
@media (min-width: 768px) {
html {
/* Normal scaling on tablet and desktop (100% = 16px) */
font-size: 100%;
}
}
.bg-primary a, .bg-primary a,
.bg-primary-dark a { .bg-primary-dark a {
@apply text-white/90 hover:text-white transition-colors; @apply text-white/90 hover:text-white transition-colors;

View File

@@ -56,21 +56,21 @@ module.exports = {
heading: ['Inter', 'system-ui', 'sans-serif'], heading: ['Inter', 'system-ui', 'sans-serif'],
body: ['Inter', 'system-ui', 'sans-serif'], body: ['Inter', 'system-ui', 'sans-serif'],
}, },
// Enhanced Fluid Typography with CSS Clamp // Standard Fluid Typography
// Improved readability with better line heights and spacing // Responsive scaling is handled globally via the html selector percentage in globals.css
fontSize: { fontSize: {
'xs': ['clamp(0.75rem, 0.7rem + 0.2vw, 0.875rem)', { lineHeight: '1.6' }], 'xs': ['0.75rem', { lineHeight: '1.6' }],
'sm': ['clamp(0.875rem, 0.8rem + 0.25vw, 1rem)', { lineHeight: '1.6' }], 'sm': ['0.875rem', { lineHeight: '1.6' }],
'base': ['clamp(1rem, 0.9rem + 0.35vw, 1.125rem)', { lineHeight: '1.7' }], 'base': ['1rem', { lineHeight: '1.7' }],
'lg': ['clamp(1.125rem, 1rem + 0.4vw, 1.25rem)', { lineHeight: '1.7' }], 'lg': ['1.125rem', { lineHeight: '1.7' }],
'xl': ['clamp(1.25rem, 1.1rem + 0.5vw, 1.5rem)', { lineHeight: '1.6' }], 'xl': ['1.25rem', { lineHeight: '1.6' }],
'2xl': ['clamp(1.5rem, 1.3rem + 0.75vw, 1.875rem)', { lineHeight: '1.5' }], '2xl': ['1.5rem', { lineHeight: '1.5' }],
'3xl': ['clamp(1.875rem, 1.6rem + 1vw, 2.25rem)', { lineHeight: '1.4' }], '3xl': ['1.875rem', { lineHeight: '1.4' }],
'4xl': ['clamp(2.25rem, 1.9rem + 1.25vw, 3rem)', { lineHeight: '1.3' }], '4xl': ['2.25rem', { lineHeight: '1.3' }],
'5xl': ['clamp(3rem, 2.5rem + 2vw, 3.75rem)', { lineHeight: '1.25' }], '5xl': ['3rem', { lineHeight: '1.25' }],
'6xl': ['clamp(3.75rem, 3rem + 2.5vw, 4.5rem)', { lineHeight: '1.2' }], '6xl': ['3.75rem', { lineHeight: '1.2' }],
'7xl': ['clamp(4.5rem, 3.5rem + 3vw, 5.5rem)', { lineHeight: '1.15' }], '7xl': ['4.5rem', { lineHeight: '1.15' }],
'8xl': ['clamp(5.5rem, 4rem + 4vw, 7rem)', { lineHeight: '1.1' }], '8xl': ['6rem', { lineHeight: '1.1' }],
}, },
fontWeight: { fontWeight: {
regular: '400', regular: '400',

View File

@@ -3,18 +3,11 @@
"compilerOptions": { "compilerOptions": {
"baseUrl": ".", "baseUrl": ".",
"paths": { "paths": {
"@/*": [ "@/*": ["./*"],
"./*" "lib/*": ["./lib/*"],
], "components/*": ["./components/*"],
"lib/*": [ "data/*": ["./data/*"],
"./lib/*" "@payload-config": ["./payload.config.ts"]
],
"components/*": [
"./components/*"
],
"data/*": [
"./data/*"
]
} }
}, },
"include": [ "include": [
@@ -25,11 +18,5 @@
"tests/**/*.test.ts", "tests/**/*.test.ts",
".next/dev/types/**/*.ts" ".next/dev/types/**/*.ts"
], ],
"exclude": [ "exclude": ["node_modules", "scripts", "reference", "data", "remotion"]
"node_modules",
"scripts",
"reference",
"data",
"remotion"
]
} }