Compare commits

..

7 Commits

Author SHA1 Message Date
64c6873735 fix: img urls
All checks were successful
Build & Deploy / 🔍 Prepare (push) Successful in 37s
Build & Deploy / 🧪 QA (push) Successful in 3m16s
Build & Deploy / 🏗️ Build (push) Successful in 4m19s
Build & Deploy / 🚀 Deploy (push) Successful in 26s
Build & Deploy / 🧪 Smoke Test (push) Successful in 1m17s
Build & Deploy / ⚡ Lighthouse (push) Successful in 3m13s
Build & Deploy / 🔔 Notify (push) Successful in 5s
2026-02-18 19:16:21 +01:00
0d39beef70 feat(infra): configure next.js image proxy to hide backend url
- Implemented /_img/ rewrite in next.config.mjs to proxy requests to IMGPROXY_URL
- Updated lib/imgproxy.ts to use local /_img path instead of public endpoint
- Replaced NEXT_PUBLIC_IMGPROXY_URL (build-time) with IMGPROXY_URL (runtime) env var
- Updated docker-compose.yml to strip build args and inject runtime IMGPROXY_URL
- Cleaned up Dockerfile and audit scripts
2026-02-18 15:58:27 +01:00
95d0d094e1 feat(infra): configure imgproxy to use next.js rewrite proxy
- Added /_img/ rewrite rule in next.config.mjs to proxy image requests to IMGPROXY_URL
- Updated lib/imgproxy.ts to use local /_img path instead of exposed public URL
- Replaced NEXT_PUBLIC_IMGPROXY_URL (build-time) with IMGPROXY_URL (runtime)
- Updated Dockerfile and docker-compose.yml to strip unused build args
2026-02-18 15:57:44 +01:00
38cf6a8d75 fix(infra): make IMGPROXY_URL_MAPPING configurable via environment variables
This ensures that the image proxy correctly maps public domains to internal
Docker hostnames across different environments (testing, staging, production)
without manual configuration of the docker-compose.yml file.
2026-02-18 11:57:03 +01:00
ea55580e18 perf: optimize server-side analytics and notifications to resolve 32s transaction delay
All checks were successful
Build & Deploy / 🔍 Prepare (push) Successful in 7s
Build & Deploy / 🧪 QA (push) Successful in 1m55s
Build & Deploy / 🏗️ Build (push) Successful in 4m18s
Build & Deploy / 🚀 Deploy (push) Successful in 26s
Build & Deploy / 🧪 Smoke Test (push) Successful in 49s
Build & Deploy / ⚡ Lighthouse (push) Successful in 4m11s
Build & Deploy / 🔔 Notify (push) Successful in 2s
- Added 5s timeout to GotifyNotificationService
- Reduced timeout to 2s in UmamiAnalyticsService
- Implemented non-blocking analytics tracking in layout using Next.js after() API
2026-02-18 10:24:10 +01:00
df2dd23206 feat: optimize performance and SEO, integrate Lighthouse CI
All checks were successful
Build & Deploy / 🔍 Prepare (push) Successful in 7s
Build & Deploy / 🧪 QA (push) Successful in 1m53s
Build & Deploy / 🏗️ Build (push) Successful in 7m44s
Build & Deploy / 🚀 Deploy (push) Successful in 33s
Build & Deploy / 🧪 Smoke Test (push) Successful in 59s
Build & Deploy / ⚡ Lighthouse (push) Successful in 3m14s
Build & Deploy / 🔔 Notify (push) Successful in 1s
- Integrated imgproxy for centralized image optimization
- Implemented Lighthouse CI in Gitea pipeline with native Chromium
- Reached 100/100 SEO score by fixing canonicals, hreflang, and link text
- Optimized LCP by forcing Hero component visibility until hydration
- Decoupled analytics into an async shell to reduce TTI
2026-02-18 10:01:00 +01:00
374fcc9689 feat(a11y): implement screen reader support and accessibility optimizations
All checks were successful
Build & Deploy / 🔍 Prepare (push) Successful in 11s
Build & Deploy / 🧪 QA (push) Successful in 2m6s
Build & Deploy / 🏗️ Build (push) Successful in 7m29s
Build & Deploy / 🚀 Deploy (push) Successful in 30s
Build & Deploy / 🧪 Smoke Test (push) Successful in 1m13s
Build & Deploy / 🔔 Notify (push) Successful in 1s
2026-02-18 00:59:31 +01:00
43 changed files with 1691 additions and 158 deletions

View File

@@ -57,6 +57,9 @@ SENTRY_DSN=
IMAGE_TAG=latest
TRAEFIK_HOST=klz-cables.com
ENV_FILE=.env
# IMGPROXY_URL: The backend URL of the imgproxy instance (e.g. img.infra.mintel.me)
# Next.js will proxy requests from /_img to this URL.
IMGPROXY_URL=https://img.infra.mintel.me
# ────────────────────────────────────────────────────────────────────────────
# Varnish Configuration

View File

@@ -34,3 +34,9 @@ jobs:
- name: 🧪 QA Checks
run: pnpm check:mdx && pnpm lint && pnpm typecheck && pnpm test
- name: 🏗️ Build
run: pnpm build
- name: ♿ Accessibility Check
run: pnpm check:a11y

View File

@@ -406,11 +406,79 @@ jobs:
run: pnpm run check:og
# ──────────────────────────────────────────────────────────────────────────────
# JOB 6: Notifications
# JOB 6: Lighthouse (Performance & Accessibility)
# ──────────────────────────────────────────────────────────────────────────────
lighthouse:
name: ⚡ Lighthouse
needs: [prepare, deploy]
if: success() && needs.prepare.outputs.target != 'skip'
runs-on: docker
container:
image: catthehacker/ubuntu:act-latest
steps:
- name: Checkout repository
uses: actions/checkout@v4
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: 20
- name: Setup pnpm
uses: pnpm/action-setup@v3
with:
version: 10
- name: 🔐 Registry Auth
run: |
echo "@mintel:registry=https://${{ vars.REGISTRY_HOST || 'npm.infra.mintel.me' }}" > .npmrc
echo "//${{ vars.REGISTRY_HOST || 'npm.infra.mintel.me' }}/:_authToken=${{ secrets.REGISTRY_PASS }}" >> .npmrc
- name: Install dependencies
run: pnpm install --frozen-lockfile
- name: 🔍 Install Chromium (Native & ARM64)
run: |
apt-get update
apt-get install -y gnupg wget ca-certificates
# Detect OS
OS_ID=$(. /etc/os-release && echo $ID)
CODENAME=$(. /etc/os-release && echo $VERSION_CODENAME)
if [ "$OS_ID" = "debian" ]; then
echo "🎯 Debian detected - installing native chromium"
apt-get install -y chromium
else
echo "🎯 Ubuntu detected - adding xtradeb PPA"
mkdir -p /etc/apt/keyrings
KEY_ID="82BB6851C64F6880"
# Fetch PPA key
wget -qO- "https://keyserver.ubuntu.com/pks/lookup?op=get&search=0x$KEY_ID" | gpg --dearmor > /etc/apt/keyrings/xtradeb.gpg
# Add PPA repository
echo "deb [signed-by=/etc/apt/keyrings/xtradeb.gpg] http://ppa.launchpad.net/xtradeb/apps/ubuntu $CODENAME main" > /etc/apt/sources.list.d/xtradeb-ppa.list
# PRIORITY PINNING: Force PPA over Snap-dummy
printf "Package: *\nPin: release o=LP-PPA-xtradeb-apps\nPin-Priority: 1001\n" > /etc/apt/preferences.d/xtradeb
apt-get update
apt-get install -y --allow-downgrades chromium
fi
# Standardize binary paths
[ -f /usr/bin/chromium ] && ln -sf /usr/bin/chromium /usr/bin/google-chrome
[ -f /usr/bin/chromium ] && ln -sf /usr/bin/chromium /usr/bin/chromium-browser
- name: ⚡ Run Lighthouse CI
env:
NEXT_PUBLIC_BASE_URL: ${{ needs.prepare.outputs.next_public_url }}
GATEKEEPER_PASSWORD: ${{ secrets.GATEKEEPER_PASSWORD || 'klz2026' }}
CHROME_PATH: /usr/bin/chromium
PAGESPEED_LIMIT: 8
run: pnpm run pagespeed:test
# ──────────────────────────────────────────────────────────────────────────────
# JOB 7: Notifications
# ──────────────────────────────────────────────────────────────────────────────
notifications:
name: 🔔 Notify
needs: [prepare, deploy, smoke_test]
needs: [prepare, deploy, smoke_test, lighthouse]
if: always()
runs-on: docker
container:

