Compare commits

...

21 Commits

Author SHA1 Message Date
93cb12d7d9 fix(imgproxy): URL-encode plain source URLs
All checks were successful
Build & Deploy / 🔍 Prepare (push) Successful in 13s
Build & Deploy / 🧪 QA (push) Successful in 1m49s
Build & Deploy / 🏗️ Build (push) Successful in 2m57s
Build & Deploy / 🚀 Deploy (push) Successful in 26s
Build & Deploy / 🧪 Smoke Test (push) Successful in 49s
Build & Deploy / ⚡ Lighthouse (push) Successful in 4m23s
Build & Deploy / 🔔 Notify (push) Successful in 1s
- Use encodeURIComponent for source URLs in plain/ format
- Prevents 308 redirect loops caused by double-slash normalization
- Prevents invalid URL structures for imgproxy
2026-02-19 17:15:58 +01:00
44f0c430a9 fix(infra): whitelist video files and source maps
All checks were successful
Build & Deploy / 🔍 Prepare (push) Successful in 7s
Build & Deploy / 🧪 QA (push) Successful in 1m28s
Build & Deploy / 🏗️ Build (push) Successful in 7m31s
Build & Deploy / 🚀 Deploy (push) Successful in 26s
Build & Deploy / 🧪 Smoke Test (push) Successful in 1m4s
Build & Deploy / ⚡ Lighthouse (push) Successful in 3m17s
Build & Deploy / 🔔 Notify (push) Successful in 2s
- Added webm, mp4, map to Traefik whitelist to bypass Gatekeeper
- Added webm, mp4, map to middleware exclusion to prevent locale redirects
- This fixes 404 errors for background videos and source maps on protected environments
2026-02-19 16:04:58 +01:00
1478909a73 fix(imgproxy): switch from base64 to plain URL format
All checks were successful
Build & Deploy / 🔍 Prepare (push) Successful in 13s
Build & Deploy / 🧪 QA (push) Successful in 1m27s
Build & Deploy / 🏗️ Build (push) Successful in 7m41s
Build & Deploy / 🚀 Deploy (push) Successful in 26s
Build & Deploy / 🧪 Smoke Test (push) Successful in 49s
Build & Deploy / ⚡ Lighthouse (push) Successful in 4m6s
Build & Deploy / 🔔 Notify (push) Successful in 1s
Use plain/ source URL format instead of base64 encoding.
Base64 was causing 404 errors from imgproxy.
Plain format verified working via direct curl tests.
2026-02-19 15:07:20 +01:00
837abd4921 fix(infra): whitelist static image assets in traefik
All checks were successful
Build & Deploy / 🔍 Prepare (push) Successful in 13s
Build & Deploy / 🧪 QA (push) Successful in 1m59s
Build & Deploy / 🏗️ Build (push) Successful in 10m13s
Build & Deploy / 🚀 Deploy (push) Successful in 27s
Build & Deploy / 🧪 Smoke Test (push) Successful in 49s
Build & Deploy / ⚡ Lighthouse (push) Successful in 4m16s
Build & Deploy / 🔔 Notify (push) Successful in 2s
- Added PathRegexp for .svg, .png, .jpg, etc. to public router
- Allows central imgproxy to fetch source images from protected staging environment
- Resolves broken images caused by imgproxy receiving login page HTML
2026-02-19 01:52:41 +01:00
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
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
02bd1dcd7f fix(infra): restore official production volume and repair directus snapshot
All checks were successful
Build & Deploy / 🔍 Prepare (push) Successful in 7s
Build & Deploy / 🧪 QA (push) Successful in 1m28s
Build & Deploy / 🏗️ Build (push) Successful in 4m7s
Build & Deploy / 🚀 Deploy (push) Successful in 32s
Build & Deploy / 🧪 Smoke Test (push) Successful in 48s
Build & Deploy / 🔔 Notify (push) Successful in 3s
- Hardened docker-compose.yml to use klz-cablescom_directus-db-data volume
- Added mandatory 'relations: []' key to Directus snapshot.yaml
- Aligned internal network mappings for db connectivity
2026-02-17 22:49:21 +01:00
54 changed files with 2071 additions and 328 deletions

View File

@@ -57,6 +57,9 @@ SENTRY_DSN=
IMAGE_TAG=latest IMAGE_TAG=latest
TRAEFIK_HOST=klz-cables.com TRAEFIK_HOST=klz-cables.com
ENV_FILE=.env 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 # Varnish Configuration

View File

@@ -34,3 +34,9 @@ jobs:
- name: 🧪 QA Checks - name: 🧪 QA Checks
run: pnpm check:mdx && pnpm lint && pnpm typecheck && pnpm test 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 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: notifications:
name: 🔔 Notify name: 🔔 Notify
needs: [prepare, deploy, smoke_test] needs: [prepare, deploy, smoke_test, lighthouse]
if: always() if: always()
runs-on: docker runs-on: docker
container: container:

5
.gitignore vendored
View File

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

26
.pa11yci.json Normal file
View File

