Compare commits

...

22 Commits

Author SHA1 Message Date
db754a6325 fix: remove residual merge conflict marker in package.json
All checks were successful
Build & Deploy / 🔍 Prepare (push) Successful in 9s
Build & Deploy / 🧪 QA (push) Successful in 1m15s
Build & Deploy / 🏗️ Build (push) Successful in 3m33s
Build & Deploy / 🚀 Deploy (push) Successful in 15s
Build & Deploy / 🧪 Post-Deploy Verification (push) Successful in 4m29s
Build & Deploy / 🔔 Notify (push) Successful in 2s
2026-04-11 19:05:05 +02:00
9dc553b76c fix(contact): import env instead of undefined config
# Conflicts:
#	package.json
2026-04-11 18:08:35 +02:00
48101cc421 chore: Pin @react-email/components to 1.0.8 to fix broken upstream 1.0.9 release 2026-04-11 18:06:52 +02:00
615f762d5e fix(mail): add overrideAccess to form submissions and mail fallbacks for branch deploys 2026-04-11 18:06:49 +02:00
0c4c6e8dc0 release: v2.3.17
Some checks failed
Build & Deploy / 🔍 Prepare (push) Successful in 16s
Build & Deploy / 🚀 Deploy (push) Successful in 28s
Build & Deploy / 🧪 QA (push) Successful in 2m28s
Build & Deploy / 🏗️ Build (push) Successful in 4m37s
Build & Deploy / 🧪 Post-Deploy Verification (push) Successful in 4m31s
Build & Deploy / 🔔 Notify (push) Successful in 2s
Nightly QA / 🔗 Links & Deps (push) Successful in 2m16s
Nightly QA / 🎭 Lighthouse (push) Successful in 4m1s
Nightly QA / 🔍 Static Analysis (push) Failing after 4m17s
Nightly QA / ♿ Accessibility (push) Successful in 5m0s
Nightly QA / 🔔 Notify (push) Successful in 3s
2026-04-10 23:22:04 +02:00
3046a19113 merge: apply mailer, contact env, and ci hardening fixes
Some checks failed
Build & Deploy / 🔍 Prepare (push) Has been cancelled
Build & Deploy / 🧪 QA (push) Has been cancelled
Build & Deploy / 🏗️ Build (push) Has been cancelled
Build & Deploy / 🚀 Deploy (push) Has been cancelled
Build & Deploy / 🧪 Post-Deploy Verification (push) Has been cancelled
Build & Deploy / 🔔 Notify (push) Has been cancelled
2026-04-10 23:21:45 +02:00
a4df12ddb3 fix(mail,ci): restore mail hardening, contact env parsing, and ci tag validation
Some checks failed
Build & Deploy / 🔍 Prepare (push) Successful in 17s
Build & Deploy / 🧪 QA (push) Successful in 2m48s
Build & Deploy / 🏗️ Build (push) Failing after 1h35m52s
Build & Deploy / 🚀 Deploy (push) Has been cancelled
Build & Deploy / 🧪 Post-Deploy Verification (push) Has been cancelled
Build & Deploy / 🔔 Notify (push) Has been cancelled
2026-04-10 23:15:13 +02:00
73542237d5 fix(mail): harden mailer and fix missing notification recipients 2026-04-10 23:13:39 +02:00
52b66da16b emergency: rollback AI Search (revert to main code)
Some checks failed
Build & Deploy / 🔍 Prepare (push) Successful in 21s
Build & Deploy / 🧪 QA (push) Successful in 2m39s
Build & Deploy / 🏗️ Build (push) Failing after 22m1s
Build & Deploy / 🚀 Deploy (push) Has been skipped
Build & Deploy / 🧪 Post-Deploy Verification (push) Has been skipped
Build & Deploy / 🔔 Notify (push) Successful in 4s
2026-04-10 23:11:31 +02:00
d75a83ccf2 2.2.14
All checks were successful
Nightly QA / 🔗 Links & Deps (push) Successful in 2m37s
Nightly QA / 🎭 Lighthouse (push) Successful in 3m31s
Nightly QA / ♿ Accessibility (push) Successful in 4m27s
Nightly QA / 🔍 Static Analysis (push) Successful in 6m59s
Nightly QA / 🔔 Notify (push) Successful in 2s
Build & Deploy / 🔍 Prepare (push) Successful in 9s
Build & Deploy / 🧪 QA (push) Successful in 59s
Build & Deploy / 🏗️ Build (push) Successful in 2m28s
Build & Deploy / 🚀 Deploy (push) Successful in 1m19s
Build & Deploy / 🧪 Post-Deploy Verification (push) Successful in 4m33s
Build & Deploy / 🔔 Notify (push) Successful in 2s
2026-03-17 10:21:32 +01:00
5991bd8392 test(e2e): support dynamic slug resolution for blog posts in locale smoke test 2026-03-17 10:21:30 +01:00
6207e04bf5 2.2.13
Some checks failed
Build & Deploy / 🔍 Prepare (push) Successful in 7s
Build & Deploy / 🧪 QA (push) Successful in 1m41s
Build & Deploy / 🏗️ Build (push) Successful in 4m35s
Build & Deploy / 🚀 Deploy (push) Successful in 18s
Build & Deploy / 🧪 Post-Deploy Verification (push) Failing after 3m35s
Build & Deploy / 🔔 Notify (push) Successful in 1s
Nightly QA / 🎭 Lighthouse (push) Successful in 2m57s
Nightly QA / 🔗 Links & Deps (push) Successful in 3m15s
Nightly QA / ♿ Accessibility (push) Successful in 4m57s
Nightly QA / 🔍 Static Analysis (push) Successful in 7m0s
Nightly QA / 🔔 Notify (push) Successful in 2s
2026-03-16 23:15:10 +01:00
8ffb5967d3 fix(seo): correct canonical tags and localized blog post hreflang 2026-03-16 23:15:04 +01:00
8ba1c7ea38 style(pdf): align AGB layout with technical datasheet hero and spacing
All checks were successful
Build & Deploy / 🔍 Prepare (push) Successful in 6s
Build & Deploy / 🧪 QA (push) Successful in 53s
Build & Deploy / 🏗️ Build (push) Successful in 2m13s
Build & Deploy / 🚀 Deploy (push) Successful in 15s
Build & Deploy / 🧪 Post-Deploy Verification (push) Successful in 4m16s
Build & Deploy / 🔔 Notify (push) Successful in 1s
2026-03-16 07:53:29 +01:00
a546ffe69c fix(pdf): remove broken helvetica font registration causing 500 error
Some checks failed
Build & Deploy / 🔍 Prepare (push) Successful in 6s
Build & Deploy / 🧪 QA (push) Successful in 56s
Build & Deploy / 🏗️ Build (push) Successful in 2m29s
Build & Deploy / 🚀 Deploy (push) Failing after 15s
Build & Deploy / 🧪 Post-Deploy Verification (push) Has been skipped
Build & Deploy / 🔔 Notify (push) Successful in 2s
2026-03-16 07:47:47 +01:00
15740db51e chore(ci): re-trigger pipeline after testing db schema hotfix
All checks were successful
Build & Deploy / 🔍 Prepare (push) Successful in 6s
Build & Deploy / 🧪 QA (push) Successful in 51s
Build & Deploy / 🏗️ Build (push) Successful in 2m20s
Build & Deploy / 🚀 Deploy (push) Successful in 15s
Build & Deploy / 🧪 Post-Deploy Verification (push) Successful in 4m7s
Build & Deploy / 🔔 Notify (push) Successful in 1s
2026-03-16 07:38:54 +01:00
13ab755857 fix(docker): bypass internal registry for base images to prevent 404s
Some checks failed
Build & Deploy / 🔍 Prepare (push) Successful in 6s
Build & Deploy / 🧪 QA (push) Successful in 51s
Build & Deploy / 🏗️ Build (push) Successful in 2m23s
Build & Deploy / 🚀 Deploy (push) Successful in 18s
Build & Deploy / 🧪 Post-Deploy Verification (push) Successful in 3m33s
Build & Deploy / 🔔 Notify (push) Successful in 2s
Nightly QA / 🔗 Links & Deps (push) Successful in 2m57s
Nightly QA / 🎭 Lighthouse (push) Successful in 3m32s
Nightly QA / 🔍 Static Analysis (push) Failing after 4m49s
Nightly QA / ♿ Accessibility (push) Successful in 5m12s
Nightly QA / 🔔 Notify (push) Successful in 19s
2026-03-15 23:39:22 +01:00
1a68af0eec fix(pdf): align AGB page PDF layout with datasheet design tokens
Some checks failed
Nightly QA / 🔗 Links & Deps (push) Successful in 2m21s
Nightly QA / 🎭 Lighthouse (push) Successful in 4m15s
Nightly QA / 🔍 Static Analysis (push) Failing after 4m45s
Nightly QA / ♿ Accessibility (push) Successful in 5m16s
Nightly QA / 🔔 Notify (push) Successful in 3s
Build & Deploy / 🔍 Prepare (push) Successful in 7s
Build & Deploy / 🧪 QA (push) Successful in 57s
Build & Deploy / 🏗️ Build (push) Failing after 18s
Build & Deploy / 🚀 Deploy (push) Has been skipped
Build & Deploy / 🧪 Post-Deploy Verification (push) Has been skipped
Build & Deploy / 🔔 Notify (push) Successful in 2s
2026-03-13 22:24:33 +01:00
275784745d feat(db): add migration for pages redirect fields
Some checks failed
Build & Deploy / 🔍 Prepare (push) Successful in 6s
Build & Deploy / 🧪 QA (push) Successful in 54s
Build & Deploy / 🏗️ Build (push) Failing after 15s
Build & Deploy / 🚀 Deploy (push) Has been skipped
Build & Deploy / 🧪 Post-Deploy Verification (push) Has been skipped
Build & Deploy / 🔔 Notify (push) Successful in 2s
2026-03-13 22:19:29 +01:00
4aef49cf2c fix: remove agbs rewrite rules that conflict with slug-mapping (redirect loop)
Some checks failed
Build & Deploy / 🔍 Prepare (push) Successful in 6s
Build & Deploy / 🧪 QA (push) Successful in 56s
Build & Deploy / 🏗️ Build (push) Failing after 16s
Build & Deploy / 🚀 Deploy (push) Has been skipped
Build & Deploy / 🧪 Post-Deploy Verification (push) Has been skipped
Build & Deploy / 🔔 Notify (push) Successful in 2s
2026-03-13 15:35:01 +01:00
8ad3abb6f3 fix(docker): restore valid v1.8.20 base image tag
Some checks failed
Build & Deploy / 🔍 Prepare (push) Successful in 6s
Build & Deploy / 🧪 QA (push) Successful in 1m0s
Build & Deploy / 🏗️ Build (push) Failing after 15s
Build & Deploy / 🚀 Deploy (push) Has been skipped
Build & Deploy / 🧪 Post-Deploy Verification (push) Has been skipped
Build & Deploy / 🔔 Notify (push) Successful in 1s
Nightly QA / 🔗 Links & Deps (push) Successful in 2m32s
Nightly QA / 🎭 Lighthouse (push) Successful in 3m3s
Nightly QA / 🔍 Static Analysis (push) Failing after 4m43s
Nightly QA / ♿ Accessibility (push) Successful in 5m20s
Nightly QA / 🔔 Notify (push) Successful in 14s
2026-03-12 19:12:56 +01:00
1d75b60236 fix(docker): use correctly versioned mintel v2 base images
Some checks failed
Build & Deploy / 🔍 Prepare (push) Successful in 5s
Build & Deploy / 🧪 QA (push) Successful in 53s
Build & Deploy / 🏗️ Build (push) Failing after 15s
Build & Deploy / 🚀 Deploy (push) Has been skipped
Build & Deploy / 🧪 Post-Deploy Verification (push) Has been skipped
Build & Deploy / 🔔 Notify (push) Successful in 1s
2026-03-12 14:39:05 +01:00
15 changed files with 251 additions and 140 deletions

