Compare commits

..

9 Commits

Author SHA1 Message Date
75c6d363c0 fix: update Klaus Mintel's job title to Geschäftsführer in German
All checks were successful
Build & Deploy / 🔍 Prepare (push) Successful in 7s
Build & Deploy / 🧪 QA (push) Successful in 1m53s
Build & Deploy / 🏗️ Build (push) Successful in 4m16s
Build & Deploy / 🚀 Deploy (push) Successful in 31s
Build & Deploy / 🧪 Smoke Test (push) Successful in 51s
Build & Deploy / ⚡ Lighthouse (push) Successful in 3m35s
Build & Deploy / 🔔 Notify (push) Successful in 2s
2026-02-19 00:46:36 +01:00
a2b7f28b9f fix: update Klaus Mintel's job title to Geschäftsführer
Some checks failed
Build & Deploy / 🔍 Prepare (push) Successful in 12s
Build & Deploy / 🏗️ Build (push) Has been cancelled
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
Build & Deploy / 🧪 QA (push) Has been cancelled
2026-02-19 00:46:02 +01:00
52ecd1b052 fix(middleware): exclude /_img proxy path from locale redirects
Some checks failed
Build & Deploy / 🔍 Prepare (push) Successful in 12s
Build & Deploy / 🧪 QA (push) Successful in 1m46s
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
Build & Deploy / 🏗️ Build (push) Has been cancelled
- Exclude /_img from middleware matcher to prevent locale redirects
- Clean commit for middleware fix
2026-02-19 00:43:36 +01:00
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
23 changed files with 416 additions and 194 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",
@@ -120,7 +122,7 @@
"quote": "Manchmal braucht es nur einen klaren Kopf und das richtige Kabel, um die Welt ein Stück besser zu machen.",
"description": "Klaus ist der Fels in der Brandung selbst wenn das Kabelchaos überhandnimmt. Mit jahrzehntelanger Erfahrung und einem stabilen Netzwerk sorgt er dafür, dass alles glatt läuft. Er denkt nicht nur in Lösungen, sondern bringt auch Humor und den nötigen Weitblick mit, um selbst komplexe Themen locker auf den Punkt zu bringen.",
"linkedin": "Klaus' LinkedIn",
"role": "Gründer & Visionär"
"role": "Geschäftsführer"
},
"manifesto": {
"title": "Unser Manifest",

View File

@@ -122,7 +122,7 @@
"quote": "Sometimes all it takes is a clear head and a good cable to make the world a little better.",
"description": "Klaus is the man with the experience, bringing perspective and calm to the table—even when cable chaos threatens to take over. With impressive industry knowledge and a network as solid as our cables, he ensures everything runs smoothly. Klaus isnt just a problem solver; hes a strategic thinker who knows how to get to the point with a touch of humor.",
"linkedin": "Check Klaus' LinkedIn",
"role": "Founder & Visionary"
"role": "Managing Director"
},
"manifesto": {
"title": "Our manifesto",

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,8 @@ 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|_img|favicon.ico|manifest.webmanifest|.*\\.(?:svg|png|jpg|jpeg|gif|webp|pdf|txt|vcf|xml)$).*)',
'/(de|en)/:path*',
'/(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();