@@ -0,0 +1,26 @@
{
"defaults": {
"standard": "WCAG2AA",
"runners": ["axe", "htmlcs"],
"ignore": [],
"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, description: description,
alternates: { alternates: {
canonical: `${SITE_URL}/${locale}/blog/${slug}`, 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: { openGraph: {
title: `${post.frontmatter.title} | KLZ Cables`, title: `${post.frontmatter.title} | KLZ Cables`,

View File

@@ -58,7 +58,7 @@ export default async function BlogIndex({ params }: BlogIndexProps) {
<div className="bg-neutral-light min-h-screen"> <div className="bg-neutral-light min-h-screen">
{/* Hero Section - Immersive Magazine Feel */} {/* Hero Section - Immersive Magazine Feel */}
<Reveal> <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 && ( {featuredPost && featuredPost.frontmatter.featuredImage && (
<> <>
<Image <Image
@@ -101,7 +101,7 @@ export default async function BlogIndex({ params }: BlogIndexProps) {
)} )}
</div> </div>
</Container> </Container>
</section> </article>
</Reveal> </Reveal>
<Section className="bg-neutral-light py-12 md:py-28"> <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) => ( {remainingPosts.map((post, idx) => (
<Reveal key={post.slug} delay={idx * 100}> <Reveal key={post.slug} delay={idx * 100}>
<Link href={`/${locale}/blog/${post.slug}`} className="group block"> <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 && ( {post.frontmatter.featuredImage && (
<div className="relative h-48 md:h-72 overflow-hidden"> <div className="relative h-48 md:h-72 overflow-hidden">
<Image <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"> <Heading level={3} subtitle={t('info.subtitle')} className="mb-6 md:mb-8">
{t('info.howToReachUs')} {t('info.howToReachUs')}
</Heading> </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="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"> <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 <svg
@@ -197,7 +197,7 @@ export default async function ContactPage({ params }: ContactPageProps) {
</a> </a>
</div> </div>
</div> </div>
</div> </address>
</div> </div>
<div className="p-6 md:p-10 bg-white rounded-2xl md:rounded-3xl border border-neutral-medium shadow-sm animate-fade-in"> <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

@@ -1,15 +1,16 @@
import Footer from '@/components/Footer'; import Footer from '@/components/Footer';
import Header from '@/components/Header'; import Header from '@/components/Header';
import JsonLd from '@/components/JsonLd'; import JsonLd from '@/components/JsonLd';
import AnalyticsProvider from '@/components/analytics/AnalyticsProvider'; import SkipLink from '@/components/SkipLink';
import ScrollDepthTracker from '@/components/analytics/ScrollDepthTracker';
import CMSConnectivityNotice from '@/components/CMSConnectivityNotice'; import CMSConnectivityNotice from '@/components/CMSConnectivityNotice';
import { RecordModeProvider } from '@/components/record-mode/RecordModeContext'; import { RecordModeProvider } from '@/components/record-mode/RecordModeContext';
import { RecordModeVisuals } from '@/components/record-mode/RecordModeVisuals'; import { RecordModeVisuals } from '@/components/record-mode/RecordModeVisuals';
import { ToolCoordinator } from '@/components/record-mode/ToolCoordinator'; import { ToolCoordinator } from '@/components/record-mode/ToolCoordinator';
import AnalyticsShell from '@/components/analytics/AnalyticsShell';
import { Metadata, Viewport } from 'next'; import { Metadata, Viewport } from 'next';
import { NextIntlClientProvider } from 'next-intl'; import { NextIntlClientProvider } from 'next-intl';
import { getMessages } from 'next-intl/server'; import { getMessages } from 'next-intl/server';
import dynamic from 'next/dynamic';
import { Suspense } from 'react'; import { Suspense } from 'react';
import '../../styles/globals.css'; import '../../styles/globals.css';
import { SITE_URL } from '@/lib/schema'; import { SITE_URL } from '@/lib/schema';
@@ -23,22 +24,37 @@ const inter = Inter({
variable: '--font-inter', variable: '--font-inter',
}); });
export const metadata: Metadata = { export async function generateMetadata(props: {
metadataBase: new URL(SITE_URL), params: Promise<{ locale: string }>;
icons: { }): Promise<Metadata> {
icon: [ const params = await props.params;
{ url: '/favicon.ico', sizes: 'any' }, const { locale } = params;
{ url: '/logo-blue.svg', type: 'image/svg+xml' },
], return {
apple: [{ url: '/apple-touch-icon.png', sizes: '180x180', type: 'image/png' }], 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' }],
},
};
}
export const viewport: Viewport = { export const viewport: Viewport = {
width: 'device-width', width: 'device-width',
initialScale: 1, initialScale: 1,
maximumScale: 1, maximumScale: 5,
userScalable: false, userScalable: true,
viewportFit: 'cover', viewportFit: 'cover',
themeColor: '#001a4d', themeColor: '#001a4d',
}; };
@@ -80,7 +96,8 @@ export default async function Layout(props: {
}); });
} }
serverServices.analytics.trackPageview(); // Server-side analytics tracking removed to prevent duplicate/empty events.
// Client-side AnalyticsProvider handles all pageviews.
} catch { } catch {
if (process.env.NODE_ENV !== 'production' || !process.env.CI) { if (process.env.NODE_ENV !== 'production' || !process.env.CI) {
console.warn( console.warn(
@@ -100,18 +117,22 @@ export default async function Layout(props: {
<NextIntlClientProvider messages={messages} locale={safeLocale}> <NextIntlClientProvider messages={messages} locale={safeLocale}>
<RecordModeProvider isEnabled={recordModeEnabled}> <RecordModeProvider isEnabled={recordModeEnabled}>
<RecordModeVisuals> <RecordModeVisuals>
<SkipLink />
<JsonLd /> <JsonLd />
<Header /> <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 /> <Footer />
</RecordModeVisuals> </RecordModeVisuals>
<CMSConnectivityNotice /> <CMSConnectivityNotice />
<Suspense fallback={null}> <AnalyticsShell />
<AnalyticsProvider />
<ScrollDepthTracker />
</Suspense>
<ToolCoordinator feedbackEnabled={feedbackEnabled} /> <ToolCoordinator feedbackEnabled={feedbackEnabled} />
</RecordModeProvider> </RecordModeProvider>
</NextIntlClientProvider> </NextIntlClientProvider>

View File

@@ -79,7 +79,9 @@ export async function generateMetadata({
} }
const title = t('title') || 'KLZ Cables'; 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 { return {
title, title,

View File

@@ -5,7 +5,7 @@ import ProductTabs from '@/components/ProductTabs';
import ProductTechnicalData from '@/components/ProductTechnicalData'; import ProductTechnicalData from '@/components/ProductTechnicalData';
import RelatedProducts from '@/components/RelatedProducts'; import RelatedProducts from '@/components/RelatedProducts';
import DatasheetDownload from '@/components/DatasheetDownload'; 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 { getDatasheetPath } from '@/lib/datasheets';
import { getAllProducts, getProductBySlug } from '@/lib/mdx'; import { getAllProducts, getProductBySlug } from '@/lib/mdx';
import { mapFileSlugToTranslated, mapSlugToFileSlug } from '@/lib/slugs'; import { mapFileSlugToTranslated, mapSlugToFileSlug } from '@/lib/slugs';
@@ -239,57 +239,59 @@ export default async function ProductPage({ params }: ProductPageProps) {
href={`/${locale}/products/${productSlug}/${product.translatedSlug}`} 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" 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"> <Card tag="article" className="premium-card-reset">
{product.frontmatter.images?.[0] && ( <div className="aspect-[4/3] relative bg-neutral-light/30 p-12 overflow-hidden">
<> {product.frontmatter.images?.[0] && (
<Image <>
src={product.frontmatter.images[0]} <Image
alt={product.frontmatter.title} src={product.frontmatter.images[0]}
fill alt={product.frontmatter.title}
className="object-contain p-8 transition-transform duration-700 group-hover:scale-110 z-10" fill
sizes="(max-width: 768px) 100vw, (max-width: 1024px) 50vw, 33vw" 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" /> {/* 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>
<div className="flex flex-wrap gap-2 mb-4"> <div className="p-8 md:p-10">
{product.frontmatter.categories.map((cat, i) => ( <div className="flex flex-wrap gap-2 mb-4">
<span {product.frontmatter.categories.map((cat, i) => (
key={i} <span
className="text-[10px] font-bold uppercase tracking-widest text-primary/40" key={i}
> className="text-[10px] font-bold uppercase tracking-widest text-primary/40"
{cat} >
{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> </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> </div>
<h2 className="text-2xl md:text-3xl font-bold text-text-primary group-hover:text-primary transition-colors mb-4 leading-tight"> </Card>
{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>
</Link> </Link>
))} ))}
</div> </div>

View File

@@ -114,7 +114,7 @@ export default async function TeamPage({ params }: TeamPageProps) {
</Reveal> </Reveal>
{/* Michael Bodemer Section - Sticky Narrative Split Layout */} {/* 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"> <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"> <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" /> <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" /> <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> </Reveal>
</div> </div>
</section> </article>
{/* Legacy Section - Immersive Background */} {/* Legacy Section - Immersive Background */}
<Reveal> <Reveal>
@@ -217,7 +217,7 @@ export default async function TeamPage({ params }: TeamPageProps) {
</Reveal> </Reveal>
{/* Klaus Mintel Section - Reversed Split Layout */} {/* 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"> <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"> <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 <Image
@@ -264,7 +264,7 @@ export default async function TeamPage({ params }: TeamPageProps) {
</div> </div>
</Reveal> </Reveal>
</div> </div>
</section> </article>
{/* Manifesto Section - Modern Grid */} {/* Manifesto Section - Modern Grid */}
<Section className="bg-white text-primary py-16 md:py-28"> <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>
</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) => ( {[0, 1, 2, 3, 4, 5].map((idx) => (
<div <li
key={idx} 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" 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"> <p className="text-sm md:text-lg text-text-secondary leading-relaxed">
{t(`manifesto.items.${idx}.description`)} {t(`manifesto.items.${idx}.description`)}
</p> </p>
</div> </li>
))} ))}
</div> </ul>
</div> </div>
</Container> </Container>
</Section> </Section>

View File

@@ -65,9 +65,28 @@ export async function POST(request: NextRequest) {
return NextResponse.json({ status: 'ok' }); return NextResponse.json({ status: 'ok' });
} catch (error) { } catch (error) {
logger.error('Failed to proxy analytics request', { const errorMessage = error instanceof Error ? error.message : String(error);
error: (error as Error).message, 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

@@ -66,7 +66,11 @@ export default function ContactForm() {
if (status === 'success') { if (status === 'success') {
return ( 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"> <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 <svg
className="w-10 h-10 text-primary-dark" className="w-10 h-10 text-primary-dark"
@@ -93,7 +97,11 @@ export default function ContactForm() {
if (status === 'error') { if (status === 'error') {
return ( 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"> <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 <svg
className="w-10 h-10 text-destructive-foreground" className="w-10 h-10 text-destructive-foreground"
@@ -132,40 +140,43 @@ export default function ContactForm() {
</Heading> </Heading>
<form onSubmit={handleSubmit} className="grid grid-cols-1 md:grid-cols-2 gap-4 md:gap-8"> <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"> <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 <Input
type="text" type="text"
id="name" id="contact-name"
name="name" name="name"
autoComplete="name" autoComplete="name"
enterKeyHint="next" enterKeyHint="next"
onFocus={() => handleFocus('name')} onFocus={() => handleFocus('contact-name')}
aria-label={t('form.name')}
required required
/> />
</div> </div>
<div className="space-y-1 md:space-y-2"> <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 <Input
type="email" type="email"
id="email" id="contact-email"
name="email" name="email"
autoComplete="email" autoComplete="email"
inputMode="email" inputMode="email"
enterKeyHint="next" enterKeyHint="next"
placeholder={t('form.emailPlaceholder')} placeholder={t('form.emailPlaceholder')}
onFocus={() => handleFocus('email')} onFocus={() => handleFocus('contact-email')}
aria-label={t('form.email')}
required required
/> />
</div> </div>
<div className="md:col-span-2 space-y-1 md:space-y-2"> <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 <Textarea
id="message" id="contact-message"
name="message" name="message"
rows={4} rows={4}
enterKeyHint="send" enterKeyHint="send"
placeholder={t('form.messagePlaceholder')} placeholder={t('form.messagePlaceholder')}
onFocus={() => handleFocus('message')} onFocus={() => handleFocus('contact-message')}
aria-label={t('form.message')}
required required
/> />
</div> </div>

View File

@@ -42,6 +42,7 @@ export default function DatasheetDownload({ datasheetPath, className }: Datashee
fill="none" fill="none"
stroke="currentColor" stroke="currentColor"
viewBox="0 0 24 24" viewBox="0 0 24 24"
aria-hidden="true"
> >
<path <path
strokeLinecap="round" strokeLinecap="round"
@@ -69,7 +70,13 @@ export default function DatasheetDownload({ datasheetPath, className }: Datashee
{/* Arrow Icon */} {/* 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"> <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 <path
strokeLinecap="round" strokeLinecap="round"
strokeLinejoin="round" strokeLinejoin="round"

View File

@@ -34,7 +34,7 @@ export default function Footer() {
> >
<Image <Image
src="/logo-white.svg" src="/logo-white.svg"
alt={t('products')} alt="KLZ Vertriebs GmbH"
width={150} width={150}
height={40} height={40}
className="h-10 w-auto transition-transform duration-500 group-hover:scale-110" className="h-10 w-auto transition-transform duration-500 group-hover:scale-110"
@@ -67,9 +67,9 @@ export default function Footer() {
{/* Links Columns */} {/* Links Columns */}
<div className="lg:col-span-2"> <div className="lg:col-span-2">
<h4 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')} {t('legal')}
</h4> </h3>
<ul className="space-y-4 text-white/70 list-none m-0 p-0"> <ul className="space-y-4 text-white/70 list-none m-0 p-0">
<li> <li>
<Link <Link
@@ -120,9 +120,9 @@ export default function Footer() {
</div> </div>
<div className="lg:col-span-2"> <div className="lg:col-span-2">
<h4 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')} {t('company')}
</h4> </h3>
<ul className="space-y-4 text-white/70 list-none m-0 p-0"> <ul className="space-y-4 text-white/70 list-none m-0 p-0">
<li> <li>
<Link <Link
@@ -189,9 +189,9 @@ export default function Footer() {
{/* Recent Posts Column */} {/* Recent Posts Column */}
<div className="lg:col-span-4"> <div className="lg:col-span-4">
<h4 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')} {t('recentPosts')}
</h4> </h3>
<ul className="space-y-6 list-none m-0 p-0"> <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"> <p className="text-white/80 font-bold group-hover:text-accent transition-colors leading-snug mb-2 text-base md:text-base">
{post.title} {post.title}
</p> </p>
<span className="text-xs text-white/40 uppercase tracking-widest"> <span className="text-xs text-white/70 uppercase tracking-widest">
{t('readArticle')} &rarr; {t('readArticle')} &rarr;
</span> </span>
</Link> </Link>
@@ -240,7 +240,7 @@ export default function Footer() {
</div> </div>
</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> <p>{t('copyright', { year: currentYear })}</p>
<div className="flex gap-8"> <div className="flex gap-8">
<Link <Link

View File

@@ -6,7 +6,7 @@ import { motion } from 'framer-motion';
import { useTranslations } from 'next-intl'; import { useTranslations } from 'next-intl';
import { usePathname } from 'next/navigation'; import { usePathname } from 'next/navigation';
import { Button } from './ui'; import { Button } from './ui';
import { useEffect, useState } from 'react'; import { useEffect, useState, useRef } from 'react';
import { cn } from './ui'; import { cn } from './ui';
import { useAnalytics } from './analytics/useAnalytics'; import { useAnalytics } from './analytics/useAnalytics';
import { AnalyticsEvents } from './analytics/analytics-events'; import { AnalyticsEvents } from './analytics/analytics-events';
@@ -17,6 +17,8 @@ export default function Header() {
const { trackEvent } = useAnalytics(); const { trackEvent } = useAnalytics();
const [isScrolled, setIsScrolled] = useState(false); const [isScrolled, setIsScrolled] = useState(false);
const [isMobileMenuOpen, setIsMobileMenuOpen] = useState(false); const [isMobileMenuOpen, setIsMobileMenuOpen] = useState(false);
const mobileMenuRef = useRef<HTMLDivElement>(null);
const closeButtonRef = useRef<HTMLButtonElement>(null);
// Extract locale from pathname // Extract locale from pathname
const currentLocale = pathname.split('/')[1] || 'en'; 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
// Prevent scroll when mobile menu is open and handle focus trap
useEffect(() => { useEffect(() => {
if (isMobileMenuOpen) { if (isMobileMenuOpen) {
document.body.style.overflow = 'hidden'; 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 { } else {
document.body.style.overflow = 'unset'; document.body.style.overflow = 'unset';
} }
@@ -227,6 +272,8 @@ export default function Header() {
textColorClass, textColorClass,
)} )}
aria-label={t('toggleMenu')} aria-label={t('toggleMenu')}
aria-expanded={isMobileMenuOpen}
aria-controls="mobile-menu"
initial={{ scale: 0.8, opacity: 0, rotate: -180 }} initial={{ scale: 0.8, opacity: 0, rotate: -180 }}
animate={{ scale: 1, opacity: 1, rotate: 0 }} animate={{ scale: 1, opacity: 1, rotate: 0 }}
transition={{ transition={{
@@ -288,8 +335,13 @@ export default function Header() {
? 'opacity-100 translate-y-0' ? 'opacity-100 translate-y-0'
: 'opacity-0 -translate-y-full pointer-events-none', : 'opacity-0 -translate-y-full pointer-events-none',
)} )}
id="mobile-menu"
role="dialog"
aria-modal="true"
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" className="flex-grow flex flex-col justify-center items-center p-8 space-y-8"
initial="closed" initial="closed"
animate={isMobileMenuOpen ? 'open' : 'closed'} animate={isMobileMenuOpen ? 'open' : 'closed'}
@@ -411,7 +463,7 @@ export default function Header() {
<Image src="/logo-white.svg" alt={t('home')} width={80} height={80} unoptimized /> <Image src="/logo-white.svg" alt={t('home')} width={80} height={80} unoptimized />
</motion.div> </motion.div>
</motion.div> </motion.div>
</motion.div> </motion.nav>
</div> </div>
</motion.header> </motion.header>
</> </>

View File

@@ -1,6 +1,6 @@
'use client'; 'use client';
import React, { useEffect, useState, useCallback } from 'react'; import React, { useEffect, useState, useCallback, useRef } from 'react';
import Image from 'next/image'; import Image from 'next/image';
import { createPortal } from 'react-dom'; import { createPortal } from 'react-dom';
import { motion, AnimatePresence } from 'framer-motion'; import { motion, AnimatePresence } from 'framer-motion';
@@ -19,6 +19,8 @@ export default function Lightbox({ isOpen, images, initialIndex, onClose }: Ligh
const pathname = usePathname(); const pathname = usePathname();
const [currentIndex, setCurrentIndex] = useState(initialIndex); const [currentIndex, setCurrentIndex] = useState(initialIndex);
const [mounted, setMounted] = useState(false); const [mounted, setMounted] = useState(false);
const closeButtonRef = useRef<HTMLButtonElement>(null);
const previousFocusRef = useRef<HTMLElement | null>(null);
useEffect(() => { useEffect(() => {
setMounted(true); // eslint-disable-line react-hooks/set-state-in-effect 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]); }, [isOpen, currentIndex, updateUrl]);
useEffect(() => { 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) => { const handleKeyDown = (e: KeyboardEvent) => {
if (e.key === 'Escape') handleClose(); if (e.key === 'Escape') handleClose();
if (e.key === 'ArrowLeft') prevImage(); if (e.key === 'ArrowLeft') prevImage();
if (e.key === 'ArrowRight') nextImage(); 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 // Lock scroll
@@ -101,7 +141,11 @@ export default function Lightbox({ isOpen, images, initialIndex, onClose }: Ligh
return createPortal( return createPortal(
<AnimatePresence> <AnimatePresence>
{isOpen && ( {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 <motion.div
initial={{ opacity: 0 }} initial={{ opacity: 0 }}
animate={{ opacity: 1 }} animate={{ opacity: 1 }}
@@ -116,6 +160,7 @@ export default function Lightbox({ isOpen, images, initialIndex, onClose }: Ligh
animate={{ opacity: 1, scale: 1 }} animate={{ opacity: 1, scale: 1 }}
exit={{ opacity: 0, scale: 0.5 }} exit={{ opacity: 0, scale: 0.5 }}
transition={{ delay: 0.1, duration: 0.4 }} transition={{ delay: 0.1, duration: 0.4 }}
ref={closeButtonRef}
onClick={handleClose} 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" 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" aria-label="Close lightbox"

View File

@@ -14,25 +14,30 @@ interface ProductSidebarProps {
className?: string; className?: string;
} }
export default function ProductSidebar({ productName, productImage, datasheetPath, className }: ProductSidebarProps) { export default function ProductSidebar({
productName,
productImage,
datasheetPath,
className,
}: ProductSidebarProps) {
const t = useTranslations('Products'); const t = useTranslations('Products');
return ( 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 */} {/* 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-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"> <div className="bg-primary p-6 text-white relative overflow-hidden">
{/* Background Accent - Saturated Blue Glow */} {/* 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" /> <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 */} {/* Product Thumbnail with Reflection */}
{productImage && ( {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 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"> <div className="relative w-full h-full transition-transform duration-1000 ease-out group-hover:scale-105">
<Image <Image
src={productImage} src={productImage}
alt={productName} alt={productName}
fill fill
className="object-contain p-2 drop-shadow-[0_20px_30px_rgba(0,0,0,0.4)]" className="object-contain p-2 drop-shadow-[0_20px_30px_rgba(0,0,0,0.4)]"
/> />
{/* Subtle Reflection Overlay */} {/* 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"> <h3 className="text-lg md:text-xl font-heading font-black m-0 tracking-tighter uppercase leading-none">
{t('requestQuote')} {t('requestQuote')}
</h3> </h3>
<Scribble <Scribble
variant="underline" variant="underline"
className="w-full h-3 -bottom-3 left-0 text-accent/80" className="w-full h-3 -bottom-3 left-0 text-accent/80"
color="var(--color-accent)" color="var(--color-accent)"
/> />
</div> </div>
@@ -57,16 +62,14 @@ export default function ProductSidebar({ productName, productImage, datasheetPat
</p> </p>
</div> </div>
</div> </div>
<div className="p-6 bg-neutral-light/50"> <div className="p-6 bg-neutral-light/50">
<RequestQuoteForm productName={productName} /> <RequestQuoteForm productName={productName} />
</div> </div>
</div> </div>
{/* Datasheet Download */} {/* Datasheet Download */}
{datasheetPath && ( {datasheetPath && <DatasheetDownload datasheetPath={datasheetPath} className="mt-0" />}
<DatasheetDownload datasheetPath={datasheetPath} className="mt-0" /> </aside>
)}
</div>
); );
} }

View File

@@ -31,9 +31,9 @@ export default function ProductTechnicalData({ data }: ProductTechnicalDataProps
const { technicalItems = [], voltageTables = [] } = data; const { technicalItems = [], voltageTables = [] } = data;
const toggleTable = (idx: number) => { const toggleTable = (idx: number) => {
setExpandedTables(prev => ({ setExpandedTables((prev) => ({
...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"> <dl className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-x-12 gap-y-8">
{technicalItems.map((item, idx) => ( {technicalItems.map((item, idx) => (
<div key={idx} className="flex flex-col group"> <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"> <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> </dd>
</div> </div>
))} ))}
@@ -61,29 +68,38 @@ export default function ProductTechnicalData({ data }: ProductTechnicalDataProps
{voltageTables.map((table, idx) => { {voltageTables.map((table, idx) => {
const isExpanded = expandedTables[idx]; const isExpanded = expandedTables[idx];
const hasManyRows = table.rows.length > 10; const hasManyRows = table.rows.length > 10;
return ( 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"> <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" /> <div className="w-2 h-8 bg-accent rounded-full" />
{table.voltageLabel !== 'Voltage unknown' && table.voltageLabel !== 'Spannung unbekannt' {table.voltageLabel !== 'Voltage unknown' &&
? table.voltageLabel table.voltageLabel !== 'Spannung unbekannt'
? table.voltageLabel
: 'Technical Specifications'} : 'Technical Specifications'}
</h3> </h3>
{table.metaItems.length > 0 && ( {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"> <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) => ( {table.metaItems.map((item, mIdx) => (
<div key={mIdx}> <div key={mIdx}>
<dt className="text-[10px] font-black uppercase tracking-[0.2em] text-primary/40 mb-1">{item.label}</dt> <dt className="text-[10px] font-black uppercase tracking-[0.2em] text-primary/40 mb-1">
<dd className="font-bold text-primary">{item.value} {item.unit}</dd> {item.label}
</dt>
<dd className="font-bold text-primary">
{item.value} {item.unit}
</dd>
</div> </div>
))} ))}
</dl> </dl>
)} )}
<div className="relative"> <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 ${ 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]' !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"> <table className="min-w-full border-separate border-spacing-0">
<thead> <thead>
<tr> <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. Config.
</th> </th>
{table.columns.map((col, cIdx) => ( {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} {col.label}
</th> </th>
))} ))}
@@ -108,7 +131,10 @@ export default function ProductTechnicalData({ data }: ProductTechnicalDataProps
{row.configuration} {row.configuration}
</td> </td>
{row.cells.map((cell, cellIdx) => ( {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} {cell}
</td> </td>
))} ))}
@@ -127,6 +153,8 @@ export default function ProductTechnicalData({ data }: ProductTechnicalDataProps
<div className="mt-8 flex justify-center"> <div className="mt-8 flex justify-center">
<button <button
onClick={() => toggleTable(idx)} 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" 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')} {isExpanded ? t('showLess') : t('showMore')}

View File

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

View File

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

View File

@@ -18,31 +18,31 @@ export default function Scribble({ variant, className, color = '#82ed20' }: Scri
opacity: 1, opacity: 1,
transition: { transition: {
duration: 1.8, duration: 1.8,
ease: "easeInOut", ease: 'easeInOut',
} },
} },
}; };
if (variant === 'circle') { if (variant === 'circle') {
return ( return (
<svg <svg
className={cn("absolute pointer-events-none", className)} className={cn('absolute pointer-events-none', className)}
role="presentation" aria-hidden="true"
viewBox="0 0 800 350" viewBox="0 0 800 350"
preserveAspectRatio="none" preserveAspectRatio="none"
> >
<motion.path <motion.path
variants={pathVariants} variants={pathVariants}
initial="hidden" initial="hidden"
whileInView="visible" whileInView="visible"
viewport={{ once: true }} viewport={{ once: true }}
transform="matrix(0.9791300296783447,0,0,0.9791300296783447,400,179)" transform="matrix(0.9791300296783447,0,0,0.9791300296783447,400,179)"
strokeLinejoin="miter" strokeLinejoin="miter"
fillOpacity="0" fillOpacity="0"
strokeMiterlimit="4" strokeMiterlimit="4"
stroke={color} stroke={color}
strokeOpacity="1" strokeOpacity="1"
strokeWidth="20" 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" 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> </svg>
@@ -51,20 +51,20 @@ export default function Scribble({ variant, className, color = '#82ed20' }: Scri
if (variant === 'underline') { if (variant === 'underline') {
return ( return (
<svg <svg
className={cn("absolute pointer-events-none", className)} className={cn('absolute pointer-events-none', className)}
role="presentation" aria-hidden="true"
viewBox="-400 -55 730 60" viewBox="-400 -55 730 60"
preserveAspectRatio="none" preserveAspectRatio="none"
> >
<motion.path <motion.path
variants={pathVariants} variants={pathVariants}
initial="hidden" initial="hidden"
whileInView="visible" whileInView="visible"
viewport={{ once: true }} 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" 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} stroke={color}
strokeWidth="20" strokeWidth="20"
fill="none" fill="none"
/> />
</svg> </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 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 className="absolute top-0 left-0 w-full h-full bg-[radial-gradient(#3b82f6_1px,transparent_1px)] [background-size:40px_40px]" />
</div> </div>
{/* Decorative accent */} {/* 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="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="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"> <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'} {isDe ? 'Lösungen' : 'Solutions'}
</div> </div>
<h3 className="text-2xl md:text-4xl font-bold text-white mb-8 leading-tight"> <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> <span className="text-accent block">{isDe ? 'Energiewende?' : 'Energy Transition?'}</span>
</h3> </h3>
<p className="text-xl text-white/70 mb-10 leading-relaxed max-w-2xl"> <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.' ? '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> </p>
<div className="grid grid-cols-1 md:grid-cols-2 gap-6 mb-12"> <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 ? 'Strategischer Hub für schnelle Lieferung' : 'Strategic hub for fast delivery',
isDe ? 'Nachhaltige Kabelinfrastruktur' : 'Sustainable cable infrastructure', isDe ? 'Nachhaltige Kabelinfrastruktur' : 'Sustainable cable infrastructure',
isDe ? 'Expertenberatung für Großprojekte' : 'Expert consulting for large-scale projects', isDe
isDe ? 'Zertifizierte Qualität nach EU-Standards' : 'Certified quality according to EU standards' ? '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) => ( ].map((item, i) => (
<div key={i} className="flex items-center gap-4 text-white/80"> <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"> <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"> <svg
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={3} d="M5 13l4 4L19 7" /> 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> </svg>
</div> </div>
<span className="text-sm font-medium">{item}</span> <span className="text-sm font-medium">{item}</span>
</div> </div>
))} ))}
</div> </div>
<div className="flex flex-col sm:flex-row gap-6 items-start sm:items-center pt-8 border-t border-white/10"> <div className="flex flex-col sm:flex-row gap-6 items-start sm:items-center pt-8 border-t border-white/10">
<Link <Link
href={`/${locale}/contact`} 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" 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'} {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"> <svg
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M17 8l4 4m0 0l-4 4m4-4H3" /> 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> </svg>
</Link> </Link>
<p className="text-white/50 text-sm font-medium"> <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> </p>
</div> </div>
</div> </div>

View File

@@ -32,24 +32,24 @@ export default function Experience() {
<p className="pl-9">{t('p2')}</p> <p className="pl-9">{t('p2')}</p>
</div> </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="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')} {t('certifiedQuality')}
</div> </dt>
<div className="text-base md:text-lg font-bold uppercase tracking-widest text-white/60"> <dd className="text-base md:text-lg font-bold uppercase tracking-widest text-white/60">
{t('vdeApproved')} {t('vdeApproved')}
</div> </dd>
</div> </div>
<div className="animate-fade-in" style={{ animationDelay: '100ms' }}> <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')} {t('fullSpectrum')}
</div> </dt>
<div className="text-base md:text-lg font-bold uppercase tracking-widest text-white/60"> <dd className="text-base md:text-lg font-bold uppercase tracking-widest text-white/60">
{t('solutionsRange')} {t('solutionsRange')}
</div> </dd>
</div> </div>
</div> </dl>
</div> </div>
</Container> </Container>
</Section> </Section>

View File

@@ -33,6 +33,8 @@ export default function GallerySection() {
{images.map((src, idx) => ( {images.map((src, idx) => (
<button <button
key={idx} key={idx}
type="button"
aria-label={`${t('alt')} ${idx + 1}`}
onClick={() => { onClick={() => {
const params = new URLSearchParams(searchParams.toString()); const params = new URLSearchParams(searchParams.toString());
params.set('photo', idx.toString()); params.set('photo', idx.toString());
@@ -47,6 +49,7 @@ export default function GallerySection() {
fill fill
className="object-cover transition-transform duration-1000 group-hover:scale-110" className="object-cover transition-transform duration-1000 group-hover:scale-110"
sizes="(max-width: 768px) 100vw, (max-width: 1200px) 50vw, 33vw" 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 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" /> <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: { visible: {
opacity: 1, opacity: 1,
transition: { transition: {
staggerChildren: 0.12, staggerChildren: 0.1,
delayChildren: 0.4, delayChildren: 0.1,
}, },
}, },
} as const; } as const;
const headingVariants = { const headingVariants = {
hidden: { opacity: 0, y: 60, scale: 0.85 }, hidden: { opacity: 1, y: 30, scale: 0.95 },
visible: { visible: {
opacity: 1, opacity: 1,
y: 0, y: 0,
scale: 1, 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; } as const;

View File

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

View File

@@ -43,6 +43,7 @@ export default function ProductCategories() {
return ( return (
<Section className="bg-neutral-light py-0 md:py-0 lg:py-0 -mt-px"> <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"> <div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4">
{categories.map((category, idx) => ( {categories.map((category, idx) => (
<Link <Link

View File

@@ -32,60 +32,69 @@ export default async function RecentPosts({ locale }: RecentPostsProps) {
</Link> </Link>
</div> </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) => ( {recentPosts.map((post) => (
<Link key={post.slug} href={`/${locale}/blog/${post.slug}`} className="group block"> <li key={post.slug}>
<Card className="h-full flex flex-col border-none shadow-lg hover:shadow-2xl transition-all duration-500 rounded-3xl"> <Link href={`/${locale}/blog/${post.slug}`} className="group block h-full">
{post.frontmatter.featuredImage && ( <Card
<div className="relative h-64 overflow-hidden"> tag="article"
<Image className="h-full flex flex-col border-none shadow-lg hover:shadow-2xl transition-all duration-500 rounded-3xl"
src={post.frontmatter.featuredImage} >
alt={post.frontmatter.title} {post.frontmatter.featuredImage && (
fill <div className="relative h-64 overflow-hidden">
className="w-full h-full object-cover transition-transform duration-700 group-hover:scale-110" <Image
sizes="(max-width: 768px) 100vw, 33vw" src={post.frontmatter.featuredImage}
/> alt={post.frontmatter.title}
<div className="absolute inset-0 image-overlay-gradient opacity-0 group-hover:opacity-100 transition-opacity duration-500" /> fill
{post.frontmatter.category && ( className="w-full h-full object-cover transition-transform duration-700 group-hover:scale-110"
<Badge variant="accent" className="absolute top-4 left-4 shadow-md"> sizes="(max-width: 768px) 100vw, 33vw"
{post.frontmatter.category} loading="lazy"
</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"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M17 8l4 4m0 0l-4 4m4-4H3"
/> />
</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>
</div> </Card>
</Card> </Link>
</Link> </li>
))} ))}
</div> </ul>
</Container> </Container>
</Section> </Section>
); );

View File

@@ -17,32 +17,54 @@ export default function WhyChooseUs() {
<p className="text-base md:text-lg text-text-secondary leading-relaxed"> <p className="text-base md:text-lg text-text-secondary leading-relaxed">
{t('subtitle')} {t('subtitle')}
</p> </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) => ( {[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"> <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"> <svg
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={3} d="M5 13l4 4L19 7" /> 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> </svg>
</div> </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">
</div> {t(`features.${i}`)}
</span>
</li>
))} ))}
</div> </ul>
</div> </div>
</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) => ( {[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"> <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"
>
<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"> <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> </div>
<h3 className="text-xl font-bold mb-4 text-primary-dark">{t(`items.${idx}.title`)}</h3> <h3 className="text-xl font-bold mb-4 text-primary-dark">
<p className="text-text-secondary text-base md:text-base leading-relaxed">{t(`items.${idx}.description`)}</p> {t(`items.${idx}.title`)}
</div> </h3>
<p className="text-text-secondary text-base md:text-base leading-relaxed">
{t(`items.${idx}.description`)}
</p>
</li>
))} ))}
</div> </ul>
</div> </div>
</Container> </Container>
</Section> </Section>

View File

@@ -1,10 +1,14 @@
import React from 'react'; import React from 'react';
import { cn } from './utils'; 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 ( return (
<div className={cn('premium-card overflow-hidden', className)} {...props}> <Tag className={cn('premium-card overflow-hidden', className)} {...props}>
{children} {children}
</div> </Tag>
); );
} }

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 featuredImage: /uploads/2025/02/image_fx_-6.webp
locale: de locale: de
category: Kabel Technologie 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! # 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. 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

@@ -224,4 +224,6 @@ systemFields:
schema: schema:
is_indexed: true is_indexed: true
relations: []

View File

@@ -15,6 +15,8 @@ services:
- klz.localhost - klz.localhost
env_file: env_file:
- ${ENV_FILE:-.env} - ${ENV_FILE:-.env}
environment:
IMGPROXY_URL: ${IMGPROXY_URL:-http://klz-imgproxy:8080}
labels: labels:
- "traefik.enable=true" - "traefik.enable=true"
# HTTP ⇒ HTTPS redirect # HTTP ⇒ HTTPS redirect
@@ -30,7 +32,7 @@ services:
- "traefik.http.routers.${PROJECT_NAME:-klz}.middlewares=${AUTH_MIDDLEWARE:-klz-ratelimit,klz-forward,klz-compress}" - "traefik.http.routers.${PROJECT_NAME:-klz}.middlewares=${AUTH_MIDDLEWARE:-klz-ratelimit,klz-forward,klz-compress}"
# Public Router (Whitelist for OG Images, Sitemaps, Health) # 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(`/logo-white.svg`) || PathPrefix(`/icon-white.svg`) || 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$`) || PathRegexp(`.*\\.(svg|png|jpg|jpeg|gif|webp|ico|webm|mp4|map)$`))"
- "traefik.http.routers.${PROJECT_NAME:-klz}-public.entrypoints=${TRAEFIK_ENTRYPOINT:-web}" - "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.certresolver=${TRAEFIK_CERT_RESOLVER:-}"
- "traefik.http.routers.${PROJECT_NAME:-klz}-public.tls=${TRAEFIK_TLS:-false}" - "traefik.http.routers.${PROJECT_NAME:-klz}-public.tls=${TRAEFIK_TLS:-false}"
@@ -117,6 +119,7 @@ services:
PUBLIC_URL: ${DIRECTUS_URL:-https://cms.klz-cables.com} PUBLIC_URL: ${DIRECTUS_URL:-https://cms.klz-cables.com}
HOST: '0.0.0.0' HOST: '0.0.0.0'
networks: networks:
- default
- infra - infra
volumes: volumes:
- ./directus/uploads:/directus/uploads - ./directus/uploads:/directus/uploads
@@ -149,7 +152,40 @@ services:
volumes: volumes:
- directus-db-data:/var/lib/postgresql/data - directus-db-data:/var/lib/postgresql/data
networks: networks:
- default
klz-imgproxy:
image: darthsim/imgproxy:latest
restart: unless-stopped
networks:
- default
- infra - 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_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: networks:
default: default:
@@ -159,3 +195,5 @@ networks:
volumes: volumes:
directus-db-data: directus-db-data:
external: true
name: klz-cablescom_directus-db-data

View File

@@ -36,7 +36,7 @@ function createConfig() {
analytics: { analytics: {
umami: { umami: {
websiteId: env.UMAMI_WEBSITE_ID, 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), enabled: Boolean(env.UMAMI_WEBSITE_ID),
}, },
}, },

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

@@ -0,0 +1,33 @@
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;
}) {
// 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, {
width,
resizing_type: 'fit',
gravity: 'fv', // Use face-aware focusing (face detection)
});
}

64
lib/imgproxy.ts Normal file
View File

@@ -0,0 +1,64 @@
/**
* 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;
}
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/ with plain/ source URL format
// plain/ format works reliably with imgproxy URL mapping
// Format: <base_url>/unsafe/<options>/plain/<source_url>[@<extension>]
const suffix = extension ? `@${extension}` : '';
return `${baseUrl}/unsafe/${processingOptions}/plain/${encodeURIComponent(absoluteSrc)}${suffix}`;
}

View File

@@ -91,7 +91,7 @@ export class UmamiAnalyticsService implements AnalyticsService {
// Add a timeout to prevent hanging requests // Add a timeout to prevent hanging requests
const controller = new AbortController(); 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> = { const headers: Record<string, string> = {
'Content-Type': 'application/json', 'Content-Type': 'application/json',

View File

@@ -17,6 +17,9 @@ export class GotifyNotificationService implements NotificationService {
const url = new URL('message', this.config.url); const url = new URL('message', this.config.url);
url.searchParams.set('token', this.config.token); url.searchParams.set('token', this.config.token);
const controller = new AbortController();
const timeoutId = setTimeout(() => controller.abort(), 5000);
const response = await fetch(url.toString(), { const response = await fetch(url.toString(), {
method: 'POST', method: 'POST',
headers: { headers: {
@@ -27,8 +30,11 @@ export class GotifyNotificationService implements NotificationService {
message, message,
priority, priority,
}), }),
signal: controller.signal,
}); });
clearTimeout(timeoutId);
if (!response.ok) { if (!response.ok) {
const errorText = await response.text(); const errorText = await response.text();
console.error('Gotify notification failed:', { 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,12 +58,14 @@
} }
}, },
"Navigation": { "Navigation": {
"home": "Start", "menu": "Menü",
"home": "KLZ Cables Startseite",
"team": "Team", "team": "Team",
"products": "Produkte", "products": "Produkte",
"blog": "Blog", "blog": "Blog",
"contact": "Kontakt", "contact": "Kontakt",
"toggleMenu": "Menü umschalten" "toggleMenu": "Menü umschalten",
"skipToContent": "Zum Inhalt springen"
}, },
"Footer": { "Footer": {
"legal": "Rechtliches", "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.", "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.", "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", "linkedin": "Klaus' LinkedIn",
"role": "Gründer & Visionär" "role": "Geschäftsführer"
}, },
"manifesto": { "manifesto": {
"title": "Unser Manifest", "title": "Unser Manifest",
@@ -394,4 +396,4 @@
"cta": "Zurück zur Sicherheit" "cta": "Zurück zur Sicherheit"
} }
} }
} }

View File

@@ -58,12 +58,14 @@
} }
}, },
"Navigation": { "Navigation": {
"home": "Home", "menu": "Menu",
"home": "KLZ Cables Home",
"team": "Team", "team": "Team",
"products": "Products", "products": "Products",
"blog": "Blog", "blog": "Blog",
"contact": "Contact", "contact": "Contact",
"toggleMenu": "Toggle Menu" "toggleMenu": "Toggle Menu",
"skipToContent": "Skip to content"
}, },
"Footer": { "Footer": {
"legal": "Legal", "legal": "Legal",
@@ -120,7 +122,7 @@
"quote": "Sometimes all it takes is a clear head and a good cable to make the world a little better.", "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.", "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", "linkedin": "Check Klaus' LinkedIn",
"role": "Founder & Visionary" "role": "Managing Director"
}, },
"manifesto": { "manifesto": {
"title": "Our manifesto", "title": "Our manifesto",
@@ -394,4 +396,4 @@
"cta": "Back to Safety" "cta": "Back to Safety"
} }
} }
} }

View File

@@ -1,5 +1,5 @@
import createMiddleware from 'next-intl/middleware'; import createMiddleware from 'next-intl/middleware';
import { NextRequest } from 'next/server'; import { NextRequest, NextResponse } from 'next/server';
// Create the internationalization middleware // Create the internationalization middleware
const intlMiddleware = createMiddleware({ const intlMiddleware = createMiddleware({
@@ -20,9 +20,11 @@ export default function middleware(request: NextRequest) {
pathname.startsWith('/errors') || pathname.startsWith('/errors') ||
pathname.startsWith('/health') || pathname.startsWith('/health') ||
pathname.includes('/api/og') || pathname.includes('/api/og') ||
pathname.includes('opengraph-image') pathname.includes('opengraph-image') ||
pathname.endsWith('sitemap.xml') ||
pathname.endsWith('manifest.webmanifest')
) { ) {
return; return NextResponse.next();
} }
// Build header object for logging // Build header object for logging
@@ -93,6 +95,8 @@ export default function middleware(request: NextRequest) {
export const config = { export const config = {
matcher: [ matcher: [
'/((?!api|_next/static|_next/image|favicon.ico|.*\\.(?:svg|png|jpg|jpeg|gif|webp|pdf|txt|vcf)$).*)', '/((?!api|_next/static|_next/image|_img|favicon.ico|manifest.webmanifest|.*\\.(?:svg|png|jpg|jpeg|gif|webp|pdf|txt|vcf|xml|webm|mp4|map)$).*)',
'/(de|en)/:path*',
'/(de|en)/:path*',
], ],
}; };

2
next-env.d.ts vendored
View File

@@ -1,6 +1,6 @@
/// <reference types="next" /> /// <reference types="next" />
/// <reference types="next/image-types/global" /> /// <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 // NOTE: This file should not be edited
// see https://nextjs.org/docs/app/api-reference/config/typescript for more information. // see https://nextjs.org/docs/app/api-reference/config/typescript for more information.

View File

@@ -317,16 +317,10 @@ const nextConfig = {
]; ];
}, },
images: { images: {
remotePatterns: [ loader: 'custom',
{ loaderFile: './lib/imgproxy-loader.ts',
protocol: 'https',
hostname: 'klz-cables.com',
port: '',
pathname: '/wp-content/uploads/**',
},
],
dangerouslyAllowSVG: true, dangerouslyAllowSVG: true,
contentDispositionType: 'attachment', contentDispositionType: "attachment",
contentSecurityPolicy: "default-src 'self'; script-src 'none'; sandbox;", contentSecurityPolicy: "default-src 'self'; script-src 'none'; sandbox;",
}, },
async rewrites() { async rewrites() {
@@ -341,11 +335,20 @@ const nextConfig = {
const directusUrl = process.env.INTERNAL_DIRECTUS_URL || process.env.DIRECTUS_URL || 'https://cms.klz-cables.com'; 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 [ return [
{ {
source: '/cms/:path*', source: '/cms/:path*',
destination: `${directusUrl}/:path*`, destination: `${directusUrl}/:path*`,
}, },
{
source: '/_img/:path*',
destination: `${imgproxyUrl}/:path*`,
},
]; ];
}, },
}; };

View File

@@ -12,6 +12,7 @@
"@react-email/components": "^1.0.7", "@react-email/components": "^1.0.7",
"@react-pdf/renderer": "^4.3.2", "@react-pdf/renderer": "^4.3.2",
"@sentry/nextjs": "^10.38.0", "@sentry/nextjs": "^10.38.0",
"@types/recharts": "^2.0.1",
"axios": "^1.13.5", "axios": "^1.13.5",
"clsx": "^2.1.1", "clsx": "^2.1.1",
"framer-motion": "^12.34.0", "framer-motion": "^12.34.0",
@@ -32,6 +33,7 @@
"react-dom": "^19.2.4", "react-dom": "^19.2.4",
"react-email": "^5.2.5", "react-email": "^5.2.5",
"react-leaflet": "^4.2.1", "react-leaflet": "^4.2.1",
"recharts": "^3.7.0",
"require-in-the-middle": "^8.0.1", "require-in-the-middle": "^8.0.1",
"resend": "^3.5.0", "resend": "^3.5.0",
"schema-dts": "^1.1.5", "schema-dts": "^1.1.5",
@@ -63,15 +65,19 @@
"@vitejs/plugin-react": "^5.1.4", "@vitejs/plugin-react": "^5.1.4",
"@vitest/ui": "^4.0.16", "@vitest/ui": "^4.0.16",
"autoprefixer": "^10.4.23", "autoprefixer": "^10.4.23",
"cheerio": "^1.2.0",
"eslint": "^9.18.0", "eslint": "^9.18.0",
"happy-dom": "^20.6.1", "happy-dom": "^20.6.1",
"husky": "^9.1.7", "husky": "^9.1.7",
"lint-staged": "^16.2.7", "lint-staged": "^16.2.7",
"lucide-react": "^0.563.0", "lucide-react": "^0.563.0",
"pa11y-ci": "^4.0.1",
"postcss": "^8.5.6", "postcss": "^8.5.6",
"prettier": "^3.8.1", "prettier": "^3.8.1",
"puppeteer": "^24.37.3",
"remotion": "^4.0.421", "remotion": "^4.0.421",
"sass": "^1.97.1", "sass": "^1.97.1",
"start-server-and-test": "^2.1.3",
"tailwindcss": "^4.1.18", "tailwindcss": "^4.1.18",
"tsx": "^4.21.0", "tsx": "^4.21.0",
"typescript": "^5.7.2", "typescript": "^5.7.2",
@@ -89,6 +95,8 @@
"test:og": "vitest run tests/og-image.test.ts", "test:og": "vitest run tests/og-image.test.ts",
"check:og": "tsx scripts/check-og-images.ts", "check:og": "tsx scripts/check-og-images.ts",
"check:mdx": "node scripts/validate-mdx.mjs", "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: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: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", "cms:branding:staging": "DIRECTUS_URL=https://cms.staging.klz-cables.com npx tsx --env-file=.env scripts/setup-directus-branding.ts",
@@ -108,6 +116,7 @@
"cms:push:testing:DANGER": "./scripts/sync-directus.sh push testing", "cms:push:testing:DANGER": "./scripts/sync-directus.sh push testing",
"cms:push:prod:DANGER": "./scripts/sync-directus.sh push production", "cms:push:prod:DANGER": "./scripts/sync-directus.sh push production",
"pagespeed:test": "tsx ./scripts/pagespeed-sitemap.ts", "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')))\"", "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:render": "remotion render WebsiteVideo remotion/index.ts out.mp4",
"remotion:preview": "remotion preview remotion/index.ts", "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 // Using a more robust way to execute and capture output
// We remove 'npx lhci upload' to keep everything local and avoid Google-hosted reports // 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...`); console.log(`💻 Executing LHCI...`);

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();