diff --git a/app/[locale]/[slug]/page.tsx b/app/[locale]/[slug]/page.tsx index f1313581..b17c167b 100644 --- a/app/[locale]/[slug]/page.tsx +++ b/app/[locale]/[slug]/page.tsx @@ -1,4 +1,4 @@ -import { notFound } from 'next/navigation'; +import { notFound, redirect } from 'next/navigation'; import { Container, Badge, Heading } from '@/components/ui'; import { getTranslations, setRequestLocale } from 'next-intl/server'; import { Metadata } from 'next'; @@ -21,15 +21,18 @@ export async function generateMetadata({ params }: PageProps): Promise if (!pageData) return {}; - const fileSlug = await mapSlugToFileSlug(slug, locale); + const fileSlug = await mapSlugToFileSlug(pageData.slug || slug, locale); const deSlug = await mapFileSlugToTranslated(fileSlug, 'de'); const enSlug = await mapFileSlugToTranslated(fileSlug, 'en'); + // Determine correct localized slug based on current locale + const currentLocaleSlug = locale === 'de' ? deSlug : enSlug; + return { title: pageData.frontmatter.title, description: pageData.frontmatter.excerpt || '', alternates: { - canonical: `${SITE_URL}/${locale}/${slug}`, + canonical: `${SITE_URL}/${locale}/${currentLocaleSlug}`, languages: { de: `${SITE_URL}/de/${deSlug}`, en: `${SITE_URL}/en/${enSlug}`, @@ -39,7 +42,7 @@ export async function generateMetadata({ params }: PageProps): Promise openGraph: { title: `${pageData.frontmatter.title} | KLZ Cables`, description: pageData.frontmatter.excerpt || '', - url: `${SITE_URL}/${locale}/${slug}`, + url: `${SITE_URL}/${locale}/${currentLocaleSlug}`, }, twitter: { card: 'summary_large_image', @@ -59,6 +62,13 @@ export default async function StandardPage({ params }: PageProps) { 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 if (pageData.frontmatter.layout === 'fullBleed') { return ( diff --git a/app/[locale]/blog/[slug]/page.tsx b/app/[locale]/blog/[slug]/page.tsx index fd79c51d..3ecd4afe 100644 --- a/app/[locale]/blog/[slug]/page.tsx +++ b/app/[locale]/blog/[slug]/page.tsx @@ -1,4 +1,4 @@ -import { notFound } from 'next/navigation'; +import { notFound, redirect } from 'next/navigation'; import JsonLd from '@/components/JsonLd'; import { SITE_URL } from '@/lib/schema'; import { getPostBySlug, getAdjacentPosts, getReadingTime } from '@/lib/blog'; @@ -32,7 +32,7 @@ export async function generateMetadata({ params }: BlogPostProps): Promise { - const errorUrl = typeof window !== 'undefined' ? window.location.pathname : 'unknown'; - trackEvent(AnalyticsEvents.ERROR, { - type: '404_not_found', - path: errorUrl, - }); + // Try to determine the requested path + const headersList = await headers(); + const urlPath = headersList.get('x-invoke-path') || ''; - // Explicitly send the 404 to Sentry so we have visibility into broken links - import('@sentry/nextjs').then((Sentry) => { - Sentry.withScope((scope) => { - scope.setTag('status_code', '404'); - scope.setTag('path', errorUrl); - Sentry.captureMessage(`Route Not Found: ${errorUrl}`, 'warning'); - }); - }); - }, [trackEvent]); + let suggestedUrl = null; + let suggestedLang = null; + + // If we have a path, try to see if the last segment (slug) exists in ANY locale + if (urlPath) { + const slug = urlPath.split('/').filter(Boolean).pop(); + if (slug) { + try { + 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 ( - - {/* Industrial Background Element */} -
- 404 -
+ <> + + + {/* Industrial Background Element */} +
+ 404 +
-
- - 404 +
+ + 404 + + +
+ + + {t('title')} - -
- - {t('title')} - +

{t('description')}

-

{t('description')}

+ {suggestedUrl && ( +
+
+
+

+ Did you mean to visit the {suggestedLang} version? +

+

+ This page exists, but in another language. +

+ +
+
+ )} -
- - -
+
+ + +
- {/* Decorative Industrial Line */} -
- + {/* Decorative Industrial Line */} +
+ + ); } diff --git a/app/[locale]/products/[...slug]/page.tsx b/app/[locale]/products/[...slug]/page.tsx index b2a41e3b..d42d6a3d 100644 --- a/app/[locale]/products/[...slug]/page.tsx +++ b/app/[locale]/products/[...slug]/page.tsx @@ -14,7 +14,7 @@ import { getTranslations, setRequestLocale } from 'next-intl/server'; import { getProductOGImageMetadata } from '@/lib/metadata'; import Image from 'next/image'; import Link from 'next/link'; -import { notFound } from 'next/navigation'; +import { notFound, redirect } from 'next/navigation'; import ProductEngagementTracker from '@/components/analytics/ProductEngagementTracker'; import PayloadRichText from '@/components/PayloadRichText'; @@ -53,7 +53,7 @@ export async function generateMetadata({ params }: ProductPageProps): Promise 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)) { const allProducts = await getAllProducts(locale); diff --git a/components/analytics/ClientNotFoundTracker.tsx b/components/analytics/ClientNotFoundTracker.tsx new file mode 100644 index 00000000..b5af55e9 --- /dev/null +++ b/components/analytics/ClientNotFoundTracker.tsx @@ -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; +} diff --git a/lib/blog.ts b/lib/blog.ts index 5d694ff3..196efea3 100644 --- a/lib/blog.ts +++ b/lib/blog.ts @@ -59,7 +59,8 @@ export async function getPostBySlug(slug: string, locale: string): Promise 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; const doc = docs[0]; diff --git a/lib/pages.ts b/lib/pages.ts index 98d2eb10..f39667ad 100644 --- a/lib/pages.ts +++ b/lib/pages.ts @@ -1,5 +1,7 @@ import { getPayload } from 'payload'; import configPromise from '@payload-config'; +import { mapSlugToFileSlug } from './slugs'; +import { config } from '@/lib/config'; export interface PageFrontmatter { title: string; @@ -44,19 +46,81 @@ function mapDoc(doc: any): PageData { export async function getPageBySlug(slug: string, locale: string): Promise { try { const payload = await getPayload({ config: configPromise }); + const fileSlug = await mapSlugToFileSlug(slug, locale); - const result = await payload.find({ - collection: 'pages' as any, + // Try finding exact match first + let result = await payload.find({ + collection: 'pages', where: { - slug: { equals: slug }, + and: [ + { slug: { equals: fileSlug } }, + ...(!config.showDrafts ? [{ _status: { equals: 'published' } }] : []), + ], }, locale: locale as any, + depth: 1, limit: 1, }); - const docs = result.docs as any[]; - if (!docs || docs.length === 0) return null; - return mapDoc(docs[0]); + // Fallback: search ALL locales + if (result.docs.length === 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) { console.error(`[Payload] getPageBySlug failed for ${slug}:`, error); return null;