View File

@@ -124,13 +124,13 @@ jobs:
if [[ -n "$UPSTREAM_VERSION" && "$UPSTREAM_VERSION" != "workspace:"* ]]; then if [[ -n "$UPSTREAM_VERSION" && "$UPSTREAM_VERSION" != "workspace:"* ]]; then
# 1. Discovery (Works without token for public repositories) # 1. Discovery (Works without token for public repositories)
UPSTREAM_SHA=$(git ls-remote --tags https://git.infra.mintel.me/mmintel/at-mintel.git "$TAG_TO_WAIT" | grep "$TAG_TO_WAIT" | tail -n1 | awk '{print $1}') UPSTREAM_SHA=$(git ls-remote --tags https://git.infra.mintel.me/mmintel/at-mintel.git "$TAG_TO_WAIT" 2>/dev/null | grep "$TAG_TO_WAIT" | awk '{print $1}' | tail -n1 || echo "")
if [[ -z "$UPSTREAM_SHA" ]]; then if [[ -z "$UPSTREAM_SHA" ]]; then
echo "❌ Error: Tag $TAG_TO_WAIT not found in mmintel/at-mintel." echo "⚠️ Warning: Tag $TAG_TO_WAIT not found in mmintel/at-mintel."
exit 1 else
echo "✅ Tag verified: Found upstream SHA $UPSTREAM_SHA for $TAG_TO_WAIT"
fi fi
echo "✅ Tag verified: Found upstream SHA $UPSTREAM_SHA for $TAG_TO_WAIT"
# 2. Status Check (Requires GITEA_PAT for cross-repo API access) # 2. Status Check (Requires GITEA_PAT for cross-repo API access)
POLL_TOKEN="${{ secrets.GITEA_PAT || secrets.MINTEL_PRIVATE_TOKEN }}" POLL_TOKEN="${{ secrets.GITEA_PAT || secrets.MINTEL_PRIVATE_TOKEN }}"
@@ -249,8 +249,8 @@ jobs:
MAIL_PORT: ${{ secrets.SMTP_PORT || vars.SMTP_PORT || '587' }} MAIL_PORT: ${{ secrets.SMTP_PORT || vars.SMTP_PORT || '587' }}
MAIL_USERNAME: ${{ secrets.SMTP_USER || vars.SMTP_USER }} MAIL_USERNAME: ${{ secrets.SMTP_USER || vars.SMTP_USER }}
MAIL_PASSWORD: ${{ secrets.SMTP_PASS || vars.SMTP_PASS }} MAIL_PASSWORD: ${{ secrets.SMTP_PASS || vars.SMTP_PASS }}
MAIL_FROM: ${{ secrets.SMTP_FROM || vars.SMTP_FROM }} MAIL_FROM: ${{ secrets.SMTP_FROM || vars.SMTP_FROM || 'noreply@klz-cables.com' }}
MAIL_RECIPIENTS: ${{ secrets.CONTACT_RECIPIENT || vars.CONTACT_RECIPIENT }} MAIL_RECIPIENTS: ${{ secrets.CONTACT_RECIPIENT || vars.CONTACT_RECIPIENT || 'info@klz-cables.com' }}
# Monitoring # Monitoring
SENTRY_DSN: ${{ secrets.SENTRY_DSN || vars.SENTRY_DSN }} SENTRY_DSN: ${{ secrets.SENTRY_DSN || vars.SENTRY_DSN }}

View File

@@ -1,5 +1,9 @@
# Stage 1: Builder # Stage 1: Builder
FROM git.infra.mintel.me/mmintel/nextjs:latest 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 git.infra.mintel.me/mmintel/runtime:latest 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"

View File

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

View File

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

View File

@@ -1,6 +1,7 @@
'use server'; 'use server';
import { sendEmail } from '@/lib/mail/mailer'; import { sendEmail } from '@/lib/mail/mailer';
import { env } from '@/lib/env';
import { render, ContactFormNotification, ConfirmationMessage } from '@mintel/mail'; import { render, ContactFormNotification, ConfirmationMessage } from '@mintel/mail';
import React from 'react'; import React from 'react';
import { getServerAppServices } from '@/lib/services/create-services.server'; import { getServerAppServices } from '@/lib/services/create-services.server';
@@ -54,6 +55,7 @@ export async function sendContactFormAction(formData: FormData) {
type: productName ? 'product_quote' : 'contact', type: productName ? 'product_quote' : 'contact',
productName: productName || undefined, productName: productName || undefined,
}, },
overrideAccess: true,
}); });
logger.info('Successfully saved form submission to Payload CMS', { logger.info('Successfully saved form submission to Payload CMS', {
@@ -86,6 +88,7 @@ export async function sendContactFormAction(formData: FormData) {
); );
if (!isTestSubmission) { if (!isTestSubmission) {
logger.info('Sending internal notification', { recipients: env.MAIL_RECIPIENTS });
const notificationResult = await sendEmail({ const notificationResult = await sendEmail({
replyTo: email, replyTo: email,
subject: notificationSubject, subject: notificationSubject,
@@ -97,14 +100,18 @@ export async function sendContactFormAction(formData: FormData) {
messageId: notificationResult.messageId, messageId: notificationResult.messageId,
}); });
} else { } else {
logger.error('Notification email FAILED', { logger.error('Notification email DELIVERY FAILED', {
error: notificationResult.error, error: notificationResult.error,
subject: notificationSubject, subject: notificationSubject,
email, recipients: env.MAIL_RECIPIENTS,
}); });
services.errors.captureException( services.errors.captureException(
new Error(`Notification email failed: ${notificationResult.error}`), new Error(`Notification email failed: ${notificationResult.error}`),
{ action: 'sendContactFormAction_notification', email }, {
action: 'sendContactFormAction_notification',
email,
recipients: env.MAIL_RECIPIENTS
},
); );
} }
} else { } else {
@@ -121,6 +128,7 @@ export async function sendContactFormAction(formData: FormData) {
); );
if (!isTestSubmission) { if (!isTestSubmission) {
logger.info('Sending customer confirmation', { to: email });
const confirmationResult = await sendEmail({ const confirmationResult = await sendEmail({
to: email, to: email,
subject: confirmationSubject, subject: confirmationSubject,
@@ -132,7 +140,7 @@ export async function sendContactFormAction(formData: FormData) {
messageId: confirmationResult.messageId, messageId: confirmationResult.messageId,
}); });
} else { } else {
logger.error('Confirmation email FAILED', { logger.error('Confirmation email DELIVERY FAILED', {
error: confirmationResult.error, error: confirmationResult.error,
subject: confirmationSubject, subject: confirmationSubject,
to: email, to: email,

View File

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

View File

@@ -42,7 +42,7 @@ const envExtension = {
MAIL_USERNAME: z.string().optional(), MAIL_USERNAME: z.string().optional(),
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().trim().optional(),
}; };
/** /**

View File

@@ -32,16 +32,27 @@ interface SendEmailOptions {
} }
export async function sendEmail({ to, replyTo, subject, html }: SendEmailOptions) { export async function sendEmail({ to, replyTo, subject, html }: SendEmailOptions) {
const recipients = to || config.mail.recipients;
const logger = getServerAppServices().logger.child({ component: 'mailer' }); const logger = getServerAppServices().logger.child({ component: 'mailer' });
if (!recipients) { // Resolve recipients: priority to 'to' override, fallback to global MAIL_RECIPIENTS
logger.error('No email recipients configured (MAIL_RECIPIENTS is empty and no "to" provided)', { subject }); const resolvedTo = to || config.mail.recipients;
// Normalize recipients (handle arrays or comma-strings)
const recipients = Array.isArray(resolvedTo)
? resolvedTo.join(', ')
: (resolvedTo?.toString() || '');
if (!recipients || recipients.trim() === '') {
logger.error('Email delivery ABORTED: No recipients configured', {
subject,
providedTo: to,
configRecipients: config.mail.recipients
});
return { success: false as const, error: 'No recipients configured' }; return { success: false as const, error: 'No recipients configured' };
} }
if (!config.mail.from) { if (!config.mail.from) {
logger.error('MAIL_FROM is not configured — cannot send email', { subject, recipients }); logger.error('Email delivery ABORTED: MAIL_FROM is missing', { subject, recipients });
return { success: false as const, error: 'MAIL_FROM is not configured' }; return { success: false as const, error: 'MAIL_FROM is not configured' };
} }
@@ -53,14 +64,36 @@ export async function sendEmail({ to, replyTo, subject, html }: SendEmailOptions
html, html,
}; };
try { try {
const info = await getTransporter().sendMail(mailOptions); const transporter = getTransporter();
logger.info('Email sent successfully', { messageId: info.messageId, subject, recipients }); logger.info('Attempting to send email via SMTP', {
host: config.mail.host,
subject,
recipients,
hasReplyTo: !!replyTo
});
const info = await transporter.sendMail(mailOptions);
logger.info('Email sent successfully', {
messageId: info.messageId,
subject,
recipients,
response: info.response
});
return { success: true, messageId: info.messageId }; return { success: true, messageId: info.messageId };
} catch (error) { } catch (error) {
const errorMsg = error instanceof Error ? error.message : String(error); const errorMsg = error instanceof Error ? error.message : String(error);
logger.error('Error sending email', { error: errorMsg, subject, recipients }); logger.error('SMTP Transport failed', {
error: errorMsg,
subject,
recipients,
config: {
host: config.mail.host,
user: config.mail.user ? '***' : 'not set'
}
});
return { success: false, error: errorMsg }; return { success: false, error: errorMsg };
} }
} }

View File

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

View File

@@ -1,21 +1,14 @@
import * as React from 'react'; import * as React from 'react';
import { Document, Page, View, Text, 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 (matching datasheet) ────────────────────────────────── // ─── Brand Tokens (matching datasheet) ──────────────────────────────────
const C = { const C = {
navy: '#001a4d', navy: '#001a4d',
navyDeep: '#000d26', navyDeep: '#000d26',
green: '#4da612', accent: '#82ed20',
greenLight: '#e8f5d8',
white: '#FFFFFF', white: '#FFFFFF',
offWhite: '#f8f9fa', offWhite: '#f8f9fa',
gray100: '#f3f4f6', gray100: '#f3f4f6',
@@ -26,7 +19,7 @@ const C = {
gray900: '#111827', gray900: '#111827',
}; };
const MARGIN = 56; const MARGIN = 72;
const styles = StyleSheet.create({ const styles = StyleSheet.create({
page: { page: {
@@ -34,7 +27,7 @@ const styles = StyleSheet.create({
lineHeight: 1.5, lineHeight: 1.5,
backgroundColor: C.white, backgroundColor: C.white,
paddingTop: 0, paddingTop: 0,
paddingBottom: 80, paddingBottom: 100,
fontFamily: 'Helvetica', fontFamily: 'Helvetica',
}, },
@@ -42,11 +35,12 @@ const styles = StyleSheet.create({
hero: { hero: {
backgroundColor: C.white, backgroundColor: C.white,
paddingTop: 24, paddingTop: 24,
paddingBottom: 0, paddingBottom: 20,
paddingHorizontal: MARGIN, paddingHorizontal: MARGIN,
marginBottom: 20, marginBottom: 20,
position: 'relative', position: 'relative',
borderBottomWidth: 0, borderBottomWidth: 1,
borderBottomColor: C.gray200,
}, },
header: { header: {
@@ -57,42 +51,45 @@ const styles = StyleSheet.create({
}, },
logoText: { logoText: {
fontSize: 22, fontSize: 24,
fontWeight: 700, fontWeight: 700,
color: C.navyDeep, color: C.navyDeep,
letterSpacing: 2, letterSpacing: 1,
textTransform: 'uppercase', textTransform: 'uppercase',
}, },
docTitle: { docTitle: {
fontSize: 8, fontSize: 10,
fontWeight: 700, fontWeight: 700,
color: C.green, color: C.navy,
letterSpacing: 2, letterSpacing: 2,
textTransform: 'uppercase', textTransform: 'uppercase',
}, },
// Content Area productHero: {
content: { marginTop: 0,
paddingHorizontal: MARGIN,
}, },
pageTitle: { pageTitle: {
fontSize: 24, fontSize: 24,
fontWeight: 700, fontWeight: 700,
color: C.navyDeep, color: C.navyDeep,
marginBottom: 8, marginBottom: 0,
marginTop: 10,
textTransform: 'uppercase', textTransform: 'uppercase',
letterSpacing: -0.5, letterSpacing: -0.5,
}, },
accentBar: { accentBar: {
width: 30, width: 30,
height: 2, height: 3,
backgroundColor: C.green, backgroundColor: C.accent,
marginBottom: 20, marginTop: 8,
borderRadius: 1, borderRadius: 1.5,
},
// Content Area
content: {
paddingHorizontal: MARGIN,
}, },
// Lexical Elements // Lexical Elements
@@ -136,7 +133,7 @@ const styles = StyleSheet.create({
listItemBullet: { listItemBullet: {
width: 12, width: 12,
fontSize: 10, fontSize: 10,
color: C.green, color: C.accent,
fontWeight: 700, fontWeight: 700,
}, },
listItemContent: { listItemContent: {
@@ -146,7 +143,7 @@ const styles = StyleSheet.create({
lineHeight: 1.7, lineHeight: 1.7,
}, },
link: { link: {
color: C.green, color: C.accent,
textDecoration: 'none', textDecoration: 'none',
}, },
textBold: { textBold: {
@@ -161,31 +158,31 @@ const styles = StyleSheet.create({
// Footer — matches brochure style // Footer — matches brochure style
footer: { footer: {
position: 'absolute', position: 'absolute',
bottom: 28, bottom: 40,
left: MARGIN, left: MARGIN,
right: MARGIN, right: MARGIN,
flexDirection: 'row', flexDirection: 'row',
justifyContent: 'space-between', justifyContent: 'space-between',
alignItems: 'center', alignItems: 'center',
paddingTop: 12, paddingTop: 24,
borderTopWidth: 2, borderTopWidth: 1,
borderTopColor: C.green, borderTopColor: C.gray200,
}, },
footerText: { footerText: {
fontSize: 7, fontSize: 8,
color: C.gray400, color: C.gray400,
fontWeight: 400, fontWeight: 500,
textTransform: 'uppercase', textTransform: 'uppercase',
letterSpacing: 0.8, letterSpacing: 1,
}, },
footerBrand: { footerBrand: {
fontSize: 9, fontSize: 10,
fontWeight: 700, fontWeight: 700,
color: C.navyDeep, color: C.navyDeep,
textTransform: 'uppercase', textTransform: 'uppercase',
letterSpacing: 1.5, letterSpacing: 1,
}, },
}); });
@@ -305,12 +302,14 @@ export const PDFPage: React.FC<PDFPageProps> = ({ page, locale = 'de' }) => {
</View> </View>
<Text style={styles.docTitle}>{locale === 'en' ? 'Document' : 'Dokument'}</Text> <Text style={styles.docTitle}>{locale === 'en' ? 'Document' : 'Dokument'}</Text>
</View> </View>
<View style={styles.productHero}>
<Text style={styles.pageTitle}>{page.title}</Text>
<View style={styles.accentBar} />
</View>
</View> </View>
<View style={styles.content}> <View style={styles.content}>
<Text style={styles.pageTitle}>{page.title}</Text>
<View style={styles.accentBar} />
<View> <View>
{page.content?.root?.children?.map((node: any, i: number) => {page.content?.root?.children?.map((node: any, i: number) =>
renderLexicalNode(node, i), renderLexicalNode(node, i),

View File

@@ -443,10 +443,6 @@ const nextConfig = {
source: '/de/kontakt', source: '/de/kontakt',
destination: '/de/contact', destination: '/de/contact',
}, },
{
source: '/de/agbs',
destination: '/de/terms',
},
// Safety rewrites for English locale using German slugs (legacy or content errors) // Safety rewrites for English locale using German slugs (legacy or content errors)
{ {
source: '/en/produkte', source: '/en/produkte',
@@ -468,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: [],

View File

@@ -13,7 +13,7 @@
"@payloadcms/next": "^3.77.0", "@payloadcms/next": "^3.77.0",
"@payloadcms/richtext-lexical": "^3.77.0", "@payloadcms/richtext-lexical": "^3.77.0",
"@payloadcms/ui": "^3.77.0", "@payloadcms/ui": "^3.77.0",
"@react-email/components": "^1.0.7", "@react-email/components": "1.0.8",
"@react-pdf/renderer": "^4.3.2", "@react-pdf/renderer": "^4.3.2",
"@sentry/nextjs": "^10.39.0", "@sentry/nextjs": "^10.39.0",
"@types/recharts": "^2.0.1", "@types/recharts": "^2.0.1",
@@ -139,7 +139,7 @@
"prepare": "husky", "prepare": "husky",
"preinstall": "npx only-allow pnpm" "preinstall": "npx only-allow pnpm"
}, },
"version": "2.2.12", "version": "2.3.17",
"pnpm": { "pnpm": {
"onlyBuiltDependencies": [ "onlyBuiltDependencies": [
"@parcel/watcher", "@parcel/watcher",

View File

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

View 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";
`);
}

View File

@@ -3,6 +3,7 @@ import * as migration_20260223_195151_remove_sku_unique from './20260223_195151_
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_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 = [
{ {
@@ -30,4 +31,9 @@ export const migrations = [
down: migration_20260305_215000_products_featured_image.down, down: migration_20260305_215000_products_featured_image.down,
name: '20260305_215000_products_featured_image', 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',
},
]; ];