fix: lang switch
All checks were successful
Build & Deploy / 🔍 Prepare (push) Successful in 6s
Build & Deploy / 🧪 QA (push) Successful in 2m14s
Build & Deploy / 🏗️ Build (push) Successful in 3m24s
Build & Deploy / 🚀 Deploy (push) Successful in 16s
Build & Deploy / 🧪 Post-Deploy Verification (push) Successful in 42m44s
Build & Deploy / 🔔 Notify (push) Successful in 2s

This commit is contained in:
2026-02-27 02:56:23 +01:00
parent b3057d8be0
commit 9e7f6ec76f
8 changed files with 311 additions and 71 deletions

View File

@@ -1,4 +1,4 @@
import { notFound } from 'next/navigation'; import { notFound, redirect } 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';
@@ -21,15 +21,18 @@ export async function generateMetadata({ params }: PageProps): Promise<Metadata>
if (!pageData) return {}; if (!pageData) return {};
const fileSlug = await mapSlugToFileSlug(slug, locale); const fileSlug = await mapSlugToFileSlug(pageData.slug || slug, locale);
const deSlug = await mapFileSlugToTranslated(fileSlug, 'de'); const deSlug = await mapFileSlugToTranslated(fileSlug, 'de');
const enSlug = await mapFileSlugToTranslated(fileSlug, 'en'); const enSlug = await mapFileSlugToTranslated(fileSlug, 'en');
// Determine correct localized slug based on current locale
const currentLocaleSlug = locale === 'de' ? deSlug : enSlug;
return { return {
title: pageData.frontmatter.title, title: pageData.frontmatter.title,
description: pageData.frontmatter.excerpt || '', description: pageData.frontmatter.excerpt || '',
alternates: { alternates: {
canonical: `${SITE_URL}/${locale}/${slug}`, canonical: `${SITE_URL}/${locale}/${currentLocaleSlug}`,
languages: { languages: {
de: `${SITE_URL}/de/${deSlug}`, de: `${SITE_URL}/de/${deSlug}`,
en: `${SITE_URL}/en/${enSlug}`, en: `${SITE_URL}/en/${enSlug}`,
@@ -39,7 +42,7 @@ export async function generateMetadata({ params }: PageProps): Promise<Metadata>
openGraph: { openGraph: {
title: `${pageData.frontmatter.title} | KLZ Cables`, title: `${pageData.frontmatter.title} | KLZ Cables`,
description: pageData.frontmatter.excerpt || '', description: pageData.frontmatter.excerpt || '',
url: `${SITE_URL}/${locale}/${slug}`, url: `${SITE_URL}/${locale}/${currentLocaleSlug}`,
}, },
twitter: { twitter: {
card: 'summary_large_image', card: 'summary_large_image',
@@ -59,6 +62,13 @@ export default async function StandardPage({ params }: PageProps) {
notFound(); notFound();
} }
// Redirect if accessed via a different locale's slug
const fileSlug = await mapSlugToFileSlug(pageData.slug || slug, locale);
const correctSlug = await mapFileSlugToTranslated(fileSlug, locale);
if (correctSlug && correctSlug !== slug) {
redirect(`/${locale}/${correctSlug}`);
}
// Full-bleed pages render blocks edge-to-edge without the generic article wrapper // Full-bleed pages render blocks edge-to-edge without the generic article wrapper
if (pageData.frontmatter.layout === 'fullBleed') { if (pageData.frontmatter.layout === 'fullBleed') {
return ( return (

View File

@@ -1,4 +1,4 @@
import { notFound } from 'next/navigation'; import { notFound, redirect } 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 { getPostBySlug, getAdjacentPosts, getReadingTime } from '@/lib/blog'; import { getPostBySlug, getAdjacentPosts, getReadingTime } from '@/lib/blog';
@@ -32,7 +32,7 @@ export async function generateMetadata({ params }: BlogPostProps): Promise<Metad
title: post.frontmatter.title, title: post.frontmatter.title,
description: description, description: description,
alternates: { alternates: {
canonical: `${SITE_URL}/${locale}/blog/${slug}`, canonical: `${SITE_URL}/${locale}/blog/${post.slug}`,
}, },
openGraph: { openGraph: {
title: `${post.frontmatter.title} | KLZ Cables`, title: `${post.frontmatter.title} | KLZ Cables`,
@@ -40,7 +40,7 @@ export async function generateMetadata({ params }: BlogPostProps): Promise<Metad
type: 'article', type: 'article',
publishedTime: post.frontmatter.date, publishedTime: post.frontmatter.date,
authors: ['KLZ Cables'], authors: ['KLZ Cables'],
url: `${SITE_URL}/${locale}/blog/${slug}`, url: `${SITE_URL}/${locale}/blog/${post.slug}`,
}, },
twitter: { twitter: {
card: 'summary_large_image', card: 'summary_large_image',
@@ -54,12 +54,19 @@ export default async function BlogPost({ params }: BlogPostProps) {
const { locale, slug } = await params; const { locale, slug } = await params;
setRequestLocale(locale); setRequestLocale(locale);
const post = await getPostBySlug(slug, locale); const post = await getPostBySlug(slug, locale);
const { prev, next, isPrevRandom, isNextRandom } = await getAdjacentPosts(slug, locale);
if (!post) { if (!post) {
notFound(); notFound();
} }
// If the user accessed this post using a slug from a different locale
// (e.g. via the generic language switcher), redirect them to the correct localized slug URL
if (post.slug && post.slug !== slug) {
redirect(`/${locale}/blog/${post.slug}`);
}
const { prev, next, isPrevRandom, isNextRandom } = await getAdjacentPosts(post.slug, locale);
// Convert Lexical content into a plain string to estimate reading time roughly // Convert Lexical content into a plain string to estimate reading time roughly
const rawTextContent = JSON.stringify(post.content); const rawTextContent = JSON.stringify(post.content);

View File

@@ -59,6 +59,21 @@ export default async function ContactPage({ params }: ContactPageProps) {
const { locale } = await params; const { locale } = await params;
setRequestLocale(locale); setRequestLocale(locale);
const t = await getTranslations({ locale, namespace: 'Contact' }); const t = await getTranslations({ locale, namespace: 'Contact' });
// Get translated slug to redirect if user used incorrect static slug
const { headers } = await import('next/headers');
const headersList = await headers();
const urlPath = headersList.get('x-invoke-path') || '';
const currentSlug = urlPath.split('/').pop();
if (currentSlug) {
const contactSlugDe = locale === 'de' ? 'kontakt' : 'contact';
if (currentSlug !== contactSlugDe && (currentSlug === 'kontakt' || currentSlug === 'contact')) {
const { redirect } = await import('next/navigation');
redirect(`/${locale}/${contactSlugDe}`);
}
}
return ( return (
<div className="flex flex-col min-h-screen bg-neutral-light"> <div className="flex flex-col min-h-screen bg-neutral-light">
<JsonLd <JsonLd

View File

@@ -1,66 +1,137 @@
'use client'; import { getTranslations } from 'next-intl/server';
import { useTranslations } from 'next-intl';
import { Container, Button, Heading } from '@/components/ui'; import { Container, Button, Heading } from '@/components/ui';
import Scribble from '@/components/Scribble'; import Scribble from '@/components/Scribble';
import { useEffect } from 'react'; import { getPayload } from 'payload';
import { useAnalytics } from '@/components/analytics/useAnalytics'; import configPromise from '@payload-config';
import { AnalyticsEvents } from '@/components/analytics/analytics-events'; import { headers } from 'next/headers';
import ClientNotFoundTracker from '@/components/analytics/ClientNotFoundTracker';
export default function NotFound() { export default async function NotFound() {
const t = useTranslations('Error.notFound'); const t = await getTranslations('Error.notFound');
const { trackEvent } = useAnalytics();
useEffect(() => { // Try to determine the requested path
const errorUrl = typeof window !== 'undefined' ? window.location.pathname : 'unknown'; const headersList = await headers();
trackEvent(AnalyticsEvents.ERROR, { const urlPath = headersList.get('x-invoke-path') || '';
type: '404_not_found',
path: errorUrl,
});
// Explicitly send the 404 to Sentry so we have visibility into broken links let suggestedUrl = null;
import('@sentry/nextjs').then((Sentry) => { let suggestedLang = null;
Sentry.withScope((scope) => {
scope.setTag('status_code', '404'); // If we have a path, try to see if the last segment (slug) exists in ANY locale
scope.setTag('path', errorUrl); if (urlPath) {
Sentry.captureMessage(`Route Not Found: ${errorUrl}`, 'warning'); const slug = urlPath.split('/').filter(Boolean).pop();
}); if (slug) {
}); try {
}, [trackEvent]); const payload = await getPayload({ config: configPromise });
// Check posts
const postRes = await payload.find({
collection: 'posts',
where: { slug: { equals: slug } },
locale: 'all',
limit: 1,
});
// Check products
const productRes =
postRes.docs.length === 0
? await payload.find({
collection: 'products',
where: { slug: { equals: slug } },
locale: 'all',
limit: 1,
})
: { docs: [] };
// Check pages
const pageRes =
postRes.docs.length === 0 && productRes.docs.length === 0
? await payload.find({
collection: 'pages',
where: { slug: { equals: slug } },
locale: 'all',
limit: 1,
})
: { docs: [] };
const anyDoc = postRes.docs[0] || productRes.docs[0] || pageRes.docs[0];
if (anyDoc) {
// If the doc exists, we can figure out its native locale or
// offer the alternative locale (if we are in 'de', offer 'en')
const currentLocale = urlPath.startsWith('/en') ? 'en' : 'de';
const alternativeLocale = currentLocale === 'de' ? 'en' : 'de';
suggestedLang = alternativeLocale === 'de' ? 'Deutsch' : 'English';
// Reconstruct the URL for the alternative locale
const pathParts = urlPath.split('/').filter(Boolean);
if (pathParts.length > 0 && (pathParts[0] === 'en' || pathParts[0] === 'de')) {
pathParts[0] = alternativeLocale;
} else {
pathParts.unshift(alternativeLocale);
}
suggestedUrl = '/' + pathParts.join('/');
}
} catch (e) {
// Ignore Payload errors in 404
}
}
}
return ( return (
<Container className="relative py-24 flex flex-col items-center justify-center text-center min-h-[70vh] overflow-hidden"> <>
{/* Industrial Background Element */} <ClientNotFoundTracker path={urlPath} />
<div className="absolute inset-0 -z-10 opacity-[0.03] pointer-events-none flex items-center justify-center"> <Container className="relative py-24 flex flex-col items-center justify-center text-center min-h-[70vh] overflow-hidden">
<span className="text-[20rem] font-bold select-none">404</span> {/* Industrial Background Element */}
</div> <div className="absolute inset-0 -z-10 opacity-[0.03] pointer-events-none flex items-center justify-center">
<span className="text-[20rem] font-bold select-none">404</span>
</div>
<div className="relative mb-8"> <div className="relative mb-8">
<Heading level={1} className="text-6xl md:text-8xl font-bold mb-2"> <Heading level={1} className="text-6xl md:text-8xl font-bold mb-2">
404 404
</Heading>
<Scribble
variant="circle"
className="w-[150%] h-[150%] -top-[25%] -left-[25%] text-accent/40"
/>
</div>
<Heading level={2} className="text-2xl md:text-3xl font-bold mb-4 text-primary">
{t('title')}
</Heading> </Heading>
<Scribble
variant="circle"
className="w-[150%] h-[150%] -top-[25%] -left-[25%] text-accent/40"
/>
</div>
<Heading level={2} className="text-2xl md:text-3xl font-bold mb-4 text-primary"> <p className="text-text-secondary mb-10 max-w-md text-lg">{t('description')}</p>
{t('title')}
</Heading>
<p className="text-white/60 mb-10 max-w-md text-lg">{t('description')}</p> {suggestedUrl && (
<div className="mb-12 p-6 bg-accent/10 border border-accent/20 rounded-2xl animate-fade-in shadow-lg relative overflow-hidden group">
<div className="absolute inset-0 bg-accent/5 -skew-x-12 translate-x-full group-hover:translate-x-0 transition-transform duration-700" />
<div className="relative z-10">
<h3 className="text-primary font-bold mb-2 text-lg">
Did you mean to visit the {suggestedLang} version?
</h3>
<p className="text-text-secondary text-sm mb-4">
This page exists, but in another language.
</p>
<Button href={suggestedUrl} variant="accent" size="md" className="w-full sm:w-auto">
Go to {suggestedLang} Version
</Button>
</div>
</div>
)}
<div className="flex flex-col sm:flex-row gap-4"> <div className="flex flex-col sm:flex-row gap-4">
<Button href="/" variant="accent" size="lg"> <Button href="/" variant={suggestedUrl ? 'outline' : 'accent'} size="lg">
{t('cta')} {t('cta')}
</Button> </Button>
<Button href="/contact" variant="outline" size="lg"> <Button href="/contact" variant={suggestedUrl ? 'ghost' : 'outline'} size="lg">
Contact Support Contact Support
</Button> </Button>
</div> </div>
{/* Decorative Industrial Line */} {/* Decorative Industrial Line */}
<div className="absolute bottom-0 left-1/2 -translate-x-1/2 w-px h-24 bg-gradient-to-t from-accent/50 to-transparent" /> <div className="absolute bottom-0 left-1/2 -translate-x-1/2 w-px h-24 bg-gradient-to-t from-accent/50 to-transparent" />
</Container> </Container>
</>
); );
} }

View File

@@ -14,7 +14,7 @@ import { getTranslations, setRequestLocale } from 'next-intl/server';
import { getProductOGImageMetadata } from '@/lib/metadata'; import { getProductOGImageMetadata } from '@/lib/metadata';
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, redirect } from 'next/navigation';
import ProductEngagementTracker from '@/components/analytics/ProductEngagementTracker'; import ProductEngagementTracker from '@/components/analytics/ProductEngagementTracker';
import PayloadRichText from '@/components/PayloadRichText'; import PayloadRichText from '@/components/PayloadRichText';
@@ -53,7 +53,7 @@ export async function generateMetadata({ params }: ProductPageProps): Promise<Me
title: categoryTitle, title: categoryTitle,
description: categoryDesc, description: categoryDesc,
alternates: { alternates: {
canonical: `${SITE_URL}/${locale}/${await mapFileSlugToTranslated('products', locale)}/${productSlug}`, canonical: `${SITE_URL}/${locale}/${await mapFileSlugToTranslated('products', locale)}/${await mapFileSlugToTranslated(fileSlug, locale)}`,
languages: { languages: {
de: `${SITE_URL}/de/${await mapFileSlugToTranslated('products', 'de')}/${await mapFileSlugToTranslated(fileSlug, 'de')}`, de: `${SITE_URL}/de/${await mapFileSlugToTranslated('products', 'de')}/${await mapFileSlugToTranslated(fileSlug, 'de')}`,
en: `${SITE_URL}/en/${await mapFileSlugToTranslated('products', 'en')}/${await mapFileSlugToTranslated(fileSlug, 'en')}`, en: `${SITE_URL}/en/${await mapFileSlugToTranslated('products', 'en')}/${await mapFileSlugToTranslated(fileSlug, 'en')}`,
@@ -75,11 +75,13 @@ export async function generateMetadata({ params }: ProductPageProps): Promise<Me
const product = await getProductBySlug(productSlug, locale); const product = await getProductBySlug(productSlug, locale);
if (!product) return {}; if (!product) return {};
const currentLocalePath = await getLocalizedPath(locale);
return { return {
title: product.frontmatter.title, title: product.frontmatter.title,
description: product.frontmatter.description, description: product.frontmatter.description,
alternates: { alternates: {
canonical: `${SITE_URL}/${locale}/${await mapFileSlugToTranslated('products', locale)}/${slug.join('/')}`, canonical: `${SITE_URL}/${locale}/${currentLocalePath}`,
languages: { languages: {
de: `${SITE_URL}/de/${await getLocalizedPath('de')}`, de: `${SITE_URL}/de/${await getLocalizedPath('de')}`,
en: `${SITE_URL}/en/${await getLocalizedPath('en')}`, en: `${SITE_URL}/en/${await getLocalizedPath('en')}`,
@@ -90,7 +92,7 @@ export async function generateMetadata({ params }: ProductPageProps): Promise<Me
title: product.frontmatter.title, title: product.frontmatter.title,
description: product.frontmatter.description, description: product.frontmatter.description,
type: 'website', type: 'website',
url: `${SITE_URL}/${locale}/products/${slug.join('/')}`, url: `${SITE_URL}/${locale}/${currentLocalePath}`,
images: getProductOGImageMetadata(productSlug, product.frontmatter.title, locale), images: getProductOGImageMetadata(productSlug, product.frontmatter.title, locale),
}, },
twitter: { twitter: {
@@ -114,7 +116,19 @@ export default async function ProductPage({ params }: ProductPageProps) {
'high-voltage-cables', 'high-voltage-cables',
'solar-cables', 'solar-cables',
]; ];
const fileSlug = await mapSlugToFileSlug(productSlug, locale);
const fileSlugs = await Promise.all(slug.map((s) => mapSlugToFileSlug(s, locale)));
const translatedSlugsForLocale = await Promise.all(
fileSlugs.map((fs) => mapFileSlugToTranslated(fs, locale)),
);
// If the requested slugs don't exactly match the translated slugs for the current locale
// (i.e. if the user used the static language switcher but kept the original locale's slugs)
if (slug.join('/') !== translatedSlugsForLocale.join('/')) {
redirect(`/${locale}/${productsSlug}/${translatedSlugsForLocale.join('/')}`);
}
const fileSlug = fileSlugs[fileSlugs.length - 1];
if (categories.includes(fileSlug)) { if (categories.includes(fileSlug)) {
const allProducts = await getAllProducts(locale); const allProducts = await getAllProducts(locale);

View File

@@ -0,0 +1,26 @@
'use client';
import { useEffect } from 'react';
import { useAnalytics } from './useAnalytics';
import { AnalyticsEvents } from './analytics-events';
export default function ClientNotFoundTracker({ path }: { path: string }) {
const { trackEvent } = useAnalytics();
useEffect(() => {
trackEvent(AnalyticsEvents.ERROR, {
type: '404_not_found',
path,
});
import('@sentry/nextjs').then((Sentry) => {
Sentry.withScope((scope) => {
scope.setTag('status_code', '404');
scope.setTag('path', path);
Sentry.captureMessage(`Route Not Found: ${path}`, 'warning');
});
});
}, [trackEvent, path]);
return null;
}

View File

@@ -59,7 +59,8 @@ export async function getPostBySlug(slug: string, locale: string): Promise<PostD
try { try {
const payload = await getPayload({ config: configPromise }); const payload = await getPayload({ config: configPromise });
const { docs } = await payload.find({ // First try: Find in the requested locale
let { docs } = await payload.find({
collection: 'posts', collection: 'posts',
where: { where: {
slug: { equals: slug }, slug: { equals: slug },
@@ -70,6 +71,38 @@ export async function getPostBySlug(slug: string, locale: string): Promise<PostD
limit: 1, limit: 1,
}); });
// Fallback: If not found, try searching across all locales.
// This happens when a user uses the static language switcher
// e.g. switching from /en/blog/en-slug to /de/blog/en-slug.
if (!docs || docs.length === 0) {
const { docs: crossLocaleDocs } = await payload.find({
collection: 'posts',
where: {
slug: { equals: slug },
...(!config.showDrafts ? { _status: { equals: 'published' } } : {}),
},
locale: 'all',
draft: config.showDrafts,
limit: 1,
});
if (crossLocaleDocs && crossLocaleDocs.length > 0) {
// Fetch the found document again, but strictly in the requested locale
// so we get the correctly translated fields (like the localized slug)
const { docs: correctLocaleDocs } = await payload.find({
collection: 'posts',
where: {
id: { equals: crossLocaleDocs[0].id },
},
locale: locale as any,
draft: config.showDrafts,
limit: 1,
});
docs = correctLocaleDocs;
}
}
if (!docs || docs.length === 0) return null; if (!docs || docs.length === 0) return null;
const doc = docs[0]; const doc = docs[0];

View File

@@ -1,5 +1,7 @@
import { getPayload } from 'payload'; import { getPayload } from 'payload';
import configPromise from '@payload-config'; import configPromise from '@payload-config';
import { mapSlugToFileSlug } from './slugs';
import { config } from '@/lib/config';
export interface PageFrontmatter { export interface PageFrontmatter {
title: string; title: string;
@@ -44,19 +46,81 @@ function mapDoc(doc: any): PageData {
export async function getPageBySlug(slug: string, locale: string): Promise<PageData | null> { export async function getPageBySlug(slug: string, locale: string): Promise<PageData | null> {
try { try {
const payload = await getPayload({ config: configPromise }); const payload = await getPayload({ config: configPromise });
const fileSlug = await mapSlugToFileSlug(slug, locale);
const result = await payload.find({ // Try finding exact match first
collection: 'pages' as any, let result = await payload.find({
collection: 'pages',
where: { where: {
slug: { equals: slug }, and: [
{ slug: { equals: fileSlug } },
...(!config.showDrafts ? [{ _status: { equals: 'published' } }] : []),
],
}, },
locale: locale as any, locale: locale as any,
depth: 1,
limit: 1, limit: 1,
}); });
const docs = result.docs as any[]; // Fallback: search ALL locales
if (!docs || docs.length === 0) return null; if (result.docs.length === 0) {
return mapDoc(docs[0]); const crossResult = await payload.find({
collection: 'pages',
where: {
and: [
{ slug: { equals: fileSlug } },
...(!config.showDrafts ? [{ _status: { equals: 'published' } }] : []),
],
},
locale: 'all',
depth: 1,
limit: 1,
});
if (crossResult.docs.length > 0) {
// Fetch missing exact match by internal id
result = await payload.find({
collection: 'pages',
where: {
id: { equals: crossResult.docs[0].id },
},
locale: locale as any,
depth: 1,
limit: 1,
});
}
}
if (result.docs.length > 0) {
const doc = result.docs[0];
return {
slug: doc.slug,
frontmatter: {
title: doc.title,
excerpt: doc.excerpt || '',
featuredImage:
typeof doc.featuredImage === 'object' && doc.featuredImage !== null
? doc.featuredImage.sizes?.card?.url || doc.featuredImage.url
: null,
focalX:
typeof doc.featuredImage === 'object' && doc.featuredImage !== null
? doc.featuredImage.focalX
: 50,
focalY:
typeof doc.featuredImage === 'object' && doc.featuredImage !== null
? doc.featuredImage.focalY
: 50,
layout:
doc.layout === 'fullBleed' || doc.layout === 'default'
? doc.layout
: ('default' as const),
},
content: doc.content,
};
}
return null;
} catch (error) { } catch (error) {
console.error(`[Payload] getPageBySlug failed for ${slug}:`, error); console.error(`[Payload] getPageBySlug failed for ${slug}:`, error);
return null; return null;