Compare commits

...

6 Commits

Author SHA1 Message Date
f0672600e4 fix(infra): correct traefik host rule syntax for public router
All checks were successful
Build & Deploy / 🔍 Prepare (push) Successful in 11s
Build & Deploy / 🧪 QA (push) Successful in 1m28s
Build & Deploy / 🏗️ Build (push) Successful in 7m17s
Build & Deploy / 🚀 Deploy (push) Successful in 26s
Build & Deploy / 🧪 Smoke Test (push) Successful in 1m4s
Build & Deploy / ⚡ Lighthouse (push) Successful in 2m40s
Build & Deploy / 🔔 Notify (push) Successful in 1s
- Fixed invalid Traefik rule syntax in docker-compose.yml (was using raw hostname)
- Updated middleware.ts to explicitly allow localized paths
- Ensures whitelist for OG images/health checks is recognized
2026-02-18 23:43:54 +01:00
61daeaf03f fix(analytics): Resolve Umami proxy 500 error and empty server events
Some checks failed
Build & Deploy / 🔍 Prepare (push) Successful in 7s
Build & Deploy / 🧪 QA (push) Successful in 1m58s
Build & Deploy / 🏗️ Build (push) Successful in 4m10s
Build & Deploy / 🚀 Deploy (push) Successful in 30s
Build & Deploy / 🧪 Smoke Test (push) Failing after 56s
Build & Deploy / ⚡ Lighthouse (push) Successful in 2m48s
Build & Deploy / 🔔 Notify (push) Successful in 1s
2026-02-18 23:34:56 +01:00
9d935ce03b fix(infra): simplify traefik whitelist rules for og images
Some checks failed
Build & Deploy / 🔍 Prepare (push) Successful in 11s
Build & Deploy / 🧪 QA (push) Successful in 1m49s
Build & Deploy / 🏗️ Build (push) Successful in 2m56s
Build & Deploy / 🚀 Deploy (push) Successful in 30s
Build & Deploy / 🧪 Smoke Test (push) Failing after 47s
Build & Deploy / ⚡ Lighthouse (push) Successful in 3m29s
Build & Deploy / 🔔 Notify (push) Successful in 2s
- Replaced complex PathRegexp with explicit PathPrefix rules for /api/og and /opengraph-image
- Added localized prefixes (/de/, /en/) to ensure Gatekeeper bypass works reliable
2026-02-18 22:04:46 +01:00
9fab9a4536 fix(infra): whitelist /_img proxy path and restore image config
Some checks failed
Build & Deploy / 🔍 Prepare (push) Successful in 7s
Build & Deploy / 🧪 QA (push) Successful in 2m23s
Build & Deploy / 🏗️ Build (push) Successful in 4m21s
Build & Deploy / 🚀 Deploy (push) Successful in 30s
Build & Deploy / 🧪 Smoke Test (push) Failing after 46s
Build & Deploy / ⚡ Lighthouse (push) Successful in 3m36s
Build & Deploy / 🔔 Notify (push) Successful in 2s
- Whitelisted /_img path in Traefik labels to allow public access (fixing login page images)
- Restored dangerouslyAllowSVG and CSP settings in next.config.mjs (lost in shallow merge)
- Ensuring Next.js proxy works correctly behind Gatekeeper
2026-02-18 21:42:33 +01:00
291f6aa34f feat: improve accessibility and SEO (100/100 Lighthouse score)
Some checks failed
Build & Deploy / 🔍 Prepare (push) Successful in 7s
Build & Deploy / 🧪 QA (push) Successful in 1m27s
Build & Deploy / 🏗️ Build (push) Has started running
Build & Deploy / 🚀 Deploy (push) Has been cancelled
Build & Deploy / 🧪 Smoke Test (push) Has been cancelled
Build & Deploy / ⚡ Lighthouse (push) Has been cancelled
Build & Deploy / 🔔 Notify (push) Has been cancelled
Fixes color contrast, canonical URLs, viewport scaling, semantic lists,

and resolves 404 errors for manifest/imgproxy.
2026-02-18 21:36:02 +01:00
a111851176 chore: deep semantic HTML audit and improvements across all pages
All checks were successful
Build & Deploy / 🔍 Prepare (push) Successful in 13s
Build & Deploy / 🧪 QA (push) Successful in 2m56s
Build & Deploy / 🏗️ Build (push) Successful in 4m14s
Build & Deploy / 🚀 Deploy (push) Successful in 29s
Build & Deploy / 🧪 Smoke Test (push) Successful in 1m3s
Build & Deploy / ⚡ Lighthouse (push) Successful in 3m7s
Build & Deploy / 🔔 Notify (push) Successful in 2s
2026-02-18 19:26:15 +01:00
22 changed files with 413 additions and 192 deletions

View File