5
.gitignore vendored
View File

@@ -2,6 +2,11 @@ node_modules
.next
.DS_Store
# Lighthouse CI
.lighthouseci/
lighthouserc.cjs
.lighthouserc.json
# Directus
directus/uploads
!directus/extensions/

26
.pa11yci.json Normal file
View File

@@ -0,0 +1,26 @@
{
"defaults": {
"standard": "WCAG2AA",
"runners": ["axe", "htmlcs"],
"ignore": ["color-contrast"],
"timeout": 50000,
"wait": 1000,
"chromeLaunchConfig": {
"args": ["--no-sandbox", "--disable-setuid-sandbox", "--disable-dev-shm-usage"]
},
"threshold": 25
},
"urls": [
"http://localhost:3000/en",
"http://localhost:3000/en/blog",
"http://localhost:3000/en/blog/which-cables-for-wind-power-differences-from-low-to-extra-high-voltage-explained-2",
"http://localhost:3000/en/contact",
"http://localhost:3000/en/team",
"http://localhost:3000/en/products",
"http://localhost:3000/en/products/medium-voltage-cables",
"http://localhost:3000/en/products/low-voltage-cables",
"http://localhost:3000/en/products/medium-voltage-cables/n2xs2y",
"http://localhost:3000/en/legal-notice",
"http://localhost:3000/en/privacy-policy"
]
}

View File

