Compare commits
26 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| d75a83ccf2 | |||
| 5991bd8392 | |||
| 6207e04bf5 | |||
| 8ffb5967d3 | |||
| 8ba1c7ea38 | |||
| a546ffe69c | |||
| 15740db51e | |||
| 13ab755857 | |||
| 1a68af0eec | |||
| 275784745d | |||
| 4aef49cf2c | |||
| 8ad3abb6f3 | |||
| 1d75b60236 | |||
| 3dff19eca2 | |||
| 07b01c622a | |||
| 50de18c09c | |||
| dbee0cd8bc | |||
| f30f8ddd8d | |||
| bb9fd65dbb | |||
| 036fba8b53 | |||
| 3e8d5ad8b6 | |||
| 70ad2e3041 | |||
| 5376b939d5 | |||
| 6f80e72c1d | |||
| d9334f558d | |||
| cb436d31d0 |
17
Dockerfile
17
Dockerfile
@@ -1,5 +1,9 @@
|
|||||||
# Stage 1: Builder
|
# Stage 1: Builder
|
||||||
FROM registry.infra.mintel.me/mintel/nextjs:v1.8.20 AS base
|
FROM node:20-alpine AS base
|
||||||
|
RUN apk add --no-cache libc6-compat curl
|
||||||
|
|
||||||
|
# Enable pnpm
|
||||||
|
RUN corepack enable && corepack prepare pnpm@10.3.0 --activate
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
|
|
||||||
# Arguments for build-time configuration
|
# Arguments for build-time configuration
|
||||||
@@ -52,12 +56,17 @@ ENV UV_THREADPOOL_SIZE=3
|
|||||||
RUN pnpm build
|
RUN pnpm build
|
||||||
|
|
||||||
# Stage 2: Runner
|
# Stage 2: Runner
|
||||||
FROM registry.infra.mintel.me/mintel/runtime:v1.8.20 AS runner
|
FROM node:20-alpine AS runner
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
|
|
||||||
|
# Install curl for health checks
|
||||||
|
RUN apk add --no-cache curl
|
||||||
|
|
||||||
# Create nextjs user and group (standardized in runtime image but ensuring local ownership)
|
# Create nextjs user and group (standardized in runtime image but ensuring local ownership)
|
||||||
USER root
|
RUN addgroup --system --gid 1001 nodejs && \
|
||||||
RUN chown -R nextjs:nodejs /app
|
adduser --system --uid 1001 nextjs && \
|
||||||
|
chown -R nextjs:nodejs /app
|
||||||
|
|
||||||
USER nextjs
|
USER nextjs
|
||||||
|
|
||||||
ENV HOSTNAME="0.0.0.0"
|
ENV HOSTNAME="0.0.0.0"
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { notFound, redirect } from 'next/navigation';
|
import { notFound, redirect, permanentRedirect } from 'next/navigation';
|
||||||
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';
|
||||||
@@ -62,6 +62,15 @@ export default async function StandardPage({ params }: PageProps) {
|
|||||||
notFound();
|
notFound();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Handle explicit CMS redirects (e.g. /en/terms -> /de/terms)
|
||||||
|
if (pageData.redirectUrl) {
|
||||||
|
if (pageData.redirectPermanent) {
|
||||||
|
permanentRedirect(pageData.redirectUrl);
|
||||||
|
} else {
|
||||||
|
redirect(pageData.redirectUrl);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Redirect if accessed via a different locale's slug
|
// Redirect if accessed via a different locale's slug
|
||||||
const fileSlug = await mapSlugToFileSlug(pageData.slug || slug, locale);
|
const fileSlug = await mapSlugToFileSlug(pageData.slug || slug, locale);
|
||||||
const correctSlug = await mapFileSlugToTranslated(fileSlug, locale);
|
const correctSlug = await mapFileSlugToTranslated(fileSlug, locale);
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import {
|
|||||||
getAdjacentPosts,
|
getAdjacentPosts,
|
||||||
getReadingTime,
|
getReadingTime,
|
||||||
extractLexicalHeadings,
|
extractLexicalHeadings,
|
||||||
|
getPostSlugs,
|
||||||
} from '@/lib/blog';
|
} from '@/lib/blog';
|
||||||
import { Metadata } from 'next';
|
import { Metadata } from 'next';
|
||||||
import Link from 'next/link';
|
import Link from 'next/link';
|
||||||
@@ -33,12 +34,21 @@ export async function generateMetadata({ params }: BlogPostProps): Promise<Metad
|
|||||||
|
|
||||||
if (!post) return {};
|
if (!post) return {};
|
||||||
|
|
||||||
|
const slugs = await getPostSlugs(slug, locale);
|
||||||
|
const deSlug = slugs?.de || post.slug;
|
||||||
|
const enSlug = slugs?.en || post.slug;
|
||||||
|
|
||||||
const description = post.frontmatter.excerpt || '';
|
const description = post.frontmatter.excerpt || '';
|
||||||
return {
|
return {
|
||||||
title: post.frontmatter.title,
|
title: post.frontmatter.title,
|
||||||
description: description,
|
description: description,
|
||||||
alternates: {
|
alternates: {
|
||||||
canonical: `${SITE_URL}/${locale}/blog/${post.slug}`,
|
canonical: `${SITE_URL}/${locale}/blog/${post.slug}`,
|
||||||
|
languages: {
|
||||||
|
de: `${SITE_URL}/de/blog/${deSlug}`,
|
||||||
|
en: `${SITE_URL}/en/blog/${enSlug}`,
|
||||||
|
'x-default': `${SITE_URL}/en/blog/${enSlug}`,
|
||||||
|
},
|
||||||
},
|
},
|
||||||
openGraph: {
|
openGraph: {
|
||||||
title: `${post.frontmatter.title} | KLZ Cables`,
|
title: `${post.frontmatter.title} | KLZ Cables`,
|
||||||
@@ -134,13 +144,13 @@ export default async function BlogPost({ params }: BlogPostProps) {
|
|||||||
<span>{getReadingTime(rawTextContent)} 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) && (
|
||||||
<>
|
<>
|
||||||
<span className="w-1 h-1 bg-white/30 rounded-full" />
|
<span className="w-1 h-1 bg-white/30 rounded-full" />
|
||||||
<span className="px-2 py-0.5 border border-white/40 text-white/80 rounded uppercase tracking-widest text-[10px] md:text-xs font-bold">
|
<span className="px-2 py-0.5 border border-white/40 text-white/80 rounded uppercase tracking-widest text-[10px] md:text-xs font-bold">
|
||||||
Draft Preview
|
Draft Preview
|
||||||
</span>
|
</span>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -171,13 +181,13 @@ export default async function BlogPost({ params }: BlogPostProps) {
|
|||||||
<span>{getReadingTime(rawTextContent)} 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) && (
|
||||||
<>
|
<>
|
||||||
<span className="w-1 h-1 bg-neutral-300 rounded-full" />
|
<span className="w-1 h-1 bg-neutral-300 rounded-full" />
|
||||||
<span className="px-2 py-0.5 border border-orange-500/50 text-orange-600 rounded uppercase tracking-widest text-[10px] md:text-xs font-bold">
|
<span className="px-2 py-0.5 border border-orange-500/50 text-orange-600 rounded uppercase tracking-widest text-[10px] md:text-xs font-bold">
|
||||||
Draft Preview
|
Draft Preview
|
||||||
</span>
|
</span>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</header>
|
</header>
|
||||||
|
|||||||
@@ -35,13 +35,6 @@ export async function generateMetadata(props: {
|
|||||||
},
|
},
|
||||||
metadataBase: new URL(baseUrl),
|
metadataBase: new URL(baseUrl),
|
||||||
manifest: '/manifest.webmanifest',
|
manifest: '/manifest.webmanifest',
|
||||||
alternates: {
|
|
||||||
canonical: `${baseUrl}/${locale}`,
|
|
||||||
languages: {
|
|
||||||
de: `${baseUrl}/de`,
|
|
||||||
en: `${baseUrl}/en`,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
icons: {
|
icons: {
|
||||||
icon: [
|
icon: [
|
||||||
{ url: '/favicon.ico', sizes: 'any' },
|
{ url: '/favicon.ico', sizes: 'any' },
|
||||||
@@ -132,11 +125,7 @@ export default async function Layout(props: {
|
|||||||
const feedbackEnabled = process.env.NEXT_PUBLIC_FEEDBACK_ENABLED === 'true';
|
const feedbackEnabled = process.env.NEXT_PUBLIC_FEEDBACK_ENABLED === 'true';
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<html
|
<html lang={safeLocale} className={`overflow-x-hidden ${inter.variable}`}>
|
||||||
lang={safeLocale}
|
|
||||||
className={`scroll-smooth overflow-x-hidden ${inter.variable}`}
|
|
||||||
data-scroll-behavior="smooth"
|
|
||||||
>
|
|
||||||
<head>
|
<head>
|
||||||
<link rel="preconnect" href="https://fonts.googleapis.com" />
|
<link rel="preconnect" href="https://fonts.googleapis.com" />
|
||||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossOrigin="anonymous" />
|
<link rel="preconnect" href="https://fonts.gstatic.com" crossOrigin="anonymous" />
|
||||||
|
|||||||
54
lib/blog.ts
54
lib/blog.ts
@@ -136,6 +136,60 @@ export async function getPostBySlug(slug: string, locale: string): Promise<PostD
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function getPostSlugs(slug: string, locale: string): Promise<Record<string, string>> {
|
||||||
|
try {
|
||||||
|
const payload = await getPayload({ config: configPromise });
|
||||||
|
|
||||||
|
// First, find the post in the current locale to get its ID
|
||||||
|
let { docs } = await payload.find({
|
||||||
|
collection: 'posts',
|
||||||
|
where: {
|
||||||
|
slug: { equals: slug },
|
||||||
|
...(!config.showDrafts ? { _status: { equals: 'published' } } : {}),
|
||||||
|
},
|
||||||
|
locale: locale as any,
|
||||||
|
draft: config.showDrafts,
|
||||||
|
limit: 1,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!docs || docs.length === 0) {
|
||||||
|
// Fallback: search across all locales
|
||||||
|
const { docs: crossLocaleDocs } = await payload.find({
|
||||||
|
collection: 'posts',
|
||||||
|
where: {
|
||||||
|
slug: { equals: slug },
|
||||||
|
...(!config.showDrafts ? { _status: { equals: 'published' } } : {}),
|
||||||
|
},
|
||||||
|
locale: 'all',
|
||||||
|
draft: config.showDrafts,
|
||||||
|
limit: 1,
|
||||||
|
});
|
||||||
|
docs = crossLocaleDocs;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!docs || docs.length === 0) return {};
|
||||||
|
|
||||||
|
const postId = docs[0].id;
|
||||||
|
|
||||||
|
// Fetch the post with locale 'all' to get all localized fields
|
||||||
|
const { docs: allLocalesDocs } = await payload.find({
|
||||||
|
collection: 'posts',
|
||||||
|
where: {
|
||||||
|
id: { equals: postId },
|
||||||
|
},
|
||||||
|
locale: 'all',
|
||||||
|
draft: config.showDrafts,
|
||||||
|
limit: 1,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!allLocalesDocs || allLocalesDocs.length === 0) return {};
|
||||||
|
return (allLocalesDocs[0].slug as unknown as Record<string, string>) || {};
|
||||||
|
} catch (error) {
|
||||||
|
console.error(`[Payload] getPostSlugs failed for ${slug}:`, error);
|
||||||
|
return {};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
export async function getAllPosts(locale: string): Promise<PostData[]> {
|
export async function getAllPosts(locale: string): Promise<PostData[]> {
|
||||||
try {
|
try {
|
||||||
const payload = await getPayload({ config: configPromise });
|
const payload = await getPayload({ config: configPromise });
|
||||||
|
|||||||
@@ -11,10 +11,21 @@ export async function getOgFonts() {
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
console.log(`[OG] Loading fonts: bold=${boldFontPath}, regular=${regularFontPath}`);
|
console.log(`[OG] Loading fonts: bold=${boldFontPath}, regular=${regularFontPath}`);
|
||||||
const boldFont = readFileSync(boldFontPath);
|
const boldFontBuffer = readFileSync(boldFontPath);
|
||||||
const regularFont = readFileSync(regularFontPath);
|
const regularFontBuffer = readFileSync(regularFontPath);
|
||||||
|
|
||||||
|
// Satori (Vercel OG) strictly requires an ArrayBuffer, not a Node Buffer view.
|
||||||
|
const boldFont = boldFontBuffer.buffer.slice(
|
||||||
|
boldFontBuffer.byteOffset,
|
||||||
|
boldFontBuffer.byteOffset + boldFontBuffer.byteLength,
|
||||||
|
);
|
||||||
|
const regularFont = regularFontBuffer.buffer.slice(
|
||||||
|
regularFontBuffer.byteOffset,
|
||||||
|
regularFontBuffer.byteOffset + regularFontBuffer.byteLength,
|
||||||
|
);
|
||||||
|
|
||||||
console.log(
|
console.log(
|
||||||
`[OG] Fonts loaded successfully (${boldFont.length} and ${regularFont.length} bytes)`,
|
`[OG] Fonts loaded successfully (${boldFont.byteLength} and ${regularFont.byteLength} bytes)`,
|
||||||
);
|
);
|
||||||
|
|
||||||
return [
|
return [
|
||||||
|
|||||||
@@ -15,6 +15,8 @@ export interface PageFrontmatter {
|
|||||||
|
|
||||||
export interface PageData {
|
export interface PageData {
|
||||||
slug: string;
|
slug: string;
|
||||||
|
redirectUrl?: string;
|
||||||
|
redirectPermanent?: boolean;
|
||||||
frontmatter: PageFrontmatter;
|
frontmatter: PageFrontmatter;
|
||||||
content: any; // Lexical AST Document
|
content: any; // Lexical AST Document
|
||||||
}
|
}
|
||||||
@@ -96,6 +98,8 @@ export async function getPageBySlug(slug: string, locale: string): Promise<PageD
|
|||||||
|
|
||||||
return {
|
return {
|
||||||
slug: doc.slug,
|
slug: doc.slug,
|
||||||
|
redirectUrl: doc.redirectUrl,
|
||||||
|
redirectPermanent: doc.redirectPermanent ?? true,
|
||||||
frontmatter: {
|
frontmatter: {
|
||||||
title: doc.title,
|
title: doc.title,
|
||||||
excerpt: doc.excerpt || '',
|
excerpt: doc.excerpt || '',
|
||||||
|
|||||||
@@ -1,22 +1,8 @@
|
|||||||
import * as React from 'react';
|
import * as React from 'react';
|
||||||
import {
|
import { Document, Page, View, Text, Image, StyleSheet, Font } from '@react-pdf/renderer';
|
||||||
Document,
|
|
||||||
Page,
|
|
||||||
View,
|
|
||||||
Text,
|
|
||||||
Image,
|
|
||||||
StyleSheet,
|
|
||||||
Font,
|
|
||||||
} from '@react-pdf/renderer';
|
|
||||||
|
|
||||||
// Register fonts (using system fonts for now, can be customized)
|
// Standard fonts like Helvetica are built-in to PDF and don't require registration
|
||||||
Font.register({
|
// unless we want to use specific TTF files. Using built-in Helvetica for maximum stability.
|
||||||
family: 'Helvetica',
|
|
||||||
fonts: [
|
|
||||||
{ src: '/fonts/Helvetica.ttf', fontWeight: 400 },
|
|
||||||
{ src: '/fonts/Helvetica-Bold.ttf', fontWeight: 700 },
|
|
||||||
],
|
|
||||||
});
|
|
||||||
|
|
||||||
// Industrial/technical/restrained design - STYLEGUIDE.md compliant
|
// Industrial/technical/restrained design - STYLEGUIDE.md compliant
|
||||||
const styles = StyleSheet.create({
|
const styles = StyleSheet.create({
|
||||||
@@ -302,10 +288,7 @@ const getLabels = (locale: 'en' | 'de') => {
|
|||||||
return labels[locale];
|
return labels[locale];
|
||||||
};
|
};
|
||||||
|
|
||||||
export const PDFDatasheet: React.FC<PDFDatasheetProps> = ({
|
export const PDFDatasheet: React.FC<PDFDatasheetProps> = ({ product, locale }) => {
|
||||||
product,
|
|
||||||
locale,
|
|
||||||
}) => {
|
|
||||||
const labels = getLabels(locale);
|
const labels = getLabels(locale);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -317,9 +300,7 @@ export const PDFDatasheet: React.FC<PDFDatasheetProps> = ({
|
|||||||
<View>
|
<View>
|
||||||
<Text style={styles.logoText}>KLZ</Text>
|
<Text style={styles.logoText}>KLZ</Text>
|
||||||
</View>
|
</View>
|
||||||
<Text style={styles.docTitle}>
|
<Text style={styles.docTitle}>{labels.productDatasheet}</Text>
|
||||||
{labels.productDatasheet}
|
|
||||||
</Text>
|
|
||||||
</View>
|
</View>
|
||||||
|
|
||||||
<View style={styles.productRow}>
|
<View style={styles.productRow}>
|
||||||
@@ -328,7 +309,8 @@ export const PDFDatasheet: React.FC<PDFDatasheetProps> = ({
|
|||||||
<View style={styles.categories}>
|
<View style={styles.categories}>
|
||||||
{product.categories.map((cat, index) => (
|
{product.categories.map((cat, index) => (
|
||||||
<Text key={index} style={styles.productMeta}>
|
<Text key={index} style={styles.productMeta}>
|
||||||
{cat.name}{index < product.categories.length - 1 ? ' • ' : ''}
|
{cat.name}
|
||||||
|
{index < product.categories.length - 1 ? ' • ' : ''}
|
||||||
</Text>
|
</Text>
|
||||||
))}
|
))}
|
||||||
</View>
|
</View>
|
||||||
@@ -337,12 +319,8 @@ export const PDFDatasheet: React.FC<PDFDatasheetProps> = ({
|
|||||||
</View>
|
</View>
|
||||||
<View style={styles.productImageCol}>
|
<View style={styles.productImageCol}>
|
||||||
{product.featuredImage ? (
|
{product.featuredImage ? (
|
||||||
<Image
|
<Image src={product.featuredImage} style={styles.heroImage} />
|
||||||
src={product.featuredImage}
|
|
||||||
style={styles.heroImage}
|
|
||||||
/>
|
|
||||||
) : (
|
) : (
|
||||||
|
|
||||||
<Text style={styles.noImage}>{labels.noImage}</Text>
|
<Text style={styles.noImage}>{labels.noImage}</Text>
|
||||||
)}
|
)}
|
||||||
</View>
|
</View>
|
||||||
@@ -356,7 +334,11 @@ export const PDFDatasheet: React.FC<PDFDatasheetProps> = ({
|
|||||||
<Text style={styles.sectionTitle}>{labels.description}</Text>
|
<Text style={styles.sectionTitle}>{labels.description}</Text>
|
||||||
<View style={styles.sectionAccent} />
|
<View style={styles.sectionAccent} />
|
||||||
<Text style={styles.description}>
|
<Text style={styles.description}>
|
||||||
{stripHtml(product.applicationHtml || product.shortDescriptionHtml || product.descriptionHtml)}
|
{stripHtml(
|
||||||
|
product.applicationHtml ||
|
||||||
|
product.shortDescriptionHtml ||
|
||||||
|
product.descriptionHtml,
|
||||||
|
)}
|
||||||
</Text>
|
</Text>
|
||||||
</View>
|
</View>
|
||||||
)}
|
)}
|
||||||
@@ -372,17 +354,14 @@ export const PDFDatasheet: React.FC<PDFDatasheetProps> = ({
|
|||||||
key={index}
|
key={index}
|
||||||
style={[
|
style={[
|
||||||
styles.specsTableRow,
|
styles.specsTableRow,
|
||||||
index === product.attributes.length - 1 &&
|
index === product.attributes.length - 1 && styles.specsTableRowLast,
|
||||||
styles.specsTableRowLast,
|
|
||||||
]}
|
]}
|
||||||
>
|
>
|
||||||
<View style={styles.specsTableLabelCell}>
|
<View style={styles.specsTableLabelCell}>
|
||||||
<Text style={styles.specsTableLabelText}>{attr.name}</Text>
|
<Text style={styles.specsTableLabelText}>{attr.name}</Text>
|
||||||
</View>
|
</View>
|
||||||
<View style={styles.specsTableValueCell}>
|
<View style={styles.specsTableValueCell}>
|
||||||
<Text style={styles.specsTableValueText}>
|
<Text style={styles.specsTableValueText}>{attr.options.join(', ')}</Text>
|
||||||
{attr.options.join(', ')}
|
|
||||||
</Text>
|
|
||||||
</View>
|
</View>
|
||||||
</View>
|
</View>
|
||||||
))}
|
))}
|
||||||
|
|||||||
207
lib/pdf-page.tsx
207
lib/pdf-page.tsx
@@ -1,129 +1,126 @@
|
|||||||
import * as React from 'react';
|
import * as React from 'react';
|
||||||
import { Document, Page, View, Text, Image, StyleSheet, Font, Link } from '@react-pdf/renderer';
|
import { Document, Page, View, Text, StyleSheet, Font, Link } from '@react-pdf/renderer';
|
||||||
|
|
||||||
// Register fonts (using system fonts for now, can be customized)
|
// Standard fonts like Helvetica are built-in to PDF and don't require registration
|
||||||
Font.register({
|
// unless we want to use specific TTF files. Using built-in Helvetica for maximum stability.
|
||||||
family: 'Helvetica',
|
|
||||||
fonts: [
|
|
||||||
{ src: '/fonts/Helvetica.ttf', fontWeight: 400 },
|
|
||||||
{ src: '/fonts/Helvetica-Bold.ttf', fontWeight: 700 },
|
|
||||||
],
|
|
||||||
});
|
|
||||||
|
|
||||||
// ─── Brand Tokens ────────────────────────────────────────
|
|
||||||
|
|
||||||
|
// ─── Brand Tokens (matching datasheet) ──────────────────────────────────
|
||||||
const C = {
|
const C = {
|
||||||
navy: '#001a4d',
|
navy: '#001a4d',
|
||||||
navyDeep: '#000d26',
|
navyDeep: '#000d26',
|
||||||
green: '#4da612',
|
accent: '#82ed20',
|
||||||
white: '#FFFFFF',
|
white: '#FFFFFF',
|
||||||
offWhite: '#f8f9fa',
|
offWhite: '#f8f9fa',
|
||||||
|
gray100: '#f3f4f6',
|
||||||
gray200: '#e5e7eb',
|
gray200: '#e5e7eb',
|
||||||
|
gray300: '#d1d5db',
|
||||||
gray400: '#9ca3af',
|
gray400: '#9ca3af',
|
||||||
gray600: '#4b5563',
|
gray600: '#4b5563',
|
||||||
gray900: '#111827',
|
gray900: '#111827',
|
||||||
};
|
};
|
||||||
|
|
||||||
const MARGIN = 56;
|
const MARGIN = 72;
|
||||||
const HEADER_H = 52;
|
|
||||||
const FOOTER_H = 48;
|
|
||||||
const BODY_TOP = HEADER_H + 20;
|
|
||||||
|
|
||||||
const styles = StyleSheet.create({
|
const styles = StyleSheet.create({
|
||||||
page: {
|
page: {
|
||||||
color: C.gray900,
|
color: C.gray900,
|
||||||
lineHeight: 1.5,
|
lineHeight: 1.5,
|
||||||
backgroundColor: C.white,
|
backgroundColor: C.white,
|
||||||
paddingTop: BODY_TOP,
|
paddingTop: 0,
|
||||||
paddingBottom: FOOTER_H + 40,
|
paddingBottom: 100,
|
||||||
paddingHorizontal: MARGIN,
|
|
||||||
fontFamily: 'Helvetica',
|
fontFamily: 'Helvetica',
|
||||||
},
|
},
|
||||||
header: {
|
|
||||||
position: 'absolute',
|
// Hero-style header
|
||||||
top: 0,
|
hero: {
|
||||||
left: 0,
|
backgroundColor: C.white,
|
||||||
right: 0,
|
paddingTop: 24,
|
||||||
height: HEADER_H,
|
paddingBottom: 20,
|
||||||
flexDirection: 'row',
|
|
||||||
justifyContent: 'space-between',
|
|
||||||
alignItems: 'flex-end',
|
|
||||||
paddingHorizontal: MARGIN,
|
paddingHorizontal: MARGIN,
|
||||||
paddingBottom: 12,
|
marginBottom: 20,
|
||||||
borderBottomWidth: 0,
|
position: 'relative',
|
||||||
|
borderBottomWidth: 1,
|
||||||
|
borderBottomColor: C.gray200,
|
||||||
},
|
},
|
||||||
logoText: {
|
|
||||||
fontSize: 16,
|
header: {
|
||||||
fontWeight: 700,
|
|
||||||
color: C.navy,
|
|
||||||
},
|
|
||||||
docTitleLabel: {
|
|
||||||
fontSize: 7,
|
|
||||||
fontWeight: 700,
|
|
||||||
color: C.gray400,
|
|
||||||
letterSpacing: 1.2,
|
|
||||||
textTransform: 'uppercase',
|
|
||||||
},
|
|
||||||
footer: {
|
|
||||||
position: 'absolute',
|
|
||||||
bottom: 20,
|
|
||||||
left: MARGIN,
|
|
||||||
right: MARGIN,
|
|
||||||
flexDirection: 'row',
|
flexDirection: 'row',
|
||||||
justifyContent: 'space-between',
|
justifyContent: 'space-between',
|
||||||
alignItems: 'center',
|
alignItems: 'center',
|
||||||
borderTopWidth: 0.5,
|
marginBottom: 16,
|
||||||
borderTopColor: C.gray200,
|
|
||||||
paddingTop: 8,
|
|
||||||
},
|
},
|
||||||
footerText: {
|
|
||||||
fontSize: 7,
|
logoText: {
|
||||||
color: C.gray400,
|
fontSize: 24,
|
||||||
letterSpacing: 0.8,
|
fontWeight: 700,
|
||||||
|
color: C.navyDeep,
|
||||||
|
letterSpacing: 1,
|
||||||
textTransform: 'uppercase',
|
textTransform: 'uppercase',
|
||||||
},
|
},
|
||||||
|
|
||||||
|
docTitle: {
|
||||||
|
fontSize: 10,
|
||||||
|
fontWeight: 700,
|
||||||
|
color: C.navy,
|
||||||
|
letterSpacing: 2,
|
||||||
|
textTransform: 'uppercase',
|
||||||
|
},
|
||||||
|
|
||||||
|
productHero: {
|
||||||
|
marginTop: 0,
|
||||||
|
},
|
||||||
|
|
||||||
pageTitle: {
|
pageTitle: {
|
||||||
fontSize: 24,
|
fontSize: 24,
|
||||||
fontWeight: 700,
|
fontWeight: 700,
|
||||||
color: C.navyDeep,
|
color: C.navyDeep,
|
||||||
marginBottom: 16,
|
marginBottom: 0,
|
||||||
textTransform: 'uppercase',
|
textTransform: 'uppercase',
|
||||||
letterSpacing: -0.5,
|
letterSpacing: -0.5,
|
||||||
},
|
},
|
||||||
|
|
||||||
accentBar: {
|
accentBar: {
|
||||||
width: 40,
|
width: 30,
|
||||||
height: 3,
|
height: 3,
|
||||||
backgroundColor: C.green,
|
backgroundColor: C.accent,
|
||||||
marginBottom: 24,
|
marginTop: 8,
|
||||||
|
borderRadius: 1.5,
|
||||||
|
},
|
||||||
|
|
||||||
|
// Content Area
|
||||||
|
content: {
|
||||||
|
paddingHorizontal: MARGIN,
|
||||||
},
|
},
|
||||||
|
|
||||||
// Lexical Elements
|
// Lexical Elements
|
||||||
paragraph: {
|
paragraph: {
|
||||||
fontSize: 10,
|
fontSize: 10,
|
||||||
color: C.gray600,
|
color: C.gray600,
|
||||||
lineHeight: 1.6,
|
lineHeight: 1.7,
|
||||||
marginBottom: 12,
|
marginBottom: 12,
|
||||||
},
|
},
|
||||||
heading1: {
|
heading1: {
|
||||||
fontSize: 18,
|
fontSize: 16,
|
||||||
|
fontWeight: 700,
|
||||||
|
color: C.navyDeep,
|
||||||
|
marginTop: 20,
|
||||||
|
marginBottom: 10,
|
||||||
|
textTransform: 'uppercase',
|
||||||
|
letterSpacing: 0.5,
|
||||||
|
},
|
||||||
|
heading2: {
|
||||||
|
fontSize: 12,
|
||||||
fontWeight: 700,
|
fontWeight: 700,
|
||||||
color: C.navyDeep,
|
color: C.navyDeep,
|
||||||
marginTop: 16,
|
marginTop: 16,
|
||||||
marginBottom: 8,
|
marginBottom: 8,
|
||||||
},
|
},
|
||||||
heading2: {
|
|
||||||
fontSize: 14,
|
|
||||||
fontWeight: 700,
|
|
||||||
color: C.navyDeep,
|
|
||||||
marginTop: 14,
|
|
||||||
marginBottom: 6,
|
|
||||||
},
|
|
||||||
heading3: {
|
heading3: {
|
||||||
fontSize: 12,
|
fontSize: 10,
|
||||||
fontWeight: 700,
|
fontWeight: 700,
|
||||||
color: C.navyDeep,
|
color: C.navyDeep,
|
||||||
marginTop: 12,
|
marginTop: 12,
|
||||||
marginBottom: 4,
|
marginBottom: 6,
|
||||||
},
|
},
|
||||||
list: {
|
list: {
|
||||||
marginBottom: 12,
|
marginBottom: 12,
|
||||||
@@ -136,16 +133,17 @@ const styles = StyleSheet.create({
|
|||||||
listItemBullet: {
|
listItemBullet: {
|
||||||
width: 12,
|
width: 12,
|
||||||
fontSize: 10,
|
fontSize: 10,
|
||||||
color: C.green,
|
color: C.accent,
|
||||||
|
fontWeight: 700,
|
||||||
},
|
},
|
||||||
listItemContent: {
|
listItemContent: {
|
||||||
flex: 1,
|
flex: 1,
|
||||||
fontSize: 10,
|
fontSize: 10,
|
||||||
color: C.gray600,
|
color: C.gray600,
|
||||||
lineHeight: 1.6,
|
lineHeight: 1.7,
|
||||||
},
|
},
|
||||||
link: {
|
link: {
|
||||||
color: C.green,
|
color: C.accent,
|
||||||
textDecoration: 'none',
|
textDecoration: 'none',
|
||||||
},
|
},
|
||||||
textBold: {
|
textBold: {
|
||||||
@@ -154,7 +152,37 @@ const styles = StyleSheet.create({
|
|||||||
color: C.navyDeep,
|
color: C.navyDeep,
|
||||||
},
|
},
|
||||||
textItalic: {
|
textItalic: {
|
||||||
fontStyle: 'italic', // Not actually working without proper font set, but we will fallback
|
fontStyle: 'italic',
|
||||||
|
},
|
||||||
|
|
||||||
|
// Footer — matches brochure style
|
||||||
|
footer: {
|
||||||
|
position: 'absolute',
|
||||||
|
bottom: 40,
|
||||||
|
left: MARGIN,
|
||||||
|
right: MARGIN,
|
||||||
|
flexDirection: 'row',
|
||||||
|
justifyContent: 'space-between',
|
||||||
|
alignItems: 'center',
|
||||||
|
paddingTop: 24,
|
||||||
|
borderTopWidth: 1,
|
||||||
|
borderTopColor: C.gray200,
|
||||||
|
},
|
||||||
|
|
||||||
|
footerText: {
|
||||||
|
fontSize: 8,
|
||||||
|
color: C.gray400,
|
||||||
|
fontWeight: 500,
|
||||||
|
textTransform: 'uppercase',
|
||||||
|
letterSpacing: 1,
|
||||||
|
},
|
||||||
|
|
||||||
|
footerBrand: {
|
||||||
|
fontSize: 10,
|
||||||
|
fontWeight: 700,
|
||||||
|
color: C.navyDeep,
|
||||||
|
textTransform: 'uppercase',
|
||||||
|
letterSpacing: 1,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -232,11 +260,10 @@ const renderLexicalNode = (node: any, idx: number): React.ReactNode => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
case 'linebreak': {
|
case 'linebreak': {
|
||||||
// React-PDF doesn't handle `<br/>`, but a newline char usually works inside `<Text>`.
|
|
||||||
return <Text key={idx}>{'\n'}</Text>;
|
return <Text key={idx}>{'\n'}</Text>;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Ignore payload blocks recursively to avoid crashing, as pages should mainly use rich text
|
// Ignore payload blocks recursively to avoid crashing
|
||||||
case 'block':
|
case 'block':
|
||||||
return null;
|
return null;
|
||||||
|
|
||||||
@@ -267,20 +294,32 @@ export const PDFPage: React.FC<PDFPageProps> = ({ page, locale = 'de' }) => {
|
|||||||
return (
|
return (
|
||||||
<Document>
|
<Document>
|
||||||
<Page size="A4" style={styles.page}>
|
<Page size="A4" style={styles.page}>
|
||||||
<View style={styles.header} fixed>
|
{/* Hero Header */}
|
||||||
<Text style={styles.logoText}>KLZ</Text>
|
<View style={styles.hero} fixed>
|
||||||
<Text style={styles.docTitleLabel}>{locale === 'en' ? 'Document' : 'Dokument'}</Text>
|
<View style={styles.header}>
|
||||||
|
<View>
|
||||||
|
<Text style={styles.logoText}>KLZ</Text>
|
||||||
|
</View>
|
||||||
|
<Text style={styles.docTitle}>{locale === 'en' ? 'Document' : 'Dokument'}</Text>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
<View style={styles.productHero}>
|
||||||
|
<Text style={styles.pageTitle}>{page.title}</Text>
|
||||||
|
<View style={styles.accentBar} />
|
||||||
|
</View>
|
||||||
</View>
|
</View>
|
||||||
|
|
||||||
<Text style={styles.pageTitle}>{page.title}</Text>
|
<View style={styles.content}>
|
||||||
<View style={styles.accentBar} />
|
<View>
|
||||||
|
{page.content?.root?.children?.map((node: any, i: number) =>
|
||||||
<View>
|
renderLexicalNode(node, i),
|
||||||
{page.content?.root?.children?.map((node: any, i: number) => renderLexicalNode(node, i))}
|
)}
|
||||||
|
</View>
|
||||||
</View>
|
</View>
|
||||||
|
|
||||||
|
{/* Minimal footer */}
|
||||||
<View style={styles.footer} fixed>
|
<View style={styles.footer} fixed>
|
||||||
<Text style={styles.footerText}>KLZ CABLES</Text>
|
<Text style={styles.footerBrand}>KLZ CABLES</Text>
|
||||||
<Text style={styles.footerText}>{dateStr}</Text>
|
<Text style={styles.footerText}>{dateStr}</Text>
|
||||||
</View>
|
</View>
|
||||||
</Page>
|
</Page>
|
||||||
|
|||||||
@@ -3,7 +3,7 @@
|
|||||||
"pages": {
|
"pages": {
|
||||||
"impressum": "impressum",
|
"impressum": "impressum",
|
||||||
"datenschutz": "datenschutz",
|
"datenschutz": "datenschutz",
|
||||||
"agbs": "agbs",
|
"agbs": "terms",
|
||||||
"kontakt": "contact",
|
"kontakt": "contact",
|
||||||
"team": "team",
|
"team": "team",
|
||||||
"blog": "blog",
|
"blog": "blog",
|
||||||
@@ -74,7 +74,7 @@
|
|||||||
"privacyPolicy": "Datenschutz",
|
"privacyPolicy": "Datenschutz",
|
||||||
"privacyPolicySlug": "datenschutz",
|
"privacyPolicySlug": "datenschutz",
|
||||||
"terms": "AGB",
|
"terms": "AGB",
|
||||||
"termsSlug": "agbs",
|
"termsSlug": "terms",
|
||||||
"products": "Produkte",
|
"products": "Produkte",
|
||||||
"lowVoltage": "Niederspannungskabel",
|
"lowVoltage": "Niederspannungskabel",
|
||||||
"mediumVoltage": "Mittelspannungskabel",
|
"mediumVoltage": "Mittelspannungskabel",
|
||||||
|
|||||||
@@ -3,7 +3,7 @@
|
|||||||
"pages": {
|
"pages": {
|
||||||
"legal-notice": "impressum",
|
"legal-notice": "impressum",
|
||||||
"privacy-policy": "datenschutz",
|
"privacy-policy": "datenschutz",
|
||||||
"terms": "agbs",
|
"terms": "terms",
|
||||||
"contact": "contact",
|
"contact": "contact",
|
||||||
"team": "team",
|
"team": "team",
|
||||||
"blog": "blog",
|
"blog": "blog",
|
||||||
|
|||||||
@@ -12,12 +12,18 @@ const nextConfig = {
|
|||||||
maxInactiveAge: 60 * 1000,
|
maxInactiveAge: 60 * 1000,
|
||||||
},
|
},
|
||||||
experimental: {
|
experimental: {
|
||||||
|
staleTimes: {
|
||||||
|
dynamic: 0,
|
||||||
|
static: 30,
|
||||||
|
},
|
||||||
optimizePackageImports: ['lucide-react', 'framer-motion', '@/components/ui'],
|
optimizePackageImports: ['lucide-react', 'framer-motion', '@/components/ui'],
|
||||||
cpus: 3,
|
cpus: 3,
|
||||||
workerThreads: false,
|
workerThreads: false,
|
||||||
|
serverActions: {
|
||||||
|
allowedOrigins: ["*.klz-cables.com", "*.branch.klz-cables.com", "localhost:3000", "klz.localhost"],
|
||||||
|
},
|
||||||
},
|
},
|
||||||
reactStrictMode: false,
|
reactStrictMode: false,
|
||||||
swcMinify: true,
|
|
||||||
productionBrowserSourceMaps: false,
|
productionBrowserSourceMaps: false,
|
||||||
logging: {
|
logging: {
|
||||||
fetches: {
|
fetches: {
|
||||||
@@ -458,10 +464,6 @@ const nextConfig = {
|
|||||||
source: '/en/datenschutz',
|
source: '/en/datenschutz',
|
||||||
destination: '/en/privacy-policy',
|
destination: '/en/privacy-policy',
|
||||||
},
|
},
|
||||||
{
|
|
||||||
source: '/en/agbs',
|
|
||||||
destination: '/en/terms',
|
|
||||||
},
|
|
||||||
],
|
],
|
||||||
afterFiles: [],
|
afterFiles: [],
|
||||||
fallback: [],
|
fallback: [],
|
||||||
|
|||||||
@@ -139,7 +139,7 @@
|
|||||||
"prepare": "husky",
|
"prepare": "husky",
|
||||||
"preinstall": "npx only-allow pnpm"
|
"preinstall": "npx only-allow pnpm"
|
||||||
},
|
},
|
||||||
"version": "2.2.12",
|
"version": "2.2.14",
|
||||||
"pnpm": {
|
"pnpm": {
|
||||||
"onlyBuiltDependencies": [
|
"onlyBuiltDependencies": [
|
||||||
"@parcel/watcher",
|
"@parcel/watcher",
|
||||||
|
|||||||
@@ -87,7 +87,9 @@ export interface Config {
|
|||||||
products: ProductsSelect<false> | ProductsSelect<true>;
|
products: ProductsSelect<false> | ProductsSelect<true>;
|
||||||
pages: PagesSelect<false> | PagesSelect<true>;
|
pages: PagesSelect<false> | PagesSelect<true>;
|
||||||
'payload-kv': PayloadKvSelect<false> | PayloadKvSelect<true>;
|
'payload-kv': PayloadKvSelect<false> | PayloadKvSelect<true>;
|
||||||
'payload-locked-documents': PayloadLockedDocumentsSelect<false> | PayloadLockedDocumentsSelect<true>;
|
'payload-locked-documents':
|
||||||
|
| PayloadLockedDocumentsSelect<false>
|
||||||
|
| PayloadLockedDocumentsSelect<true>;
|
||||||
'payload-preferences': PayloadPreferencesSelect<false> | PayloadPreferencesSelect<true>;
|
'payload-preferences': PayloadPreferencesSelect<false> | PayloadPreferencesSelect<true>;
|
||||||
'payload-migrations': PayloadMigrationsSelect<false> | PayloadMigrationsSelect<true>;
|
'payload-migrations': PayloadMigrationsSelect<false> | PayloadMigrationsSelect<true>;
|
||||||
};
|
};
|
||||||
@@ -98,6 +100,9 @@ export interface Config {
|
|||||||
globals: {};
|
globals: {};
|
||||||
globalsSelect: {};
|
globalsSelect: {};
|
||||||
locale: 'de' | 'en';
|
locale: 'de' | 'en';
|
||||||
|
widgets: {
|
||||||
|
collections: CollectionsWidget;
|
||||||
|
};
|
||||||
user: User;
|
user: User;
|
||||||
jobs: {
|
jobs: {
|
||||||
tasks: unknown;
|
tasks: unknown;
|
||||||
@@ -328,6 +333,14 @@ export interface Page {
|
|||||||
layout?: ('default' | 'fullBleed') | null;
|
layout?: ('default' | 'fullBleed') | null;
|
||||||
excerpt?: string | null;
|
excerpt?: string | null;
|
||||||
featuredImage?: (number | null) | Media;
|
featuredImage?: (number | null) | Media;
|
||||||
|
/**
|
||||||
|
* If set, visiting this page will immediately redirect the user to this URL (e.g. /de/terms).
|
||||||
|
*/
|
||||||
|
redirectUrl?: string | null;
|
||||||
|
/**
|
||||||
|
* Check for a permanent (301) redirect. Uncheck for a temporary (302) redirect.
|
||||||
|
*/
|
||||||
|
redirectPermanent?: boolean | null;
|
||||||
content: {
|
content: {
|
||||||
root: {
|
root: {
|
||||||
type: string;
|
type: string;
|
||||||
@@ -574,6 +587,8 @@ export interface PagesSelect<T extends boolean = true> {
|
|||||||
layout?: T;
|
layout?: T;
|
||||||
excerpt?: T;
|
excerpt?: T;
|
||||||
featuredImage?: T;
|
featuredImage?: T;
|
||||||
|
redirectUrl?: T;
|
||||||
|
redirectPermanent?: T;
|
||||||
content?: T;
|
content?: T;
|
||||||
updatedAt?: T;
|
updatedAt?: T;
|
||||||
createdAt?: T;
|
createdAt?: T;
|
||||||
@@ -619,6 +634,16 @@ export interface PayloadMigrationsSelect<T extends boolean = true> {
|
|||||||
updatedAt?: T;
|
updatedAt?: T;
|
||||||
createdAt?: T;
|
createdAt?: T;
|
||||||
}
|
}
|
||||||
|
/**
|
||||||
|
* This interface was referenced by `Config`'s JSON-Schema
|
||||||
|
* via the `definition` "collections_widget".
|
||||||
|
*/
|
||||||
|
export interface CollectionsWidget {
|
||||||
|
data?: {
|
||||||
|
[k: string]: unknown;
|
||||||
|
};
|
||||||
|
width: 'full';
|
||||||
|
}
|
||||||
/**
|
/**
|
||||||
* This interface was referenced by `Config`'s JSON-Schema
|
* This interface was referenced by `Config`'s JSON-Schema
|
||||||
* via the `definition` "StatsBlock".
|
* via the `definition` "StatsBlock".
|
||||||
@@ -957,7 +982,6 @@ export interface Auth {
|
|||||||
[k: string]: unknown;
|
[k: string]: unknown;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
declare module 'payload' {
|
declare module 'payload' {
|
||||||
export interface GeneratedTypes extends Config {}
|
export interface GeneratedTypes extends Config {}
|
||||||
}
|
}
|
||||||
@@ -71,6 +71,8 @@ export default buildConfig({
|
|||||||
},
|
},
|
||||||
db: postgresAdapter({
|
db: postgresAdapter({
|
||||||
prodMigrations: migrations,
|
prodMigrations: migrations,
|
||||||
|
migrationDir:
|
||||||
|
process.env.NODE_ENV === 'production' ? undefined : path.resolve(dirname, 'src/migrations'),
|
||||||
pool: {
|
pool: {
|
||||||
connectionString:
|
connectionString:
|
||||||
process.env.DATABASE_URI ||
|
process.env.DATABASE_URI ||
|
||||||
|
|||||||
3541
pnpm-lock.yaml
generated
3541
pnpm-lock.yaml
generated
File diff suppressed because it is too large
Load Diff
@@ -13,7 +13,6 @@ fi
|
|||||||
|
|
||||||
DB_NAME="${PAYLOAD_DB_NAME:-payload}"
|
DB_NAME="${PAYLOAD_DB_NAME:-payload}"
|
||||||
DB_USER="${PAYLOAD_DB_USER:-payload}"
|
DB_USER="${PAYLOAD_DB_USER:-payload}"
|
||||||
DB_CONTAINER="klz-2026-klz-db-1"
|
|
||||||
BACKUP_DIR="./backups"
|
BACKUP_DIR="./backups"
|
||||||
TIMESTAMP=$(date +"%Y%m%d_%H%M%S")
|
TIMESTAMP=$(date +"%Y%m%d_%H%M%S")
|
||||||
BACKUP_FILE="${BACKUP_DIR}/payload_${TIMESTAMP}.sql.gz"
|
BACKUP_FILE="${BACKUP_DIR}/payload_${TIMESTAMP}.sql.gz"
|
||||||
@@ -21,20 +20,21 @@ BACKUP_FILE="${BACKUP_DIR}/payload_${TIMESTAMP}.sql.gz"
|
|||||||
# Ensure backup directory exists
|
# Ensure backup directory exists
|
||||||
mkdir -p "$BACKUP_DIR"
|
mkdir -p "$BACKUP_DIR"
|
||||||
|
|
||||||
# Check if container is running
|
# Check if database container is running
|
||||||
if ! docker ps --format '{{.Names}}' | grep -q "$DB_CONTAINER"; then
|
if ! docker compose ps --services --filter "status=running" | grep -qx "klz-db"; then
|
||||||
echo "❌ Database container '$DB_CONTAINER' is not running."
|
echo "⚠️ Database container 'klz-db' is not running. Starting it..."
|
||||||
echo " Start it with: docker compose up -d klz-db"
|
docker compose up -d klz-db
|
||||||
exit 1
|
echo "⏳ Waiting for database to be ready..."
|
||||||
|
sleep 3
|
||||||
fi
|
fi
|
||||||
|
|
||||||
echo "📦 Backing up Payload database..."
|
echo "📦 Backing up Payload database..."
|
||||||
echo " Container: $DB_CONTAINER"
|
echo " Service: klz-db"
|
||||||
echo " Database: $DB_NAME"
|
echo " Database: $DB_NAME"
|
||||||
echo " Output: $BACKUP_FILE"
|
echo " Output: $BACKUP_FILE"
|
||||||
|
|
||||||
# Run pg_dump inside the container and compress
|
# Run pg_dump inside the container and compress
|
||||||
docker exec "$DB_CONTAINER" pg_dump -U "$DB_USER" -d "$DB_NAME" --clean --if-exists | gzip > "$BACKUP_FILE"
|
docker compose exec -T klz-db pg_dump -U "$DB_USER" -d "$DB_NAME" --clean --if-exists | gzip > "$BACKUP_FILE"
|
||||||
|
|
||||||
# Show result
|
# Show result
|
||||||
SIZE=$(du -h "$BACKUP_FILE" | cut -f1)
|
SIZE=$(du -h "$BACKUP_FILE" | cut -f1)
|
||||||
|
|||||||
@@ -38,11 +38,21 @@ function getExpectedTranslation(
|
|||||||
sourcePath: string,
|
sourcePath: string,
|
||||||
sourceLocale: string,
|
sourceLocale: string,
|
||||||
targetLocale: string,
|
targetLocale: string,
|
||||||
): string {
|
alternates: { hreflang: string; href: string }[],
|
||||||
|
): string | null {
|
||||||
const segments = sourcePath.split('/').filter(Boolean);
|
const segments = sourcePath.split('/').filter(Boolean);
|
||||||
// First segment is locale
|
|
||||||
segments[0] = targetLocale;
|
segments[0] = targetLocale;
|
||||||
|
|
||||||
|
// Blog posts have dynamic slugs. If it's a blog post, trust the alternate tag
|
||||||
|
// if the href is present in the sitemap.
|
||||||
|
// The Smoke Test's primary job is ensuring the alternate links point to valid pages.
|
||||||
|
if (segments[1] === (targetLocale === 'de' ? 'blog' : 'blog') && segments.length > 2) {
|
||||||
|
const altLink = alternates.find((a) => a.hreflang === targetLocale);
|
||||||
|
if (altLink) {
|
||||||
|
return new URL(altLink.href).pathname;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const map = sourceLocale === 'de' ? SLUG_MAP : REVERSE_SLUG_MAP;
|
const map = sourceLocale === 'de' ? SLUG_MAP : REVERSE_SLUG_MAP;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -50,7 +60,7 @@ function getExpectedTranslation(
|
|||||||
segments
|
segments
|
||||||
.map((seg, i) => {
|
.map((seg, i) => {
|
||||||
if (i === 0) return seg; // locale
|
if (i === 0) return seg; // locale
|
||||||
return map[seg] || seg; // translate or keep (product names like n2x2y stay the same)
|
return map[seg] || seg; // translate or keep
|
||||||
})
|
})
|
||||||
.join('/')
|
.join('/')
|
||||||
);
|
);
|
||||||
@@ -118,7 +128,7 @@ async function main() {
|
|||||||
if (alt.hreflang === locale) continue; // Same locale, skip
|
if (alt.hreflang === locale) continue; // Same locale, skip
|
||||||
|
|
||||||
// 1. Check slug translation is correct
|
// 1. Check slug translation is correct
|
||||||
const expectedPath = getExpectedTranslation(path, locale, alt.hreflang);
|
const expectedPath = getExpectedTranslation(path, locale, alt.hreflang, alternates);
|
||||||
const actualPath = new URL(alt.href).pathname;
|
const actualPath = new URL(alt.href).pathname;
|
||||||
|
|
||||||
if (actualPath !== expectedPath) {
|
if (actualPath !== expectedPath) {
|
||||||
|
|||||||
52
src/migrations/20260305_215000_products_featured_image.ts
Normal file
52
src/migrations/20260305_215000_products_featured_image.ts
Normal file
@@ -0,0 +1,52 @@
|
|||||||
|
import { MigrateUpArgs, MigrateDownArgs, sql } from '@payloadcms/db-postgres';
|
||||||
|
|
||||||
|
export async function up({ db }: MigrateUpArgs): Promise<void> {
|
||||||
|
// Add featured_image_id to products and _products_v
|
||||||
|
await db.execute(sql`
|
||||||
|
ALTER TABLE "products" ADD COLUMN IF NOT EXISTS "featured_image_id" integer;
|
||||||
|
`);
|
||||||
|
await db.execute(sql`
|
||||||
|
ALTER TABLE "_products_v" ADD COLUMN IF NOT EXISTS "version_featured_image_id" integer;
|
||||||
|
`);
|
||||||
|
|
||||||
|
// Add foreign key constraints
|
||||||
|
await db.execute(sql`
|
||||||
|
DO $$ BEGIN
|
||||||
|
ALTER TABLE "products" ADD CONSTRAINT "products_featured_image_id_media_id_fk" FOREIGN KEY ("featured_image_id") REFERENCES "public"."media"("id") ON DELETE set null ON UPDATE no action;
|
||||||
|
EXCEPTION WHEN duplicate_object THEN null; END $$;
|
||||||
|
`);
|
||||||
|
await db.execute(sql`
|
||||||
|
DO $$ BEGIN
|
||||||
|
ALTER TABLE "_products_v" ADD CONSTRAINT "_products_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;
|
||||||
|
EXCEPTION WHEN duplicate_object THEN null; END $$;
|
||||||
|
`);
|
||||||
|
|
||||||
|
// Add indexes
|
||||||
|
await db.execute(sql`
|
||||||
|
CREATE INDEX IF NOT EXISTS "products_featured_image_idx" ON "products" USING btree ("featured_image_id");
|
||||||
|
`);
|
||||||
|
await db.execute(sql`
|
||||||
|
CREATE INDEX IF NOT EXISTS "_products_v_version_version_featured_image_idx" ON "_products_v" USING btree ("version_featured_image_id");
|
||||||
|
`);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function down({ db }: MigrateDownArgs): Promise<void> {
|
||||||
|
await db.execute(sql`
|
||||||
|
ALTER TABLE "products" DROP CONSTRAINT IF EXISTS "products_featured_image_id_media_id_fk";
|
||||||
|
`);
|
||||||
|
await db.execute(sql`
|
||||||
|
ALTER TABLE "_products_v" DROP CONSTRAINT IF EXISTS "_products_v_version_featured_image_id_media_id_fk";
|
||||||
|
`);
|
||||||
|
await db.execute(sql`
|
||||||
|
DROP INDEX IF EXISTS "products_featured_image_idx";
|
||||||
|
`);
|
||||||
|
await db.execute(sql`
|
||||||
|
DROP INDEX IF EXISTS "_products_v_version_version_featured_image_idx";
|
||||||
|
`);
|
||||||
|
await db.execute(sql`
|
||||||
|
ALTER TABLE "products" DROP COLUMN IF EXISTS "featured_image_id";
|
||||||
|
`);
|
||||||
|
await db.execute(sql`
|
||||||
|
ALTER TABLE "_products_v" DROP COLUMN IF EXISTS "version_featured_image_id";
|
||||||
|
`);
|
||||||
|
}
|
||||||
22
src/migrations/20260312_120000_pages_redirect_fields.ts
Normal file
22
src/migrations/20260312_120000_pages_redirect_fields.ts
Normal file
@@ -0,0 +1,22 @@
|
|||||||
|
import { MigrateUpArgs, MigrateDownArgs, sql } from '@payloadcms/db-postgres';
|
||||||
|
|
||||||
|
export async function up({ db }: MigrateUpArgs): Promise<void> {
|
||||||
|
// redirect_permanent is a non-localized checkbox → stored on the main pages table
|
||||||
|
await db.execute(sql`
|
||||||
|
ALTER TABLE "pages" ADD COLUMN IF NOT EXISTS "redirect_permanent" boolean DEFAULT true;
|
||||||
|
`);
|
||||||
|
|
||||||
|
// redirect_url is a localized text field → stored on the pages_locales table
|
||||||
|
await db.execute(sql`
|
||||||
|
ALTER TABLE "pages_locales" ADD COLUMN IF NOT EXISTS "redirect_url" varchar;
|
||||||
|
`);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function down({ db }: MigrateDownArgs): Promise<void> {
|
||||||
|
await db.execute(sql`
|
||||||
|
ALTER TABLE "pages" DROP COLUMN IF EXISTS "redirect_permanent";
|
||||||
|
`);
|
||||||
|
await db.execute(sql`
|
||||||
|
ALTER TABLE "pages_locales" DROP COLUMN IF EXISTS "redirect_url";
|
||||||
|
`);
|
||||||
|
}
|
||||||
@@ -2,6 +2,8 @@ import * as migration_20260223_195005_products_collection from './20260223_19500
|
|||||||
import * as migration_20260223_195151_remove_sku_unique from './20260223_195151_remove_sku_unique';
|
import * as migration_20260223_195151_remove_sku_unique from './20260223_195151_remove_sku_unique';
|
||||||
import * as migration_20260225_003500_add_pages_collection from './20260225_003500_add_pages_collection';
|
import * as migration_20260225_003500_add_pages_collection from './20260225_003500_add_pages_collection';
|
||||||
import * as migration_20260225_175000_native_localization from './20260225_175000_native_localization';
|
import * as migration_20260225_175000_native_localization from './20260225_175000_native_localization';
|
||||||
|
import * as migration_20260305_215000_products_featured_image from './20260305_215000_products_featured_image';
|
||||||
|
import * as migration_20260312_120000_pages_redirect_fields from './20260312_120000_pages_redirect_fields';
|
||||||
|
|
||||||
export const migrations = [
|
export const migrations = [
|
||||||
{
|
{
|
||||||
@@ -24,4 +26,14 @@ export const migrations = [
|
|||||||
down: migration_20260225_175000_native_localization.down,
|
down: migration_20260225_175000_native_localization.down,
|
||||||
name: '20260225_175000_native_localization',
|
name: '20260225_175000_native_localization',
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
up: migration_20260305_215000_products_featured_image.up,
|
||||||
|
down: migration_20260305_215000_products_featured_image.down,
|
||||||
|
name: '20260305_215000_products_featured_image',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
up: migration_20260312_120000_pages_redirect_fields.up,
|
||||||
|
down: migration_20260312_120000_pages_redirect_fields.down,
|
||||||
|
name: '20260312_120000_pages_redirect_fields',
|
||||||
|
},
|
||||||
];
|
];
|
||||||
|
|||||||
@@ -72,6 +72,33 @@ export const Pages: CollectionConfig = {
|
|||||||
position: 'sidebar',
|
position: 'sidebar',
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
type: 'collapsible',
|
||||||
|
label: 'Redirect Settings',
|
||||||
|
admin: {
|
||||||
|
position: 'sidebar',
|
||||||
|
},
|
||||||
|
fields: [
|
||||||
|
{
|
||||||
|
name: 'redirectUrl',
|
||||||
|
type: 'text',
|
||||||
|
localized: true,
|
||||||
|
admin: {
|
||||||
|
description:
|
||||||
|
'If set, visiting this page will immediately redirect the user to this URL (e.g. /de/terms).',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'redirectPermanent',
|
||||||
|
type: 'checkbox',
|
||||||
|
defaultValue: true,
|
||||||
|
admin: {
|
||||||
|
description:
|
||||||
|
'Check for a permanent (301) redirect. Uncheck for a temporary (302) redirect.',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
{
|
{
|
||||||
name: 'content',
|
name: 'content',
|
||||||
type: 'richText',
|
type: 'richText',
|
||||||
|
|||||||
Reference in New Issue
Block a user