@@ -2,7 +2,7 @@
"defaults": {
"standard": "WCAG2AA",
"runners": ["axe", "htmlcs"],
"ignore": ["color-contrast"],
"ignore": [],
"timeout": 50000,
"wait": 1000,
"chromeLaunchConfig": {

View File

@@ -58,7 +58,7 @@ export default async function BlogIndex({ params }: BlogIndexProps) {
<div className="bg-neutral-light min-h-screen">
{/* Hero Section - Immersive Magazine Feel */}
<Reveal>
<section className="relative h-[50vh] md:h-[70vh] min-h-[400px] md:min-h-[600px] flex items-center overflow-hidden bg-primary-dark">
<article className="relative h-[50vh] md:h-[70vh] min-h-[400px] md:min-h-[600px] flex items-center overflow-hidden bg-primary-dark">
{featuredPost && featuredPost.frontmatter.featuredImage && (
<>
<Image
@@ -101,7 +101,7 @@ export default async function BlogIndex({ params }: BlogIndexProps) {
)}
</div>
</Container>
</section>
</article>
</Reveal>
<Section className="bg-neutral-light py-12 md:py-28">
@@ -146,7 +146,10 @@ export default async function BlogIndex({ params }: BlogIndexProps) {
{remainingPosts.map((post, idx) => (
<Reveal key={post.slug} delay={idx * 100}>
<Link href={`/${locale}/blog/${post.slug}`} className="group block">
<Card className="h-full flex flex-col border-none shadow-lg hover:shadow-2xl transition-all duration-500 rounded-2xl md:rounded-3xl overflow-hidden">
<Card
tag="article"
className="h-full flex flex-col border-none shadow-lg hover:shadow-2xl transition-all duration-500 rounded-2xl md:rounded-3xl overflow-hidden"
>
{post.frontmatter.featuredImage && (
<div className="relative h-48 md:h-72 overflow-hidden">
<Image

View File

@@ -136,7 +136,7 @@ export default async function ContactPage({ params }: ContactPageProps) {
<Heading level={3} subtitle={t('info.subtitle')} className="mb-6 md:mb-8">
{t('info.howToReachUs')}
</Heading>
<div className="space-y-4 md:space-y-8">
<address className="space-y-4 md:space-y-8 not-italic">
<div className="flex items-start gap-4 md:gap-6 group">
<div className="w-10 h-10 md:w-14 md:h-14 rounded-xl md:rounded-2xl bg-saturated/10 flex items-center justify-center text-saturated group-hover:bg-accent group-hover:text-primary-dark transition-all duration-300 shadow-sm flex-shrink-0">
<svg
@@ -197,7 +197,7 @@ export default async function ContactPage({ params }: ContactPageProps) {
</a>
</div>
</div>
</div>
</address>
</div>
<div className="p-6 md:p-10 bg-white rounded-2xl md:rounded-3xl border border-neutral-medium shadow-sm animate-fade-in">

View File

@@ -24,29 +24,37 @@ const inter = Inter({
variable: '--font-inter',
});
export const metadata: Metadata = {
metadataBase: new URL(SITE_URL),
alternates: {
canonical: '/',
languages: {
de: '/de',
en: '/en',
export async function generateMetadata(props: {
params: Promise<{ locale: string }>;
}): Promise<Metadata> {
const params = await props.params;
const { locale } = params;
return {
metadataBase: new URL(SITE_URL),
manifest: '/manifest.webmanifest',
alternates: {
canonical: locale === 'en' ? '/' : `/${locale}`,
languages: {
de: '/de',
en: '/en',
},
},
},
icons: {
icon: [
{ url: '/favicon.ico', sizes: 'any' },
{ url: '/logo-blue.svg', type: 'image/svg+xml' },
],
apple: [{ url: '/apple-touch-icon.png', sizes: '180x180', type: 'image/png' }],
},
};
icons: {
icon: [
{ url: '/favicon.ico', sizes: 'any' },
{ url: '/logo-blue.svg', type: 'image/svg+xml' },
],
apple: [{ url: '/apple-touch-icon.png', sizes: '180x180', type: 'image/png' }],
},
};
}
export const viewport: Viewport = {
width: 'device-width',
initialScale: 1,
maximumScale: 1,
userScalable: false,
maximumScale: 5,
userScalable: true,
viewportFit: 'cover',
themeColor: '#001a4d',
};
@@ -88,10 +96,8 @@ export default async function Layout(props: {
});
}
const { after } = await import('next/server');
after(() => {
serverServices.analytics.trackPageview();
});
// Server-side analytics tracking removed to prevent duplicate/empty events.
// Client-side AnalyticsProvider handles all pageviews.
} catch {
if (process.env.NODE_ENV !== 'production' || !process.env.CI) {
console.warn(

View File

@@ -5,7 +5,7 @@ import ProductTabs from '@/components/ProductTabs';
import ProductTechnicalData from '@/components/ProductTechnicalData';
import RelatedProducts from '@/components/RelatedProducts';
import DatasheetDownload from '@/components/DatasheetDownload';
import { Badge, Container, Heading, Section } from '@/components/ui';
import { Badge, Card, Container, Heading, Section } from '@/components/ui';
import { getDatasheetPath } from '@/lib/datasheets';
import { getAllProducts, getProductBySlug } from '@/lib/mdx';
import { mapFileSlugToTranslated, mapSlugToFileSlug } from '@/lib/slugs';
@@ -239,57 +239,59 @@ export default async function ProductPage({ params }: ProductPageProps) {
href={`/${locale}/products/${productSlug}/${product.translatedSlug}`}
className="group block bg-white rounded-[32px] overflow-hidden shadow-sm hover:shadow-2xl transition-all duration-500 border border-neutral-dark/5"
>
<div className="aspect-[4/3] relative bg-neutral-light/30 p-12 overflow-hidden">
{product.frontmatter.images?.[0] && (
<>
<Image
src={product.frontmatter.images[0]}
alt={product.frontmatter.title}
fill
className="object-contain p-8 transition-transform duration-700 group-hover:scale-110 z-10"
sizes="(max-width: 768px) 100vw, (max-width: 1024px) 50vw, 33vw"
/>
{/* Subtle reflection/shadow effect */}
<div className="absolute bottom-4 left-1/2 -translate-x-1/2 w-2/3 h-4 bg-black/5 blur-xl rounded-full" />
</>
)}
</div>
<div className="p-8 md:p-10">
<div className="flex flex-wrap gap-2 mb-4">
{product.frontmatter.categories.map((cat, i) => (
<span
key={i}
className="text-[10px] font-bold uppercase tracking-widest text-primary/40"
>
{cat}
<Card tag="article" className="premium-card-reset">
<div className="aspect-[4/3] relative bg-neutral-light/30 p-12 overflow-hidden">
{product.frontmatter.images?.[0] && (
<>
<Image
src={product.frontmatter.images[0]}
alt={product.frontmatter.title}
fill
className="object-contain p-8 transition-transform duration-700 group-hover:scale-110 z-10"
sizes="(max-width: 768px) 100vw, (max-width: 1024px) 50vw, 33vw"
/>
{/* Subtle reflection/shadow effect */}
<div className="absolute bottom-4 left-1/2 -translate-x-1/2 w-2/3 h-4 bg-black/5 blur-xl rounded-full" />
</>
)}
</div>
<div className="p-8 md:p-10">
<div className="flex flex-wrap gap-2 mb-4">
{product.frontmatter.categories.map((cat, i) => (
<span
key={i}
className="text-[10px] font-bold uppercase tracking-widest text-primary/40"
>
{cat}
</span>
))}
</div>
<h2 className="text-2xl md:text-3xl font-bold text-text-primary group-hover:text-primary transition-colors mb-4 leading-tight">
{product.frontmatter.title}
</h2>
<p className="text-text-secondary line-clamp-2 text-base leading-relaxed mb-8">
{product.frontmatter.description}
</p>
<div className="flex items-center text-primary font-bold group-hover:text-accent-dark transition-colors">
<span className="border-b-2 border-primary/10 group-hover:border-accent-dark transition-colors pb-1">
{t('details')}
</span>
))}
<svg
className="w-5 h-5 ml-3 transition-transform group-hover:translate-x-1"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M17 8l4 4m0 0l-4 4m4-4H3"
/>
</svg>
</div>
</div>
<h2 className="text-2xl md:text-3xl font-bold text-text-primary group-hover:text-primary transition-colors mb-4 leading-tight">
{product.frontmatter.title}
</h2>
<p className="text-text-secondary line-clamp-2 text-base leading-relaxed mb-8">
{product.frontmatter.description}
</p>
<div className="flex items-center text-primary font-bold group-hover:text-accent-dark transition-colors">
<span className="border-b-2 border-primary/10 group-hover:border-accent-dark transition-colors pb-1">
{t('details')}
</span>
<svg
className="w-5 h-5 ml-3 transition-transform group-hover:translate-x-1"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M17 8l4 4m0 0l-4 4m4-4H3"
/>
</svg>
</div>
</div>
</Card>
</Link>
))}
</div>

View File

@@ -114,7 +114,7 @@ export default async function TeamPage({ params }: TeamPageProps) {
</Reveal>
{/* Michael Bodemer Section - Sticky Narrative Split Layout */}
<section className="relative bg-white overflow-hidden">
<article className="relative bg-white overflow-hidden">
<div className="flex flex-col lg:flex-row">
<Reveal className="w-full lg:w-1/2 p-6 md:p-24 lg:p-32 flex flex-col justify-center bg-primary-dark text-white relative order-2 lg:order-1">
<div className="absolute top-0 right-0 w-32 h-full bg-accent/5 -skew-x-12 translate-x-1/2" />
@@ -161,7 +161,7 @@ export default async function TeamPage({ params }: TeamPageProps) {
<div className="absolute inset-0 bg-gradient-to-t from-primary-dark/60 lg:bg-gradient-to-r lg:from-primary-dark/20 to-transparent" />
</Reveal>
</div>
</section>
</article>
{/* Legacy Section - Immersive Background */}
<Reveal>
@@ -217,7 +217,7 @@ export default async function TeamPage({ params }: TeamPageProps) {
</Reveal>
{/* Klaus Mintel Section - Reversed Split Layout */}
<section className="relative bg-white overflow-hidden">
<article className="relative bg-white overflow-hidden">
<div className="flex flex-col lg:flex-row">
<Reveal className="w-full lg:w-1/2 relative min-h-[400px] md:min-h-[600px] lg:min-h-screen overflow-hidden order-1">
<Image
@@ -264,7 +264,7 @@ export default async function TeamPage({ params }: TeamPageProps) {
</div>
</Reveal>
</div>
</section>
</article>
{/* Manifesto Section - Modern Grid */}
<Section className="bg-white text-primary py-16 md:py-28">
@@ -292,9 +292,9 @@ export default async function TeamPage({ params }: TeamPageProps) {
</div>
</div>
</div>
<div className="sticky-narrative-content grid grid-cols-1 md:grid-cols-2 gap-4 md:gap-10">
<ul className="sticky-narrative-content grid grid-cols-1 md:grid-cols-2 gap-4 md:gap-10 list-none p-0 m-0">
{[0, 1, 2, 3, 4, 5].map((idx) => (
<div
<li
key={idx}
className="p-6 md:p-10 bg-neutral-light rounded-2xl md:rounded-[40px] border border-transparent hover:border-accent hover:bg-white hover:shadow-2xl transition-all duration-500 group active:scale-[0.98] touch-target-none"
>
@@ -309,9 +309,9 @@ export default async function TeamPage({ params }: TeamPageProps) {
<p className="text-sm md:text-lg text-text-secondary leading-relaxed">
{t(`manifesto.items.${idx}.description`)}
</p>
</div>
</li>
))}
</div>
</ul>
</div>
</Container>
</Section>

View File

@@ -65,9 +65,28 @@ export async function POST(request: NextRequest) {
return NextResponse.json({ status: 'ok' });
} catch (error) {
logger.error('Failed to proxy analytics request', {
error: (error as Error).message,
const errorMessage = error instanceof Error ? error.message : String(error);
const errorStack = error instanceof Error ? error.stack : undefined;
// Console error to ensure it appears in logs even if logger fails
console.error('CRITICAL PROXY ERROR:', {
message: errorMessage,
stack: errorStack,
endpoint: config.analytics.umami.apiEndpoint,
});
return NextResponse.json({ error: 'Internal Server Error' }, { status: 500 });
logger.error('Failed to proxy analytics request', {
error: errorMessage,
stack: errorStack,
});
return NextResponse.json(
{
error: 'Internal Server Error',
details: errorMessage, // Expose error for debugging
endpoint: config.analytics.umami.apiEndpoint ? 'configured' : 'missing',
},
{ status: 500 },
);
}
}

View File

@@ -67,9 +67,9 @@ export default function Footer() {
{/* Links Columns */}
<div className="lg:col-span-2">
<h2 className="text-accent font-bold uppercase tracking-widest text-xs md:text-sm mb-8">
<h3 className="text-accent font-bold uppercase tracking-widest text-xs md:text-sm mb-8">
{t('legal')}
</h2>
</h3>
<ul className="space-y-4 text-white/70 list-none m-0 p-0">
<li>
<Link
@@ -120,9 +120,9 @@ export default function Footer() {
</div>
<div className="lg:col-span-2">
<h2 className="text-accent font-bold uppercase tracking-widest text-xs md:text-sm mb-8">
<h3 className="text-accent font-bold uppercase tracking-widest text-xs md:text-sm mb-8">
{t('company')}
</h2>
</h3>
<ul className="space-y-4 text-white/70 list-none m-0 p-0">
<li>
<Link
@@ -189,9 +189,9 @@ export default function Footer() {
{/* Recent Posts Column */}
<div className="lg:col-span-4">
<h2 className="text-accent font-bold uppercase tracking-widest text-xs md:text-sm mb-8">
<h3 className="text-accent font-bold uppercase tracking-widest text-xs md:text-sm mb-8">
{t('recentPosts')}
</h2>
</h3>
<ul className="space-y-6 list-none m-0 p-0">
{[
{
@@ -230,7 +230,7 @@ export default function Footer() {
<p className="text-white/80 font-bold group-hover:text-accent transition-colors leading-snug mb-2 text-base md:text-base">
{post.title}
</p>
<span className="text-xs text-white/40 uppercase tracking-widest">
<span className="text-xs text-white/70 uppercase tracking-widest">
{t('readArticle')} &rarr;
</span>
</Link>
@@ -240,7 +240,7 @@ export default function Footer() {
</div>
</div>
<div className="pt-12 border-t border-white/10 flex flex-col md:flex-row justify-between items-center gap-8 text-white/40 text-xs md:text-sm font-medium">
<div className="pt-12 border-t border-white/10 flex flex-col md:flex-row justify-between items-center gap-8 text-white/70 text-xs md:text-sm font-medium">
<p>{t('copyright', { year: currentYear })}</p>
<div className="flex gap-8">
<Link

View File

@@ -341,7 +341,7 @@ export default function Header() {
aria-label={t('menu')}
ref={mobileMenuRef}
>
<motion.div
<motion.nav
className="flex-grow flex flex-col justify-center items-center p-8 space-y-8"
initial="closed"
animate={isMobileMenuOpen ? 'open' : 'closed'}
@@ -463,7 +463,7 @@ export default function Header() {
<Image src="/logo-white.svg" alt={t('home')} width={80} height={80} unoptimized />
</motion.div>
</motion.div>
</motion.div>
</motion.nav>
</div>
</motion.header>
</>

View File

@@ -14,25 +14,30 @@ interface ProductSidebarProps {
className?: string;
}
export default function ProductSidebar({ productName, productImage, datasheetPath, className }: ProductSidebarProps) {
export default function ProductSidebar({
productName,
productImage,
datasheetPath,
className,
}: ProductSidebarProps) {
const t = useTranslations('Products');
return (
<div className={cn("flex flex-col gap-4 animate-slight-fade-in-from-bottom", className)}>
<aside className={cn('flex flex-col gap-4 animate-slight-fade-in-from-bottom', className)}>
{/* Request Quote Form Card */}
<div className="bg-white rounded-3xl border border-neutral-medium shadow-sm transition-all duration-500 hover:shadow-2xl hover:-translate-y-1 overflow-hidden group/card">
<div className="bg-primary p-6 text-white relative overflow-hidden">
{/* Background Accent - Saturated Blue Glow */}
<div className="absolute top-0 right-0 w-40 h-40 bg-saturated/30 rounded-full -translate-y-1/2 translate-x-1/2 blur-[80px] pointer-events-none" />
{/* Product Thumbnail with Reflection */}
{productImage && (
<div className="relative w-full aspect-[16/10] mb-6 rounded-2xl overflow-hidden bg-white/5 backdrop-blur-md p-4 border border-white/10 z-10 group">
<div className="relative w-full h-full transition-transform duration-1000 ease-out group-hover:scale-105">
<Image
src={productImage}
alt={productName}
fill
<Image
src={productImage}
alt={productName}
fill
className="object-contain p-2 drop-shadow-[0_20px_30px_rgba(0,0,0,0.4)]"
/>
{/* Subtle Reflection Overlay */}
@@ -46,9 +51,9 @@ export default function ProductSidebar({ productName, productImage, datasheetPat
<h3 className="text-lg md:text-xl font-heading font-black m-0 tracking-tighter uppercase leading-none">
{t('requestQuote')}
</h3>
<Scribble
variant="underline"
className="w-full h-3 -bottom-3 left-0 text-accent/80"
<Scribble
variant="underline"
className="w-full h-3 -bottom-3 left-0 text-accent/80"
color="var(--color-accent)"
/>
</div>
@@ -57,16 +62,14 @@ export default function ProductSidebar({ productName, productImage, datasheetPat
</p>
</div>
</div>
<div className="p-6 bg-neutral-light/50">
<RequestQuoteForm productName={productName} />
</div>
</div>
{/* Datasheet Download */}
{datasheetPath && (
<DatasheetDownload datasheetPath={datasheetPath} className="mt-0" />
)}
</div>
{datasheetPath && <DatasheetDownload datasheetPath={datasheetPath} className="mt-0" />}
</aside>
);
}

View File

@@ -32,24 +32,24 @@ export default function Experience() {
<p className="pl-9">{t('p2')}</p>
</div>
<div className="mt-16 grid grid-cols-1 md:grid-cols-2 gap-12">
<dl className="mt-16 grid grid-cols-1 md:grid-cols-2 gap-12">
<div className="animate-fade-in">
<div className="text-2xl md:text-3xl font-extrabold text-accent mb-4">
<dt className="text-2xl md:text-3xl font-extrabold text-accent mb-4">
{t('certifiedQuality')}
</div>
<div className="text-base md:text-lg font-bold uppercase tracking-widest text-white/60">
</dt>
<dd className="text-base md:text-lg font-bold uppercase tracking-widest text-white/60">
{t('vdeApproved')}
</div>
</dd>
</div>
<div className="animate-fade-in" style={{ animationDelay: '100ms' }}>
<div className="text-2xl md:text-3xl font-extrabold text-accent mb-4">
<dt className="text-2xl md:text-3xl font-extrabold text-accent mb-4">
{t('fullSpectrum')}
</div>
<div className="text-base md:text-lg font-bold uppercase tracking-widest text-white/60">
</dt>
<dd className="text-base md:text-lg font-bold uppercase tracking-widest text-white/60">
{t('solutionsRange')}
</div>
</dd>
</div>
</div>
</dl>
</div>
</Container>
</Section>

View File

@@ -32,62 +32,69 @@ export default async function RecentPosts({ locale }: RecentPostsProps) {
</Link>
</div>
<div className="grid grid-cols-1 md:grid-cols-3 gap-8 md:gap-10">
<ul className="grid grid-cols-1 md:grid-cols-3 gap-8 md:gap-10 list-none p-0 m-0">
{recentPosts.map((post) => (
<Link key={post.slug} href={`/${locale}/blog/${post.slug}`} className="group block">
<Card className="h-full flex flex-col border-none shadow-lg hover:shadow-2xl transition-all duration-500 rounded-3xl">
{post.frontmatter.featuredImage && (
<div className="relative h-64 overflow-hidden">
<Image
src={post.frontmatter.featuredImage}
alt={post.frontmatter.title}
fill
className="w-full h-full object-cover transition-transform duration-700 group-hover:scale-110"
sizes="(max-width: 768px) 100vw, 33vw"
loading="lazy"
/>
<div className="absolute inset-0 image-overlay-gradient opacity-0 group-hover:opacity-100 transition-opacity duration-500" />
{post.frontmatter.category && (
<Badge variant="accent" className="absolute top-4 left-4 shadow-md">
{post.frontmatter.category}
</Badge>
)}
</div>
)}
<div className="p-6 md:p-8 flex flex-col flex-grow">
<div className="text-xs md:text-sm font-medium text-text-light mb-3 md:mb-4 flex items-center gap-2">
<span className="w-6 md:w-8 h-px bg-neutral-medium" />
{new Date(post.frontmatter.date).toLocaleDateString(locale, {
year: 'numeric',
month: 'long',
day: 'numeric',
})}
</div>
<h3 className="text-lg md:text-xl font-bold text-primary group-hover:text-accent-dark transition-colors line-clamp-2 mb-4 md:mb-6 leading-tight">
{post.frontmatter.title}
</h3>
<div className="mt-auto flex items-center text-primary font-bold group-hover:underline decoration-2 underline-offset-4">
{t('readMore')}
<svg
className="ml-2 w-5 h-5 transition-transform group-hover:translate-x-2"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
aria-hidden="true"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M17 8l4 4m0 0l-4 4m4-4H3"
<li key={post.slug}>
<Link href={`/${locale}/blog/${post.slug}`} className="group block h-full">
<Card
tag="article"
className="h-full flex flex-col border-none shadow-lg hover:shadow-2xl transition-all duration-500 rounded-3xl"
>
{post.frontmatter.featuredImage && (
<div className="relative h-64 overflow-hidden">
<Image
src={post.frontmatter.featuredImage}
alt={post.frontmatter.title}
fill
className="w-full h-full object-cover transition-transform duration-700 group-hover:scale-110"
sizes="(max-width: 768px) 100vw, 33vw"
loading="lazy"
/>
</svg>
<div className="absolute inset-0 image-overlay-gradient opacity-0 group-hover:opacity-100 transition-opacity duration-500" />
{post.frontmatter.category && (
<Badge variant="accent" className="absolute top-4 left-4 shadow-md">
{post.frontmatter.category}
</Badge>
)}
</div>
)}
<div className="p-6 md:p-8 flex flex-col flex-grow">
<div className="text-xs md:text-sm font-medium text-text-light mb-3 md:mb-4 flex items-center gap-2">
<span className="w-6 md:w-8 h-px bg-neutral-medium" />
<time dateTime={post.frontmatter.date}>
{new Date(post.frontmatter.date).toLocaleDateString(locale, {
year: 'numeric',
month: 'long',
day: 'numeric',
})}
</time>
</div>
<h3 className="text-lg md:text-xl font-bold text-primary group-hover:text-accent-dark transition-colors line-clamp-2 mb-4 md:mb-6 leading-tight">
{post.frontmatter.title}
</h3>
<div className="mt-auto flex items-center text-primary font-bold group-hover:underline decoration-2 underline-offset-4">
{t('readMore')}
<svg
className="ml-2 w-5 h-5 transition-transform group-hover:translate-x-2"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
aria-hidden="true"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M17 8l4 4m0 0l-4 4m4-4H3"
/>
</svg>
</div>
</div>
</div>
</Card>
</Link>
</Card>
</Link>
</li>
))}
</div>
</ul>
</Container>
</Section>
);

View File

@@ -18,9 +18,9 @@ export default function WhyChooseUs() {
{t('subtitle')}
</p>
<div className="mt-12 space-y-6">
<ul className="mt-12 space-y-6 list-none p-0">
{[0, 1, 2, 3].map((i) => (
<div key={i} className="flex items-center gap-4">
<li key={i} className="flex items-center gap-4">
<div className="flex-shrink-0 w-6 h-6 rounded-full bg-accent flex items-center justify-center">
<svg
className="w-4 h-4 text-primary-dark"
@@ -40,14 +40,14 @@ export default function WhyChooseUs() {
<span className="font-bold text-primary-dark text-base md:text-base">
{t(`features.${i}`)}
</span>
</div>
</li>
))}
</div>
</ul>
</div>
</div>
<div className="lg:col-span-8 grid grid-cols-1 md:grid-cols-2 gap-8 order-2 lg:order-1">
<ul className="lg:col-span-8 grid grid-cols-1 md:grid-cols-2 gap-8 order-2 lg:order-1 list-none p-0 m-0">
{[0, 1, 2, 3].map((idx) => (
<div
<li
key={idx}
className="p-10 bg-white rounded-3xl border border-neutral-medium hover:border-accent transition-all duration-500 hover:shadow-xl group"
>
@@ -62,9 +62,9 @@ export default function WhyChooseUs() {
<p className="text-text-secondary text-base md:text-base leading-relaxed">
{t(`items.${idx}.description`)}
</p>
</div>
</li>
))}
</div>
</ul>
</div>
</Container>
</Section>

View File

@@ -1,10 +1,14 @@
import React from 'react';
import { cn } from './utils';
export function Card({ className, children, ...props }: React.HTMLAttributes<HTMLDivElement>) {
interface CardProps extends React.HTMLAttributes<HTMLElement> {
tag?: 'div' | 'article' | 'section' | 'aside' | 'header' | 'footer' | 'nav' | 'main';
}
export function Card({ className, children, tag: Tag = 'div', ...props }: CardProps) {
return (
<div className={cn('premium-card overflow-hidden', className)} {...props}>
<Tag className={cn('premium-card overflow-hidden', className)} {...props}>
{children}
</div>
</Tag>
);
}

View File

@@ -32,7 +32,7 @@ services:
- "traefik.http.routers.${PROJECT_NAME:-klz}.middlewares=${AUTH_MIDDLEWARE:-klz-ratelimit,klz-forward,klz-compress}"
# Public Router (Whitelist for OG Images, Sitemaps, Health)
- "traefik.http.routers.${PROJECT_NAME:-klz}-public.rule=(${TRAEFIK_HOST_RULE:-Host(`${TRAEFIK_HOST:-klz-cables.com}`)}) && (PathPrefix(`/health`) || PathPrefix(`/sitemap.xml`) || PathPrefix(`/robots.txt`) || PathPrefix(`/manifest.webmanifest`) || PathRegexp(`^/([a-z]{2}/)?api/og`) || PathRegexp(`^/([a-z]{2}/)?opengraph-image$`) || PathRegexp(`^/([a-z]{2}/)?blog/opengraph-image$`) || PathRegexp(`^/sitemap(-[0-9]+)?\\.xml$`))"
- "traefik.http.routers.${PROJECT_NAME:-klz}-public.rule=(${TRAEFIK_HOST_RULE:-Host(`${TRAEFIK_HOST:-klz-cables.com}`)}) && (PathPrefix(`/health`) || PathPrefix(`/sitemap.xml`) || PathPrefix(`/robots.txt`) || PathPrefix(`/manifest.webmanifest`) || PathPrefix(`/_img`) || PathPrefix(`/api/og`) || PathPrefix(`/de/api/og`) || PathPrefix(`/en/api/og`) || PathPrefix(`/opengraph-image`) || PathPrefix(`/de/opengraph-image`) || PathPrefix(`/en/opengraph-image`) || PathPrefix(`/blog/opengraph-image`) || PathPrefix(`/de/blog/opengraph-image`) || PathPrefix(`/en/blog/opengraph-image`) || PathRegexp(`^/sitemap(-[0-9]+)?\\.xml$`))"
- "traefik.http.routers.${PROJECT_NAME:-klz}-public.entrypoints=${TRAEFIK_ENTRYPOINT:-web}"
- "traefik.http.routers.${PROJECT_NAME:-klz}-public.tls.certresolver=${TRAEFIK_CERT_RESOLVER:-}"
- "traefik.http.routers.${PROJECT_NAME:-klz}-public.tls=${TRAEFIK_TLS:-false}"

View File

@@ -36,7 +36,7 @@ function createConfig() {
analytics: {
umami: {
websiteId: env.UMAMI_WEBSITE_ID,
apiEndpoint: env.UMAMI_API_ENDPOINT,
apiEndpoint: env.UMAMI_API_ENDPOINT || 'https://analytics.infra.mintel.me',
enabled: Boolean(env.UMAMI_WEBSITE_ID),
},
},

View File

@@ -17,6 +17,12 @@ export default function imgproxyLoader({
width: number;
_quality?: number;
}) {
// Skip imgproxy for SVGs as they are vectors and don't benefit from resizing,
// and often cause 404s if the source is not correctly resolvable by imgproxy.
if (src.toLowerCase().endsWith('.svg')) {
return src;
}
// We use the width provided by Next.js for responsive images
// Height is set to 0 to maintain aspect ratio
return getImgproxyUrl(src, {

View File

@@ -58,12 +58,14 @@
}
},
"Navigation": {
"menu": "Menü",
"home": "KLZ Cables Startseite",
"team": "Team",
"products": "Produkte",
"blog": "Blog",
"contact": "Kontakt",
"toggleMenu": "Menü umschalten"
"toggleMenu": "Menü umschalten",
"skipToContent": "Zum Inhalt springen"
},
"Footer": {
"legal": "Rechtliches",

View File

@@ -21,7 +21,8 @@ export default function middleware(request: NextRequest) {
pathname.startsWith('/health') ||
pathname.includes('/api/og') ||
pathname.includes('opengraph-image') ||
pathname.endsWith('sitemap.xml')
pathname.endsWith('sitemap.xml') ||
pathname.endsWith('manifest.webmanifest')
) {
return NextResponse.next();
}
@@ -94,6 +95,7 @@ export default function middleware(request: NextRequest) {
export const config = {
matcher: [
'/((?!api|_next/static|_next/image|favicon.ico|.*\\.(?:svg|png|jpg|jpeg|gif|webp|pdf|txt|vcf|xml)$).*)',
'/((?!api|_next/static|_next/image|favicon.ico|manifest.webmanifest|.*\\.(?:svg|png|jpg|jpeg|gif|webp|pdf|txt|vcf|xml)$).*)',
'/(de|en)/:path*',
],
};

View File

@@ -319,6 +319,9 @@ const nextConfig = {
images: {
loader: 'custom',
loaderFile: './lib/imgproxy-loader.ts',
dangerouslyAllowSVG: true,
contentDispositionType: "attachment",
contentSecurityPolicy: "default-src 'self'; script-src 'none'; sandbox;",
},
async rewrites() {
const umamiUrl =

View File

@@ -96,6 +96,7 @@
"check:og": "tsx scripts/check-og-images.ts",
"check:mdx": "node scripts/validate-mdx.mjs",
"check:a11y": "start-server-and-test start http://localhost:3000 'pa11y-ci'",
"check:wcag": "tsx ./scripts/wcag-sitemap.ts",
"cms:branding:local": "DIRECTUS_URL=${DIRECTUS_URL:-http://cms.klz.localhost} npx tsx --env-file=.env scripts/setup-directus-branding.ts",
"cms:branding:testing": "DIRECTUS_URL=https://cms.testing.klz-cables.com npx tsx --env-file=.env scripts/setup-directus-branding.ts",
"cms:branding:staging": "DIRECTUS_URL=https://cms.staging.klz-cables.com npx tsx --env-file=.env scripts/setup-directus-branding.ts",

163
scripts/wcag-sitemap.ts Normal file
View File

@@ -0,0 +1,163 @@
import axios from 'axios';
import * as cheerio from 'cheerio';
import { execSync } from 'child_process';
import * as fs from 'fs';
import * as path from 'path';
/**
* WCAG Audit Script
*
* 1. Fetches sitemap.xml from the target URL
* 2. Extracts all URLs
* 3. Runs pa11y-ci on those URLs
*/
const targetUrl =
process.argv[2] || process.env.NEXT_PUBLIC_BASE_URL || 'https://testing.klz-cables.com';
const limit = process.env.PAGESPEED_LIMIT ? parseInt(process.env.PAGESPEED_LIMIT) : 20;
const gatekeeperPassword = process.env.GATEKEEPER_PASSWORD || 'klz2026';
async function main() {
console.log(`\n🚀 Starting WCAG Audit for: ${targetUrl}`);
console.log(`📊 Limit: ${limit} pages\n`);
try {
// 1. Fetch Sitemap
const sitemapUrl = `${targetUrl.replace(/\/$/, '')}/sitemap.xml`;
console.log(`📥 Fetching sitemap from ${sitemapUrl}...`);
const response = await axios.get(sitemapUrl, {
headers: {
Cookie: `klz_gatekeeper_session=${gatekeeperPassword}`,
},
validateStatus: (status) => status < 400,
});
const $ = cheerio.load(response.data, { xmlMode: true });
let urls = $('url loc')
.map((i, el) => $(el).text())
.get();
// Cleanup, filter and normalize domains to targetUrl
const urlPattern = /https?:\/\/[^\/]+/;
urls = [...new Set(urls)]
.filter((u) => u.startsWith('http'))
.map((u) => u.replace(urlPattern, targetUrl.replace(/\/$/, '')))
.sort();
console.log(`✅ Found ${urls.length} URLs in sitemap.`);
if (urls.length === 0) {
console.error('❌ No URLs found in sitemap. Is the site up?');
process.exit(1);
}
if (urls.length > limit) {
console.log(
`⚠️ Too many pages (${urls.length}). Limiting to ${limit} representative pages.`,
);
const home = urls.filter((u) => u.endsWith('/de') || u.endsWith('/en') || u === targetUrl);
const others = urls.filter((u) => !home.includes(u));
urls = [...home, ...others.slice(0, limit - home.length)];
}
console.log(`🧪 Pages to be tested:`);
urls.forEach((u) => console.log(` - ${u}`));
// 2. Prepare pa11y-ci config
const baseConfigPath = path.join(process.cwd(), '.pa11yci.json');
let baseConfig: any = { defaults: {} };
if (fs.existsSync(baseConfigPath)) {
baseConfig = JSON.parse(fs.readFileSync(baseConfigPath, 'utf8'));
}
// Extract domain for cookie
const urlObj = new URL(targetUrl);
const domain = urlObj.hostname;
// Update config with discovered URLs and gatekeeper cookie
const tempConfig = {
...baseConfig,
defaults: {
...baseConfig.defaults,
actions: [
`set cookie klz_gatekeeper_session=${gatekeeperPassword} domain=${domain} path=/`,
...(baseConfig.defaults?.actions || []),
],
timeout: 60000, // Increase timeout for slower pages
},
urls: urls,
};
const tempConfigPath = path.join(process.cwd(), '.pa11yci.temp.json');
const reportPath = path.join(process.cwd(), '.pa11yci-report.json');
fs.writeFileSync(tempConfigPath, JSON.stringify(tempConfig, null, 2));
// 3. Execute pa11y-ci
console.log(`\n💻 Executing pa11y-ci...`);
const pa11yCommand = `npx pa11y-ci --config .pa11yci.temp.json --reporter json > .pa11yci-report.json`;
try {
execSync(pa11yCommand, {
encoding: 'utf8',
stdio: 'inherit',
});
} catch (err: any) {
// pa11y-ci exits with non-zero if issues are found, which is expected
}
// 4. Summarize Results
if (fs.existsSync(reportPath)) {
const reportData = JSON.parse(fs.readFileSync(reportPath, 'utf8'));
console.log(`\n📊 WCAG Audit Summary:\n`);
const summaryTable = Object.keys(reportData.results).map((url) => {
const results = reportData.results[url];
const errors = results.filter((r: any) => r.type === 'error').length;
const warnings = results.filter((r: any) => r.type === 'warning').length;
const notices = results.filter((r: any) => r.type === 'notice').length;
// Clean URL for display
const displayUrl = url.replace(targetUrl, '') || '/';
return {
URL: displayUrl.length > 50 ? displayUrl.substring(0, 47) + '...' : displayUrl,
Errors: errors,
Warnings: warnings,
Notices: notices,
Status: errors === 0 ? '✅' : '❌',
};
});
console.table(summaryTable);
const totalErrors = summaryTable.reduce((acc, curr) => acc + curr.Errors, 0);
const totalPages = summaryTable.length;
const cleanPages = summaryTable.filter((p) => p.Errors === 0).length;
console.log(`\n📈 Result: ${cleanPages}/${totalPages} pages are error-free.`);
if (totalErrors > 0) {
console.log(` Total Errors discovered: ${totalErrors}`);
}
}
console.log(`\n✨ WCAG Audit completed!`);
} catch (error: any) {
console.error(`\n❌ Error during WCAG Audit:`);
if (axios.isAxiosError(error)) {
console.error(`Status: ${error.response?.status}`);
console.error(`URL: ${error.config?.url}`);
} else {
console.error(error.message);
}
process.exit(1);
} finally {
// Clean up temp files
['.pa11yci.temp.json', '.pa11yci-report.json'].forEach((f) => {
const p = path.join(process.cwd(), f);
if (fs.existsSync(p)) fs.unlinkSync(p);
});
}
}
main();