@@ -32,11 +32,6 @@ export async function generateMetadata({ params }: BlogPostProps): Promise<Metad
description: description,
alternates: {
canonical: `${SITE_URL}/${locale}/blog/${slug}`,
languages: {
de: `${SITE_URL}/de/blog/${slug}`,
en: `${SITE_URL}/en/blog/${slug}`,
'x-default': `${SITE_URL}/en/blog/${slug}`,
},
},
openGraph: {
title: `${post.frontmatter.title} | KLZ Cables`,

View File

@@ -1,15 +1,16 @@
import Footer from '@/components/Footer';
import Header from '@/components/Header';
import JsonLd from '@/components/JsonLd';
import AnalyticsProvider from '@/components/analytics/AnalyticsProvider';
import ScrollDepthTracker from '@/components/analytics/ScrollDepthTracker';
import SkipLink from '@/components/SkipLink';
import CMSConnectivityNotice from '@/components/CMSConnectivityNotice';
import { RecordModeProvider } from '@/components/record-mode/RecordModeContext';
import { RecordModeVisuals } from '@/components/record-mode/RecordModeVisuals';
import { ToolCoordinator } from '@/components/record-mode/ToolCoordinator';
import AnalyticsShell from '@/components/analytics/AnalyticsShell';
import { Metadata, Viewport } from 'next';
import { NextIntlClientProvider } from 'next-intl';
import { getMessages } from 'next-intl/server';
import dynamic from 'next/dynamic';
import { Suspense } from 'react';
import '../../styles/globals.css';
import { SITE_URL } from '@/lib/schema';
@@ -25,6 +26,13 @@ const inter = Inter({
export const metadata: Metadata = {
metadataBase: new URL(SITE_URL),
alternates: {
canonical: '/',
languages: {
de: '/de',
en: '/en',
},
},
icons: {
icon: [
{ url: '/favicon.ico', sizes: 'any' },
@@ -80,7 +88,10 @@ export default async function Layout(props: {
});
}
serverServices.analytics.trackPageview();
const { after } = await import('next/server');
after(() => {
serverServices.analytics.trackPageview();
});
} catch {
if (process.env.NODE_ENV !== 'production' || !process.env.CI) {
console.warn(
@@ -100,18 +111,22 @@ export default async function Layout(props: {
<NextIntlClientProvider messages={messages} locale={safeLocale}>
<RecordModeProvider isEnabled={recordModeEnabled}>
<RecordModeVisuals>
<SkipLink />
<JsonLd />
<Header />
<main className="flex-grow animate-fade-in overflow-visible">{children}</main>
<main
id="main-content"
className="flex-grow animate-fade-in overflow-visible"
tabIndex={-1}
>
{children}
</main>
<Footer />
</RecordModeVisuals>
<CMSConnectivityNotice />
<Suspense fallback={null}>
<AnalyticsProvider />
<ScrollDepthTracker />
</Suspense>
<AnalyticsShell />
<ToolCoordinator feedbackEnabled={feedbackEnabled} />
</RecordModeProvider>
</NextIntlClientProvider>

View File

@@ -79,7 +79,9 @@ export async function generateMetadata({
}
const title = t('title') || 'KLZ Cables';
const description = t('description') || '';
const description =
t('description') ||
'Ihr Experte für hochwertige Stromkabel, Mittelspannungslösungen und Solarkabel. Zuverlässige Infrastruktur für eine grüne Energiezukunft.';
return {
title,

View File

@@ -66,7 +66,11 @@ export default function ContactForm() {
if (status === 'success') {
return (
<Card className="p-6 md:p-12 rounded-2xl md:rounded-[40px] border-none shadow-2xl text-center">
<Card
className="p-6 md:p-12 rounded-2xl md:rounded-[40px] border-none shadow-2xl text-center"
role="alert"
aria-live="polite"
>
<div className="w-20 h-20 bg-accent rounded-full flex items-center justify-center mx-auto mb-6 shadow-lg shadow-accent/20">
<svg
className="w-10 h-10 text-primary-dark"
@@ -93,7 +97,11 @@ export default function ContactForm() {
if (status === 'error') {
return (
<Card className="p-6 md:p-12 rounded-2xl md:rounded-[40px] border-destructive/20 shadow-2xl text-center bg-destructive/5 animate-slide-up">
<Card
className="p-6 md:p-12 rounded-2xl md:rounded-[40px] border-destructive/20 shadow-2xl text-center bg-destructive/5 animate-slide-up"
role="alert"
aria-live="assertive"
>
<div className="w-20 h-20 bg-destructive rounded-full flex items-center justify-center mx-auto mb-6 shadow-lg shadow-destructive/20">
<svg
className="w-10 h-10 text-destructive-foreground"
@@ -132,40 +140,43 @@ export default function ContactForm() {
</Heading>
<form onSubmit={handleSubmit} className="grid grid-cols-1 md:grid-cols-2 gap-4 md:gap-8">
<div className="space-y-1 md:space-y-2">
<Label htmlFor="name">{t('form.name')}</Label>
<Label htmlFor="contact-name">{t('form.name')}</Label>
<Input
type="text"
id="name"
id="contact-name"
name="name"
autoComplete="name"
enterKeyHint="next"
onFocus={() => handleFocus('name')}
onFocus={() => handleFocus('contact-name')}
aria-label={t('form.name')}
required
/>
</div>
<div className="space-y-1 md:space-y-2">
<Label htmlFor="email">{t('form.email')}</Label>
<Label htmlFor="contact-email">{t('form.email')}</Label>
<Input
type="email"
id="email"
id="contact-email"
name="email"
autoComplete="email"
inputMode="email"
enterKeyHint="next"
placeholder={t('form.emailPlaceholder')}
onFocus={() => handleFocus('email')}
onFocus={() => handleFocus('contact-email')}
aria-label={t('form.email')}
required
/>
</div>
<div className="md:col-span-2 space-y-1 md:space-y-2">
<Label htmlFor="message">{t('form.message')}</Label>
<Label htmlFor="contact-message">{t('form.message')}</Label>
<Textarea
id="message"
id="contact-message"
name="message"
rows={4}
enterKeyHint="send"
placeholder={t('form.messagePlaceholder')}
onFocus={() => handleFocus('message')}
onFocus={() => handleFocus('contact-message')}
aria-label={t('form.message')}
required
/>
</div>

View File

@@ -42,6 +42,7 @@ export default function DatasheetDownload({ datasheetPath, className }: Datashee
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
aria-hidden="true"
>
<path
strokeLinecap="round"
@@ -69,7 +70,13 @@ export default function DatasheetDownload({ datasheetPath, className }: Datashee
{/* Arrow Icon */}
<div className="flex h-10 w-10 items-center justify-center rounded-full bg-white/5 text-white/20 group-hover:bg-accent group-hover:text-white group-hover:translate-x-1 transition-all duration-500">
<svg className="h-6 w-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<svg
className="h-6 w-6"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
aria-hidden="true"
>
<path
strokeLinecap="round"
strokeLinejoin="round"

View File

@@ -34,7 +34,7 @@ export default function Footer() {
>
<Image
src="/logo-white.svg"
alt={t('products')}
alt="KLZ Vertriebs GmbH"
width={150}
height={40}
className="h-10 w-auto transition-transform duration-500 group-hover:scale-110"
@@ -67,9 +67,9 @@ export default function Footer() {
{/* Links Columns */}
<div className="lg:col-span-2">
<h4 className="text-accent font-bold uppercase tracking-widest text-xs md:text-sm mb-8">
<h2 className="text-accent font-bold uppercase tracking-widest text-xs md:text-sm mb-8">
{t('legal')}
</h4>
</h2>
<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">
<h4 className="text-accent font-bold uppercase tracking-widest text-xs md:text-sm mb-8">
<h2 className="text-accent font-bold uppercase tracking-widest text-xs md:text-sm mb-8">
{t('company')}
</h4>
</h2>
<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">
<h4 className="text-accent font-bold uppercase tracking-widest text-xs md:text-sm mb-8">
<h2 className="text-accent font-bold uppercase tracking-widest text-xs md:text-sm mb-8">
{t('recentPosts')}
</h4>
</h2>
<ul className="space-y-6 list-none m-0 p-0">
{[
{

View File

@@ -6,7 +6,7 @@ import { motion } from 'framer-motion';
import { useTranslations } from 'next-intl';
import { usePathname } from 'next/navigation';
import { Button } from './ui';
import { useEffect, useState } from 'react';
import { useEffect, useState, useRef } from 'react';
import { cn } from './ui';
import { useAnalytics } from './analytics/useAnalytics';
import { AnalyticsEvents } from './analytics/analytics-events';
@@ -17,6 +17,8 @@ export default function Header() {
const { trackEvent } = useAnalytics();
const [isScrolled, setIsScrolled] = useState(false);
const [isMobileMenuOpen, setIsMobileMenuOpen] = useState(false);
const mobileMenuRef = useRef<HTMLDivElement>(null);
const closeButtonRef = useRef<HTMLButtonElement>(null);
// Extract locale from pathname
const currentLocale = pathname.split('/')[1] || 'en';
@@ -34,9 +36,52 @@ export default function Header() {
}, []);
// Prevent scroll when mobile menu is open
// Prevent scroll when mobile menu is open and handle focus trap
useEffect(() => {
if (isMobileMenuOpen) {
document.body.style.overflow = 'hidden';
// Focus trap logic
const focusableElements = mobileMenuRef.current?.querySelectorAll(
'button, [href], input, select, textarea, [tabindex]:not([tabindex="-1"])',
);
if (focusableElements && focusableElements.length > 0) {
const firstElement = focusableElements[0] as HTMLElement;
const lastElement = focusableElements[focusableElements.length - 1] as HTMLElement;
const handleTabKey = (e: KeyboardEvent) => {
if (e.key === 'Tab') {
if (e.shiftKey) {
if (document.activeElement === firstElement) {
e.preventDefault();
lastElement.focus();
}
} else {
if (document.activeElement === lastElement) {
e.preventDefault();
firstElement.focus();
}
}
}
};
const handleEscapeKey = (e: KeyboardEvent) => {
if (e.key === 'Escape') {
setIsMobileMenuOpen(false);
}
};
document.addEventListener('keydown', handleTabKey);
document.addEventListener('keydown', handleEscapeKey);
// Focus the first element when menu opens
setTimeout(() => firstElement.focus(), 100);
return () => {
document.removeEventListener('keydown', handleTabKey);
document.removeEventListener('keydown', handleEscapeKey);
};
}
} else {
document.body.style.overflow = 'unset';
}
@@ -227,6 +272,8 @@ export default function Header() {
textColorClass,
)}
aria-label={t('toggleMenu')}
aria-expanded={isMobileMenuOpen}
aria-controls="mobile-menu"
initial={{ scale: 0.8, opacity: 0, rotate: -180 }}
animate={{ scale: 1, opacity: 1, rotate: 0 }}
transition={{
@@ -288,6 +335,11 @@ export default function Header() {
? 'opacity-100 translate-y-0'
: 'opacity-0 -translate-y-full pointer-events-none',
)}
id="mobile-menu"
role="dialog"
aria-modal="true"
aria-label={t('menu')}
ref={mobileMenuRef}
>
<motion.div
className="flex-grow flex flex-col justify-center items-center p-8 space-y-8"

View File

@@ -1,6 +1,6 @@
'use client';
import React, { useEffect, useState, useCallback } from 'react';
import React, { useEffect, useState, useCallback, useRef } from 'react';
import Image from 'next/image';
import { createPortal } from 'react-dom';
import { motion, AnimatePresence } from 'framer-motion';
@@ -19,6 +19,8 @@ export default function Lightbox({ isOpen, images, initialIndex, onClose }: Ligh
const pathname = usePathname();
const [currentIndex, setCurrentIndex] = useState(initialIndex);
const [mounted, setMounted] = useState(false);
const closeButtonRef = useRef<HTMLButtonElement>(null);
const previousFocusRef = useRef<HTMLElement | null>(null);
useEffect(() => {
setMounted(true); // eslint-disable-line react-hooks/set-state-in-effect
@@ -76,12 +78,50 @@ export default function Lightbox({ isOpen, images, initialIndex, onClose }: Ligh
}, [isOpen, currentIndex, updateUrl]);
useEffect(() => {
if (!isOpen) return;
if (!isOpen) {
if (previousFocusRef.current) {
previousFocusRef.current.focus();
}
return;
}
// Capture previous focus
previousFocusRef.current = document.activeElement as HTMLElement;
// Focus close button on open
setTimeout(() => closeButtonRef.current?.focus(), 100);
const handleKeyDown = (e: KeyboardEvent) => {
if (e.key === 'Escape') handleClose();
if (e.key === 'ArrowLeft') prevImage();
if (e.key === 'ArrowRight') nextImage();
// Focus Trap
if (e.key === 'Tab') {
const focusableElements = document.querySelectorAll(
'button, [href], input, select, textarea, [tabindex]:not([tabindex="-1"])',
);
const modalElements = Array.from(focusableElements).filter((el) =>
document.querySelector('[role="dialog"]')?.contains(el),
);
if (modalElements.length === 0) return;
const firstElement = modalElements[0] as HTMLElement;
const lastElement = modalElements[modalElements.length - 1] as HTMLElement;
if (e.shiftKey) {
if (document.activeElement === firstElement) {
lastElement.focus();
e.preventDefault();
}
} else {
if (document.activeElement === lastElement) {
firstElement.focus();
e.preventDefault();
}
}
}
};
// Lock scroll
@@ -101,7 +141,11 @@ export default function Lightbox({ isOpen, images, initialIndex, onClose }: Ligh
return createPortal(
<AnimatePresence>
{isOpen && (
<div className="fixed inset-0 z-[99999] flex items-center justify-center">
<div
className="fixed inset-0 z-[99999] flex items-center justify-center"
role="dialog"
aria-modal="true"
>
<motion.div
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
@@ -116,6 +160,7 @@ export default function Lightbox({ isOpen, images, initialIndex, onClose }: Ligh
animate={{ opacity: 1, scale: 1 }}
exit={{ opacity: 0, scale: 0.5 }}
transition={{ delay: 0.1, duration: 0.4 }}
ref={closeButtonRef}
onClick={handleClose}
className="absolute top-6 right-6 text-white/60 hover:text-white transition-all duration-500 z-[10000] rounded-full w-14 h-14 flex items-center justify-center hover:bg-white/5 group border border-white/10"
aria-label="Close lightbox"

View File

@@ -31,9 +31,9 @@ export default function ProductTechnicalData({ data }: ProductTechnicalDataProps
const { technicalItems = [], voltageTables = [] } = data;
const toggleTable = (idx: number) => {
setExpandedTables(prev => ({
setExpandedTables((prev) => ({
...prev,
[idx]: !prev[idx]
[idx]: !prev[idx],
}));
};
@@ -48,9 +48,16 @@ export default function ProductTechnicalData({ data }: ProductTechnicalDataProps
<dl className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-x-12 gap-y-8">
{technicalItems.map((item, idx) => (
<div key={idx} className="flex flex-col group">
<dt className="text-sm font-bold uppercase tracking-widest text-primary/40 mb-2 group-hover:text-accent transition-colors">{item.label}</dt>
<dt className="text-sm font-bold uppercase tracking-widest text-primary/40 mb-2 group-hover:text-accent transition-colors">
{item.label}
</dt>
<dd className="text-lg font-semibold text-text-primary">
{item.value} {item.unit && <span className="text-sm font-normal text-text-secondary ml-1">{item.unit}</span>}
{item.value}{' '}
{item.unit && (
<span className="text-sm font-normal text-text-secondary ml-1">
{item.unit}
</span>
)}
</dd>
</div>
))}
@@ -61,29 +68,38 @@ export default function ProductTechnicalData({ data }: ProductTechnicalDataProps
{voltageTables.map((table, idx) => {
const isExpanded = expandedTables[idx];
const hasManyRows = table.rows.length > 10;
return (
<div key={idx} className="bg-white p-8 md:p-12 rounded-[32px] shadow-sm border border-neutral-dark/5 overflow-hidden">
<div
key={idx}
className="bg-white p-8 md:p-12 rounded-[32px] shadow-sm border border-neutral-dark/5 overflow-hidden"
>
<h3 className="text-2xl font-bold text-primary mb-8 flex items-center gap-3">
<div className="w-2 h-8 bg-accent rounded-full" />
{table.voltageLabel !== 'Voltage unknown' && table.voltageLabel !== 'Spannung unbekannt'
? table.voltageLabel
{table.voltageLabel !== 'Voltage unknown' &&
table.voltageLabel !== 'Spannung unbekannt'
? table.voltageLabel
: 'Technical Specifications'}
</h3>
{table.metaItems.length > 0 && (
<dl className="grid grid-cols-1 sm:grid-cols-2 md:grid-cols-3 lg:grid-cols-4 gap-8 mb-12 bg-neutral-light/50 p-8 rounded-2xl border border-neutral-dark/5">
{table.metaItems.map((item, mIdx) => (
<div key={mIdx}>
<dt className="text-[10px] font-black uppercase tracking-[0.2em] text-primary/40 mb-1">{item.label}</dt>
<dd className="font-bold text-primary">{item.value} {item.unit}</dd>
<dt className="text-[10px] font-black uppercase tracking-[0.2em] text-primary/40 mb-1">
{item.label}
</dt>
<dd className="font-bold text-primary">
{item.value} {item.unit}
</dd>
</div>
))}
</dl>
)}
<div className="relative">
<div
<div
id={`voltage-table-${idx}`}
className={`overflow-x-auto -mx-8 md:-mx-12 px-8 md:px-12 transition-all duration-500 ease-in-out ${
!isExpanded && hasManyRows ? 'max-h-[400px] overflow-y-hidden' : 'max-h-[none]'
}`}
@@ -91,11 +107,18 @@ export default function ProductTechnicalData({ data }: ProductTechnicalDataProps
<table className="min-w-full border-separate border-spacing-0">
<thead>
<tr>
<th scope="col" className="px-3 py-3 text-left text-[10px] font-black text-primary/40 uppercase tracking-[0.2em] sticky left-0 bg-white z-10 border-b border-neutral-dark/10">
<th
scope="col"
className="px-3 py-3 text-left text-[10px] font-black text-primary/40 uppercase tracking-[0.2em] sticky left-0 bg-white z-10 border-b border-neutral-dark/10"
>
Config.
</th>
{table.columns.map((col, cIdx) => (
<th key={cIdx} scope="col" className="px-3 py-3 text-left text-[10px] font-black text-primary/40 uppercase tracking-[0.2em] whitespace-nowrap border-b border-neutral-dark/10">
<th
key={cIdx}
scope="col"
className="px-3 py-3 text-left text-[10px] font-black text-primary/40 uppercase tracking-[0.2em] whitespace-nowrap border-b border-neutral-dark/10"
>
{col.label}
</th>
))}
@@ -108,7 +131,10 @@ export default function ProductTechnicalData({ data }: ProductTechnicalDataProps
{row.configuration}
</td>
{row.cells.map((cell, cellIdx) => (
<td key={cellIdx} className="px-3 py-2 text-xs text-text-secondary whitespace-nowrap">
<td
key={cellIdx}
className="px-3 py-2 text-xs text-text-secondary whitespace-nowrap"
>
{cell}
</td>
))}
@@ -127,6 +153,8 @@ export default function ProductTechnicalData({ data }: ProductTechnicalDataProps
<div className="mt-8 flex justify-center">
<button
onClick={() => toggleTable(idx)}
aria-expanded={isExpanded}
aria-controls={`voltage-table-${idx}`}
className="px-8 py-3 rounded-full bg-primary text-white text-sm font-bold uppercase tracking-widest hover:bg-accent hover:text-primary transition-all duration-300 shadow-lg hover:shadow-accent/20"
>
{isExpanded ? t('showLess') : t('showMore')}

View File

@@ -106,6 +106,7 @@ export default async function RelatedProducts({
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
aria-hidden="true"
>
<path
strokeLinecap="round"

View File

@@ -80,7 +80,11 @@ export default function RequestQuoteForm({ productName }: RequestQuoteFormProps)
if (status === 'success') {
return (
<div className="bg-accent/5 border border-accent/20 text-primary-dark p-4 rounded-xl text-center animate-fade-in !mt-0 w-full">
<div
className="bg-accent/5 border border-accent/20 text-primary-dark p-4 rounded-xl text-center animate-fade-in !mt-0 w-full"
role="alert"
aria-live="polite"
>
<div className="flex justify-center mb-3">
<div className="w-10 h-10 bg-accent rounded-full flex items-center justify-center shadow-lg shadow-accent/20">
<svg
@@ -118,7 +122,11 @@ export default function RequestQuoteForm({ productName }: RequestQuoteFormProps)
if (status === 'error') {
return (
<div className="bg-destructive/5 border border-destructive/20 text-destructive p-4 rounded-xl text-center animate-fade-in !mt-0 w-full">
<div
className="bg-destructive/5 border border-destructive/20 text-destructive p-4 rounded-xl text-center animate-fade-in !mt-0 w-full"
role="alert"
aria-live="assertive"
>
<div className="flex justify-center mb-3">
<div className="w-10 h-10 bg-destructive rounded-full flex items-center justify-center shadow-lg shadow-destructive/20">
<svg
@@ -158,25 +166,27 @@ export default function RequestQuoteForm({ productName }: RequestQuoteFormProps)
<div className="space-y-1 !mt-0">
<Input
type="email"
id="email"
id="quote-email"
required
value={email}
onChange={(e) => setEmail(e.target.value)}
onFocus={() => handleFocus('email')}
onFocus={() => handleFocus('quote-email')}
placeholder={t('email')}
aria-label={t('email')}
className="h-9 text-xs !mt-0"
/>
</div>
<div className="space-y-1 !mt-0">
<Textarea
id="request"
id="quote-request"
required
rows={3}
value={request}
onChange={(e) => setRequest(e.target.value)}
onFocus={() => handleFocus('request')}
onFocus={() => handleFocus('quote-request')}
placeholder={t('message')}
aria-label={t('message')}
className="text-xs !mt-0"
/>
</div>

View File

@@ -18,31 +18,31 @@ export default function Scribble({ variant, className, color = '#82ed20' }: Scri
opacity: 1,
transition: {
duration: 1.8,
ease: "easeInOut",
}
}
ease: 'easeInOut',
},
},
};
if (variant === 'circle') {
return (
<svg
className={cn("absolute pointer-events-none", className)}
role="presentation"
viewBox="0 0 800 350"
<svg
className={cn('absolute pointer-events-none', className)}
aria-hidden="true"
viewBox="0 0 800 350"
preserveAspectRatio="none"
>
<motion.path
<motion.path
variants={pathVariants}
initial="hidden"
whileInView="visible"
viewport={{ once: true }}
transform="matrix(0.9791300296783447,0,0,0.9791300296783447,400,179)"
strokeLinejoin="miter"
fillOpacity="0"
strokeMiterlimit="4"
stroke={color}
strokeOpacity="1"
strokeWidth="20"
transform="matrix(0.9791300296783447,0,0,0.9791300296783447,400,179)"
strokeLinejoin="miter"
fillOpacity="0"
strokeMiterlimit="4"
stroke={color}
strokeOpacity="1"
strokeWidth="20"
d=" M253,-161 C253,-161 -284.78900146484375,-201.4600067138672 -376,-21 C-469,163 67.62300109863281,174.2100067138672 256,121 C564,34 250.82899475097656,-141.6929931640625 19.10700035095215,-116.93599700927734"
/>
</svg>
@@ -51,20 +51,20 @@ export default function Scribble({ variant, className, color = '#82ed20' }: Scri
if (variant === 'underline') {
return (
<svg
className={cn("absolute pointer-events-none", className)}
role="presentation"
viewBox="-400 -55 730 60"
<svg
className={cn('absolute pointer-events-none', className)}
aria-hidden="true"
viewBox="-400 -55 730 60"
preserveAspectRatio="none"
>
<motion.path
<motion.path
variants={pathVariants}
initial="hidden"
whileInView="visible"
viewport={{ once: true }}
d="m -383.25 -6 c 55.25 -22 130.75 -33.5 293.25 -38 c 54.5 -0.5 195 -2.5 401 15"
stroke={color}
strokeWidth="20"
d="m -383.25 -6 c 55.25 -22 130.75 -33.5 293.25 -38 c 54.5 -0.5 195 -2.5 401 15"
stroke={color}
strokeWidth="20"
fill="none"
/>
</svg>

16
components/SkipLink.tsx Normal file
View File

@@ -0,0 +1,16 @@
'use client';
import { useTranslations } from 'next-intl';
export default function SkipLink() {
const t = useTranslations('Navigation');
return (
<a
href="#main-content"
className="sr-only focus:not-sr-only focus:fixed focus:top-4 focus:left-4 focus:z-[100] focus:px-6 focus:py-3 focus:bg-white focus:text-primary-dark focus:font-bold focus:rounded-lg focus:shadow-xl focus:outline-none focus:ring-2 focus:ring-accent transition-all"
>
{t('skipToContent')}
</a>
);
}

View File

@@ -0,0 +1,20 @@
'use client';
import dynamic from 'next/dynamic';
import { Suspense } from 'react';
const DynamicAnalyticsProvider = dynamic(() => import('./AnalyticsProvider'), {
ssr: false,
});
const DynamicScrollDepthTracker = dynamic(() => import('./ScrollDepthTracker'), {
ssr: false,
});
export default function AnalyticsShell() {
return (
<Suspense fallback={null}>
<DynamicAnalyticsProvider />
<DynamicScrollDepthTracker />
</Suspense>
);
}

View File

@@ -14,57 +14,84 @@ export default function PowerCTA({ locale }: PowerCTAProps) {
<div className="absolute inset-0 opacity-10 pointer-events-none">
<div className="absolute top-0 left-0 w-full h-full bg-[radial-gradient(#3b82f6_1px,transparent_1px)] [background-size:40px_40px]" />
</div>
{/* Decorative accent */}
<div className="absolute top-0 right-0 w-64 h-64 bg-primary/20 rounded-full blur-3xl -mr-32 -mt-32 transition-transform group-hover:scale-110 duration-1000" />
<div className="relative z-10">
<div className="inline-block px-4 py-1 bg-accent/20 text-accent text-xs font-bold uppercase tracking-[0.2em] rounded-full mb-8">
{isDe ? 'Lösungen' : 'Solutions'}
</div>
<h3 className="text-2xl md:text-4xl font-bold text-white mb-8 leading-tight">
{isDe ? 'Bereit für die' : 'Ready for the'}
{isDe ? 'Bereit für die' : 'Ready for the'}
<span className="text-accent block">{isDe ? 'Energiewende?' : 'Energy Transition?'}</span>
</h3>
<p className="text-xl text-white/70 mb-10 leading-relaxed max-w-2xl">
{isDe
{isDe
? 'Von der Planung von Wind- und Solarparks bis zur Lieferung hochwertiger Energiekabel erwecken wir Ihre Projekte zum Leben.'
: 'From wind and solar park planning to delivering high-quality energy cables, we bring your projects to life.'
}
: 'From wind and solar park planning to delivering high-quality energy cables, we bring your projects to life.'}
</p>
<div className="grid grid-cols-1 md:grid-cols-2 gap-6 mb-12">
{[
isDe ? 'Strategischer Hub für schnelle Lieferung' : 'Strategic hub for fast delivery',
isDe ? 'Nachhaltige Kabelinfrastruktur' : 'Sustainable cable infrastructure',
isDe ? 'Expertenberatung für Großprojekte' : 'Expert consulting for large-scale projects',
isDe ? 'Zertifizierte Qualität nach EU-Standards' : 'Certified quality according to EU standards'
isDe
? 'Expertenberatung für Großprojekte'
: 'Expert consulting for large-scale projects',
isDe
? 'Zertifizierte Qualität nach EU-Standards'
: 'Certified quality according to EU standards',
].map((item, i) => (
<div key={i} className="flex items-center gap-4 text-white/80">
<div className="w-6 h-6 rounded-full bg-accent/20 flex items-center justify-center flex-shrink-0">
<svg className="w-3 h-3 text-accent" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={3} d="M5 13l4 4L19 7" />
<svg
className="w-3 h-3 text-accent"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
aria-hidden="true"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={3}
d="M5 13l4 4L19 7"
/>
</svg>
</div>
<span className="text-sm font-medium">{item}</span>
</div>
))}
</div>
<div className="flex flex-col sm:flex-row gap-6 items-start sm:items-center pt-8 border-t border-white/10">
<Link
href={`/${locale}/contact`}
className="inline-flex items-center gap-3 px-8 py-4 bg-primary text-white font-bold rounded-full hover:bg-primary/90 transition-all shadow-xl hover:shadow-primary/20 transform hover:-translate-y-1 group/btn"
>
{isDe ? 'Projekt anfragen' : 'Inquire Project'}
<svg className="w-5 h-5 transition-transform group-hover/btn: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
className="w-5 h-5 transition-transform group-hover/btn:translate-x-1"
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>
</Link>
<p className="text-white/50 text-sm font-medium">
{isDe ? 'Kostenlose Erstberatung für Ihr Vorhaben.' : 'Free initial consultation for your project.'}
{isDe
? 'Kostenlose Erstberatung für Ihr Vorhaben.'
: 'Free initial consultation for your project.'}
</p>
</div>
</div>

View File

@@ -33,6 +33,8 @@ export default function GallerySection() {
{images.map((src, idx) => (
<button
key={idx}
type="button"
aria-label={`${t('alt')} ${idx + 1}`}
onClick={() => {
const params = new URLSearchParams(searchParams.toString());
params.set('photo', idx.toString());
@@ -47,6 +49,7 @@ export default function GallerySection() {
fill
className="object-cover transition-transform duration-1000 group-hover:scale-110"
sizes="(max-width: 768px) 100vw, (max-width: 1200px) 50vw, 33vw"
loading="lazy"
/>
<div className="absolute inset-0 bg-primary-dark/20 group-hover:bg-transparent transition-all duration-500" />
<div className="absolute inset-0 border-0 group-hover:border-[16px] border-white/10 transition-all duration-500 pointer-events-none" />

View File

@@ -130,19 +130,19 @@ const containerVariants = {
visible: {
opacity: 1,
transition: {
staggerChildren: 0.12,
delayChildren: 0.4,
staggerChildren: 0.1,
delayChildren: 0.1,
},
},
} as const;
const headingVariants = {
hidden: { opacity: 0, y: 60, scale: 0.85 },
hidden: { opacity: 1, y: 30, scale: 0.95 },
visible: {
opacity: 1,
y: 0,
scale: 1,
transition: { duration: 1.2, ease: [0.25, 0.46, 0.45, 0.94] },
transition: { duration: 0.8, ease: [0.25, 0.46, 0.45, 0.94] },
},
} as const;

View File

@@ -137,6 +137,7 @@ export default function HeroIllustration() {
preserveAspectRatio="xMidYMid meet"
fill="none"
xmlns="http://www.w3.org/2000/svg"
aria-hidden="true"
>
<defs>
<linearGradient id="energy-pulse" x1="0%" y1="0%" x2="100%" y2="0%">

View File

@@ -43,6 +43,7 @@ export default function ProductCategories() {
return (
<Section className="bg-neutral-light py-0 md:py-0 lg:py-0 -mt-px">
<h2 className="sr-only">{t('title')}</h2>
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4">
{categories.map((category, idx) => (
<Link

View File

@@ -44,6 +44,7 @@ export default async function RecentPosts({ locale }: RecentPostsProps) {
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 && (
@@ -72,6 +73,7 @@ export default async function RecentPosts({ locale }: RecentPostsProps) {
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
aria-hidden="true"
>
<path
strokeLinecap="round"

View File

@@ -17,16 +17,29 @@ export default function WhyChooseUs() {
<p className="text-base md:text-lg text-text-secondary leading-relaxed">
{t('subtitle')}
</p>
<div className="mt-12 space-y-6">
{[0, 1, 2, 3].map((i) => (
<div 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" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={3} d="M5 13l4 4L19 7" />
<svg
className="w-4 h-4 text-primary-dark"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
aria-hidden="true"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={3}
d="M5 13l4 4L19 7"
/>
</svg>
</div>
<span className="font-bold text-primary-dark text-base md:text-base">{t(`features.${i}`)}</span>
<span className="font-bold text-primary-dark text-base md:text-base">
{t(`features.${i}`)}
</span>
</div>
))}
</div>
@@ -34,12 +47,21 @@ export default function WhyChooseUs() {
</div>
<div className="lg:col-span-8 grid grid-cols-1 md:grid-cols-2 gap-8 order-2 lg:order-1">
{[0, 1, 2, 3].map((idx) => (
<div key={idx} className="p-10 bg-white rounded-3xl border border-neutral-medium hover:border-accent transition-all duration-500 hover:shadow-xl group">
<div
key={idx}
className="p-10 bg-white rounded-3xl border border-neutral-medium hover:border-accent transition-all duration-500 hover:shadow-xl group"
>
<div className="w-14 h-14 bg-saturated/10 rounded-2xl flex items-center justify-center mb-8 group-hover:bg-accent transition-colors duration-500">
<span className="text-white font-bold text-lg group-hover:text-primary-dark">0{idx + 1}</span>
<span className="text-white font-bold text-lg group-hover:text-primary-dark">
0{idx + 1}
</span>
</div>
<h3 className="text-xl font-bold mb-4 text-primary-dark">{t(`items.${idx}.title`)}</h3>
<p className="text-text-secondary text-base md:text-base leading-relaxed">{t(`items.${idx}.description`)}</p>
<h3 className="text-xl font-bold mb-4 text-primary-dark">
{t(`items.${idx}.title`)}
</h3>
<p className="text-text-secondary text-base md:text-base leading-relaxed">
{t(`items.${idx}.description`)}
</p>
</div>
))}
</div>

52
config/lighthouserc.json Normal file
View File

@@ -0,0 +1,52 @@
{
"ci": {
"collect": {
"numberOfRuns": 1,
"settings": {
"preset": "desktop",
"onlyCategories": ["performance", "accessibility", "best-practices", "seo"],
"chromeFlags": "--no-sandbox --disable-setuid-sandbox"
}
},
"assert": {
"assertions": {
"categories:performance": [
"error",
{
"minScore": 0.8
}
],
"categories:accessibility": [
"error",
{
"minScore": 0.9
}
],
"categories:best-practices": [
"error",
{
"minScore": 0.9
}
],
"categories:seo": [
"error",
{
"minScore": 0.9
}
],
"first-contentful-paint": [
"warn",
{
"maxNumericValue": 2000
}
],
"interactive": [
"warn",
{
"maxNumericValue": 3500
}
]
}
}
}
}

View File

@@ -4,6 +4,7 @@ date: '2025-03-31T12:00:34'
featuredImage: /uploads/2025/02/image_fx_-6.webp
locale: de
category: Kabel Technologie
excerpt: Die Energiewende braucht leistungsfähige Netze. Erfahren Sie, warum Investitionen in die Kabelinfrastruktur der Schlüssel zu 100 % erneuerbarer Energie sind.
---
# 100 % erneuerbare Energie? Nur mit der richtigen Kabelinfrastruktur!
Die Vision ist klar: Ein Europa, das seinen Strom zu 100 % aus erneuerbaren Energien gewinnt. Doch während Solar- und Windparks boomen, hinkt der Ausbau der Stromnetze hinterher. Die Ursache? Eine Infrastruktur, die für fossile Kraftwerke gebaut wurde und mit den neuen Anforderungen nicht Schritt hält.

View File

@@ -15,6 +15,8 @@ services:
- klz.localhost
env_file:
- ${ENV_FILE:-.env}
environment:
IMGPROXY_URL: ${IMGPROXY_URL:-http://klz-imgproxy:8080}
labels:
- "traefik.enable=true"
# HTTP ⇒ HTTPS redirect
@@ -152,6 +154,40 @@ services:
networks:
- default
klz-imgproxy:
image: darthsim/imgproxy:latest
restart: unless-stopped
networks:
- default
- infra
extra_hosts:
- "klz.localhost:host-gateway"
- "cms.klz.localhost:host-gateway"
- "host.docker.internal:host-gateway"
environment:
IMGPROXY_URL_MAPPING: "${IMGPROXY_URL_MAPPING:-http://klz.localhost/:http://klz-app:3000/,http://cms.klz.localhost/:http://klz-cms:8055/}"
IMGPROXY_USE_ETAG: "true"
IMGPROXY_MAX_SRC_RESOLUTION: 20
IMGPROXY_ALLOWED_NETWORKS: "10.0.0.0/8,172.16.0.0/12,192.168.0.0/16"
IMGPROXY_IGNORE_SSL_ERRORS: "true"
IMGPROXY_DEBUG: "true"
labels:
- "traefik.enable=true"
# HTTP router (local dev)
- "traefik.http.routers.${PROJECT_NAME:-klz}-imgproxy.rule=Host(`img.${TRAEFIK_HOST:-klz.localhost}`)"
- "traefik.http.routers.${PROJECT_NAME:-klz}-imgproxy.entrypoints=web"
- "traefik.http.routers.${PROJECT_NAME:-klz}-imgproxy.service=${PROJECT_NAME:-klz}-imgproxy-svc"
# HTTPS router (staging/prod)
- "traefik.http.routers.${PROJECT_NAME:-klz}-imgproxy-secure.rule=Host(`img.${TRAEFIK_HOST:-klz.localhost}`)"
- "traefik.http.routers.${PROJECT_NAME:-klz}-imgproxy-secure.entrypoints=${TRAEFIK_ENTRYPOINT:-web}"
- "traefik.http.routers.${PROJECT_NAME:-klz}-imgproxy-secure.tls=${TRAEFIK_TLS:-false}"
- "traefik.http.routers.${PROJECT_NAME:-klz}-imgproxy-secure.tls.certresolver=${TRAEFIK_CERT_RESOLVER:-}"
- "traefik.http.routers.${PROJECT_NAME:-klz}-imgproxy-secure.service=${PROJECT_NAME:-klz}-imgproxy-svc"
- "traefik.http.services.${PROJECT_NAME:-klz}-imgproxy-svc.loadbalancer.server.port=8080"
- "traefik.docker.network=infra"
- "caddy=http://img.${TRAEFIK_HOST:-klz.localhost}"
- "caddy.reverse_proxy={{upstreams 8080}}"
networks:
default:
name: ${PROJECT_NAME:-klz-cables}-internal

27
lib/imgproxy-loader.ts Normal file
View File

@@ -0,0 +1,27 @@
import { getImgproxyUrl } from './imgproxy';
/**
* Next.js Image Loader for imgproxy
*
* @param {Object} props - properties from Next.js Image component
* @param {string} props.src - The source image URL
* @param {number} props.width - The desired image width
* @param {number} props.quality - The desired image quality (ignored for now as imgproxy handles it)
*/
export default function imgproxyLoader({
src,
width,
_quality,
}: {
src: string;
width: number;
_quality?: number;
}) {
// We use the width provided by Next.js for responsive images
// Height is set to 0 to maintain aspect ratio
return getImgproxyUrl(src, {
width,
resizing_type: 'fit',
gravity: 'fv', // Use face-aware focusing (face detection)
});
}

80
lib/imgproxy.ts Normal file
View File

@@ -0,0 +1,80 @@
/**
* Generates an imgproxy URL for a given source image and options.
*
* Documentation: https://docs.imgproxy.net/usage/processing
*/
interface ImgproxyOptions {
width?: number;
height?: number;
resizing_type?: 'fit' | 'fill' | 'fill-down' | 'force' | 'auto';
gravity?: string;
enlarge?: boolean;
extension?: string;
}
/**
* Encodes a string to Base64 (URL-safe)
*/
function encodeBase64(str: string): string {
if (typeof Buffer !== 'undefined') {
return Buffer.from(str)
.toString('base64')
.replace(/\+/g, '-')
.replace(/\//g, '_')
.replace(/=+$/, '');
} else {
// Fallback for browser environment if Buffer is not available
return window.btoa(str).replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '');
}
}
export function getImgproxyUrl(src: string, options: ImgproxyOptions = {}): string {
// Use local proxy path which is rewritten in next.config.mjs
const baseUrl = '/_img';
// Handle local paths or relative URLs
let absoluteSrc = src;
if (src.startsWith('/')) {
const baseUrlForSrc =
process.env.NEXT_PUBLIC_BASE_URL ||
(typeof window !== 'undefined' ? window.location.origin : 'https://klz-cables.com');
if (baseUrlForSrc) {
absoluteSrc = `${baseUrlForSrc.replace(/\/$/, '')}${src}`;
}
}
// Development mapping: Map local domains to internal Docker hostnames
// so imgproxy can fetch images without SSL issues or external routing
if (process.env.NODE_ENV === 'development') {
if (absoluteSrc.includes('klz.localhost')) {
absoluteSrc = absoluteSrc.replace(/^https?:\/\/klz\.localhost/, 'http://klz-app:3000');
} else if (absoluteSrc.includes('cms.klz.localhost')) {
absoluteSrc = absoluteSrc.replace(/^https?:\/\/cms\.klz\.localhost/, 'http://klz-cms:8055');
}
// Also handle direct container names if needed
}
const {
width = 0,
height = 0,
resizing_type = 'fit',
gravity = 'sm', // Default to smart gravity
enlarge = false,
extension = '',
} = options;
// Processing options
// Format: /rs:<type>:<width>:<height>:<enlarge>/g:<gravity>
const processingOptions = [
`rs:${resizing_type}:${width}:${height}:${enlarge ? 1 : 0}`,
`g:${gravity}`,
].join('/');
// Using /unsafe/ for now as we don't handle signatures yet
// Format: <base_url>/unsafe/<options>/<base64_url>
const suffix = extension ? `@${extension}` : '';
const encodedSrc = encodeBase64(absoluteSrc + suffix);
return `${baseUrl}/unsafe/${processingOptions}/${encodedSrc}`;
}

View File

@@ -91,7 +91,7 @@ export class UmamiAnalyticsService implements AnalyticsService {
// Add a timeout to prevent hanging requests
const controller = new AbortController();
const timeoutId = setTimeout(() => controller.abort(), 10000); // 10s timeout
const timeoutId = setTimeout(() => controller.abort(), 2000); // 2s timeout
const headers: Record<string, string> = {
'Content-Type': 'application/json',

View File

@@ -17,6 +17,9 @@ export class GotifyNotificationService implements NotificationService {
const url = new URL('message', this.config.url);
url.searchParams.set('token', this.config.token);
const controller = new AbortController();
const timeoutId = setTimeout(() => controller.abort(), 5000);
const response = await fetch(url.toString(), {
method: 'POST',
headers: {
@@ -27,8 +30,11 @@ export class GotifyNotificationService implements NotificationService {
message,
priority,
}),
signal: controller.signal,
});
clearTimeout(timeoutId);
if (!response.ok) {
const errorText = await response.text();
console.error('Gotify notification failed:', {

View File

@@ -1,19 +0,0 @@
module.exports = {
ci: {
collect: {
numberOfRuns: 1,
settings: {
preset: 'desktop',
onlyCategories: ['performance', 'accessibility', 'best-practices', 'seo'],
},
},
assert: {
assertions: {
'categories:performance': ['warn', { minScore: 0.9 }],
'categories:accessibility': ['warn', { minScore: 0.9 }],
'categories:best-practices': ['warn', { minScore: 0.9 }],
'categories:seo': ['warn', { minScore: 0.9 }],
},
},
},
};

View File

@@ -58,7 +58,7 @@
}
},
"Navigation": {
"home": "Start",
"home": "KLZ Cables Startseite",
"team": "Team",
"products": "Produkte",
"blog": "Blog",
@@ -394,4 +394,4 @@
"cta": "Zurück zur Sicherheit"
}
}
}
}

View File

@@ -58,12 +58,14 @@
}
},
"Navigation": {
"home": "Home",
"menu": "Menu",
"home": "KLZ Cables Home",
"team": "Team",
"products": "Products",
"blog": "Blog",
"contact": "Contact",
"toggleMenu": "Toggle Menu"
"toggleMenu": "Toggle Menu",
"skipToContent": "Skip to content"
},
"Footer": {
"legal": "Legal",
@@ -394,4 +396,4 @@
"cta": "Back to Safety"
}
}
}
}

View File

@@ -1,5 +1,5 @@
import createMiddleware from 'next-intl/middleware';
import { NextRequest } from 'next/server';
import { NextRequest, NextResponse } from 'next/server';
// Create the internationalization middleware
const intlMiddleware = createMiddleware({
@@ -20,9 +20,10 @@ export default function middleware(request: NextRequest) {
pathname.startsWith('/errors') ||
pathname.startsWith('/health') ||
pathname.includes('/api/og') ||
pathname.includes('opengraph-image')
pathname.includes('opengraph-image') ||
pathname.endsWith('sitemap.xml')
) {
return;
return NextResponse.next();
}
// Build header object for logging
@@ -93,6 +94,6 @@ 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)$).*)',
'/((?!api|_next/static|_next/image|favicon.ico|.*\\.(?:svg|png|jpg|jpeg|gif|webp|pdf|txt|vcf|xml)$).*)',
],
};

2
next-env.d.ts vendored
View File

@@ -1,6 +1,6 @@
/// <reference types="next" />
/// <reference types="next/image-types/global" />
import "./.next/dev/types/routes.d.ts";
import "./.next/types/routes.d.ts";
// NOTE: This file should not be edited
// see https://nextjs.org/docs/app/api-reference/config/typescript for more information.

View File

@@ -317,17 +317,8 @@ const nextConfig = {
];
},
images: {
remotePatterns: [
{
protocol: 'https',
hostname: 'klz-cables.com',
port: '',
pathname: '/wp-content/uploads/**',
},
],
dangerouslyAllowSVG: true,
contentDispositionType: 'attachment',
contentSecurityPolicy: "default-src 'self'; script-src 'none'; sandbox;",
loader: 'custom',
loaderFile: './lib/imgproxy-loader.ts',
},
async rewrites() {
const umamiUrl =
@@ -341,11 +332,20 @@ const nextConfig = {
const directusUrl = process.env.INTERNAL_DIRECTUS_URL || process.env.DIRECTUS_URL || 'https://cms.klz-cables.com';
let imgproxyUrl = process.env.IMGPROXY_URL || 'https://img.infra.mintel.me';
if (!imgproxyUrl.startsWith('http')) {
imgproxyUrl = `https://${imgproxyUrl}`;
}
return [
{
source: '/cms/:path*',
destination: `${directusUrl}/:path*`,
},
{
source: '/_img/:path*',
destination: `${imgproxyUrl}/:path*`,
},
];
},
};

View File

@@ -12,6 +12,7 @@
"@react-email/components": "^1.0.7",
"@react-pdf/renderer": "^4.3.2",
"@sentry/nextjs": "^10.38.0",
"@types/recharts": "^2.0.1",
"axios": "^1.13.5",
"clsx": "^2.1.1",
"framer-motion": "^12.34.0",
@@ -32,6 +33,7 @@
"react-dom": "^19.2.4",
"react-email": "^5.2.5",
"react-leaflet": "^4.2.1",
"recharts": "^3.7.0",
"require-in-the-middle": "^8.0.1",
"resend": "^3.5.0",
"schema-dts": "^1.1.5",
@@ -63,15 +65,19 @@
"@vitejs/plugin-react": "^5.1.4",
"@vitest/ui": "^4.0.16",
"autoprefixer": "^10.4.23",
"cheerio": "^1.2.0",
"eslint": "^9.18.0",
"happy-dom": "^20.6.1",
"husky": "^9.1.7",
"lint-staged": "^16.2.7",
"lucide-react": "^0.563.0",
"pa11y-ci": "^4.0.1",
"postcss": "^8.5.6",
"prettier": "^3.8.1",
"puppeteer": "^24.37.3",
"remotion": "^4.0.421",
"sass": "^1.97.1",
"start-server-and-test": "^2.1.3",
"tailwindcss": "^4.1.18",
"tsx": "^4.21.0",
"typescript": "^5.7.2",
@@ -89,6 +95,7 @@
"test:og": "vitest run tests/og-image.test.ts",
"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'",
"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",
@@ -108,6 +115,7 @@
"cms:push:testing:DANGER": "./scripts/sync-directus.sh push testing",
"cms:push:prod:DANGER": "./scripts/sync-directus.sh push production",
"pagespeed:test": "tsx ./scripts/pagespeed-sitemap.ts",
"pagespeed:audit": "./scripts/audit-local.sh",
"pagespeed:urls": "tsx -e \"import sitemap from './app/sitemap'; sitemap().then(urls => console.log(urls.map(u => u.url).join('\\n')))\"",
"remotion:render": "remotion render WebsiteVideo remotion/index.ts out.mp4",
"remotion:preview": "remotion preview remotion/index.ts",

930
pnpm-lock.yaml generated

File diff suppressed because it is too large Load Diff

51
scripts/audit-local.sh Executable file
View File

@@ -0,0 +1,51 @@
#!/bin/bash
# audit-local.sh
# Runs a high-fidelity Lighthouse audit locally using the Docker production stack.
set -e
echo "🚀 Starting High-Fidelity Local Audit..."
# 1. Environment and Infrastructure
export DOCKER_HOST="unix:///Users/marcmintel/.docker/run/docker.sock"
export IMGPROXY_URL="http://img.klz.localhost"
export NEXT_URL="http://klz.localhost"
docker network create infra 2>/dev/null || true
docker volume create klz-cablescom_directus-db-data 2>/dev/null || true
# 2. Start infra services (DB, CMS, Gatekeeper)
echo "📦 Starting infrastructure services..."
# Using --remove-orphans to ensure a clean state
docker-compose up -d --remove-orphans klz-db klz-cms klz-gatekeeper
# 3. Build and Start klz-app and klz-imgproxy in Production Mode
echo "🏗️ Building and starting klz-app (Production)..."
# We bypass the dev override by explicitly using the base compose file
NEXT_PUBLIC_BASE_URL=$NEXT_URL \
docker-compose -f docker-compose.yml up -d --build klz-app klz-imgproxy
# 4. Wait for application to be ready
echo "⏳ Waiting for application to be healthy..."
MAX_RETRIES=30
RETRY_COUNT=0
until $(curl -s -f -o /dev/null "$NEXT_URL/health"); do
if [ $RETRY_COUNT -eq $MAX_RETRIES ]; then
echo "❌ Error: App did not become healthy in time."
exit 1
fi
echo " ...waiting for $NEXT_URL/health"
sleep 2
RETRY_COUNT=$((RETRY_COUNT+1))
done
echo "✅ App is healthy at $NEXT_URL"
# 5. Run Lighthouse Audit
echo "⚡ Executing Lighthouse CI..."
NEXT_PUBLIC_BASE_URL=$NEXT_URL PAGESPEED_LIMIT=5 pnpm run pagespeed:test "$NEXT_URL"
echo "✨ Audit completed! Summary above."
echo "💡 You can stop the production app with: docker-compose stop klz-app"

View File

@@ -86,7 +86,7 @@ async function main() {
// Using a more robust way to execute and capture output
// We remove 'npx lhci upload' to keep everything local and avoid Google-hosted reports
const lhciCommand = `npx lhci collect ${urlArgs} ${chromePathArg} --collect.settings.chromeFlags='--no-sandbox --disable-setuid-sandbox' --collect.settings.extraHeaders='${extraHeaders}' && npx lhci assert`;
const lhciCommand = `npx lhci collect ${urlArgs} ${chromePathArg} --config=config/lighthouserc.json --collect.settings.extraHeaders='${extraHeaders}' && npx lhci assert --config=config/lighthouserc.json`;
console.log(`💻 Executing LHCI...`);