Compare commits

...

71 Commits

Author SHA1 Message Date
23bf327670 fix(analytics): relay umami events via secure nextjs proxy route handler
Some checks failed
Build & Deploy / 🔍 Prepare (push) Successful in 26s
Build & Deploy / 🧪 QA (push) Successful in 5m14s
Build & Deploy / 🧪 Smoke Test (push) Successful in 51s
Build & Deploy / 🏗️ Build (push) Successful in 4m21s
Build & Deploy / 🚀 Deploy (push) Successful in 27s
Build & Deploy / ⚡ Lighthouse (push) Failing after 2m18s
Build & Deploy / 🔔 Notify (push) Successful in 2s
2026-02-20 15:18:20 +01:00
c77f99ef37 feat(blog): johannes image
Some checks failed
Build & Deploy / 🔍 Prepare (push) Successful in 13s
Build & Deploy / 🧪 QA (push) Successful in 2m37s
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
2026-02-20 14:56:06 +01:00
bffcc98820 feat(blog): add Johannes Gleich onboarding post with SEO
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-20 14:55:35 +01:00
7519e17280 ci: enforce strict 90+ performance hurdle for LHCI pipeline
All checks were successful
Build & Deploy / 🔍 Prepare (push) Successful in 6s
Build & Deploy / 🧪 QA (push) Successful in 1m28s
Build & Deploy / 🏗️ Build (push) Successful in 7m58s
Build & Deploy / 🚀 Deploy (push) Successful in 31s
Build & Deploy / 🧪 Smoke Test (push) Successful in 1m5s
Build & Deploy / ⚡ Lighthouse (push) Successful in 3m18s
Build & Deploy / 🔔 Notify (push) Successful in 2s
2026-02-20 14:30:14 +01:00
5bd7421764 perf: enable optimizeCss to inline critical CSS and eliminate render-blocking network requests
All checks were successful
Build & Deploy / 🔍 Prepare (push) Successful in 13s
Build & Deploy / 🧪 QA (push) Successful in 1m53s
Build & Deploy / 🏗️ Build (push) Successful in 7m33s
Build & Deploy / 🚀 Deploy (push) Successful in 26s
Build & Deploy / 🧪 Smoke Test (push) Successful in 1m4s
Build & Deploy / ⚡ Lighthouse (push) Successful in 3m13s
Build & Deploy / 🔔 Notify (push) Successful in 1s
2026-02-20 14:02:09 +01:00
d7aba218d9 fix(analytics): restore next.config.mjs proxy and remove route handler
All checks were successful
Build & Deploy / 🔍 Prepare (push) Successful in 14s
Build & Deploy / 🧪 QA (push) Successful in 2m0s
Build & Deploy / 🏗️ Build (push) Successful in 4m54s
Build & Deploy / 🚀 Deploy (push) Successful in 34s
Build & Deploy / 🧪 Smoke Test (push) Successful in 2m11s
Build & Deploy / ⚡ Lighthouse (push) Successful in 5m44s
Build & Deploy / 🔔 Notify (push) Successful in 2s
2026-02-20 13:15:29 +01:00
e20d7f42c0 fix(analytics): expose UMAMI_WEBSITE_ID to client to enable tracking proxy
All checks were successful
Build & Deploy / 🔍 Prepare (push) Successful in 12s
Build & Deploy / 🧪 QA (push) Successful in 2m0s
Build & Deploy / 🏗️ Build (push) Successful in 11m25s
Build & Deploy / 🚀 Deploy (push) Successful in 28s
Build & Deploy / 🧪 Smoke Test (push) Successful in 2m27s
Build & Deploy / ⚡ Lighthouse (push) Successful in 5m39s
Build & Deploy / 🔔 Notify (push) Successful in 1s
2026-02-20 12:11:45 +01:00
16d06d3275 perf: deep react code splitting, next-intl payload scoping, and SVG hardware acceleration for PageSpeed 100
All checks were successful
Build & Deploy / 🔍 Prepare (push) Successful in 23s
Build & Deploy / 🧪 QA (push) Successful in 2m1s
Build & Deploy / 🏗️ Build (push) Successful in 7m43s
Build & Deploy / 🚀 Deploy (push) Successful in 26s
Build & Deploy / 🧪 Smoke Test (push) Successful in 1m10s
Build & Deploy / ⚡ Lighthouse (push) Successful in 3m20s
Build & Deploy / 🔔 Notify (push) Successful in 2s
2026-02-20 11:53:42 +01:00
7542f42568 perf: eliminate global JS bloat and defer autoPlay video
Some checks failed
Build & Deploy / 🔍 Prepare (push) Successful in 8s
Build & Deploy / 🧪 QA (push) Successful in 1m29s
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
- Dynamically imported ToolCoordinator dependencies
- Removes ~400KB from global layout (html2canvas, framer-motion)
- Implemented IntersectionObserver in VideoSection
- Prevents 1.8MB .webm autoPlay blocking initial network
- Restored SSR hydration visibility for LCP elements in Hero
2026-02-20 00:34:08 +01:00
474fa4f3df perf: optimize PageSpeed Insights performance
All checks were successful
Build & Deploy / 🔍 Prepare (push) Successful in 7s
Build & Deploy / 🧪 QA (push) Successful in 1m26s
Build & Deploy / 🏗️ Build (push) Successful in 4m17s
Build & Deploy / 🚀 Deploy (push) Successful in 26s
Build & Deploy / 🧪 Smoke Test (push) Successful in 52s
Build & Deploy / ⚡ Lighthouse (push) Successful in 3m41s
Build & Deploy / 🔔 Notify (push) Successful in 2s
- Suppress browser source map references to fix 404/SyntaxErrors
- Reduce legacy JS polyfills via browserslist config
- Optimize LCP by refining Hero animations and image sizes
- Implement video lazy loading and reduce SVG animation complexity
- Add preconnect hints for critical origins
2026-02-19 23:21:01 +01:00
f1d49416d1 fix(navigation): Corrected incorrect 'Home' label in both languages
All checks were successful
Build & Deploy / 🔍 Prepare (push) Successful in 17s
Build & Deploy / 🧪 QA (push) Successful in 4m11s
Build & Deploy / 🏗️ Build (push) Successful in 8m43s
Build & Deploy / 🚀 Deploy (push) Successful in 25s
Build & Deploy / 🧪 Smoke Test (push) Successful in 47s
Build & Deploy / ⚡ Lighthouse (push) Successful in 3m57s
Build & Deploy / 🔔 Notify (push) Successful in 2s
2026-02-19 21:40:20 +01:00
e3e0a7670c fix(staging): completely resolve phantom 403 imgproxy caching loops via base64, traefik routing precedence, and variable mapping
All checks were successful
Build & Deploy / 🔍 Prepare (push) Successful in 12s
Build & Deploy / 🧪 QA (push) Successful in 1m54s
Build & Deploy / 🏗️ Build (push) Successful in 7m44s
Build & Deploy / 🚀 Deploy (push) Successful in 30s
Build & Deploy / 🧪 Smoke Test (push) Successful in 1m2s
Build & Deploy / ⚡ Lighthouse (push) Successful in 3m17s
Build & Deploy / 🔔 Notify (push) Successful in 1s
2026-02-19 20:06:55 +01:00
8a87318b12 fix(imgproxy): fallback to smart gravity (sm) instead of face detection (fv)
All checks were successful
Build & Deploy / 🔍 Prepare (push) Successful in 12s
Build & Deploy / 🧪 QA (push) Successful in 1m27s
Build & Deploy / 🏗️ Build (push) Successful in 2m56s
Build & Deploy / 🚀 Deploy (push) Successful in 29s
Build & Deploy / 🧪 Smoke Test (push) Successful in 51s
Build & Deploy / ⚡ Lighthouse (push) Successful in 4m33s
Build & Deploy / 🔔 Notify (push) Successful in 3s
- 'fv' requires ML modules not present in standard imgproxy image
- 'sm' is robust and supported everywhere
- Fixes broken images on staging using Next.js Image loader
2026-02-19 18:05:29 +01:00
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
4b0433394f chore: integrate mdx validation and fix syntax errors in blog posts
All checks were successful
Build & Deploy / 🔍 Prepare (push) Successful in 16s
Build & Deploy / 🧪 QA (push) Successful in 1m49s
Build & Deploy / 🏗️ Build (push) Successful in 7m0s
Build & Deploy / 🚀 Deploy (push) Successful in 15s
Build & Deploy / 🧪 Smoke Test (push) Successful in 1m0s
Build & Deploy / 🔔 Notify (push) Successful in 2s
2026-02-17 21:36:55 +01:00
d9bddae20e refactor: enforce 'v' prefix for version tags in deploy workflow triggers and logic.
Some checks failed
Build & Deploy / 🔍 Prepare (push) Successful in 7s
Build & Deploy / 🧪 QA (push) Successful in 1m46s
Build & Deploy / 🏗️ Build (push) Successful in 7m19s
Build & Deploy / 🚀 Deploy (push) Successful in 27s
Build & Deploy / 🧪 Smoke Test (push) Failing after 1m1s
Build & Deploy / 🔔 Notify (push) Successful in 3s
2026-02-17 21:29:53 +01:00
e7c482dabf chore(git): Add pre-push hook to enforce 'v' prefix on tags
Some checks failed
Build & Deploy / 🔍 Prepare (push) Successful in 8s
Build & Deploy / 🏗️ Build (push) Has been cancelled
Build & Deploy / 🚀 Deploy (push) Has been cancelled
Build & Deploy / 🧪 Smoke Test (push) Has been cancelled
Build & Deploy / 🔔 Notify (push) Has been cancelled
Build & Deploy / 🧪 QA (push) Has been cancelled
2026-02-17 21:25:57 +01:00
8974d89b33 fix(ci): Support semantic version tags without 'v' prefix
Some checks failed
Build & Deploy / 🔍 Prepare (push) Successful in 7s
Build & Deploy / 🧪 QA (push) Successful in 1m22s
Build & Deploy / 🚀 Deploy (push) Has been cancelled
Build & Deploy / 🧪 Smoke Test (push) Has been cancelled
Build & Deploy / 🔔 Notify (push) Has been cancelled
Build & Deploy / 🏗️ Build (push) Has been cancelled
2026-02-17 21:23:15 +01:00
f99ca4d35d fix(blog): Correct MDX syntax in billion-euro-package post
All checks were successful
Build & Deploy / 🔍 Prepare (push) Successful in 12s
Build & Deploy / 🧪 QA (push) Successful in 1m42s
Build & Deploy / 🏗️ Build (push) Successful in 4m3s
Build & Deploy / 🚀 Deploy (push) Successful in 31s
Build & Deploy / 🧪 Smoke Test (push) Successful in 48s
Build & Deploy / 🔔 Notify (push) Successful in 2s
2026-02-17 20:19:22 +01:00
d10f15abe3 fix(infra): resolve gatekeeper label overwrite and alias collision
All checks were successful
Build & Deploy / 🔍 Prepare (push) Successful in 8s
Build & Deploy / 🧪 QA (push) Successful in 1m43s
Build & Deploy / 🏗️ Build (push) Successful in 7m12s
Build & Deploy / 🚀 Deploy (push) Successful in 31s
Build & Deploy / 🧪 Smoke Test (push) Successful in 56s
Build & Deploy / 🔔 Notify (push) Successful in 2s
2026-02-17 17:50:45 +01:00
9bdbcc2803 fix(orchestration): namespace Traefik labels with PROJECT_NAME to avoid collisions
All checks were successful
Build & Deploy / 🔍 Prepare (push) Successful in 11s
Build & Deploy / 🧪 QA (push) Successful in 1m39s
Build & Deploy / 🏗️ Build (push) Successful in 7m8s
Build & Deploy / 🚀 Deploy (push) Successful in 31s
Build & Deploy / 🧪 Smoke Test (push) Successful in 56s
Build & Deploy / 🔔 Notify (push) Successful in 1s
2026-02-17 17:06:16 +01:00
b08f07494c fix(orchestration): remove hardcoded external volume to fix pipeline failure
Some checks failed
Build & Deploy / 🔍 Prepare (push) Successful in 8s
Build & Deploy / 🧪 QA (push) Successful in 1m41s
Build & Deploy / 🏗️ Build (push) Successful in 2m52s
Build & Deploy / 🚀 Deploy (push) Successful in 26s
Build & Deploy / 🧪 Smoke Test (push) Failing after 45s
Build & Deploy / 🔔 Notify (push) Successful in 2s
2026-02-17 16:53:57 +01:00
1f758758e3 fix: restore CMS connectivity and schema
Some checks failed
Build & Deploy / 🔍 Prepare (push) Successful in 13s
Build & Deploy / 🧪 QA (push) Successful in 1m43s
Build & Deploy / 🏗️ Build (push) Successful in 7m8s
Build & Deploy / 🚀 Deploy (push) Failing after 19s
Build & Deploy / 🧪 Smoke Test (push) Has been skipped
Build & Deploy / 🔔 Notify (push) Successful in 2s
- Exposed Directus port 8055 for local health checks and scripting
- Added scripts to fix admin token and manually create missing collections
- Verified all service health checks are passing
2026-02-17 16:20:03 +01:00
fb8d9574b6 fix: resolve contact page 500 and Leaflet initialization errors
- Fixed Docker service names and volume configuration
- Bootstrapped Directus and applied schema
- Updated DIRECTUS_URL to local instance in .env
- Implemented manual Leaflet lifecycle management in LeafletMap.tsx
  to prevent re-initialization error
2026-02-17 16:13:31 +01:00
6856b7835c fix(deploy): enforce project name klz-cablescom for production to persist data volume
All checks were successful
Build & Deploy / 🔍 Prepare (push) Successful in 7s
Build & Deploy / 🧪 QA (push) Successful in 1m26s
Build & Deploy / 🏗️ Build (push) Successful in 7m1s
Build & Deploy / 🚀 Deploy (push) Successful in 26s
Build & Deploy / 🧪 Smoke Test (push) Successful in 1m0s
Build & Deploy / 🔔 Notify (push) Successful in 2s
2026-02-17 13:38:41 +01:00
1d074ba6d2 fix(infra): split PathPrefix into single-arg calls for Traefik v3
All checks were successful
Build & Deploy / 🔍 Prepare (push) Successful in 30s
Build & Deploy / 🧪 QA (push) Successful in 1m48s
Build & Deploy / 🏗️ Build (push) Successful in 8m0s
Build & Deploy / 🚀 Deploy (push) Successful in 21s
Build & Deploy / 🧪 Smoke Test (push) Successful in 49s
Build & Deploy / 🔔 Notify (push) Successful in 2s
Traefik v3 only accepts one argument per PathPrefix. The multi-arg syntax
silently invalidated the entire public router, causing OG images, health,
sitemap and robots.txt to fall through to the auth-protected main router.
2026-02-17 02:09:54 +01:00
0e972983bc fix(infra): add TLS entrypoint/certresolver to deploy env generation
Some checks failed
Build & Deploy / 🔍 Prepare (push) Successful in 11s
Build & Deploy / 🧪 QA (push) Successful in 1m27s
Build & Deploy / 🚀 Deploy (push) Has been cancelled
Build & Deploy / 🧪 Smoke Test (push) Has been cancelled
Build & Deploy / 🔔 Notify (push) Has been cancelled
Build & Deploy / 🏗️ Build (push) Has been cancelled
All Traefik routers were defaulting to entrypoints=web with tls=false,
making the app unreachable over HTTPS. Production worked because it had
these values set from a previous deploy, but testing never received them.
2026-02-17 02:06:34 +01:00
c979582193 fix(middleware): exclude static assets from matcher to prevent 404s on images
Some checks failed
Build & Deploy / 🔍 Prepare (push) Successful in 9s
Build & Deploy / 🧪 QA (push) Successful in 1m46s
Build & Deploy / 🚀 Deploy (push) Has been cancelled
Build & Deploy / 🏗️ Build (push) Has been cancelled
Build & Deploy / 🧪 Smoke Test (push) Has been cancelled
Build & Deploy / 🔔 Notify (push) Has been cancelled
2026-02-17 02:00:06 +01:00
e47ba31763 fix(middleware): rename proxy.ts back to middleware.ts convention to fix OG image routing
Some checks failed
Build & Deploy / 🔍 Prepare (push) Successful in 11s
Build & Deploy / 🧪 QA (push) Successful in 1m38s
Build & Deploy / 🏗️ Build (push) Successful in 3m51s
Build & Deploy / 🚀 Deploy (push) Successful in 30s
Build & Deploy / 🧪 Smoke Test (push) Failing after 59s
Build & Deploy / 🔔 Notify (push) Successful in 2s
2026-02-17 01:48:11 +01:00
28072908f7 fix(og-image): resolve 404s, migrate middleware to proxy.ts, and fix local port conflict
Some checks failed
Build & Deploy / 🔍 Prepare (push) Successful in 14s
Build & Deploy / 🧪 QA (push) Successful in 1m40s
Build & Deploy / 🏗️ Build (push) Successful in 3m59s
Build & Deploy / 🚀 Deploy (push) Successful in 30s
Build & Deploy / 🧪 Smoke Test (push) Failing after 52s
Build & Deploy / 🔔 Notify (push) Successful in 2s
2026-02-17 01:31:13 +01:00
7e6b4a3ed7 fix: pipeline
Some checks failed
Build & Deploy / 🔍 Prepare (push) Successful in 12s
Build & Deploy / 🧪 QA (push) Successful in 1m49s
Build & Deploy / 🏗️ Build (push) Successful in 3m51s
Build & Deploy / 🚀 Deploy (push) Successful in 31s
Build & Deploy / 🧪 Smoke Test (push) Failing after 54s
Build & Deploy / 🔔 Notify (push) Successful in 2s
2026-02-16 23:31:24 +01:00
d7e5a57344 fix: pipeline
Some checks failed
Build & Deploy / 🔍 Prepare (push) Successful in 11s
Build & Deploy / 🧪 QA (push) Successful in 1m43s
Build & Deploy / 🏗️ Build (push) Successful in 3m58s
Build & Deploy / 🚀 Deploy (push) Successful in 25s
Build & Deploy / 🧪 Smoke Test (push) Failing after 56s
Build & Deploy / 🔔 Notify (push) Successful in 2s
2026-02-16 23:18:41 +01:00
c859d5e677 fix: pipeline
Some checks failed
Build & Deploy / 🔍 Prepare (push) Successful in 8s
Build & Deploy / 🧪 QA (push) Successful in 1m26s
Build & Deploy / 🏗️ Build (push) Successful in 2m51s
Build & Deploy / 🚀 Deploy (push) Successful in 28s
Build & Deploy / 🧪 Smoke Test (push) Failing after 1m50s
Build & Deploy / 🔔 Notify (push) Successful in 3s
2026-02-16 23:08:12 +01:00
e036dea089 fix: pipeline
Some checks failed
Build & Deploy / 🔍 Prepare (push) Successful in 8s
Build & Deploy / 🧪 QA (push) Successful in 1m24s
Build & Deploy / 🏗️ Build (push) Successful in 3m49s
Build & Deploy / 🚀 Deploy (push) Successful in 31s
Build & Deploy / 🧪 Smoke Test (push) Failing after 53s
Build & Deploy / 🔔 Notify (push) Successful in 2s
2026-02-16 22:35:39 +01:00
39088ca868 fix: build
Some checks failed
Build & Deploy / 🔍 Prepare (push) Successful in 11s
Build & Deploy / 🧪 QA (push) Successful in 1m39s
Build & Deploy / 🏗️ Build (push) Successful in 2m50s
Build & Deploy / 🚀 Deploy (push) Successful in 30s
Build & Deploy / 🧪 Smoke Test (push) Failing after 47s
Build & Deploy / 🔔 Notify (push) Successful in 2s
2026-02-16 21:32:24 +01:00
18f9104623 fix: build
Some checks failed
Build & Deploy / 🔍 Prepare (push) Successful in 11s
Build & Deploy / 🧪 QA (push) Successful in 1m44s
Build & Deploy / 🏗️ Build (push) Successful in 6m58s
Build & Deploy / 🚀 Deploy (push) Successful in 21s
Build & Deploy / 🧪 Smoke Test (push) Failing after 53s
Build & Deploy / 🔔 Notify (push) Successful in 1s
2026-02-16 21:06:06 +01:00
76f745cc87 fix: resolve lint and build errors
Some checks failed
Build & Deploy / 🔍 Prepare (push) Successful in 6s
Build & Deploy / 🧪 QA (push) Successful in 1m22s
Build & Deploy / 🏗️ Build (push) Failing after 1m0s
Build & Deploy / 🚀 Deploy (push) Has been skipped
Build & Deploy / 🧪 Smoke Test (push) Has been skipped
Build & Deploy / 🔔 Notify (push) Successful in 1s
- Added 'use client' to not-found.tsx
- Refactored RelatedProducts to Server Component to fix 'fs' import error
- Created RelatedProductLink for client-side analytics
- Fixed lint syntax issues in RecordModeVisuals.tsx
- Fixed rule-of-hooks violation in WebsiteVideo.tsx
2026-02-16 18:50:34 +01:00
848d58010f refactor(middleware): upgrade locale redirects from 307 to 308 for better scanner compatibility
Some checks failed
Build & Deploy / 🔍 Prepare (push) Successful in 13s
Build & Deploy / 🧪 QA (push) Failing after 54s
Build & Deploy / 🏗️ Build (push) Has been skipped
Build & Deploy / 🚀 Deploy (push) Has been skipped
Build & Deploy / 🧪 Smoke Test (push) Has been skipped
Build & Deploy / 🔔 Notify (push) Successful in 1s
2026-02-16 18:45:33 +01:00
c0f5799667 feat(analytics): add blog engagement, ToC tracking, and 404 monitoring
Some checks failed
Build & Deploy / 🔍 Prepare (push) Successful in 6s
Build & Deploy / 🧪 QA (push) Failing after 1m13s
Build & Deploy / 🏗️ Build (push) Has been skipped
Build & Deploy / 🚀 Deploy (push) Has been skipped
Build & Deploy / 🧪 Smoke Test (push) Has been skipped
Build & Deploy / 🔔 Notify (push) Successful in 2s
- Added BlogEngagementTracker for reading time and completion tracking
- Added ToC click tracking in blog posts
- Added global 404 error monitoring in not-found.tsx
- Completed 'Total Transparency' suite
2026-02-16 18:31:28 +01:00
0e089f9471 feat(analytics): implement total transparency suite and SEO metadata standardization
- Added global ScrollDepthTracker (25%, 50%, 75%, 100%)
- Implemented ProductEngagementTracker for deep product analytics
- Added field-level tracking to ContactForm and RequestQuoteForm
- Standardized SEO metadata (canonical, alternates, x-default) across all routes
- Created reusable TrackedLink and TrackedButton components for server components
- Fixed 'useAnalytics' hook error in Footer.tsx by adding 'use client'
2026-02-16 18:30:29 +01:00
52b17423dd feat(analytics): add umami data distribution refinement script and cleanup temporary data exports 2026-02-16 18:08:58 +01:00
bfd3c8164b fix(infra): resolve local directus service matching, improve branding script flexibility, and cleanup build artifacts 2026-02-16 18:07:56 +01:00
b091175b89 feat: conditionally enable recording studio and feedback tool via env vars
Some checks failed
Build & Deploy / 🔍 Prepare (push) Successful in 7s
Build & Deploy / 🧪 QA (push) Failing after 1m12s
Build & Deploy / 🏗️ Build (push) Has been skipped
Build & Deploy / 🚀 Deploy (push) Has been skipped
Build & Deploy / 🧪 Smoke Test (push) Has been skipped
Build & Deploy / 🔔 Notify (push) Successful in 1s
2026-02-15 20:59:12 +01:00
1baf03a84e feat(record-mode): unify mouse tool and enhance visuals
Some checks failed
Build & Deploy / 🔍 Prepare (push) Successful in 7s
Build & Deploy / 🧪 QA (push) Failing after 1m6s
Build & Deploy / 🏗️ Build (push) Has been skipped
Build & Deploy / 🚀 Deploy (push) Has been skipped
Build & Deploy / 🧪 Smoke Test (push) Has been skipped
Build & Deploy / 🔔 Notify (push) Successful in 1s
2026-02-15 18:25:52 +01:00
483dfabe10 feat: refactor clicks to generic mouse interactions with click/hover subtypes 2026-02-15 18:17:10 +01:00
65f8b2c485 style: sharpen Studio hover previews by removing blur and diffuse shadows 2026-02-15 18:14:13 +01:00
90cdd7e713 feat: enhance Recording Studio with reorderable events, origin options, and hover previews 2026-02-15 18:13:25 +01:00
40fa2a7721 fix: industrial accuracy for record mode events via cross-window sync 2026-02-15 18:10:59 +01:00
a136e7b4a7 feat: optimize event capturing and playback accuracy 2026-02-15 18:06:50 +01:00
e615d88fd8 chore: remove temporary test file contact.html
Some checks failed
Build & Deploy / 🔍 Prepare (push) Successful in 8s
Build & Deploy / 🧪 QA (push) Successful in 1m38s
Build & Deploy / 🏗️ Build (push) Successful in 6m16s
Build & Deploy / 🚀 Deploy (push) Successful in 31s
Build & Deploy / 🧪 Smoke Test (push) Failing after 56s
Build & Deploy / 🔔 Notify (push) Successful in 2s
2026-02-13 01:38:37 +01:00
3d498f3df8 fix(og): enable automatic OG image discovery and refine Traefik whitelist
Some checks failed
Build & Deploy / 🔍 Prepare (push) Successful in 7s
Build & Deploy / 🏗️ Build (push) Has been cancelled
Build & Deploy / 🚀 Deploy (push) Has been cancelled
Build & Deploy / 🧪 Smoke Test (push) Has been cancelled
Build & Deploy / 🔔 Notify (push) Has been cancelled
Build & Deploy / 🧪 QA (push) Has been cancelled
- Removed manual 'images' metadata overrides.
- This allows Next.js to use built-in automatic discovery.
- Ensures metadata uses the dynamic metadataBase from the environment.
- Refined Traefik public router regex for sub-routes.
- Restored and verified imports in modified page.tsx files.
2026-02-13 01:38:26 +01:00
121 changed files with 37703 additions and 1711 deletions

11
.env
View File

@@ -1,10 +1,12 @@
# Application
NODE_ENV=production
NEXT_PUBLIC_BASE_URL=https://klz-cables.com
UMAMI_WEBSITE_ID=e4a2cd1c-59fb-4e5b-bac5-9dfd1d02dd81
UMAMI_API_ENDPOINT=https://analytics.infra.mintel.me
SENTRY_DSN=https://c10957d0182245b1a2a806ac2d34a197@errors.infra.mintel.me/1
LOG_LEVEL=info
NEXT_PUBLIC_FEEDBACK_ENABLED=true
NEXT_PUBLIC_FEEDBACK_ENABLED=false
NEXT_PUBLIC_RECORD_MODE_ENABLED=false
# SMTP Configuration
MAIL_HOST=smtp.eu.mailgun.org
@@ -15,15 +17,15 @@ MAIL_FROM="KLZ Cables <postmaster@mg.mintel.me>"
MAIL_RECIPIENTS=marc@cablecreations.de,info@klz-cables.com
# Directus
DIRECTUS_URL=https://cms.klz-cables.com
DIRECTUS_URL=http://klz-cms:8055
DIRECTUS_KEY=59fb8f4c1a51b18fe28ad947f713914e
DIRECTUS_SECRET=7459038d41401dfb11254cf7f1ef2d0f
DIRECTUS_ADMIN_EMAIL=marc@mintel.me
DIRECTUS_ADMIN_PASSWORD=Tim300493.
DIRECTUS_API_TOKEN=59fb8f4c1a51b18fe28ad947f713914e
DIRECTUS_DB_NAME=directus
DIRECTUS_DB_USER=directus
DIRECTUS_DB_PASSWORD=directus
DIRECTUS_DB_USER=klz_db_user
DIRECTUS_DB_PASSWORD=klz_db_pass
# Local Development
PROJECT_NAME=klz-cables
GATEKEEPER_BYPASS_ENABLED=true
@@ -33,3 +35,4 @@ GATEKEEPER_PASSWORD=klz2026
COOKIE_DOMAIN=localhost
INFRA_DIRECTUS_URL=http://localhost:8059
INFRA_DIRECTUS_TOKEN=59fb8f4c1a51b18fe28ad947f713914e
GATEKEEPER_ORIGIN=http://klz.localhost

View File

@@ -15,6 +15,7 @@ DIRECTUS_PORT=8055
# NEXT_PUBLIC_TARGET makes this information available to the frontend
TARGET=development
NEXT_PUBLIC_FEEDBACK_ENABLED=false
NEXT_PUBLIC_RECORD_MODE_ENABLED=true
# ────────────────────────────────────────────────────────────────────────────
# Analytics (Umami)
@@ -56,6 +57,9 @@ SENTRY_DSN=
IMAGE_TAG=latest
TRAEFIK_HOST=klz-cables.com
ENV_FILE=.env
# IMGPROXY_URL: The backend URL of the imgproxy instance (e.g. img.infra.mintel.me)
# Next.js will proxy requests from /_img to this URL.
IMGPROXY_URL=https://img.infra.mintel.me
# ────────────────────────────────────────────────────────────────────────────
# Varnish Configuration

View File

@@ -33,4 +33,10 @@ jobs:
NPM_TOKEN: ${{ secrets.REGISTRY_PASS }}
- name: 🧪 QA Checks
run: 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

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

5
.gitignore vendored
View File

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

32
.husky/pre-push Executable file
View File

@@ -0,0 +1,32 @@
#!/bin/sh
# Husky pre-push hook to validate tags
# Strictly enforces that all pushed tags start with 'v' (e.g., v1.0.0)
z40=0000000000000000000000000000000000000000
while read local_ref local_sha remote_ref remote_sha
do
# Check if we are pushing a tag
case "$local_ref" in
refs/tags/*)
tag_name="${local_ref#refs/tags/}"
if ! echo "$tag_name" | grep -q "^v[0-9]"; then
echo ""
echo "❌ ERROR: Invalid tag name '$tag_name'"
echo "--------------------------------------------------"
echo "Consistency check failed: All tags MUST start with 'v'."
echo "Example: v1.0.10"
echo ""
echo "Please delete the invalid tag and create a new one:"
echo " git tag -d $tag_name"
echo " git tag v$tag_name"
echo "--------------------------------------------------"
echo ""
exit 1
fi
;;
esac
done
exit 0

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

@@ -1,5 +1,5 @@
# Stage 1: Builder
FROM registry.infra.mintel.me/mintel/nextjs:v1.7.10 AS builder
FROM registry.infra.mintel.me/mintel/nextjs:v1.7.10 AS base
WORKDIR /app
# Arguments for build-time configuration
@@ -34,10 +34,17 @@ RUN --mount=type=cache,id=pnpm,target=/pnpm/store \
# Copy source code
COPY . .
# Stage 2: Development (Hot-Reloading)
FROM base AS development
ENV NODE_ENV=development
CMD ["pnpm", "dev:local"]
# Build application
# Stage 3: Builder (Production)
FROM base AS builder
RUN pnpm build
# Stage 2: Runner
# Stage 3: Runner
FROM registry.infra.mintel.me/mintel/runtime:v1.7.10 AS runner
WORKDIR /app

View File

@@ -3,6 +3,8 @@ import { getPageBySlug } from '@/lib/pages';
import { OGImageTemplate } from '@/components/OGImageTemplate';
import { getOgFonts, OG_IMAGE_SIZE } from '@/lib/og-helper';
export const size = OG_IMAGE_SIZE;
export const contentType = 'image/png';
export const runtime = 'nodejs';
export default async function Image({

View File

@@ -5,8 +5,8 @@ import { getTranslations, setRequestLocale } from 'next-intl/server';
import { Metadata } from 'next';
import { getPageBySlug, getAllPages } from '@/lib/pages';
import { mdxComponents } from '@/components/blog/MDXComponents';
import { getOGImageMetadata } from '@/lib/metadata';
import { SITE_URL } from '@/lib/schema';
import TrackedLink from '@/components/analytics/TrackedLink';
interface PageProps {
params: Promise<{
@@ -39,18 +39,17 @@ export async function generateMetadata({ params }: PageProps): Promise<Metadata>
title: pageData.frontmatter.title,
description: pageData.frontmatter.excerpt || '',
alternates: {
canonical: `/${locale}/${slug}`,
canonical: `${SITE_URL}/${locale}/${slug}`,
languages: {
de: `/de/${slug}`,
en: `/en/${slug}`,
'x-default': `/en/${slug}`,
de: `${SITE_URL}/de/${slug}`,
en: `${SITE_URL}/en/${slug}`,
'x-default': `${SITE_URL}/en/${slug}`,
},
},
openGraph: {
title: `${pageData.frontmatter.title} | KLZ Cables`,
description: pageData.frontmatter.excerpt || '',
url: `${SITE_URL}/${locale}/${slug}`,
images: getOGImageMetadata(slug, pageData.frontmatter.title, locale),
},
twitter: {
card: 'summary_large_image',
@@ -112,15 +111,19 @@ export default async function StandardPage({ params }: PageProps) {
<div className="relative z-10 max-w-2xl">
<h3 className="text-2xl md:text-3xl font-bold mb-4">{t('needHelp')}</h3>
<p className="text-lg text-white/70 mb-8">{t('supportTeamAvailable')}</p>
<a
<TrackedLink
href={`/${locale}/contact`}
className="inline-flex items-center px-8 py-4 bg-accent text-primary-dark font-bold rounded-full hover:bg-white transition-all duration-300 group/link"
eventProperties={{
location: 'generic_page_support_cta',
page_slug: slug,
}}
>
{t('contactUs')}
<span className="ml-2 transition-transform group-hover/link:translate-x-1">
&rarr;
</span>
</a>
</TrackedLink>
</div>
</div>
</div>

View File

@@ -4,6 +4,8 @@ import { OGImageTemplate } from '@/components/OGImageTemplate';
import { getOgFonts, OG_IMAGE_SIZE } from '@/lib/og-helper';
import { SITE_URL } from '@/lib/schema';
export const size = OG_IMAGE_SIZE;
export const contentType = 'image/png';
export const runtime = 'nodejs';
export default async function Image({

View File

@@ -10,8 +10,8 @@ import PowerCTA from '@/components/blog/PowerCTA';
import TableOfContents from '@/components/blog/TableOfContents';
import { mdxComponents } from '@/components/blog/MDXComponents';
import { Heading } from '@/components/ui';
import { getOGImageMetadata } from '@/lib/metadata';
import { setRequestLocale } from 'next-intl/server';
import BlogEngagementTracker from '@/components/analytics/BlogEngagementTracker';
interface BlogPostProps {
params: Promise<{
@@ -31,12 +31,7 @@ export async function generateMetadata({ params }: BlogPostProps): Promise<Metad
title: post.frontmatter.title,
description: description,
alternates: {
canonical: `/${locale}/blog/${slug}`,
languages: {
de: `/de/blog/${slug}`,
en: `/en/blog/${slug}`,
'x-default': `/en/blog/${slug}`,
},
canonical: `${SITE_URL}/${locale}/blog/${slug}`,
},
openGraph: {
title: `${post.frontmatter.title} | KLZ Cables`,
@@ -45,7 +40,6 @@ export async function generateMetadata({ params }: BlogPostProps): Promise<Metad
publishedTime: post.frontmatter.date,
authors: ['KLZ Cables'],
url: `${SITE_URL}/${locale}/blog/${slug}`,
images: getOGImageMetadata(`blog/${slug}`, post.frontmatter.title, locale),
},
twitter: {
card: 'summary_large_image',
@@ -69,6 +63,12 @@ export default async function BlogPost({ params }: BlogPostProps) {
return (
<article className="bg-white min-h-screen font-sans selection:bg-primary/10 selection:text-primary">
<BlogEngagementTracker
title={post.frontmatter.title}
slug={slug}
category={post.frontmatter.category}
readingTime={getReadingTime(post.content)}
/>
{/* Featured Image Header */}
{post.frontmatter.featuredImage ? (
<div className="relative w-full h-[70vh] min-h-[500px] overflow-hidden group">

View File

@@ -3,6 +3,8 @@ import { getTranslations } from 'next-intl/server';
import { OGImageTemplate } from '@/components/OGImageTemplate';
import { getOgFonts, OG_IMAGE_SIZE } from '@/lib/og-helper';
export const size = OG_IMAGE_SIZE;
export const contentType = 'image/png';
export const runtime = 'nodejs';
export default async function Image({ params }: { params: Promise<{ locale: string }> }) {

View File

@@ -3,8 +3,8 @@ import Image from 'next/image';
import { getAllPosts } from '@/lib/blog';
import { Section, Container, Heading, Card, Badge, Button } from '@/components/ui';
import Reveal from '@/components/Reveal';
import { getTranslations } from 'next-intl/server';
import { getOGImageMetadata } from '@/lib/metadata';
import { Metadata } from 'next';
import { getTranslations, setRequestLocale } from 'next-intl/server';
import { SITE_URL } from '@/lib/schema';
interface BlogIndexProps {
@@ -20,18 +20,17 @@ export async function generateMetadata({ params }: BlogIndexProps) {
title: t('title'),
description: t('description'),
alternates: {
canonical: `/${locale}/blog`,
canonical: `${SITE_URL}/${locale}/blog`,
languages: {
de: '/de/blog',
en: '/en/blog',
'x-default': '/en/blog',
de: `${SITE_URL}/de/blog`,
en: `${SITE_URL}/en/blog`,
'x-default': `${SITE_URL}/en/blog`,
},
},
openGraph: {
title: `${t('title')} | KLZ Cables`,
description: t('description'),
url: `${SITE_URL}/${locale}/blog`,
images: getOGImageMetadata('blog', t('title'), locale),
},
twitter: {
card: 'summary_large_image',
@@ -43,6 +42,7 @@ export async function generateMetadata({ params }: BlogIndexProps) {
export default async function BlogIndex({ params }: BlogIndexProps) {
const { locale } = await params;
setRequestLocale(locale);
const t = await getTranslations('Blog');
const posts = await getAllPosts(locale);
@@ -58,7 +58,7 @@ export default async function BlogIndex({ params }: BlogIndexProps) {
<div className="bg-neutral-light min-h-screen">
{/* Hero Section - Immersive Magazine Feel */}
<Reveal>
<section className="relative h-[50vh] md:h-[70vh] min-h-[400px] md:min-h-[600px] flex items-center overflow-hidden bg-primary-dark">
<article className="relative h-[50vh] md:h-[70vh] min-h-[400px] md:min-h-[600px] flex items-center overflow-hidden bg-primary-dark">
{featuredPost && featuredPost.frontmatter.featuredImage && (
<>
<Image
@@ -101,7 +101,7 @@ export default async function BlogIndex({ params }: BlogIndexProps) {
)}
</div>
</Container>
</section>
</article>
</Reveal>
<Section className="bg-neutral-light py-12 md:py-28">
@@ -146,7 +146,10 @@ export default async function BlogIndex({ params }: BlogIndexProps) {
{remainingPosts.map((post, idx) => (
<Reveal key={post.slug} delay={idx * 100}>
<Link href={`/${locale}/blog/${post.slug}`} className="group block">
<Card className="h-full flex flex-col border-none shadow-lg hover:shadow-2xl transition-all duration-500 rounded-2xl md:rounded-3xl overflow-hidden">
<Card
tag="article"
className="h-full flex flex-col border-none shadow-lg hover:shadow-2xl transition-all duration-500 rounded-2xl md:rounded-3xl overflow-hidden"
>
{post.frontmatter.featuredImage && (
<div className="relative h-48 md:h-72 overflow-hidden">
<Image

View File

@@ -3,6 +3,8 @@ import { getTranslations } from 'next-intl/server';
import { OGImageTemplate } from '@/components/OGImageTemplate';
import { getOgFonts, OG_IMAGE_SIZE } from '@/lib/og-helper';
export const size = OG_IMAGE_SIZE;
export const contentType = 'image/png';
export const runtime = 'nodejs';
export default async function Image({ params }: { params: Promise<{ locale: string }> }) {

View File

@@ -26,8 +26,9 @@ export async function generateMetadata({ params }: ContactPageProps): Promise<Me
alternates: {
canonical: `${SITE_URL}/${locale}/contact`,
languages: {
'de-DE': '/de/contact',
'en-US': '/en/contact',
de: `${SITE_URL}/de/contact`,
en: `${SITE_URL}/en/contact`,
'x-default': `${SITE_URL}/en/contact`,
},
},
openGraph: {
@@ -35,7 +36,6 @@ export async function generateMetadata({ params }: ContactPageProps): Promise<Me
description,
url: `${SITE_URL}/${locale}/contact`,
siteName: 'KLZ Cables',
images: getOGImageMetadata('contact', title, locale),
locale: `${locale.toUpperCase()}_DE`,
type: 'website',
},
@@ -43,7 +43,6 @@ export async function generateMetadata({ params }: ContactPageProps): Promise<Me
card: 'summary_large_image',
title: `${title} | KLZ Cables`,
description,
images: [`${SITE_URL}/${locale}/contact/opengraph-image`],
},
robots: {
index: true,
@@ -137,7 +136,7 @@ export default async function ContactPage({ params }: ContactPageProps) {
<Heading level={3} subtitle={t('info.subtitle')} className="mb-6 md:mb-8">
{t('info.howToReachUs')}
</Heading>
<div className="space-y-4 md:space-y-8">
<address className="space-y-4 md:space-y-8 not-italic">
<div className="flex items-start gap-4 md:gap-6 group">
<div className="w-10 h-10 md:w-14 md:h-14 rounded-xl md:rounded-2xl bg-saturated/10 flex items-center justify-center text-saturated group-hover:bg-accent group-hover:text-primary-dark transition-all duration-300 shadow-sm flex-shrink-0">
<svg
@@ -198,7 +197,7 @@ export default async function ContactPage({ params }: ContactPageProps) {
</a>
</div>
</div>
</div>
</address>
</div>
<div className="p-6 md:p-10 bg-white rounded-2xl md:rounded-3xl border border-neutral-medium shadow-sm animate-fade-in">

View File

@@ -1,12 +1,16 @@
import Footer from '@/components/Footer';
import Header from '@/components/Header';
import JsonLd from '@/components/JsonLd';
import AnalyticsProvider from '@/components/analytics/AnalyticsProvider';
import SkipLink from '@/components/SkipLink';
import CMSConnectivityNotice from '@/components/CMSConnectivityNotice';
import { FeedbackOverlay } from '@mintel/next-feedback';
import { RecordModeProvider } from '@/components/record-mode/RecordModeContext';
import { RecordModeVisuals } from '@/components/record-mode/RecordModeVisuals';
import { ToolCoordinator } from '@/components/record-mode/ToolCoordinator';
import AnalyticsShell from '@/components/analytics/AnalyticsShell';
import { Metadata, Viewport } from 'next';
import { NextIntlClientProvider } from 'next-intl';
import { getMessages } from 'next-intl/server';
import dynamic from 'next/dynamic';
import { Suspense } from 'react';
import '../../styles/globals.css';
import { SITE_URL } from '@/lib/schema';
@@ -20,43 +24,55 @@ const inter = Inter({
variable: '--font-inter',
});
export const metadata: Metadata = {
metadataBase: new URL(SITE_URL),
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 async function generateMetadata(props: {
params: Promise<{ locale: string }>;
}): Promise<Metadata> {
const params = await props.params;
const { locale } = params;
return {
metadataBase: new URL(SITE_URL),
manifest: '/manifest.webmanifest',
alternates: {
canonical: locale === 'en' ? '/' : `/${locale}`,
languages: {
de: '/de',
en: '/en',
},
},
icons: {
icon: [
{ url: '/favicon.ico', sizes: 'any' },
{ url: '/logo-blue.svg', type: 'image/svg+xml' },
],
apple: [{ url: '/apple-touch-icon.png', sizes: '180x180', type: 'image/png' }],
},
};
}
export const viewport: Viewport = {
width: 'device-width',
initialScale: 1,
maximumScale: 1,
userScalable: false,
maximumScale: 5,
userScalable: true,
viewportFit: 'cover',
themeColor: '#001a4d',
};
export default async function LocaleLayout({
children,
params,
}: {
export default async function Layout(props: {
children: React.ReactNode;
params: Promise<{ locale: string }>;
}) {
const { locale } = await params;
// Ensure locale is a valid string, fallback to 'en'
const params = await props.params;
const { locale } = params;
const { children } = props;
const supportedLocales = ['en', 'de'];
const localeStr = (typeof locale === 'string' ? locale : '').trim();
const safeLocale = supportedLocales.includes(localeStr) ? localeStr : 'en';
setRequestLocale(safeLocale);
let messages = {};
let messages: Record<string, any> = {};
try {
messages = await getMessages();
} catch (error) {
@@ -64,12 +80,18 @@ export default async function LocaleLayout({
messages = {};
}
// Track pageview on the server with high-fidelity header context
// Pick only the namespaces required by client components to reduce the hydration payload size
const clientKeys = ['Footer', 'Navigation', 'Contact', 'Products', 'Team', 'Home'];
const clientMessages: Record<string, any> = {};
for (const key of clientKeys) {
if (messages[key]) {
clientMessages[key] = messages[key];
}
}
const { getServerAppServices } = await import('@/lib/services/create-services.server');
const serverServices = getServerAppServices();
// We wrap this in a try-catch to allow static rendering during build
// headers() and cookies() force dynamic rendering in Next.js
try {
const { headers } = await import('next/headers');
const requestHeaders = await headers();
@@ -83,10 +105,9 @@ export default async function LocaleLayout({
});
}
// Track initial server-side pageview
serverServices.analytics.trackPageview();
// Server-side analytics tracking removed to prevent duplicate/empty events.
// Client-side AnalyticsProvider handles all pageviews.
} catch {
// Falls back to noop or client-side only during static generation
if (process.env.NODE_ENV !== 'production' || !process.env.CI) {
console.warn(
'[Layout] Static generation detected or headers unavailable, skipping server-side analytics context',
@@ -94,20 +115,39 @@ export default async function LocaleLayout({
}
}
// Read directly from process.env — bypasses all abstraction to guarantee correctness
const recordModeEnabled = process.env.NEXT_PUBLIC_RECORD_MODE_ENABLED === 'true';
const feedbackEnabled = process.env.NEXT_PUBLIC_FEEDBACK_ENABLED === 'true';
return (
<html lang={safeLocale} className={`scroll-smooth overflow-x-hidden ${inter.variable}`}>
<head>
<link rel="preconnect" href="https://fonts.googleapis.com" />
<link rel="preconnect" href="https://fonts.gstatic.com" crossOrigin="anonymous" />
<link rel="preconnect" href="https://img.infra.mintel.me" />
</head>
<body className="flex flex-col min-h-screen font-sans selection:bg-accent selection:text-primary-dark antialiased overflow-x-hidden">
<NextIntlClientProvider messages={messages} locale={safeLocale}>
<JsonLd />
<Header />
<main className="flex-grow animate-fade-in overflow-visible">{children}</main>
<Footer />
<CMSConnectivityNotice />
<NextIntlClientProvider messages={clientMessages} locale={safeLocale}>
<RecordModeProvider isEnabled={recordModeEnabled}>
<RecordModeVisuals>
<SkipLink />
<JsonLd />
<Header />
<main
id="main-content"
className="flex-grow animate-fade-in overflow-visible"
tabIndex={-1}
>
{children}
</main>
<Footer />
</RecordModeVisuals>
<Suspense fallback={null}>
<AnalyticsProvider />
</Suspense>
{config.feedbackEnabled && <FeedbackOverlay />}
<CMSConnectivityNotice />
<AnalyticsShell />
<ToolCoordinator feedbackEnabled={feedbackEnabled} />
</RecordModeProvider>
</NextIntlClientProvider>
</body>
</html>

View File

@@ -1,9 +1,21 @@
'use client';
import { useTranslations } from 'next-intl';
import { Container, Button, Heading } from '@/components/ui';
import Scribble from '@/components/Scribble';
import { useEffect } from 'react';
import { useAnalytics } from '@/components/analytics/useAnalytics';
import { AnalyticsEvents } from '@/components/analytics/analytics-events';
export default function NotFound() {
const t = useTranslations('Error.notFound');
const { trackEvent } = useAnalytics();
useEffect(() => {
trackEvent(AnalyticsEvents.ERROR, {
type: '404_not_found',
path: typeof window !== 'undefined' ? window.location.pathname : 'unknown',
});
}, [trackEvent]);
return (
<Container className="relative py-24 flex flex-col items-center justify-center text-center min-h-[70vh] overflow-hidden">
@@ -16,19 +28,17 @@ export default function NotFound() {
<Heading level={1} className="text-6xl md:text-8xl font-bold mb-2">
404
</Heading>
<Scribble
variant="circle"
className="w-[150%] h-[150%] -top-[25%] -left-[25%] text-accent/40"
<Scribble
variant="circle"
className="w-[150%] h-[150%] -top-[25%] -left-[25%] text-accent/40"
/>
</div>
<Heading level={2} className="text-2xl md:text-3xl font-bold mb-4 text-primary">
{t('title')}
</Heading>
<p className="text-white/60 mb-10 max-w-md text-lg">
{t('description')}
</p>
<p className="text-white/60 mb-10 max-w-md text-lg">{t('description')}</p>
<div className="flex flex-col sm:flex-row gap-4">
<Button href="/" variant="accent" size="lg">

View File

@@ -3,9 +3,12 @@ import { getTranslations } from 'next-intl/server';
import { OGImageTemplate } from '@/components/OGImageTemplate';
import { getOgFonts, OG_IMAGE_SIZE } from '@/lib/og-helper';
export const size = OG_IMAGE_SIZE;
export const contentType = 'image/png';
export const runtime = 'nodejs';
export default async function Image({ params }: { params: Promise<{ locale: string }> }) {
console.log('🖼️ OG Image Handler Called');
const { locale } = await params;
const t = await getTranslations({ locale, namespace: 'Index.meta' });
const fonts = await getOgFonts();

View File

@@ -1,11 +1,12 @@
import Hero from '@/components/home/Hero';
import JsonLd from '@/components/JsonLd';
import { getBreadcrumbSchema, SITE_URL } from '@/lib/schema';
import ProductCategories from '@/components/home/ProductCategories';
import WhatWeDo from '@/components/home/WhatWeDo';
import dynamic from 'next/dynamic';
import Reveal from '@/components/Reveal';
const ProductCategories = dynamic(() => import('@/components/home/ProductCategories'));
const WhatWeDo = dynamic(() => import('@/components/home/WhatWeDo'));
const RecentPosts = dynamic(() => import('@/components/home/RecentPosts'));
const Experience = dynamic(() => import('@/components/home/Experience'));
const WhyChooseUs = dynamic(() => import('@/components/home/WhyChooseUs'));
@@ -79,17 +80,19 @@ export async function generateMetadata({
}
const title = t('title') || 'KLZ Cables';
const description = t('description') || '';
const description =
t('description') ||
'Ihr Experte für hochwertige Stromkabel, Mittelspannungslösungen und Solarkabel. Zuverlässige Infrastruktur für eine grüne Energiezukunft.';
return {
title,
description,
alternates: {
canonical: `/${locale}`,
canonical: `${SITE_URL}/${locale}`,
languages: {
de: '/de',
en: '/en',
'x-default': '/en',
de: `${SITE_URL}/de`,
en: `${SITE_URL}/en`,
'x-default': `${SITE_URL}/en`,
},
},
openGraph: {

View File

@@ -5,7 +5,7 @@ import ProductTabs from '@/components/ProductTabs';
import ProductTechnicalData from '@/components/ProductTechnicalData';
import RelatedProducts from '@/components/RelatedProducts';
import DatasheetDownload from '@/components/DatasheetDownload';
import { Badge, Container, Heading, Section } from '@/components/ui';
import { Badge, Card, Container, Heading, Section } from '@/components/ui';
import { getDatasheetPath } from '@/lib/datasheets';
import { getAllProducts, getProductBySlug } from '@/lib/mdx';
import { mapFileSlugToTranslated, mapSlugToFileSlug } from '@/lib/slugs';
@@ -16,6 +16,7 @@ import { MDXRemote } from 'next-mdx-remote/rsc';
import Image from 'next/image';
import Link from 'next/link';
import { notFound } from 'next/navigation';
import ProductEngagementTracker from '@/components/analytics/ProductEngagementTracker';
interface ProductPageProps {
params: Promise<{
@@ -52,11 +53,11 @@ export async function generateMetadata({ params }: ProductPageProps): Promise<Me
title: categoryTitle,
description: categoryDesc,
alternates: {
canonical: `/${locale}/products/${productSlug}`,
canonical: `${SITE_URL}/${locale}/products/${productSlug}`,
languages: {
de: `/de/products/${await mapFileSlugToTranslated(productSlug, 'de')}`,
en: `/en/products/${await mapFileSlugToTranslated(productSlug, 'en')}`,
'x-default': `/en/products/${await mapFileSlugToTranslated(productSlug, 'en')}`,
de: `${SITE_URL}/de/products/${await mapFileSlugToTranslated(productSlug, 'de')}`,
en: `${SITE_URL}/en/products/${await mapFileSlugToTranslated(productSlug, 'en')}`,
'x-default': `${SITE_URL}/en/products/${await mapFileSlugToTranslated(productSlug, 'en')}`,
},
},
openGraph: {
@@ -80,11 +81,11 @@ export async function generateMetadata({ params }: ProductPageProps): Promise<Me
title: product.frontmatter.title,
description: product.frontmatter.description,
alternates: {
canonical: `/${locale}/products/${slug.join('/')}`,
canonical: `${SITE_URL}/${locale}/products/${slug.join('/')}`,
languages: {
de: `/de/products/${await mapFileSlugToTranslated(slug[0], 'de')}/${await mapFileSlugToTranslated(productSlug, 'de')}`,
en: `/en/products/${await mapFileSlugToTranslated(slug[0], 'en')}/${await mapFileSlugToTranslated(productSlug, 'en')}`,
'x-default': `/en/products/${await mapFileSlugToTranslated(slug[0], 'en')}/${await mapFileSlugToTranslated(productSlug, 'en')}`,
de: `${SITE_URL}/de/products/${await mapFileSlugToTranslated(slug[0], 'de')}/${await mapFileSlugToTranslated(productSlug, 'de')}`,
en: `${SITE_URL}/en/products/${await mapFileSlugToTranslated(slug[0], 'en')}/${await mapFileSlugToTranslated(productSlug, 'en')}`,
'x-default': `${SITE_URL}/en/products/${await mapFileSlugToTranslated(slug[0], 'en')}/${await mapFileSlugToTranslated(productSlug, 'en')}`,
},
},
openGraph: {
@@ -212,8 +213,11 @@ export default async function ProductPage({ params }: ProductPageProps) {
<Container className="relative z-10">
<div className="max-w-4xl animate-slide-up">
<nav className="flex items-center mb-8 text-white/40 text-sm font-bold uppercase tracking-widest">
<Link href={`/${locale}/products`} className="hover:text-accent transition-colors">
{t('title')}
<Link
href={`/${locale}/${await mapFileSlugToTranslated('products', locale)}`}
className="hover:text-accent transition-colors"
>
{t.has('breadcrumb') ? t('breadcrumb') : t('title').replace(/<[^>]*>/g, '')}
</Link>
<span className="mx-3 opacity-30">/</span>
<span className="text-white/90">{categoryTitle}</span>
@@ -235,57 +239,59 @@ export default async function ProductPage({ params }: ProductPageProps) {
href={`/${locale}/products/${productSlug}/${product.translatedSlug}`}
className="group block bg-white rounded-[32px] overflow-hidden shadow-sm hover:shadow-2xl transition-all duration-500 border border-neutral-dark/5"
>
<div className="aspect-[4/3] relative bg-neutral-light/30 p-12 overflow-hidden">
{product.frontmatter.images?.[0] && (
<>
<Image
src={product.frontmatter.images[0]}
alt={product.frontmatter.title}
fill
className="object-contain p-8 transition-transform duration-700 group-hover:scale-110 z-10"
sizes="(max-width: 768px) 100vw, (max-width: 1024px) 50vw, 33vw"
/>
{/* Subtle reflection/shadow effect */}
<div className="absolute bottom-4 left-1/2 -translate-x-1/2 w-2/3 h-4 bg-black/5 blur-xl rounded-full" />
</>
)}
</div>
<div className="p-8 md:p-10">
<div className="flex flex-wrap gap-2 mb-4">
{product.frontmatter.categories.map((cat, i) => (
<span
key={i}
className="text-[10px] font-bold uppercase tracking-widest text-primary/40"
>
{cat}
<Card tag="article" className="premium-card-reset">
<div className="aspect-[4/3] relative bg-neutral-light/30 p-12 overflow-hidden">
{product.frontmatter.images?.[0] && (
<>
<Image
src={product.frontmatter.images[0]}
alt={product.frontmatter.title}
fill
className="object-contain p-8 transition-transform duration-700 group-hover:scale-110 z-10"
sizes="(max-width: 768px) 100vw, (max-width: 1024px) 50vw, 33vw"
/>
{/* Subtle reflection/shadow effect */}
<div className="absolute bottom-4 left-1/2 -translate-x-1/2 w-2/3 h-4 bg-black/5 blur-xl rounded-full" />
</>
)}
</div>
<div className="p-8 md:p-10">
<div className="flex flex-wrap gap-2 mb-4">
{product.frontmatter.categories.map((cat, i) => (
<span
key={i}
className="text-[10px] font-bold uppercase tracking-widest text-primary/40"
>
{cat}
</span>
))}
</div>
<h2 className="text-2xl md:text-3xl font-bold text-text-primary group-hover:text-primary transition-colors mb-4 leading-tight">
{product.frontmatter.title}
</h2>
<p className="text-text-secondary line-clamp-2 text-base leading-relaxed mb-8">
{product.frontmatter.description}
</p>
<div className="flex items-center text-primary font-bold group-hover:text-accent-dark transition-colors">
<span className="border-b-2 border-primary/10 group-hover:border-accent-dark transition-colors pb-1">
{t('details')}
</span>
))}
<svg
className="w-5 h-5 ml-3 transition-transform group-hover:translate-x-1"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M17 8l4 4m0 0l-4 4m4-4H3"
/>
</svg>
</div>
</div>
<h2 className="text-2xl md:text-3xl font-bold text-text-primary group-hover:text-primary transition-colors mb-4 leading-tight">
{product.frontmatter.title}
</h2>
<p className="text-text-secondary line-clamp-2 text-base leading-relaxed mb-8">
{product.frontmatter.description}
</p>
<div className="flex items-center text-primary font-bold group-hover:text-accent-dark transition-colors">
<span className="border-b-2 border-primary/10 group-hover:border-accent-dark transition-colors pb-1">
{t('details')}
</span>
<svg
className="w-5 h-5 ml-3 transition-transform group-hover:translate-x-1"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M17 8l4 4m0 0l-4 4m4-4H3"
/>
</svg>
</div>
</div>
</Card>
</Link>
))}
</div>
@@ -353,6 +359,12 @@ export default async function ProductPage({ params }: ProductPageProps) {
return (
<div className="flex flex-col min-h-screen bg-white relative">
{/* Product Hero */}
<ProductEngagementTracker
productName={product.frontmatter.title}
productSlug={productSlug}
categories={product.frontmatter.categories}
sku={product.frontmatter.sku}
/>
<section className="relative pt-40 pb-24 overflow-hidden bg-primary-dark">
{/* Background Decorative Elements */}
<div className="absolute top-0 right-0 w-1/2 h-full bg-gradient-to-l from-accent/5 to-transparent pointer-events-none" />
@@ -361,8 +373,11 @@ export default async function ProductPage({ params }: ProductPageProps) {
<Container className="relative z-10">
<div className="max-w-4xl animate-slide-up">
<nav className="flex items-center mb-12 text-white/40 text-[10px] font-black uppercase tracking-[0.2em]">
<Link href={`/${locale}/products`} className="hover:text-accent transition-colors">
{t('title')}
<Link
href={`/${locale}/${await mapFileSlugToTranslated('products', locale)}`}
className="hover:text-accent transition-colors"
>
{t.has('breadcrumb') ? t('breadcrumb') : t('title').replace(/<[^>]*>/g, '')}
</Link>
<span className="mx-4 opacity-20">/</span>
<Link

View File

@@ -3,6 +3,8 @@ import { getTranslations } from 'next-intl/server';
import { OGImageTemplate } from '@/components/OGImageTemplate';
import { getOgFonts, OG_IMAGE_SIZE } from '@/lib/og-helper';
export const size = OG_IMAGE_SIZE;
export const contentType = 'image/png';
export const runtime = 'nodejs';
export default async function Image({ params }: { params: Promise<{ locale: string }> }) {
@@ -10,7 +12,7 @@ export default async function Image({ params }: { params: Promise<{ locale: stri
const t = await getTranslations({ locale, namespace: 'Products' });
const fonts = await getOgFonts();
const title = t('meta.title') || t('title');
const title = t('meta.title') || t('breadcrumb') || t('title').replace(/<[^>]*>/g, '');
const description = t('meta.description') || t('subtitle');
return new ImageResponse(

View File

@@ -4,10 +4,9 @@ import { Badge, Button, Card, Container, Heading, Section } from '@/components/u
import { getTranslations, setRequestLocale } from 'next-intl/server';
import { Metadata } from 'next';
import Image from 'next/image';
import Link from 'next/link';
import { mapFileSlugToTranslated } from '@/lib/slugs';
import { getOGImageMetadata } from '@/lib/metadata';
import { SITE_URL } from '@/lib/schema';
import TrackedLink from '@/components/analytics/TrackedLink';
interface ProductsPageProps {
params: Promise<{
@@ -18,24 +17,23 @@ interface ProductsPageProps {
export async function generateMetadata({ params }: ProductsPageProps): Promise<Metadata> {
const { locale } = await params;
const t = await getTranslations({ locale, namespace: 'Products' });
const title = t('meta.title') || t('title');
const title = t('meta.title') || t('breadcrumb') || t('title').replace(/<[^>]*>/g, '');
const description = t('meta.description') || t('subtitle');
return {
title,
description,
alternates: {
canonical: `/${locale}/products`,
canonical: `${SITE_URL}/${locale}/${await mapFileSlugToTranslated('products', locale)}`,
languages: {
de: '/de/products',
en: '/en/products',
'x-default': '/en/products',
de: `${SITE_URL}/de/${await mapFileSlugToTranslated('products', 'de')}`,
en: `${SITE_URL}/en/${await mapFileSlugToTranslated('products', 'en')}`,
'x-default': `${SITE_URL}/en/${await mapFileSlugToTranslated('products', 'en')}`,
},
},
openGraph: {
title: `${title} | KLZ Cables`,
description,
url: `${SITE_URL}/${locale}/products`,
images: getOGImageMetadata('products', title, locale),
url: `${SITE_URL}/${locale}/${await mapFileSlugToTranslated('products', locale)}`,
},
twitter: {
card: 'summary_large_image',
@@ -56,34 +54,36 @@ export default async function ProductsPage({ params }: ProductsPageProps) {
const highVoltageSlug = await mapFileSlugToTranslated('high-voltage-cables', locale);
const solarSlug = await mapFileSlugToTranslated('solar-cables', locale);
const productsSlug = await mapFileSlugToTranslated('products', locale);
const categories = [
{
title: t('categories.lowVoltage.title'),
desc: t('categories.lowVoltage.description'),
img: '/uploads/2024/11/low-voltage-category.webp',
icon: '/uploads/2024/11/Low-Voltage.svg',
href: `/${locale}/products/${lowVoltageSlug}`,
href: `/${locale}/${productsSlug}/${lowVoltageSlug}`,
},
{
title: t('categories.mediumVoltage.title'),
desc: t('categories.mediumVoltage.description'),
img: '/uploads/2024/11/medium-voltage-category.webp',
icon: '/uploads/2024/11/Medium-Voltage.svg',
href: `/${locale}/products/${mediumVoltageSlug}`,
href: `/${locale}/${productsSlug}/${mediumVoltageSlug}`,
},
{
title: t('categories.highVoltage.title'),
desc: t('categories.highVoltage.description'),
img: '/uploads/2024/11/high-voltage-category.webp',
icon: '/uploads/2024/11/High-Voltage.svg',
href: `/${locale}/products/${highVoltageSlug}`,
href: `/${locale}/${productsSlug}/${highVoltageSlug}`,
},
{
title: t('categories.solar.title'),
desc: t('categories.solar.description'),
img: '/uploads/2024/11/solar-category.webp',
icon: '/uploads/2024/11/Solar.svg',
href: `/${locale}/products/${solarSlug}`,
href: `/${locale}/${productsSlug}/${solarSlug}`,
},
];
@@ -135,7 +135,15 @@ export default async function ProductsPage({ params }: ProductsPageProps) {
<div className="grid grid-cols-1 md:grid-cols-2 gap-6 md:gap-8 lg:gap-12">
{categories.map((category, idx) => (
<Reveal key={idx} delay={idx * 100}>
<Link key={idx} href={category.href} className="group block">
<TrackedLink
key={idx}
href={category.href}
className="group block"
eventProperties={{
category_title: category.title,
location: 'products_index',
}}
>
<Card className="h-full border-none shadow-sm hover:shadow-2xl transition-all duration-500 rounded-[24px] md:rounded-[48px] overflow-hidden bg-white active:scale-[0.98]">
<div className="relative h-[200px] md:h-[400px] overflow-hidden">
<Image
@@ -195,7 +203,7 @@ export default async function ProductsPage({ params }: ProductsPageProps) {
</div>
</div>
</Card>
</Link>
</TrackedLink>
</Reveal>
))}
</div>

View File

@@ -3,6 +3,8 @@ import { getTranslations } from 'next-intl/server';
import { OGImageTemplate } from '@/components/OGImageTemplate';
import { getOgFonts, OG_IMAGE_SIZE } from '@/lib/og-helper';
export const size = OG_IMAGE_SIZE;
export const contentType = 'image/png';
export const runtime = 'nodejs';
export default async function Image({ params }: { params: Promise<{ locale: string }> }) {

View File

@@ -3,10 +3,10 @@ import { Metadata } from 'next';
import JsonLd from '@/components/JsonLd';
import { getBreadcrumbSchema, SITE_URL } from '@/lib/schema';
import { Section, Container, Heading, Badge, Button } from '@/components/ui';
import { getOGImageMetadata } from '@/lib/metadata';
import Image from 'next/image';
import Reveal from '@/components/Reveal';
import Gallery from '@/components/team/Gallery';
import TrackedButton from '@/components/analytics/TrackedButton';
interface TeamPageProps {
params: Promise<{
@@ -23,18 +23,17 @@ export async function generateMetadata({ params }: TeamPageProps): Promise<Metad
title,
description,
alternates: {
canonical: `/${locale}/team`,
canonical: `${SITE_URL}/${locale}/team`,
languages: {
de: '/de/team',
en: '/en/team',
'x-default': '/en/team',
de: `${SITE_URL}/de/team`,
en: `${SITE_URL}/en/team`,
'x-default': `${SITE_URL}/en/team`,
},
},
openGraph: {
title: `${title} | KLZ Cables`,
description,
url: `${SITE_URL}/${locale}/team`,
images: getOGImageMetadata('team', title, locale),
},
twitter: {
card: 'summary_large_image',
@@ -115,7 +114,7 @@ export default async function TeamPage({ params }: TeamPageProps) {
</Reveal>
{/* Michael Bodemer Section - Sticky Narrative Split Layout */}
<section className="relative bg-white overflow-hidden">
<article className="relative bg-white overflow-hidden">
<div className="flex flex-col lg:flex-row">
<Reveal className="w-full lg:w-1/2 p-6 md:p-24 lg:p-32 flex flex-col justify-center bg-primary-dark text-white relative order-2 lg:order-1">
<div className="absolute top-0 right-0 w-32 h-full bg-accent/5 -skew-x-12 translate-x-1/2" />
@@ -135,15 +134,20 @@ export default async function TeamPage({ params }: TeamPageProps) {
<p className="text-base md:text-xl leading-relaxed text-white/70 mb-6 md:mb-12 max-w-xl">
{t('michael.description')}
</p>
<Button
<TrackedButton
href="https://www.linkedin.com/in/michael-bodemer-33b493122/"
variant="accent"
size="lg"
className="group w-full md:w-auto md:h-16 md:px-10 md:text-xl active:scale-95 transition-transform"
eventProperties={{
type: 'social_linkedin',
person: 'Michael Bodemer',
location: 'team_page',
}}
>
{t('michael.linkedin')}
<span className="ml-3 transition-transform group-hover:translate-x-2">&rarr;</span>
</Button>
</TrackedButton>
</div>
</Reveal>
<Reveal className="w-full lg:w-1/2 relative min-h-[400px] md:min-h-[600px] lg:min-h-screen overflow-hidden order-1 lg:order-2">
@@ -157,7 +161,7 @@ export default async function TeamPage({ params }: TeamPageProps) {
<div className="absolute inset-0 bg-gradient-to-t from-primary-dark/60 lg:bg-gradient-to-r lg:from-primary-dark/20 to-transparent" />
</Reveal>
</div>
</section>
</article>
{/* Legacy Section - Immersive Background */}
<Reveal>
@@ -213,7 +217,7 @@ export default async function TeamPage({ params }: TeamPageProps) {
</Reveal>
{/* Klaus Mintel Section - Reversed Split Layout */}
<section className="relative bg-white overflow-hidden">
<article className="relative bg-white overflow-hidden">
<div className="flex flex-col lg:flex-row">
<Reveal className="w-full lg:w-1/2 relative min-h-[400px] md:min-h-[600px] lg:min-h-screen overflow-hidden order-1">
<Image
@@ -243,19 +247,24 @@ export default async function TeamPage({ params }: TeamPageProps) {
<p className="text-base md:text-xl leading-relaxed text-text-secondary mb-6 md:mb-12 max-w-xl">
{t('klaus.description')}
</p>
<Button
<TrackedButton
href="https://www.linkedin.com/in/klaus-mintel-b80a8b193/"
variant="saturated"
size="lg"
className="group w-full md:w-auto md:h-16 md:px-10 md:text-xl active:scale-95 transition-transform"
eventProperties={{
type: 'social_linkedin',
person: 'Klaus Mintel',
location: 'team_page',
}}
>
{t('klaus.linkedin')}
<span className="ml-3 transition-transform group-hover:translate-x-2">&rarr;</span>
</Button>
</TrackedButton>
</div>
</Reveal>
</div>
</section>
</article>
{/* Manifesto Section - Modern Grid */}
<Section className="bg-white text-primary py-16 md:py-28">
@@ -283,9 +292,9 @@ export default async function TeamPage({ params }: TeamPageProps) {
</div>
</div>
</div>
<div className="sticky-narrative-content grid grid-cols-1 md:grid-cols-2 gap-4 md:gap-10">
<ul className="sticky-narrative-content grid grid-cols-1 md:grid-cols-2 gap-4 md:gap-10 list-none p-0 m-0">
{[0, 1, 2, 3, 4, 5].map((idx) => (
<div
<li
key={idx}
className="p-6 md:p-10 bg-neutral-light rounded-2xl md:rounded-[40px] border border-transparent hover:border-accent hover:bg-white hover:shadow-2xl transition-all duration-500 group active:scale-[0.98] touch-target-none"
>
@@ -300,9 +309,9 @@ export default async function TeamPage({ params }: TeamPageProps) {
<p className="text-sm md:text-lg text-text-secondary leading-relaxed">
{t(`manifesto.items.${idx}.description`)}
</p>
</div>
</li>
))}
</div>
</ul>
</div>
</Container>
</Section>

View File

@@ -0,0 +1,32 @@
import { NextRequest, NextResponse } from 'next/server';
import fs from 'fs';
import path from 'path';
export async function POST(req: NextRequest) {
// Only allow in development
if (process.env.NODE_ENV === 'production') {
return NextResponse.json({ error: 'This route is disabled in production.' }, { status: 403 });
}
try {
const body = await req.json();
// Ensure we are in the project root by using process.cwd()
// Path: <project-root>/remotion/session.json
const remotionDir = path.join(process.cwd(), 'remotion');
const filePath = path.join(remotionDir, 'session.json');
// Create remotion directory if it doesn't exist
if (!fs.existsSync(remotionDir)) {
fs.mkdirSync(remotionDir, { recursive: true });
}
// Write the JSON file
fs.writeFileSync(filePath, JSON.stringify(body, null, 2), 'utf-8');
return NextResponse.json({ success: true, path: filePath });
} catch (error: any) {
console.error('Failed to save session:', error);
return NextResponse.json({ error: error.message }, { status: 500 });
}
}

View File

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

View File

@@ -1,85 +0,0 @@
> klz-cables-nextjs@1.0.0 build /Users/marcmintel/Projects/klz-2026
> next build
▲ Next.js 16.1.6 (Turbopack)
- Environments: .env.production, .env
- Experiments (use with caution):
· clientTraceMetadata
⚠ The "middleware" file convention is deprecated. Please use "proxy" instead. Learn more: https://nextjs.org/docs/messages/middleware-to-proxy
Creating an optimized production build ...
✓ Compiled successfully in 5.2s
Running next.config.js provided runAfterProductionCompile ...
✓ Completed runAfterProductionCompile in 329ms
Running TypeScript ...
Collecting page data using 15 workers ...
Generating static pages using 15 workers (0/21) ...
{"level":30,"time":1770803086126,"pid":70317,"hostname":"MacBook-Air-von-Marc-2.local","name":"server","msg":"Initializing server application services"}
[Layout] Static generation detected or headers unavailable, skipping server-side analytics context
[Layout] Static generation detected or headers unavailable, skipping server-side analytics context
[Layout] Static generation detected or headers unavailable, skipping server-side analytics context
[Layout] Static generation detected or headers unavailable, skipping server-side analytics context
[Layout] Static generation detected or headers unavailable, skipping server-side analytics context
[Layout] Static generation detected or headers unavailable, skipping server-side analytics context
{"level":30,"time":1770803086126,"pid":70317,"hostname":"MacBook-Air-von-Marc-2.local","name":"server","msg":"Service configuration"}
{"level":30,"time":1770803086126,"pid":70317,"hostname":"MacBook-Air-von-Marc-2.local","name":"server","msg":"Noop analytics service initialized (analytics disabled)"}
{"level":30,"time":1770803086126,"pid":70317,"hostname":"MacBook-Air-von-Marc-2.local","name":"server","msg":"Noop notification service initialized (notifications disabled)"}
{"level":30,"time":1770803086126,"pid":70317,"hostname":"MacBook-Air-von-Marc-2.local","name":"server","msg":"Noop error reporting service initialized (error reporting disabled)"}
{"level":30,"time":1770803086126,"pid":70317,"hostname":"MacBook-Air-von-Marc-2.local","name":"server","msg":"Memory cache service initialized"}
{"level":30,"time":1770803086126,"pid":70317,"hostname":"MacBook-Air-von-Marc-2.local","name":"server","msg":"Pino logger service initialized"}
{"level":30,"time":1770803086126,"pid":70317,"hostname":"MacBook-Air-von-Marc-2.local","name":"server","msg":"All application services initialized successfully"}
Generating static pages using 15 workers (5/21)
Generating static pages using 15 workers (10/21)
[Layout] Static generation detected or headers unavailable, skipping server-side analytics context
[Layout] Static generation detected or headers unavailable, skipping server-side analytics context
[Layout] Static generation detected or headers unavailable, skipping server-side analytics context
[Layout] Static generation detected or headers unavailable, skipping server-side analytics context
[Layout] Static generation detected or headers unavailable, skipping server-side analytics context
{"level":30,"time":1770803086317,"pid":70317,"hostname":"MacBook-Air-von-Marc-2.local","name":"server","msg":"Initializing server application services"}
[Layout] Static generation detected or headers unavailable, skipping server-side analytics context
{"level":30,"time":1770803086317,"pid":70317,"hostname":"MacBook-Air-von-Marc-2.local","name":"server","msg":"Service configuration"}
{"level":30,"time":1770803086317,"pid":70317,"hostname":"MacBook-Air-von-Marc-2.local","name":"server","msg":"Noop analytics service initialized (analytics disabled)"}
{"level":30,"time":1770803086317,"pid":70317,"hostname":"MacBook-Air-von-Marc-2.local","name":"server","msg":"Notification service initialized (noop)"}
{"level":30,"time":1770803086317,"pid":70317,"hostname":"MacBook-Air-von-Marc-2.local","name":"server","msg":"Noop error reporting service initialized (error reporting disabled)"}
{"level":30,"time":1770803086317,"pid":70317,"hostname":"MacBook-Air-von-Marc-2.local","name":"server","msg":"Memory cache service initialized"}
{"level":30,"time":1770803086317,"pid":70317,"hostname":"MacBook-Air-von-Marc-2.local","name":"server","msg":"Pino logger service initialized"}
{"level":30,"time":1770803086317,"pid":70317,"hostname":"MacBook-Air-von-Marc-2.local","name":"server","msg":"All application services initialized successfully"}
Generating static pages using 15 workers (15/21)
✓ Generating static pages using 15 workers (21/21) in 512.4ms
Finalizing page optimization ...
Route (app)
┌ ○ /_not-found
├ ƒ /[locale]
├ ƒ /[locale]/[slug]
├ ƒ /[locale]/[slug]/opengraph-image
├ ƒ /[locale]/api/og/product
├ ƒ /[locale]/blog
├ ƒ /[locale]/blog/[slug]
├ ƒ /[locale]/blog/[slug]/opengraph-image
├ ƒ /[locale]/blog/opengraph-image
├ ƒ /[locale]/contact
├ ƒ /[locale]/contact/opengraph-image
├ ƒ /[locale]/opengraph-image
├ ƒ /[locale]/products
├ ƒ /[locale]/products/[...slug]
├ ƒ /[locale]/products/opengraph-image
├ ƒ /[locale]/team
├ ƒ /[locale]/team/opengraph-image
├ ƒ /api/feedback
├ ƒ /api/health/cms
├ ƒ /api/whoami
├ ƒ /errors/api/relay
├ ƒ /health
├ ○ /manifest.webmanifest
├ ○ /robots.txt
├ ƒ /sitemap.xml
└ ƒ /stats/api/send
ƒ Proxy (Middleware)
○ (Static) prerendered as static content
ƒ (Dynamic) server-rendered on demand

View File

@@ -5,11 +5,30 @@ import { useTranslations } from 'next-intl';
import { Button, Heading, Card, Input, Textarea, Label } from '@/components/ui';
import { sendContactFormAction } from '@/app/actions/contact';
import { useAnalytics } from '@/components/analytics/useAnalytics';
import { AnalyticsEvents } from '@/components/analytics/analytics-events';
export default function ContactForm() {
const t = useTranslations('Contact');
const { trackEvent } = useAnalytics();
const [status, setStatus] = useState<'idle' | 'submitting' | 'success' | 'error'>('idle');
const [hasStarted, setHasStarted] = useState(false);
const handleFocus = (fieldId: string) => {
// Initial form start
if (!hasStarted) {
setHasStarted(true);
trackEvent(AnalyticsEvents.FORM_START, {
form_id: 'contact_form',
form_name: 'Contact',
});
}
// Field-level transparency
trackEvent(AnalyticsEvents.FORM_FIELD_FOCUS, {
form_id: 'contact_form',
field_id: fieldId,
});
};
async function handleSubmit(e: React.FormEvent<HTMLFormElement>) {
e.preventDefault();
@@ -29,17 +48,29 @@ export default function ContactForm() {
(e.target as HTMLFormElement).reset();
} else {
console.error('Contact form submission failed:', { email, error: result.error });
trackEvent(AnalyticsEvents.FORM_ERROR, {
form_id: 'contact_form',
error: result.error || 'submission_failed',
});
setStatus('error');
}
} catch (error) {
console.error('Contact form submission error:', { email, error });
trackEvent(AnalyticsEvents.FORM_ERROR, {
form_id: 'contact_form',
error: (error as Error).message || 'unexpected_error',
});
setStatus('error');
}
}
if (status === 'success') {
return (
<Card className="p-6 md:p-12 rounded-2xl md:rounded-[40px] border-none shadow-2xl text-center">
<Card
className="p-6 md:p-12 rounded-2xl md:rounded-[40px] border-none shadow-2xl text-center"
role="alert"
aria-live="polite"
>
<div className="w-20 h-20 bg-accent rounded-full flex items-center justify-center mx-auto mb-6 shadow-lg shadow-accent/20">
<svg
className="w-10 h-10 text-primary-dark"
@@ -66,7 +97,11 @@ export default function ContactForm() {
if (status === 'error') {
return (
<Card className="p-6 md:p-12 rounded-2xl md:rounded-[40px] border-destructive/20 shadow-2xl text-center bg-destructive/5 animate-slide-up">
<Card
className="p-6 md:p-12 rounded-2xl md:rounded-[40px] border-destructive/20 shadow-2xl text-center bg-destructive/5 animate-slide-up"
role="alert"
aria-live="assertive"
>
<div className="w-20 h-20 bg-destructive rounded-full flex items-center justify-center mx-auto mb-6 shadow-lg shadow-destructive/20">
<svg
className="w-10 h-10 text-destructive-foreground"
@@ -105,38 +140,43 @@ export default function ContactForm() {
</Heading>
<form onSubmit={handleSubmit} className="grid grid-cols-1 md:grid-cols-2 gap-4 md:gap-8">
<div className="space-y-1 md:space-y-2">
<Label htmlFor="name">{t('form.name')}</Label>
<Label htmlFor="contact-name">{t('form.name')}</Label>
<Input
type="text"
id="name"
id="contact-name"
name="name"
autoComplete="name"
enterKeyHint="next"
placeholder={t('form.namePlaceholder')}
onFocus={() => handleFocus('contact-name')}
aria-label={t('form.name')}
required
/>
</div>
<div className="space-y-1 md:space-y-2">
<Label htmlFor="email">{t('form.email')}</Label>
<Label htmlFor="contact-email">{t('form.email')}</Label>
<Input
type="email"
id="email"
id="contact-email"
name="email"
autoComplete="email"
inputMode="email"
enterKeyHint="next"
placeholder={t('form.emailPlaceholder')}
onFocus={() => handleFocus('contact-email')}
aria-label={t('form.email')}
required
/>
</div>
<div className="md:col-span-2 space-y-1 md:space-y-2">
<Label htmlFor="message">{t('form.message')}</Label>
<Label htmlFor="contact-message">{t('form.message')}</Label>
<Textarea
id="message"
id="contact-message"
name="message"
rows={4}
enterKeyHint="send"
placeholder={t('form.messagePlaceholder')}
onFocus={() => handleFocus('contact-message')}
aria-label={t('form.message')}
required
/>
</div>

View File

@@ -2,6 +2,8 @@
import { cn } from '@/components/ui/utils';
import { useTranslations } from 'next-intl';
import { useAnalytics } from './analytics/useAnalytics';
import { AnalyticsEvents } from './analytics/analytics-events';
interface DatasheetDownloadProps {
datasheetPath: string;
@@ -10,34 +12,43 @@ interface DatasheetDownloadProps {
export default function DatasheetDownload({ datasheetPath, className }: DatasheetDownloadProps) {
const t = useTranslations('Products');
const { trackEvent } = useAnalytics();
return (
<div className={cn("mt-8 animate-slight-fade-in-from-bottom", className)}>
<a
href={datasheetPath}
<div className={cn('mt-8 animate-slight-fade-in-from-bottom', className)}>
<a
href={datasheetPath}
target="_blank"
rel="noopener noreferrer"
onClick={() =>
trackEvent(AnalyticsEvents.DOWNLOAD, {
file_name: datasheetPath.split('/').pop(),
file_path: datasheetPath,
location: 'product_page',
})
}
className="group relative block w-full overflow-hidden rounded-[32px] bg-primary-dark p-1 transition-all duration-500 hover:shadow-[0_20px_50px_rgba(0,0,0,0.2)] hover:-translate-y-1"
>
{/* Animated Background Gradient */}
<div className="absolute inset-0 bg-gradient-to-r from-accent via-saturated to-accent opacity-20 group-hover:opacity-40 transition-opacity duration-500 animate-gradient-x" />
{/* Inner Content */}
<div className="relative flex items-center gap-6 rounded-[31px] bg-primary-dark/90 backdrop-blur-xl p-6 md:p-8 border border-white/10">
{/* Icon Container */}
<div className="relative flex h-16 w-16 flex-shrink-0 items-center justify-center rounded-2xl bg-white/5 border border-white/10 group-hover:bg-accent group-hover:border-white/20 transition-all duration-500">
<div className="absolute inset-0 rounded-2xl bg-accent/20 blur-xl opacity-0 group-hover:opacity-100 transition-opacity duration-500" />
<svg
className="relative h-8 w-8 text-white transition-transform duration-500 group-hover:scale-110 group-hover:rotate-3"
fill="none"
stroke="currentColor"
<svg
className="relative h-8 w-8 text-white transition-transform duration-500 group-hover:scale-110 group-hover:rotate-3"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
aria-hidden="true"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={1.5}
d="M12 10v6m0 0l-3-3m3 3l3-3m2 8H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z"
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={1.5}
d="M12 10v6m0 0l-3-3m3 3l3-3m2 8H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z"
/>
</svg>
</div>
@@ -45,7 +56,9 @@ export default function DatasheetDownload({ datasheetPath, className }: Datashee
{/* Text Content */}
<div className="flex-1">
<div className="flex items-center gap-2 mb-1">
<span className="text-[10px] font-black uppercase tracking-[0.2em] text-accent">PDF Datasheet</span>
<span className="text-[10px] font-black uppercase tracking-[0.2em] text-accent">
PDF Datasheet
</span>
</div>
<h3 className="text-xl md:text-2xl font-black text-white uppercase tracking-tighter leading-none group-hover:text-accent transition-colors duration-300">
{t('downloadDatasheet')}
@@ -57,8 +70,19 @@ export default function DatasheetDownload({ datasheetPath, className }: Datashee
{/* Arrow Icon */}
<div className="flex h-10 w-10 items-center justify-center rounded-full bg-white/5 text-white/20 group-hover:bg-accent group-hover:text-white group-hover:translate-x-1 transition-all duration-500">
<svg className="h-6 w-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2.5} d="M9 5l7 7-7 7" />
<svg
className="h-6 w-6"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
aria-hidden="true"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2.5}
d="M9 5l7 7-7 7"
/>
</svg>
</div>
</div>

View File

@@ -1,11 +1,16 @@
'use client';
import Link from 'next/link';
import Image from 'next/image';
import { useTranslations, useLocale } from 'next-intl';
import { Container } from './ui';
import { useAnalytics } from './analytics/useAnalytics';
import { AnalyticsEvents } from './analytics/analytics-events';
export default function Footer() {
const t = useTranslations('Footer');
const navT = useTranslations('Navigation');
const { trackEvent } = useAnalytics();
const locale = useLocale();
const currentYear = new Date().getFullYear();
@@ -17,10 +22,19 @@ export default function Footer() {
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-12 gap-16 mb-20">
{/* Brand Column */}
<div className="lg:col-span-4 space-y-8">
<Link href={`/${locale}`} className="inline-block group">
<Link
href={`/${locale}`}
className="inline-block group"
onClick={() =>
trackEvent(AnalyticsEvents.BUTTON_CLICK, {
target: 'home_logo',
location: 'footer',
})
}
>
<Image
src="/logo-white.svg"
alt={t('products')}
alt="KLZ Vertriebs GmbH"
width={150}
height={40}
className="h-10 w-auto transition-transform duration-500 group-hover:scale-110"
@@ -34,6 +48,13 @@ export default function Footer() {
href="https://www.linkedin.com/company/klz-vertriebs-gmbh/"
target="_blank"
rel="noopener noreferrer"
onClick={() =>
trackEvent(AnalyticsEvents.LINK_CLICK, {
type: 'social',
target: 'linkedin',
location: 'footer',
})
}
className="w-12 h-12 rounded-full bg-white/5 flex items-center justify-center text-white hover:bg-accent hover:text-primary-dark transition-all duration-300 border border-white/10"
>
<span className="sr-only">LinkedIn</span>
@@ -46,14 +67,21 @@ export default function Footer() {
{/* Links Columns */}
<div className="lg:col-span-2">
<h4 className="text-accent font-bold uppercase tracking-widest text-xs md:text-sm mb-8">
<h3 className="text-accent font-bold uppercase tracking-widest text-xs md:text-sm mb-8">
{t('legal')}
</h4>
</h3>
<ul className="space-y-4 text-white/70 list-none m-0 p-0">
<li>
<Link
href={`/${locale}/${t('legalNoticeSlug')}`}
className="text-white/70 hover:text-accent transition-all duration-300 hover:translate-x-1 inline-block"
onClick={() =>
trackEvent(AnalyticsEvents.LINK_CLICK, {
label: t('legalNotice'),
href: t('legalNoticeSlug'),
location: 'footer_legal',
})
}
>
{t('legalNotice')}
</Link>
@@ -62,6 +90,13 @@ export default function Footer() {
<Link
href={`/${locale}/${t('privacyPolicySlug')}`}
className="text-white/70 hover:text-accent transition-all duration-300 hover:translate-x-1 inline-block"
onClick={() =>
trackEvent(AnalyticsEvents.LINK_CLICK, {
label: t('privacyPolicy'),
href: t('privacyPolicySlug'),
location: 'footer_legal',
})
}
>
{t('privacyPolicy')}
</Link>
@@ -70,6 +105,13 @@ export default function Footer() {
<Link
href={`/${locale}/${t('termsSlug')}`}
className="text-white/70 hover:text-accent transition-all duration-300 hover:translate-x-1 inline-block"
onClick={() =>
trackEvent(AnalyticsEvents.LINK_CLICK, {
label: t('terms'),
href: t('termsSlug'),
location: 'footer_legal',
})
}
>
{t('terms')}
</Link>
@@ -78,22 +120,36 @@ export default function Footer() {
</div>
<div className="lg:col-span-2">
<h4 className="text-accent font-bold uppercase tracking-widest text-xs md:text-sm mb-8">
<h3 className="text-accent font-bold uppercase tracking-widest text-xs md:text-sm mb-8">
{t('company')}
</h4>
</h3>
<ul className="space-y-4 text-white/70 list-none m-0 p-0">
<li>
<Link
href={`/${locale}/team`}
className="text-white/70 hover:text-accent transition-all duration-300 hover:translate-x-1 inline-block"
onClick={() =>
trackEvent(AnalyticsEvents.LINK_CLICK, {
label: navT('team'),
href: '/team',
location: 'footer_company',
})
}
>
{navT('team')}
</Link>
</li>
<li>
<Link
href={`/${locale}/products`}
href={`/${locale}/${locale === 'de' ? 'produkte' : 'products'}`}
className="text-white/70 hover:text-accent transition-all duration-300 hover:translate-x-1 inline-block"
onClick={() =>
trackEvent(AnalyticsEvents.LINK_CLICK, {
label: navT('products'),
href: locale === 'de' ? '/produkte' : '/products',
location: 'footer_company',
})
}
>
{navT('products')}
</Link>
@@ -102,6 +158,13 @@ export default function Footer() {
<Link
href={`/${locale}/blog`}
className="text-white/70 hover:text-accent transition-all duration-300 hover:translate-x-1 inline-block"
onClick={() =>
trackEvent(AnalyticsEvents.LINK_CLICK, {
label: navT('blog'),
href: '/blog',
location: 'footer_company',
})
}
>
{navT('blog')}
</Link>
@@ -110,6 +173,13 @@ export default function Footer() {
<Link
href={`/${locale}/contact`}
className="text-white/70 hover:text-accent transition-all duration-300 hover:translate-x-1 inline-block"
onClick={() =>
trackEvent(AnalyticsEvents.LINK_CLICK, {
label: navT('contact'),
href: '/contact',
location: 'footer_company',
})
}
>
{navT('contact')}
</Link>
@@ -119,9 +189,9 @@ export default function Footer() {
{/* Recent Posts Column */}
<div className="lg:col-span-4">
<h4 className="text-accent font-bold uppercase tracking-widest text-xs md:text-sm mb-8">
<h3 className="text-accent font-bold uppercase tracking-widest text-xs md:text-sm mb-8">
{t('recentPosts')}
</h4>
</h3>
<ul className="space-y-6 list-none m-0 p-0">
{[
{
@@ -146,11 +216,21 @@ export default function Footer() {
},
].map((post, i) => (
<li key={i}>
<Link href={`/${locale}/blog/${post.slug}`} className="group block text-white/80">
<Link
href={`/${locale}/blog/${post.slug}`}
className="group block text-white/80"
onClick={() =>
trackEvent(AnalyticsEvents.BLOG_POST_VIEW, {
title: post.title,
slug: post.slug,
location: 'footer_recent',
})
}
>
<p className="text-white/80 font-bold group-hover:text-accent transition-colors leading-snug mb-2 text-base md:text-base">
{post.title}
</p>
<span className="text-xs text-white/40 uppercase tracking-widest">
<span className="text-xs text-white/70 uppercase tracking-widest">
{t('readArticle')} &rarr;
</span>
</Link>
@@ -160,13 +240,37 @@ export default function Footer() {
</div>
</div>
<div className="pt-12 border-t border-white/10 flex flex-col md:flex-row justify-between items-center gap-8 text-white/40 text-xs md:text-sm font-medium">
<div className="pt-12 border-t border-white/10 flex flex-col md:flex-row justify-between items-center gap-8 text-white/70 text-xs md:text-sm font-medium">
<p>{t('copyright', { year: currentYear })}</p>
<div className="flex gap-8">
<Link href="/en" locale="en" className="hover:text-white transition-colors">
<Link
href="/en"
locale="en"
className="hover:text-white transition-colors"
onClick={() =>
trackEvent(AnalyticsEvents.TOGGLE_SWITCH, {
type: 'language',
from: locale,
to: 'en',
location: 'footer',
})
}
>
English
</Link>
<Link href="/de" locale="de" className="hover:text-white transition-colors">
<Link
href="/de"
locale="de"
className="hover:text-white transition-colors"
onClick={() =>
trackEvent(AnalyticsEvents.TOGGLE_SWITCH, {
type: 'language',
from: locale,
to: 'de',
location: 'footer',
})
}
>
Deutsch
</Link>
</div>

View File

@@ -2,18 +2,23 @@
import Link from 'next/link';
import Image from 'next/image';
import { motion } from 'framer-motion';
import { m, LazyMotion, domAnimation } from 'framer-motion';
import { useTranslations } from 'next-intl';
import { usePathname } from 'next/navigation';
import { Button } from './ui';
import { useEffect, useState } from 'react';
import { useEffect, useState, useRef } from 'react';
import { cn } from './ui';
import { useAnalytics } from './analytics/useAnalytics';
import { AnalyticsEvents } from './analytics/analytics-events';
export default function Header() {
const t = useTranslations('Navigation');
const pathname = usePathname();
const { trackEvent } = useAnalytics();
const [isScrolled, setIsScrolled] = useState(false);
const [isMobileMenuOpen, setIsMobileMenuOpen] = useState(false);
const mobileMenuRef = useRef<HTMLDivElement>(null);
const closeButtonRef = useRef<HTMLButtonElement>(null);
// Extract locale from pathname
const currentLocale = pathname.split('/')[1] || 'en';
@@ -31,9 +36,52 @@ export default function Header() {
}, []);
// Prevent scroll when mobile menu is open
// Prevent scroll when mobile menu is open and handle focus trap
useEffect(() => {
if (isMobileMenuOpen) {
document.body.style.overflow = 'hidden';
// Focus trap logic
const focusableElements = mobileMenuRef.current?.querySelectorAll(
'button, [href], input, select, textarea, [tabindex]:not([tabindex="-1"])',
);
if (focusableElements && focusableElements.length > 0) {
const firstElement = focusableElements[0] as HTMLElement;
const lastElement = focusableElements[focusableElements.length - 1] as HTMLElement;
const handleTabKey = (e: KeyboardEvent) => {
if (e.key === 'Tab') {
if (e.shiftKey) {
if (document.activeElement === firstElement) {
e.preventDefault();
lastElement.focus();
}
} else {
if (document.activeElement === lastElement) {
e.preventDefault();
firstElement.focus();
}
}
}
};
const handleEscapeKey = (e: KeyboardEvent) => {
if (e.key === 'Escape') {
setIsMobileMenuOpen(false);
}
};
document.addEventListener('keydown', handleTabKey);
document.addEventListener('keydown', handleEscapeKey);
// Focus the first element when menu opens
setTimeout(() => firstElement.focus(), 100);
return () => {
document.removeEventListener('keydown', handleTabKey);
document.removeEventListener('keydown', handleEscapeKey);
};
}
} else {
document.body.style.overflow = 'unset';
}
@@ -49,7 +97,7 @@ export default function Header() {
const menuItems = [
{ label: t('home'), href: '/' },
{ label: t('team'), href: '/team' },
{ label: t('products'), href: '/products' },
{ label: t('products'), href: currentLocale === 'de' ? '/produkte' : '/products' },
{ label: t('blog'), href: '/blog' },
];
@@ -66,300 +114,360 @@ export default function Header() {
return (
<>
<motion.header
className={headerClass}
initial={{ y: -100, opacity: 0 }}
animate={{ y: 0, opacity: 1 }}
transition={{ duration: 0.8, ease: 'easeOut' }}
>
<div className="container mx-auto px-4 md:px-12 lg:px-16 max-w-7xl flex items-center justify-between">
<motion.div
className="flex-shrink-0 group touch-target"
initial={{ scale: 0.8, opacity: 0 }}
animate={{ scale: 1, opacity: 1 }}
transition={{ duration: 0.6, ease: 'easeOut', delay: 0.1 }}
>
<Link href={`/${currentLocale}`}>
<Image
src={logoSrc}
alt={t('home')}
width={120}
height={120}
className="h-10 md:h-14 w-auto transition-all duration-500 group-hover:scale-110"
priority
/>
</Link>
</motion.div>
<motion.div
className="flex items-center gap-4 md:gap-12"
initial="hidden"
animate="visible"
variants={{
visible: {
transition: {
staggerChildren: 0.08,
delayChildren: 0.3,
},
},
}}
>
<motion.nav className="hidden lg:flex items-center space-x-10" variants={navVariants}>
{menuItems.map((item, _idx) => (
<motion.div key={item.href} variants={navLinkVariants}>
<Link
href={`/${currentLocale}${item.href === '/' ? '' : item.href}`}
onClick={() => setIsMobileMenuOpen(false)}
className={cn(
textColorClass,
'hover:text-accent font-bold transition-all duration-500 text-base md:text-lg tracking-tight relative group inline-block hover:-translate-y-0.5',
)}
>
{item.label}
<span className="absolute -bottom-2 left-0 w-0 h-1 bg-accent transition-all duration-500 group-hover:w-full rounded-full shadow-[0_0_12px_rgba(130,237,32,0.6)]" />
</Link>
</motion.div>
))}
</motion.nav>
<motion.div
className={cn('hidden lg:flex items-center space-x-8', textColorClass)}
variants={headerRightVariants}
>
<motion.div
className="flex items-center space-x-4 text-xs md:text-sm font-extrabold tracking-widest uppercase"
initial={{ opacity: 0, x: 20 }}
animate={{ opacity: 1, x: 0 }}
transition={{ duration: 0.5, delay: 0.6 }}
>
<motion.div
initial={{ opacity: 0, scale: 0.8 }}
animate={{ opacity: 1, scale: 1 }}
transition={{ duration: 0.4, delay: 0.65 }}
>
<Link
href={getPathForLocale('en')}
className={`hover:text-accent transition-colors flex items-center gap-2 touch-target ${currentLocale === 'en' ? 'text-accent' : 'opacity-60'}`}
>
EN
</Link>
</motion.div>
<motion.div
className="w-px h-4 bg-current opacity-20"
initial={{ scaleY: 0 }}
animate={{ scaleY: 1 }}
transition={{ duration: 0.4, delay: 0.7 }}
/>
<motion.div
initial={{ opacity: 0, scale: 0.8 }}
animate={{ opacity: 1, scale: 1 }}
transition={{ duration: 0.4, delay: 0.75 }}
>
<Link
href={getPathForLocale('de')}
className={`hover:text-accent transition-colors flex items-center gap-2 touch-target ${currentLocale === 'de' ? 'text-accent' : 'opacity-60'}`}
>
DE
</Link>
</motion.div>
</motion.div>
<motion.div
initial={{ scale: 0.9, opacity: 0 }}
animate={{ scale: 1, opacity: 1 }}
transition={{ duration: 0.6, type: 'spring', stiffness: 400, delay: 0.7 }}
>
<Button
href={`/${currentLocale}/contact`}
variant="white"
size="md"
className="px-8 shadow-xl"
>
{t('contact')}
</Button>
</motion.div>
</motion.div>
{/* Mobile Menu Button */}
<motion.button
className={cn(
'lg:hidden touch-target p-2 rounded-xl bg-white/10 border border-white/20 z-50',
textColorClass,
)}
aria-label={t('toggleMenu')}
initial={{ scale: 0.8, opacity: 0, rotate: -180 }}
animate={{ scale: 1, opacity: 1, rotate: 0 }}
transition={{
duration: 0.6,
type: 'spring',
stiffness: 300,
damping: 20,
delay: 0.5,
}}
onClick={() => setIsMobileMenuOpen(!isMobileMenuOpen)}
>
<motion.svg
className="w-7 h-7"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
initial={{ scale: 0 }}
animate={{ scale: 1 }}
transition={{ duration: 0.3, delay: 0.6 }}
>
{isMobileMenuOpen ? (
<motion.path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M6 18L18 6M6 6l12 12"
initial={{ pathLength: 0 }}
animate={{ pathLength: 1 }}
transition={{ duration: 0.4, delay: 0.7 }}
/>
) : (
<motion.path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M4 6h16M4 12h16M4 18h16"
initial={{ pathLength: 0 }}
animate={{ pathLength: 1 }}
transition={{ duration: 0.4, delay: 0.7 }}
/>
)}
</motion.svg>
</motion.button>
</motion.div>
</div>
{/* Mobile Menu Overlay */}
<div
className={cn(
'fixed inset-0 bg-primary z-40 lg:hidden transition-all duration-500 ease-in-out flex flex-col',
isMobileMenuOpen
? 'opacity-100 translate-y-0'
: 'opacity-0 -translate-y-full pointer-events-none',
)}
<LazyMotion strict features={domAnimation}>
<m.header
className={headerClass}
initial={{ y: -100, opacity: 0 }}
animate={{ y: 0, opacity: 1 }}
transition={{ duration: 0.8, ease: 'easeOut' }}
>
<motion.div
className="flex-grow flex flex-col justify-center items-center p-8 space-y-8"
initial="closed"
animate={isMobileMenuOpen ? 'open' : 'closed'}
variants={{
open: {
transition: {
staggerChildren: 0.1,
delayChildren: 0.2,
},
},
}}
>
{menuItems.map((item, idx) => (
<motion.div
key={item.href}
variants={{
closed: { opacity: 0, y: 50, scale: 0.9 },
open: {
opacity: 1,
y: 0,
scale: 1,
transition: {
duration: 0.6,
ease: 'easeOut',
delay: idx * 0.08,
},
<div className="container mx-auto px-4 md:px-12 lg:px-16 max-w-7xl flex items-center justify-between">
<m.div
className="flex-shrink-0 group touch-target"
initial={{ scale: 0.8, opacity: 0 }}
animate={{ scale: 1, opacity: 1 }}
transition={{ duration: 0.6, ease: 'easeOut', delay: 0.1 }}
>
<Link
href={`/${currentLocale}`}
onClick={() =>
trackEvent(AnalyticsEvents.BUTTON_CLICK, {
target: 'home_logo',
location: 'header',
})
}
>
<Image
src={logoSrc}
alt={t('home')}
width={120}
height={120}
className="h-10 md:h-14 w-auto transition-all duration-500 group-hover:scale-110"
priority
/>
</Link>
</m.div>
<m.div
className="flex items-center gap-4 md:gap-12"
initial="hidden"
animate="visible"
variants={{
visible: {
transition: {
staggerChildren: 0.08,
delayChildren: 0.3,
},
},
}}
>
<m.nav className="hidden lg:flex items-center space-x-10" variants={navVariants}>
{menuItems.map((item, _idx) => (
<m.div key={item.href} variants={navLinkVariants}>
<Link
href={`/${currentLocale}${item.href === '/' ? '' : item.href}`}
onClick={() => {
setIsMobileMenuOpen(false);
trackEvent(AnalyticsEvents.LINK_CLICK, {
label: item.label,
href: item.href,
location: 'header_nav',
});
}}
className={cn(
textColorClass,
'hover:text-accent font-bold transition-all duration-500 text-base md:text-lg tracking-tight relative group inline-block hover:-translate-y-0.5',
)}
>
{item.label}
<span className="absolute -bottom-2 left-0 w-0 h-1 bg-accent transition-all duration-500 group-hover:w-full rounded-full shadow-[0_0_12px_rgba(130,237,32,0.6)]" />
</Link>
</m.div>
))}
</m.nav>
<m.div
className={cn('hidden lg:flex items-center space-x-8', textColorClass)}
variants={headerRightVariants}
>
<m.div
className="flex items-center space-x-4 text-xs md:text-sm font-extrabold tracking-widest uppercase"
initial={{ opacity: 0, x: 20 }}
animate={{ opacity: 1, x: 0 }}
transition={{ duration: 0.5, delay: 0.6 }}
>
<m.div
initial={{ opacity: 0, scale: 0.8 }}
animate={{ opacity: 1, scale: 1 }}
transition={{ duration: 0.4, delay: 0.65 }}
>
<Link
href={getPathForLocale('en')}
onClick={() =>
trackEvent(AnalyticsEvents.TOGGLE_SWITCH, {
type: 'language',
from: currentLocale,
to: 'en',
location: 'header',
})
}
className={`hover:text-accent transition-colors flex items-center gap-2 touch-target ${currentLocale === 'en' ? 'text-accent' : 'opacity-60'}`}
>
EN
</Link>
</m.div>
<m.div
className="w-px h-4 bg-current opacity-20"
initial={{ scaleY: 0 }}
animate={{ scaleY: 1 }}
transition={{ duration: 0.4, delay: 0.7 }}
/>
<m.div
initial={{ opacity: 0, scale: 0.8 }}
animate={{ opacity: 1, scale: 1 }}
transition={{ duration: 0.4, delay: 0.75 }}
>
<Link
href={getPathForLocale('de')}
onClick={() =>
trackEvent(AnalyticsEvents.TOGGLE_SWITCH, {
type: 'language',
from: currentLocale,
to: 'de',
location: 'header',
})
}
className={`hover:text-accent transition-colors flex items-center gap-2 touch-target ${currentLocale === 'de' ? 'text-accent' : 'opacity-60'}`}
>
DE
</Link>
</m.div>
</m.div>
<m.div
initial={{ scale: 0.9, opacity: 0 }}
animate={{ scale: 1, opacity: 1 }}
transition={{ duration: 0.6, type: 'spring', stiffness: 400, delay: 0.7 }}
>
<Button
href={`/${currentLocale}/contact`}
variant="white"
size="md"
className="px-8 shadow-xl"
onClick={() =>
trackEvent(AnalyticsEvents.BUTTON_CLICK, {
label: t('contact'),
location: 'header_cta',
})
}
>
{t('contact')}
</Button>
</m.div>
</m.div>
{/* Mobile Menu Button */}
<m.button
className={cn(
'lg:hidden touch-target p-2 rounded-xl bg-white/10 border border-white/20 z-50',
textColorClass,
)}
aria-label={t('toggleMenu')}
aria-expanded={isMobileMenuOpen}
aria-controls="mobile-menu"
initial={{ scale: 0.8, opacity: 0, rotate: -180 }}
animate={{ scale: 1, opacity: 1, rotate: 0 }}
transition={{
duration: 0.6,
type: 'spring',
stiffness: 300,
damping: 20,
delay: 0.5,
}}
onClick={() => {
const newState = !isMobileMenuOpen;
setIsMobileMenuOpen(newState);
trackEvent(AnalyticsEvents.BUTTON_CLICK, {
type: 'mobile_menu',
action: newState ? 'open' : 'close',
});
}}
>
<Link
href={`/${currentLocale}${item.href === '/' ? '' : item.href}`}
onClick={() => setIsMobileMenuOpen(false)}
className="text-2xl md:text-3xl font-extrabold text-white hover:text-accent transition-all transform block py-4"
<m.svg
className="w-7 h-7"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
initial={{ scale: 0 }}
animate={{ scale: 1 }}
transition={{ duration: 0.3, delay: 0.6 }}
>
{item.label}
</Link>
</motion.div>
))}
{isMobileMenuOpen ? (
<m.path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M6 18L18 6M6 6l12 12"
initial={{ pathLength: 0 }}
animate={{ pathLength: 1 }}
transition={{ duration: 0.4, delay: 0.7 }}
/>
) : (
<m.path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M4 6h16M4 12h16M4 18h16"
initial={{ pathLength: 0 }}
animate={{ pathLength: 1 }}
transition={{ duration: 0.4, delay: 0.7 }}
/>
)}
</m.svg>
</m.button>
</m.div>
</div>
<motion.div
className="pt-8 border-t border-white/10 w-full flex flex-col items-center space-y-8"
initial={{ opacity: 0, y: 30 }}
animate={isMobileMenuOpen ? { opacity: 1, y: 0 } : { opacity: 0, y: 30 }}
transition={{ duration: 0.5, delay: 0.8 }}
{/* Mobile Menu Overlay */}
<div
className={cn(
'fixed inset-0 bg-primary z-40 lg:hidden transition-all duration-500 ease-in-out flex flex-col',
isMobileMenuOpen
? 'opacity-100 translate-y-0'
: 'opacity-0 -translate-y-full pointer-events-none',
)}
id="mobile-menu"
role="dialog"
aria-modal="true"
aria-label={t('menu')}
ref={mobileMenuRef}
>
<m.nav
className="flex-grow flex flex-col justify-center items-center p-8 space-y-8"
initial="closed"
animate={isMobileMenuOpen ? 'open' : 'closed'}
variants={{
open: {
transition: {
staggerChildren: 0.1,
delayChildren: 0.2,
},
},
}}
>
<motion.div
className="flex items-center space-x-8 text-lg md:text-xl font-extrabold tracking-widest uppercase text-white"
{menuItems.map((item, idx) => (
<m.div
key={item.href}
variants={{
closed: { opacity: 0, y: 50, scale: 0.9 },
open: {
opacity: 1,
y: 0,
scale: 1,
transition: {
duration: 0.6,
ease: 'easeOut',
delay: idx * 0.08,
},
},
}}
>
<Link
href={`/${currentLocale}${item.href === '/' ? '' : item.href}`}
onClick={() => {
setIsMobileMenuOpen(false);
trackEvent(AnalyticsEvents.LINK_CLICK, {
label: item.label,
href: item.href,
location: 'mobile_menu',
});
}}
className="text-2xl md:text-3xl font-extrabold text-white hover:text-accent transition-all transform block py-4"
>
{item.label}
</Link>
</m.div>
))}
<m.div
className="pt-8 border-t border-white/10 w-full flex flex-col items-center space-y-8"
initial={{ opacity: 0, y: 30 }}
animate={isMobileMenuOpen ? { opacity: 1, y: 0 } : { opacity: 0, y: 30 }}
transition={{ duration: 0.5, delay: 0.8 }}
>
<m.div
className="flex items-center space-x-8 text-lg md:text-xl font-extrabold tracking-widest uppercase text-white"
initial={{ opacity: 0, scale: 0.8 }}
animate={{ opacity: 1, scale: 1 }}
transition={{ duration: 0.4, delay: 0.9 }}
>
<m.div
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
transition={{ duration: 0.3, delay: 1.0 }}
>
<Link
href={getPathForLocale('en')}
className={`hover:text-accent transition-colors ${currentLocale === 'en' ? 'text-accent' : 'opacity-60'}`}
>
EN
</Link>
</m.div>
<m.div
className="w-px h-6 bg-white/20"
initial={{ scaleX: 0 }}
animate={{ scaleX: 1 }}
transition={{ duration: 0.4, delay: 1.05 }}
/>
<m.div
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
transition={{ duration: 0.3, delay: 1.1 }}
>
<Link
href={getPathForLocale('de')}
className={`hover:text-accent transition-colors ${currentLocale === 'de' ? 'text-accent' : 'opacity-60'}`}
>
DE
</Link>
</m.div>
</m.div>
<m.div
initial={{ scale: 0.9, opacity: 0, y: 20 }}
animate={{ scale: 1, opacity: 1, y: 0 }}
transition={{ type: 'spring', stiffness: 400, damping: 20, delay: 1.2 }}
>
<Button
href={`/${currentLocale}/contact`}
variant="accent"
size="lg"
className="w-full max-w-xs py-6 text-lg md:text-xl shadow-2xl"
>
{t('contact')}
</Button>
</m.div>
</m.div>
{/* Bottom Branding */}
<m.div
className="p-12 flex justify-center opacity-20"
initial={{ opacity: 0, scale: 0.8 }}
animate={{ opacity: 1, scale: 1 }}
transition={{ duration: 0.4, delay: 0.9 }}
animate={isMobileMenuOpen ? { opacity: 0.2, scale: 1 } : { opacity: 0, scale: 0.8 }}
transition={{ duration: 0.5, delay: 1.4 }}
>
<motion.div
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
transition={{ duration: 0.3, delay: 1.0 }}
<m.div
initial={{ scale: 0.5 }}
animate={{ scale: 1 }}
transition={{ type: 'spring', stiffness: 300, delay: 1.5 }}
>
<Link
href={getPathForLocale('en')}
className={`hover:text-accent transition-colors ${currentLocale === 'en' ? 'text-accent' : 'opacity-60'}`}
>
EN
</Link>
</motion.div>
<motion.div
className="w-px h-6 bg-white/20"
initial={{ scaleX: 0 }}
animate={{ scaleX: 1 }}
transition={{ duration: 0.4, delay: 1.05 }}
/>
<motion.div
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
transition={{ duration: 0.3, delay: 1.1 }}
>
<Link
href={getPathForLocale('de')}
className={`hover:text-accent transition-colors ${currentLocale === 'de' ? 'text-accent' : 'opacity-60'}`}
>
DE
</Link>
</motion.div>
</motion.div>
<motion.div
initial={{ scale: 0.9, opacity: 0, y: 20 }}
animate={{ scale: 1, opacity: 1, y: 0 }}
transition={{ type: 'spring', stiffness: 400, damping: 20, delay: 1.2 }}
>
<Button
href={`/${currentLocale}/contact`}
variant="accent"
size="lg"
className="w-full max-w-xs py-6 text-lg md:text-xl shadow-2xl"
>
{t('contact')}
</Button>
</motion.div>
</motion.div>
{/* Bottom Branding */}
<motion.div
className="p-12 flex justify-center opacity-20"
initial={{ opacity: 0, scale: 0.8 }}
animate={isMobileMenuOpen ? { opacity: 0.2, scale: 1 } : { opacity: 0, scale: 0.8 }}
transition={{ duration: 0.5, delay: 1.4 }}
>
<motion.div
initial={{ scale: 0.5 }}
animate={{ scale: 1 }}
transition={{ type: 'spring', stiffness: 300, delay: 1.5 }}
>
<Image src="/logo-white.svg" alt={t('home')} width={80} height={80} unoptimized />
</motion.div>
</motion.div>
</motion.div>
</div>
</motion.header>
<Image src="/logo-white.svg" alt={t('home')} width={80} height={80} unoptimized />
</m.div>
</m.div>
</m.nav>
</div>
</m.header>
</LazyMotion>
</>
);
}

View File

@@ -1,18 +1,18 @@
'use client';
import { MapContainer, TileLayer, Marker, Popup } from 'react-leaflet';
import React, { useEffect, useRef } from 'react';
import L from 'leaflet';
import 'leaflet/dist/leaflet.css';
// Fix for default marker icon in Leaflet with Next.js
const DefaultIcon = L.icon({
iconUrl: 'https://unpkg.com/leaflet@1.9.4/dist/images/marker-icon.png',
shadowUrl: 'https://unpkg.com/leaflet@1.9.4/dist/images/marker-shadow.png',
iconSize: [25, 41],
iconAnchor: [12, 41],
});
if (typeof window !== 'undefined') {
const DefaultIcon = L.icon({
iconUrl: 'https://unpkg.com/leaflet@1.9.4/dist/images/marker-icon.png',
shadowUrl: 'https://unpkg.com/leaflet@1.9.4/dist/images/marker-shadow.png',
iconSize: [25, 41],
iconAnchor: [12, 41],
});
L.Marker.prototype.options.icon = DefaultIcon;
L.Marker.prototype.options.icon = DefaultIcon;
}
interface LeafletMapProps {
address: string;
@@ -21,25 +21,46 @@ interface LeafletMapProps {
}
export default function LeafletMap({ address, lat, lng }: LeafletMapProps) {
const position: [number, number] = [lat, lng];
const mapRef = useRef<HTMLDivElement>(null);
const mapInstanceRef = useRef<L.Map | null>(null);
return (
<MapContainer
center={position}
zoom={15}
scrollWheelZoom={false}
className="h-full w-full z-0"
>
<TileLayer
attribution='&copy; <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a> contributors'
url="https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png"
/>
<Marker position={position}>
<Popup>
<div className="text-primary font-bold">KLZ Cables</div>
<div className="text-sm whitespace-pre-line">{address}</div>
</Popup>
</Marker>
</MapContainer>
);
useEffect(() => {
if (!mapRef.current || mapInstanceRef.current) return;
// Initialize map
const map = L.map(mapRef.current, {
center: [lat, lng],
zoom: 15,
scrollWheelZoom: false,
});
// Add tiles
L.tileLayer('https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png', {
attribution:
'&copy; <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a> contributors',
}).addTo(map);
// Add marker
const marker = L.marker([lat, lng]).addTo(map);
// Create popup content
const popupContent = `
<div class="text-primary font-bold">KLZ Cables</div>
<div class="text-sm">${address.replace(/\n/g, '<br/>')}</div>
`;
marker.bindPopup(popupContent);
mapInstanceRef.current = map;
// Cleanup on unmount
return () => {
if (mapInstanceRef.current) {
mapInstanceRef.current.remove();
mapInstanceRef.current = null;
}
};
}, [lat, lng, address]);
return <div ref={mapRef} className="h-full w-full z-0" />;
}

View File

@@ -1,9 +1,9 @@
'use client';
import React, { useEffect, useState, useCallback } from 'react';
import React, { useEffect, useState, useCallback, useRef } from 'react';
import Image from 'next/image';
import { createPortal } from 'react-dom';
import { motion, AnimatePresence } from 'framer-motion';
import { m, LazyMotion, domAnimation, AnimatePresence } from 'framer-motion';
import { useRouter, useSearchParams, usePathname } from 'next/navigation';
interface LightboxProps {
@@ -19,6 +19,8 @@ export default function Lightbox({ isOpen, images, initialIndex, onClose }: Ligh
const pathname = usePathname();
const [currentIndex, setCurrentIndex] = useState(initialIndex);
const [mounted, setMounted] = useState(false);
const closeButtonRef = useRef<HTMLButtonElement>(null);
const previousFocusRef = useRef<HTMLElement | null>(null);
useEffect(() => {
setMounted(true); // eslint-disable-line react-hooks/set-state-in-effect
@@ -76,12 +78,50 @@ export default function Lightbox({ isOpen, images, initialIndex, onClose }: Ligh
}, [isOpen, currentIndex, updateUrl]);
useEffect(() => {
if (!isOpen) return;
if (!isOpen) {
if (previousFocusRef.current) {
previousFocusRef.current.focus();
}
return;
}
// Capture previous focus
previousFocusRef.current = document.activeElement as HTMLElement;
// Focus close button on open
setTimeout(() => closeButtonRef.current?.focus(), 100);
const handleKeyDown = (e: KeyboardEvent) => {
if (e.key === 'Escape') handleClose();
if (e.key === 'ArrowLeft') prevImage();
if (e.key === 'ArrowRight') nextImage();
// Focus Trap
if (e.key === 'Tab') {
const focusableElements = document.querySelectorAll(
'button, [href], input, select, textarea, [tabindex]:not([tabindex="-1"])',
);
const modalElements = Array.from(focusableElements).filter((el) =>
document.querySelector('[role="dialog"]')?.contains(el),
);
if (modalElements.length === 0) return;
const firstElement = modalElements[0] as HTMLElement;
const lastElement = modalElements[modalElements.length - 1] as HTMLElement;
if (e.shiftKey) {
if (document.activeElement === firstElement) {
lastElement.focus();
e.preventDefault();
}
} else {
if (document.activeElement === lastElement) {
firstElement.focus();
e.preventDefault();
}
}
}
};
// Lock scroll
@@ -99,113 +139,120 @@ export default function Lightbox({ isOpen, images, initialIndex, onClose }: Ligh
if (!mounted) return null;
return createPortal(
<AnimatePresence>
{isOpen && (
<div className="fixed inset-0 z-[99999] flex items-center justify-center">
<motion.div
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
transition={{ duration: 0.5 }}
className="absolute inset-0 bg-primary/95 backdrop-blur-xl"
onClick={handleClose}
/>
<motion.button
initial={{ opacity: 0, scale: 0.5 }}
animate={{ opacity: 1, scale: 1 }}
exit={{ opacity: 0, scale: 0.5 }}
transition={{ delay: 0.1, duration: 0.4 }}
onClick={handleClose}
className="absolute top-6 right-6 text-white/60 hover:text-white transition-all duration-500 z-[10000] rounded-full w-14 h-14 flex items-center justify-center hover:bg-white/5 group border border-white/10"
aria-label="Close lightbox"
<LazyMotion strict features={domAnimation}>
<AnimatePresence>
{isOpen && (
<div
className="fixed inset-0 z-[99999] flex items-center justify-center"
role="dialog"
aria-modal="true"
>
<div className="relative w-full h-full flex items-center justify-center group-hover:rotate-90 transition-transform duration-500">
<span className="text-3xl font-extralight leading-none mb-1">×</span>
</div>
</motion.button>
<m.div
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
transition={{ duration: 0.5 }}
className="absolute inset-0 bg-primary/95 backdrop-blur-xl"
onClick={handleClose}
/>
<motion.button
initial={{ opacity: 0, x: -20 }}
animate={{ opacity: 1, x: 0 }}
exit={{ opacity: 0, x: -20 }}
transition={{ delay: 0.2, duration: 0.4 }}
onClick={prevImage}
className="absolute left-6 top-1/2 -translate-y-1/2 text-white/60 hover:text-white transition-all duration-500 w-14 h-14 flex items-center justify-center hover:bg-white/5 rounded-full z-[10000] group border border-white/10"
aria-label="Previous image"
>
<span className="text-4xl font-extralight group-hover:-translate-x-1 transition-transform duration-500">
</span>
</motion.button>
<motion.button
initial={{ opacity: 0, x: 20 }}
animate={{ opacity: 1, x: 0 }}
exit={{ opacity: 0, x: 20 }}
transition={{ delay: 0.2, duration: 0.4 }}
onClick={nextImage}
className="absolute right-6 top-1/2 -translate-y-1/2 text-white/60 hover:text-white transition-all duration-500 w-14 h-14 flex items-center justify-center hover:bg-white/5 rounded-full z-[10000] group border border-white/10"
aria-label="Next image"
>
<span className="text-4xl font-extralight group-hover:translate-x-1 transition-transform duration-500">
</span>
</motion.button>
<motion.div
initial={{ opacity: 0, y: 40, scale: 0.95 }}
animate={{ opacity: 1, y: 0, scale: 1 }}
exit={{ opacity: 0, y: 20, scale: 0.98 }}
transition={{ duration: 0.6, ease: [0.25, 0.46, 0.45, 0.94] }}
className="relative w-full h-full max-w-6xl max-h-[85vh] flex flex-col items-center justify-center p-4 md:p-12 z-20 pointer-events-none"
>
<div className="pointer-events-auto w-full h-full flex flex-col items-center justify-center">
<div className="relative w-full h-full shadow-[0_40px_100px_-20px_rgba(0,0,0,0.6)] ring-1 ring-white/20 overflow-hidden bg-primary-dark/50 rounded-2xl flex items-center justify-center">
<AnimatePresence mode="wait" initial={false}>
<motion.div
key={currentIndex}
initial={{ opacity: 0, scale: 1.1, filter: 'blur(10px)' }}
animate={{ opacity: 1, scale: 1, filter: 'blur(0px)' }}
exit={{ opacity: 0, scale: 0.9, filter: 'blur(10px)' }}
transition={{ duration: 0.7, ease: [0.25, 0.46, 0.45, 0.94] }}
className="relative w-full h-full"
>
<Image
src={images[currentIndex]}
alt={`Gallery image ${currentIndex + 1}`}
fill
className="object-cover transition-transform duration-1000 hover:scale-[1.03]"
unoptimized
/>
</motion.div>
</AnimatePresence>
{/* Technical Detail: Subtle grid overlay to reinforce industrial precision */}
<div className="absolute inset-0 pointer-events-none opacity-[0.03] bg-[url('/grid.svg')] bg-repeat z-10" />
{/* Premium Reflection: Subtle gradient to give material feel */}
<div className="absolute inset-0 pointer-events-none bg-gradient-to-tr from-white/10 via-transparent to-transparent opacity-40 z-10" />
<m.button
initial={{ opacity: 0, scale: 0.5 }}
animate={{ opacity: 1, scale: 1 }}
exit={{ opacity: 0, scale: 0.5 }}
transition={{ delay: 0.1, duration: 0.4 }}
ref={closeButtonRef}
onClick={handleClose}
className="absolute top-6 right-6 text-white/60 hover:text-white transition-all duration-500 z-[10000] rounded-full w-14 h-14 flex items-center justify-center hover:bg-white/5 group border border-white/10"
aria-label="Close lightbox"
>
<div className="relative w-full h-full flex items-center justify-center group-hover:rotate-90 transition-transform duration-500">
<span className="text-3xl font-extralight leading-none mb-1">×</span>
</div>
</m.button>
<motion.div
initial={{ opacity: 0, y: 10 }}
animate={{ opacity: 1, y: 0 }}
exit={{ opacity: 0, y: 10 }}
transition={{ delay: 0.3, duration: 0.4 }}
className="mt-8 flex items-center gap-4"
>
<div className="h-px w-12 bg-white/20" />
<div className="bg-white/5 backdrop-blur-2xl text-white px-6 py-2 rounded-full border border-white/10 text-[11px] font-bold tracking-[0.2em] uppercase">
{currentIndex + 1} <span className="text-accent mx-3">/</span> {images.length}
<m.button
initial={{ opacity: 0, x: -20 }}
animate={{ opacity: 1, x: 0 }}
exit={{ opacity: 0, x: -20 }}
transition={{ delay: 0.2, duration: 0.4 }}
onClick={prevImage}
className="absolute left-6 top-1/2 -translate-y-1/2 text-white/60 hover:text-white transition-all duration-500 w-14 h-14 flex items-center justify-center hover:bg-white/5 rounded-full z-[10000] group border border-white/10"
aria-label="Previous image"
>
<span className="text-4xl font-extralight group-hover:-translate-x-1 transition-transform duration-500">
</span>
</m.button>
<m.button
initial={{ opacity: 0, x: 20 }}
animate={{ opacity: 1, x: 0 }}
exit={{ opacity: 0, x: 20 }}
transition={{ delay: 0.2, duration: 0.4 }}
onClick={nextImage}
className="absolute right-6 top-1/2 -translate-y-1/2 text-white/60 hover:text-white transition-all duration-500 w-14 h-14 flex items-center justify-center hover:bg-white/5 rounded-full z-[10000] group border border-white/10"
aria-label="Next image"
>
<span className="text-4xl font-extralight group-hover:translate-x-1 transition-transform duration-500">
</span>
</m.button>
<m.div
initial={{ opacity: 0, y: 40, scale: 0.95 }}
animate={{ opacity: 1, y: 0, scale: 1 }}
exit={{ opacity: 0, y: 20, scale: 0.98 }}
transition={{ duration: 0.6, ease: [0.25, 0.46, 0.45, 0.94] }}
className="relative w-full h-full max-w-6xl max-h-[85vh] flex flex-col items-center justify-center p-4 md:p-12 z-20 pointer-events-none"
>
<div className="pointer-events-auto w-full h-full flex flex-col items-center justify-center">
<div className="relative w-full h-full shadow-[0_40px_100px_-20px_rgba(0,0,0,0.6)] ring-1 ring-white/20 overflow-hidden bg-primary-dark/50 rounded-2xl flex items-center justify-center">
<AnimatePresence mode="wait" initial={false}>
<m.div
key={currentIndex}
initial={{ opacity: 0, scale: 1.1, filter: 'blur(10px)' }}
animate={{ opacity: 1, scale: 1, filter: 'blur(0px)' }}
exit={{ opacity: 0, scale: 0.9, filter: 'blur(10px)' }}
transition={{ duration: 0.7, ease: [0.25, 0.46, 0.45, 0.94] }}
className="relative w-full h-full"
>
<Image
src={images[currentIndex]}
alt={`Gallery image ${currentIndex + 1}`}
fill
className="object-cover transition-transform duration-1000 hover:scale-[1.03]"
unoptimized
/>
</m.div>
</AnimatePresence>
{/* Technical Detail: Subtle grid overlay to reinforce industrial precision */}
<div className="absolute inset-0 pointer-events-none opacity-[0.03] bg-[url('/grid.svg')] bg-repeat z-10" />
{/* Premium Reflection: Subtle gradient to give material feel */}
<div className="absolute inset-0 pointer-events-none bg-gradient-to-tr from-white/10 via-transparent to-transparent opacity-40 z-10" />
</div>
<div className="h-px w-12 bg-white/20" />
</motion.div>
</div>
</motion.div>
</div>
)}
</AnimatePresence>,
<m.div
initial={{ opacity: 0, y: 10 }}
animate={{ opacity: 1, y: 0 }}
exit={{ opacity: 0, y: 10 }}
transition={{ delay: 0.3, duration: 0.4 }}
className="mt-8 flex items-center gap-4"
>
<div className="h-px w-12 bg-white/20" />
<div className="bg-white/5 backdrop-blur-2xl text-white px-6 py-2 rounded-full border border-white/10 text-[11px] font-bold tracking-[0.2em] uppercase">
{currentIndex + 1} <span className="text-accent mx-3">/</span> {images.length}
</div>
<div className="h-px w-12 bg-white/20" />
</m.div>
</div>
</m.div>
</div>
)}
</AnimatePresence>
</LazyMotion>,
document.body,
);
}

View File

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

View File

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

View File

@@ -0,0 +1,39 @@
'use client';
import Link from 'next/link';
import { useAnalytics } from './analytics/useAnalytics';
import { AnalyticsEvents } from './analytics/analytics-events';
interface RelatedProductLinkProps {
href: string;
productSlug: string;
productTitle: string;
children: React.ReactNode;
className?: string;
}
export function RelatedProductLink({
href,
productSlug,
productTitle,
children,
className,
}: RelatedProductLinkProps) {
const { trackEvent } = useAnalytics();
return (
<Link
href={href}
className={className}
onClick={() =>
trackEvent(AnalyticsEvents.PRODUCT_VIEW, {
product_id: productSlug,
product_name: productTitle,
location: 'related_products',
})
}
>
{children}
</Link>
);
}

View File

@@ -1,8 +1,7 @@
import { getAllProducts } from '@/lib/mdx';
import { mapFileSlugToTranslated } from '@/lib/slugs';
import { getTranslations } from 'next-intl/server';
import Image from 'next/image';
import Link from 'next/link';
import { RelatedProductLink } from './RelatedProductLink';
interface RelatedProductsProps {
currentSlug: string;
@@ -10,15 +9,19 @@ interface RelatedProductsProps {
locale: string;
}
export default async function RelatedProducts({ currentSlug, categories, locale }: RelatedProductsProps) {
const allProducts = await getAllProducts(locale);
export default async function RelatedProducts({
currentSlug,
categories,
locale,
}: RelatedProductsProps) {
const products = await getAllProducts(locale);
const t = await getTranslations('Products');
// Filter products: same category, not current product
const related = allProducts
.filter(p =>
p.slug !== currentSlug &&
p.frontmatter.categories.some(cat => categories.includes(cat))
const related = products
.filter(
(p) =>
p.slug !== currentSlug && p.frontmatter.categories.some((cat) => categories.includes(cat)),
)
.slice(0, 3); // Limit to 3 for better spacing
@@ -36,23 +39,31 @@ export default async function RelatedProducts({ currentSlug, categories, locale
</div>
<div className="grid grid-cols-1 md:grid-cols-3 gap-8">
{related.map(async (product) => {
{related.map((product) => {
// Find the category slug for the link
const categorySlugs = ['low-voltage-cables', 'medium-voltage-cables', 'high-voltage-cables', 'solar-cables'];
const catSlug = categorySlugs.find(slug => {
const key = slug.replace(/-cables$/, '').replace(/-([a-z])/g, (g) => g[1].toUpperCase());
const title = t(`categories.${key}.title`);
return product.frontmatter.categories.some(cat =>
cat.toLowerCase().replace(/\s+/g, '-') === slug || cat === title
);
}) || 'low-voltage-cables';
const translatedProductSlug = await mapFileSlugToTranslated(product.slug, locale);
const categorySlugs = [
'low-voltage-cables',
'medium-voltage-cables',
'high-voltage-cables',
'solar-cables',
];
const catSlug =
categorySlugs.find((slug) => {
const key = slug
.replace(/-cables$/, '')
.replace(/-([a-z])/g, (g) => g[1].toUpperCase());
const title = t(`categories.${key}.title`);
return product.frontmatter.categories.some(
(cat) => cat.toLowerCase().replace(/\s+/g, '-') === slug || cat === title,
);
}) || 'low-voltage-cables';
return (
<Link
key={product.slug}
href={`/${locale}/products/${catSlug}/${translatedProductSlug}`}
<RelatedProductLink
key={product.slug}
href={`/${locale}/products/${catSlug}/${product.slug}`}
productSlug={product.slug}
productTitle={product.frontmatter.title}
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-[16/10] relative bg-neutral-light/30 p-8 overflow-hidden">
@@ -74,8 +85,11 @@ export default async function RelatedProducts({ currentSlug, categories, locale
</div>
<div className="p-8">
<div className="flex flex-wrap gap-2 mb-3">
{product.frontmatter.categories.slice(0, 1).map((cat, idx) => (
<span key={idx} className="text-[10px] font-bold uppercase tracking-widest text-primary/40">
{product.frontmatter.categories.slice(0, 1).map((cat: any, idx: number) => (
<span
key={idx}
className="text-[10px] font-bold uppercase tracking-widest text-primary/40"
>
{cat}
</span>
))}
@@ -87,12 +101,23 @@ export default async function RelatedProducts({ currentSlug, categories, locale
<span className="border-b-2 border-primary/10 group-hover:border-accent-dark transition-colors pb-0.5">
{t('details')}
</span>
<svg className="w-4 h-4 ml-2 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
className="w-4 h-4 ml-2 transition-transform group-hover: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>
</div>
</div>
</Link>
</RelatedProductLink>
);
})}
</div>

View File

@@ -5,6 +5,7 @@ import { useTranslations } from 'next-intl';
import { Input, Textarea, Button } from '@/components/ui';
import { sendContactFormAction } from '@/app/actions/contact';
import { useAnalytics } from '@/components/analytics/useAnalytics';
import { AnalyticsEvents } from '@/components/analytics/analytics-events';
interface RequestQuoteFormProps {
productName: string;
@@ -16,6 +17,26 @@ export default function RequestQuoteForm({ productName }: RequestQuoteFormProps)
const [email, setEmail] = useState('');
const [request, setRequest] = useState('');
const [status, setStatus] = useState<'idle' | 'submitting' | 'success' | 'error'>('idle');
const [hasStarted, setHasStarted] = useState(false);
const handleFocus = (fieldId: string) => {
// Initial form start
if (!hasStarted) {
setHasStarted(true);
trackEvent(AnalyticsEvents.FORM_START, {
form_id: 'quote_request_form',
form_name: 'Product Quote Inquiry',
product_name: productName,
});
}
// Field-level transparency
trackEvent(AnalyticsEvents.FORM_FIELD_FOCUS, {
form_id: 'quote_request_form',
field_id: fieldId,
product_name: productName,
});
};
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
@@ -39,17 +60,31 @@ export default function RequestQuoteForm({ productName }: RequestQuoteFormProps)
setEmail('');
setRequest('');
} else {
trackEvent(AnalyticsEvents.FORM_ERROR, {
form_id: 'quote_request_form',
product_name: productName,
error: result.error || 'submission_failed',
});
setStatus('error');
}
} catch (error) {
console.error('Form submission error:', error);
trackEvent(AnalyticsEvents.FORM_ERROR, {
form_id: 'quote_request_form',
product_name: productName,
error: (error as Error).message || 'unexpected_error',
});
setStatus('error');
}
};
if (status === 'success') {
return (
<div className="bg-accent/5 border border-accent/20 text-primary-dark p-4 rounded-xl text-center animate-fade-in !mt-0 w-full">
<div
className="bg-accent/5 border border-accent/20 text-primary-dark p-4 rounded-xl text-center animate-fade-in !mt-0 w-full"
role="alert"
aria-live="polite"
>
<div className="flex justify-center mb-3">
<div className="w-10 h-10 bg-accent rounded-full flex items-center justify-center shadow-lg shadow-accent/20">
<svg
@@ -87,7 +122,11 @@ export default function RequestQuoteForm({ productName }: RequestQuoteFormProps)
if (status === 'error') {
return (
<div className="bg-destructive/5 border border-destructive/20 text-destructive p-4 rounded-xl text-center animate-fade-in !mt-0 w-full">
<div
className="bg-destructive/5 border border-destructive/20 text-destructive p-4 rounded-xl text-center animate-fade-in !mt-0 w-full"
role="alert"
aria-live="assertive"
>
<div className="flex justify-center mb-3">
<div className="w-10 h-10 bg-destructive rounded-full flex items-center justify-center shadow-lg shadow-destructive/20">
<svg
@@ -127,23 +166,27 @@ export default function RequestQuoteForm({ productName }: RequestQuoteFormProps)
<div className="space-y-1 !mt-0">
<Input
type="email"
id="email"
id="quote-email"
required
value={email}
onChange={(e) => setEmail(e.target.value)}
onFocus={() => handleFocus('quote-email')}
placeholder={t('email')}
aria-label={t('email')}
className="h-9 text-xs !mt-0"
/>
</div>
<div className="space-y-1 !mt-0">
<Textarea
id="request"
id="quote-request"
required
rows={3}
value={request}
onChange={(e) => setRequest(e.target.value)}
onFocus={() => handleFocus('quote-request')}
placeholder={t('message')}
aria-label={t('message')}
className="text-xs !mt-0"
/>
</div>

View File

@@ -1,7 +1,7 @@
'use client';
import React from 'react';
import { motion, Variants } from 'framer-motion';
import { m, LazyMotion, domAnimation, Variants } from 'framer-motion';
import { cn } from '@/components/ui';
interface ScribbleProps {
@@ -18,56 +18,60 @@ export default function Scribble({ variant, className, color = '#82ed20' }: Scri
opacity: 1,
transition: {
duration: 1.8,
ease: "easeInOut",
}
}
ease: 'easeInOut',
},
},
};
if (variant === 'circle') {
return (
<svg
className={cn("absolute pointer-events-none", className)}
role="presentation"
viewBox="0 0 800 350"
preserveAspectRatio="none"
>
<motion.path
variants={pathVariants}
initial="hidden"
whileInView="visible"
viewport={{ once: true }}
transform="matrix(0.9791300296783447,0,0,0.9791300296783447,400,179)"
strokeLinejoin="miter"
fillOpacity="0"
strokeMiterlimit="4"
stroke={color}
strokeOpacity="1"
strokeWidth="20"
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>
<LazyMotion strict features={domAnimation}>
<svg
className={cn('absolute pointer-events-none', className)}
aria-hidden="true"
viewBox="0 0 800 350"
preserveAspectRatio="none"
>
<m.path
variants={pathVariants}
initial="hidden"
whileInView="visible"
viewport={{ once: true }}
transform="matrix(0.9791300296783447,0,0,0.9791300296783447,400,179)"
strokeLinejoin="miter"
fillOpacity="0"
strokeMiterlimit="4"
stroke={color}
strokeOpacity="1"
strokeWidth="20"
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>
</LazyMotion>
);
}
if (variant === 'underline') {
return (
<svg
className={cn("absolute pointer-events-none", className)}
role="presentation"
viewBox="-400 -55 730 60"
preserveAspectRatio="none"
>
<motion.path
variants={pathVariants}
initial="hidden"
whileInView="visible"
viewport={{ once: true }}
d="m -383.25 -6 c 55.25 -22 130.75 -33.5 293.25 -38 c 54.5 -0.5 195 -2.5 401 15"
stroke={color}
strokeWidth="20"
fill="none"
/>
</svg>
<LazyMotion strict features={domAnimation}>
<svg
className={cn('absolute pointer-events-none', className)}
aria-hidden="true"
viewBox="-400 -55 730 60"
preserveAspectRatio="none"
>
<m.path
variants={pathVariants}
initial="hidden"
whileInView="visible"
viewport={{ once: true }}
d="m -383.25 -6 c 55.25 -22 130.75 -33.5 293.25 -38 c 54.5 -0.5 195 -2.5 401 15"
stroke={color}
strokeWidth="20"
fill="none"
/>
</svg>
</LazyMotion>
);
}

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,34 @@
'use client';
import dynamic from 'next/dynamic';
import { Suspense, useEffect, useState } from 'react';
const DynamicAnalyticsProvider = dynamic(() => import('./AnalyticsProvider'), {
ssr: false,
});
const DynamicScrollDepthTracker = dynamic(() => import('./ScrollDepthTracker'), {
ssr: false,
});
export default function AnalyticsShell() {
const [shouldLoad, setShouldLoad] = useState(false);
useEffect(() => {
// Wait until browser is completely idle before loading heavy analytics/logger/sentry SDKs
if (typeof window !== 'undefined' && 'requestIdleCallback' in window) {
window.requestIdleCallback(() => setShouldLoad(true), { timeout: 3000 });
} else {
const timer = setTimeout(() => setShouldLoad(true), 2500);
return () => clearTimeout(timer);
}
}, []);
if (!shouldLoad) return null;
return (
<Suspense fallback={null}>
<DynamicAnalyticsProvider />
<DynamicScrollDepthTracker />
</Suspense>
);
}

View File

@@ -0,0 +1,53 @@
'use client';
import { useEffect } from 'react';
import { useAnalytics } from './useAnalytics';
import { AnalyticsEvents } from './analytics-events';
interface BlogEngagementTrackerProps {
title: string;
slug: string;
category?: string;
readingTime: number;
}
/**
* BlogEngagementTracker
* Tracks reading time and article completion.
*/
export default function BlogEngagementTracker({
title,
slug,
category,
readingTime,
}: BlogEngagementTrackerProps) {
const { trackEvent } = useAnalytics();
useEffect(() => {
// Article start
trackEvent(AnalyticsEvents.BLOG_POST_VIEW, {
title,
slug,
category,
estimated_reading_time: readingTime,
location: 'blog_post_pdp',
});
const startTime = Date.now();
return () => {
const dwellTime = Math.round((Date.now() - startTime) / 1000);
// We only consider it a "read" if they stay a reasonable amount of time
// or if they scroll (covered by ScrollDepthTracker)
trackEvent('blog_dwell_time', {
title,
slug,
seconds: dwellTime,
reading_time_completion: Math.min(100, Math.round((dwellTime / (readingTime * 60)) * 100)),
});
};
}, [title, slug, category, readingTime, trackEvent]);
return null;
}

View File

@@ -0,0 +1,50 @@
'use client';
import { useEffect } from 'react';
import { useAnalytics } from './useAnalytics';
import { AnalyticsEvents } from './analytics-events';
interface ProductEngagementTrackerProps {
productName: string;
productSlug: string;
categories: string[];
sku?: string;
}
/**
* ProductEngagementTracker
* Deep analytics for product pages.
* Tracks specific view events with full metadata for sales analysis.
*/
export default function ProductEngagementTracker({
productName,
productSlug,
categories,
sku,
}: ProductEngagementTrackerProps) {
const { trackEvent } = useAnalytics();
useEffect(() => {
// Standardized product view event for "High-Fidelity" sales insights
trackEvent(AnalyticsEvents.PRODUCT_VIEW, {
product_id: productSlug,
product_name: productName,
product_sku: sku,
product_categories: categories.join(', '),
location: 'pdp_standard',
});
// We can also track "Engagement Start" to measure dwell time later
const startTime = Date.now();
return () => {
const dwellTime = Math.round((Date.now() - startTime) / 1000);
trackEvent('pdp_dwell_time', {
product_id: productSlug,
seconds: dwellTime,
});
};
}, [productName, productSlug, categories, sku, trackEvent]);
return null;
}

View File

@@ -0,0 +1,62 @@
'use client';
import { useEffect, useRef } from 'react';
import { usePathname } from 'next/navigation';
import { useAnalytics } from './useAnalytics';
import { AnalyticsEvents } from './analytics-events';
/**
* ScrollDepthTracker
* Tracks user scroll progress across pages.
* Fires events at 25%, 50%, 75%, and 100% depth.
*/
export default function ScrollDepthTracker() {
const pathname = usePathname();
const { trackEvent } = useAnalytics();
const trackedDepths = useRef<Set<number>>(new Set());
// Reset tracking when path changes
useEffect(() => {
trackedDepths.current.clear();
}, [pathname]);
useEffect(() => {
const handleScroll = () => {
const scrollY = window.scrollY;
const windowHeight = window.innerHeight;
const documentHeight = document.documentElement.scrollHeight;
// Calculate how far the user has scrolled in percentage
// documentHeight - windowHeight is the total scrollable distance
const totalScrollable = documentHeight - windowHeight;
if (totalScrollable <= 0) return; // Not scrollable
const scrollPercentage = Math.round((scrollY / totalScrollable) * 100);
// We only care about specific milestones
const milestones = [25, 50, 75, 100];
milestones.forEach((milestone) => {
if (scrollPercentage >= milestone && !trackedDepths.current.has(milestone)) {
trackedDepths.current.add(milestone);
trackEvent(AnalyticsEvents.SCROLL_DEPTH, {
depth: milestone,
path: pathname,
});
}
});
};
// Use passive listener for better performance
window.addEventListener('scroll', handleScroll, { passive: true });
// Initial check (in case page is short or already scrolled)
handleScroll();
return () => {
window.removeEventListener('scroll', handleScroll);
};
}, [pathname, trackEvent]);
return null;
}

View File

@@ -0,0 +1,34 @@
'use client';
import React from 'react';
import { Button, ButtonProps } from '../ui/Button';
import { useAnalytics } from './useAnalytics';
import { AnalyticsEvents } from './analytics-events';
interface TrackedButtonProps extends ButtonProps {
eventName?: string;
eventProperties?: Record<string, any>;
}
/**
* A wrapper around the project's Button component that tracks click events.
* Safe to use in server components.
*/
export default function TrackedButton({
eventName = AnalyticsEvents.BUTTON_CLICK,
eventProperties = {},
onClick,
...props
}: TrackedButtonProps) {
const { trackEvent } = useAnalytics();
const handleClick = (e: React.MouseEvent<HTMLButtonElement>) => {
trackEvent(eventName, {
...eventProperties,
label: typeof props.children === 'string' ? props.children : eventProperties.label,
});
if (onClick) onClick(e);
};
return <Button {...props} onClick={handleClick} />;
}

View File

@@ -0,0 +1,44 @@
'use client';
import React from 'react';
import Link from 'next/link';
import { useAnalytics } from './useAnalytics';
import { AnalyticsEvents } from './analytics-events';
interface TrackedLinkProps {
href: string;
eventName?: string;
eventProperties?: Record<string, any>;
className?: string;
children: React.ReactNode;
onClick?: () => void;
}
/**
* A wrapper around next/link that tracks the click event.
* Useful for adding tracking to server components.
*/
export default function TrackedLink({
href,
eventName = AnalyticsEvents.LINK_CLICK,
eventProperties = {},
className,
children,
onClick,
}: TrackedLinkProps) {
const { trackEvent } = useAnalytics();
const handleClick = (e: React.MouseEvent) => {
trackEvent(eventName, {
href,
...eventProperties,
});
if (onClick) onClick();
};
return (
<Link href={href} className={className} onClick={handleClick}>
{children}
</Link>
);
}

View File

@@ -1,18 +1,18 @@
/**
* Analytics Events Utility
*
*
* Centralized definitions for common analytics events and their properties.
* This helps maintain consistency across the application and makes it easier
* to track meaningful events.
*
*
* @example
* ```tsx
* import { useAnalytics } from '@/components/analytics/useAnalytics';
* import { AnalyticsEvents } from '@/components/analytics/analytics-events';
*
*
* function ProductPage() {
* const { trackEvent } = useAnalytics();
*
*
* const handleAddToCart = (productId: string, productName: string) => {
* trackEvent(AnalyticsEvents.PRODUCT_ADD_TO_CART, {
* product_id: productId,
@@ -20,7 +20,7 @@
* page: 'product-detail'
* });
* };
*
*
* return <button onClick={() => handleAddToCart('123', 'Cable')}>Add to Cart</button>;
* }
* ```
@@ -31,6 +31,7 @@ export const AnalyticsEvents = {
PAGE_VIEW: 'pageview',
PAGE_SCROLL: 'page_scroll',
PAGE_EXIT: 'page_exit',
SCROLL_DEPTH: 'scroll_depth',
// User Interaction Events
BUTTON_CLICK: 'button_click',
@@ -38,6 +39,7 @@ export const AnalyticsEvents = {
FORM_SUBMIT: 'form_submit',
FORM_START: 'form_start',
FORM_ERROR: 'form_error',
FORM_FIELD_FOCUS: 'form_field_focus',
// E-commerce Events
PRODUCT_VIEW: 'product_view',
@@ -46,6 +48,7 @@ export const AnalyticsEvents = {
PRODUCT_PURCHASE: 'product_purchase',
PRODUCT_WISHLIST_ADD: 'product_wishlist_add',
PRODUCT_WISHLIST_REMOVE: 'product_wishlist_remove',
PRODUCT_TAB_SWITCH: 'product_tab_switch',
// Search & Filter Events
SEARCH: 'search',
@@ -71,6 +74,7 @@ export const AnalyticsEvents = {
TOGGLE_SWITCH: 'toggle_switch',
ACCORDION_TOGGLE: 'accordion_toggle',
TAB_SWITCH: 'tab_switch',
TOC_CLICK: 'toc_click',
// Error & Performance Events
ERROR: 'error',

View File

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

View File

@@ -2,6 +2,8 @@
import React, { useEffect, useState } from 'react';
import { cn } from '@/components/ui/utils';
import { useAnalytics } from '../analytics/useAnalytics';
import { AnalyticsEvents } from '../analytics/analytics-events';
interface TocItem {
id: string;
@@ -16,11 +18,12 @@ interface TableOfContentsProps {
export default function TableOfContents({ headings, locale }: TableOfContentsProps) {
const [activeId, setActiveId] = useState<string>('');
const { trackEvent } = useAnalytics();
useEffect(() => {
const observerOptions = {
rootMargin: '-10% 0% -70% 0%',
threshold: 0
threshold: 0,
};
const observer = new IntersectionObserver((entries) => {
@@ -66,15 +69,20 @@ export default function TableOfContents({ headings, locale }: TableOfContentsPro
<a
href={`#${heading.id}`}
className={cn(
"text-sm md:text-base transition-all duration-300 hover:text-primary block leading-snug",
'text-sm md:text-base transition-all duration-300 hover:text-primary block leading-snug',
activeId === heading.id
? "text-primary font-bold translate-x-1"
: "text-text-secondary font-medium hover:translate-x-1"
? 'text-primary font-bold translate-x-1'
: 'text-text-secondary font-medium hover:translate-x-1',
)}
onClick={(e) => {
e.preventDefault();
const element = document.getElementById(heading.id);
if (element) {
trackEvent(AnalyticsEvents.TOC_CLICK, {
heading_id: heading.id,
heading_text: heading.text,
location: 'blog_sidebar',
});
const yOffset = -100;
const y = element.getBoundingClientRect().top + window.pageYOffset + yOffset;
window.scrollTo({ top: y, behavior: 'smooth' });

View File

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

View File

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

View File

@@ -2,109 +2,127 @@
import Scribble from '@/components/Scribble';
import { Button, Container, Heading, Section } from '@/components/ui';
import { motion } from 'framer-motion';
import { useTranslations } from 'next-intl';
import { m, LazyMotion, domAnimation } from 'framer-motion';
import { useTranslations, useLocale } from 'next-intl';
import dynamic from 'next/dynamic';
import { useAnalytics } from '../analytics/useAnalytics';
import { AnalyticsEvents } from '../analytics/analytics-events';
const HeroIllustration = dynamic(() => import('./HeroIllustration'), { ssr: false });
export default function Hero() {
const t = useTranslations('Home.hero');
const locale = useLocale();
const { trackEvent } = useAnalytics();
return (
<Section className="relative min-h-[85vh] md:h-[90vh] flex flex-col items-center justify-center overflow-hidden bg-primary py-12 md:py-0 lg:py-0">
<Container className="relative z-10 text-center md:text-left text-white w-full order-2 md:order-none">
<motion.div
className="max-w-5xl mx-auto md:mx-0"
initial="hidden"
animate="visible"
variants={containerVariants}
>
<motion.div variants={headingVariants}>
<Heading
level={1}
className="text-center md:text-left mb-6 md:mb-8 md:max-w-none text-white text-4xl sm:text-5xl md:text-7xl font-extrabold [text-shadow:_-2px_-2px_0_#002b49,_2px_-2px_0_#002b49,_-2px_2px_0_#002b49,_2px_2px_0_#002b49,_-2px_0_0_#002b49,_2px_0_0_#002b49,_0_-2px_0_#002b49,_0_2px_0_#002b49]"
>
{t.rich('title', {
green: (chunks) => (
<span className="relative inline-block">
<motion.span
className="relative z-10 text-accent italic"
variants={accentVariants}
>
{chunks}
</motion.span>
<motion.div
variants={scribbleVariants}
className="w-[140%] h-[140%] -top-[20%] -left-[20%] text-accent/30 hidden md:block absolute -z-10"
>
<Scribble variant="circle" />
</motion.div>
</span>
),
})}
</Heading>
</motion.div>
<motion.div variants={subtitleVariants}>
<p className="text-lg md:text-xl text-white/90 leading-relaxed max-w-2xl mb-10 md:mb-12">
{t('subtitle')}
</p>
</motion.div>
<motion.div
className="flex flex-col sm:flex-row justify-center md:justify-start gap-4 md:gap-6"
variants={buttonContainerVariants}
<LazyMotion strict features={domAnimation}>
<Container className="relative z-10 text-center md:text-left text-white w-full order-2 md:order-none">
<m.div
className="max-w-5xl mx-auto md:mx-0"
initial="hidden"
animate="visible"
variants={containerVariants}
>
<motion.div variants={buttonVariants}>
<Button
href="/contact"
variant="accent"
size="lg"
className="group w-full sm:w-auto h-14 md:h-16 px-8 md:px-10 text-base md:text-lg"
<m.div variants={headingVariants}>
<Heading
level={1}
className="text-center md:text-left mb-6 md:mb-8 md:max-w-none text-white text-4xl sm:text-5xl md:text-7xl font-extrabold [text-shadow:_-2px_-2px_0_#002b49,_2px_-2px_0_#002b49,_-2px_2px_0_#002b49,_2px_2px_0_#002b49,_-2px_0_0_#002b49,_2px_0_0_#002b49,_0_-2px_0_#002b49,_0_2px_0_#002b49]"
>
{t('cta')}
<span className="transition-transform group-hover/btn:translate-x-1">&rarr;</span>
</Button>
</motion.div>
<motion.div variants={buttonVariants}>
<Button
href="/products"
variant="white"
size="lg"
className="group w-full sm:w-auto h-14 md:h-16 px-8 md:px-10 text-base md:text-lg md:bg-white md:text-primary md:hover:bg-neutral-light md:border-none"
>
{t('exploreProducts')}
</Button>
</motion.div>
</motion.div>
</motion.div>
</Container>
{t.rich('title', {
green: (chunks) => (
<span className="relative inline-block">
<m.span
className="relative z-10 text-accent italic"
variants={accentVariants}
>
{chunks}
</m.span>
<m.div
variants={scribbleVariants}
className="w-[140%] h-[140%] -top-[20%] -left-[20%] text-accent/30 hidden md:block absolute -z-10"
>
<Scribble variant="circle" />
</m.div>
</span>
),
})}
</Heading>
</m.div>
<m.div variants={subtitleVariants}>
<p className="text-lg md:text-xl text-white/90 leading-relaxed max-w-2xl mb-10 md:mb-12">
{t('subtitle')}
</p>
</m.div>
<m.div
className="flex flex-col sm:flex-row justify-center md:justify-start gap-4 md:gap-6"
variants={buttonContainerVariants}
>
<m.div variants={buttonVariants}>
<Button
href="/contact"
variant="accent"
size="lg"
className="group w-full sm:w-auto h-14 md:h-16 px-8 md:px-10 text-base md:text-lg"
onClick={() =>
trackEvent(AnalyticsEvents.BUTTON_CLICK, {
label: t('cta'),
location: 'home_hero_primary',
})
}
>
{t('cta')}
<span className="transition-transform group-hover/btn:translate-x-1">&rarr;</span>
</Button>
</m.div>
<m.div variants={buttonVariants}>
<Button
href={`/${locale}/${locale === 'de' ? 'produkte' : 'products'}`}
variant="white"
size="lg"
className="group w-full sm:w-auto h-14 md:h-16 px-8 md:px-10 text-base md:text-lg md:bg-white md:text-primary md:hover:bg-neutral-light md:border-none"
onClick={() =>
trackEvent(AnalyticsEvents.BUTTON_CLICK, {
label: t('exploreProducts'),
location: 'home_hero_secondary',
})
}
>
{t('exploreProducts')}
</Button>
</m.div>
</m.div>
</m.div>
</Container>
<motion.div
className="relative md:absolute inset-0 z-0 w-full h-[40vh] md:h-full order-1 md:order-none mb-[-80px] md:mb-0 mt-[80px] md:mt-0 overflow-visible pointer-events-none"
initial={{ opacity: 0, scale: 0.95, filter: 'blur(20px)' }}
animate={{ opacity: 1, scale: 1, filter: 'blur(0px)' }}
transition={{ duration: 2.2, ease: 'easeOut', delay: 0.05 }}
>
<HeroIllustration />
</motion.div>
<m.div
className="relative md:absolute inset-0 z-0 w-full h-[40vh] md:h-full order-1 md:order-none mb-[-80px] md:mb-0 mt-[80px] md:mt-0 overflow-visible pointer-events-none"
initial={{ opacity: 0, scale: 0.95, filter: 'blur(20px)' }}
animate={{ opacity: 1, scale: 1, filter: 'blur(0px)' }}
transition={{ duration: 2.2, ease: 'easeOut', delay: 0.05 }}
>
<HeroIllustration />
</m.div>
<motion.div
className="absolute bottom-6 md:bottom-10 left-1/2 -translate-x-1/2 hidden sm:block"
initial={{ opacity: 0, y: 16 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 1, ease: 'easeOut', delay: 3 }}
>
<div className="w-6 h-10 border-2 border-white/30 rounded-full flex justify-center p-1">
<motion.div
className="w-1 h-2 bg-white rounded-full"
animate={{ y: [0, -10, 0] }}
transition={{
duration: 1.5,
repeat: Infinity,
ease: 'easeInOut',
}}
/>
</div>
</motion.div>
<m.div
className="absolute bottom-6 md:bottom-10 left-1/2 -translate-x-1/2 hidden sm:block"
initial={{ opacity: 0, y: 16 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 1, ease: 'easeOut', delay: 3 }}
>
<div className="w-6 h-10 border-2 border-white/30 rounded-full flex justify-center p-1">
<m.div
className="w-1 h-2 bg-white rounded-full"
animate={{ y: [0, -10, 0] }}
transition={{
duration: 1.5,
repeat: Infinity,
ease: 'easeInOut',
}}
/>
</div>
</m.div>
</LazyMotion>
</Section>
);
}
@@ -114,19 +132,19 @@ const containerVariants = {
visible: {
opacity: 1,
transition: {
staggerChildren: 0.12,
delayChildren: 0.4,
staggerChildren: 0.1,
delayChildren: 0.1,
},
},
} as const;
const headingVariants = {
hidden: { opacity: 0, y: 60, scale: 0.85 },
hidden: { opacity: 1, y: 10, scale: 0.98 },
visible: {
opacity: 1,
y: 0,
scale: 1,
transition: { duration: 1.2, ease: [0.25, 0.46, 0.45, 0.94] },
transition: { duration: 0.4, ease: [0.25, 0.46, 0.45, 0.94] },
},
} as const;
@@ -151,17 +169,17 @@ const scribbleVariants = {
} as const;
const subtitleVariants = {
hidden: { opacity: 0, y: 40, scale: 0.95 },
hidden: { opacity: 1, y: 20, scale: 0.98 },
visible: {
opacity: 1,
y: 0,
scale: 1,
transition: { duration: 1, ease: [0.25, 0.46, 0.45, 0.94] },
transition: { duration: 0.6, ease: [0.25, 0.46, 0.45, 0.94], delay: 0.1 },
},
} as const;
const buttonContainerVariants = {
hidden: { opacity: 0 },
hidden: { opacity: 1 },
visible: {
opacity: 1,
transition: {
@@ -172,7 +190,7 @@ const buttonContainerVariants = {
} as const;
const buttonVariants = {
hidden: { opacity: 0, y: 30, scale: 0.9 },
hidden: { opacity: 1, y: 30, scale: 0.9 },
visible: {
opacity: 1,
y: 0,

View File

@@ -137,6 +137,7 @@ export default function HeroIllustration() {
preserveAspectRatio="xMidYMid meet"
fill="none"
xmlns="http://www.w3.org/2000/svg"
aria-hidden="true"
>
<defs>
<linearGradient id="energy-pulse" x1="0%" y1="0%" x2="100%" y2="0%">
@@ -153,15 +154,15 @@ export default function HeroIllustration() {
<stop offset="70%" stopColor="white" stopOpacity="0.4" />
<stop offset="100%" stopColor="white" stopOpacity="0" />
</linearGradient>
<filter id="glow" x="-50%" y="-50%" width="200%" height="200%">
<feGaussianBlur stdDeviation="3" result="blur" />
<filter id="glow" x="-20%" y="-20%" width="140%" height="140%">
<feGaussianBlur stdDeviation="1.5" result="blur" />
<feMerge>
<feMergeNode in="blur" />
<feMergeNode in="SourceGraphic" />
</feMerge>
</filter>
<filter id="soft-glow" x="-100%" y="-100%" width="300%" height="300%">
<feGaussianBlur stdDeviation="2" result="blur" />
<filter id="soft-glow" x="-50%" y="-50%" width="200%" height="200%">
<feGaussianBlur stdDeviation="1" result="blur" />
<feMerge>
<feMergeNode in="blur" />
<feMergeNode in="SourceGraphic" />
@@ -214,10 +215,10 @@ export default function HeroIllustration() {
</g>
{/* ANIMATED ENERGY FLOW */}
<g filter="url(#glow)">
<g>
{POWER_LINES.map((line, i) => {
// Only animate a subset of lines to reduce main-thread work
if (i % 2 !== 0) return null;
// Only animate a small subset of lines to reduce main-thread work significantly
if (i % 5 !== 0) return null;
const from = gridToScreen(line.from.col, line.from.row);
const to = gridToScreen(line.to.col, line.to.row);
const length = Math.sqrt(Math.pow(to.x - from.x, 2) + Math.pow(to.y - from.y, 2));
@@ -231,16 +232,12 @@ export default function HeroIllustration() {
stroke="url(#energy-pulse)"
strokeWidth="3"
strokeLinecap="round"
strokeDasharray={`${length * 0.2} ${length * 0.8}`}
>
<animate
attributeName="stroke-dashoffset"
from={length}
to={0}
dur={`${1.5 + (i % 3) * 0.5}s`}
repeatCount="indefinite"
/>
</line>
style={{
strokeDasharray: `${length * 0.2} ${length * 0.8}`,
strokeDashoffset: length,
animation: `flow ${1.5 + (i % 3) * 0.5}s linear infinite`,
}}
/>
);
})}
</g>
@@ -266,14 +263,13 @@ export default function HeroIllustration() {
strokeWidth="1"
strokeOpacity="0.3"
/>
<circle r="3" fill="#82ed20" fillOpacity="0.3" filter="url(#soft-glow)">
<animate
attributeName="fillOpacity"
values="0.2;0.5;0.2"
dur="2s"
repeatCount="indefinite"
/>
</circle>
<circle
r="3"
fill="#82ed20"
fillOpacity="0.3"
filter="url(#soft-glow)"
style={{ animation: 'solar-pulse 2s ease-in-out infinite' }}
/>
</g>
);
})}
@@ -293,28 +289,26 @@ export default function HeroIllustration() {
strokeOpacity="0.3"
/>
<g transform="translate(0, -60)">
{[0, 120, 240].map((angle, j) => (
<line
key={`blade-${i}-${j}`}
x1="0"
y1="0"
x2="0"
y2="-30"
stroke="white"
strokeWidth="1.5"
strokeOpacity="0.4"
transform={`rotate(${angle})`}
>
<animateTransform
attributeName="transform"
type="rotate"
from={`${angle} 0 0`}
to={`${angle + 360} 0 0`}
dur={`${3 + i}s`}
repeatCount="indefinite"
<g
style={{
transformOrigin: '0px 0px',
animation: `spin-slow ${3 + i}s linear infinite`,
}}
>
{[0, 120, 240].map((angle, j) => (
<line
key={`blade-${i}-${j}`}
x1="0"
y1="0"
x2="0"
y2="-30"
stroke="white"
strokeWidth="1.5"
strokeOpacity="0.4"
transform={`rotate(${angle})`}
/>
</line>
))}
))}
</g>
</g>
</g>
);

View File

@@ -8,39 +8,42 @@ export default function ProductCategories() {
const t = useTranslations('Products');
const locale = useLocale();
const productsBase = locale === 'de' ? 'produkte' : 'products';
const categories = [
{
title: t('categories.lowVoltage.title'),
desc: t('categories.lowVoltage.description'),
img: '/uploads/2024/11/low-voltage-category.webp',
icon: '/uploads/2024/11/Low-Voltage.svg',
href: `/${locale}/products/low-voltage-cables`,
href: `/${locale}/${productsBase}/${locale === 'de' ? 'niederspannungskabel' : 'low-voltage-cables'}`,
},
{
title: t('categories.mediumVoltage.title'),
desc: t('categories.mediumVoltage.description'),
img: '/uploads/2024/11/medium-voltage-category.webp',
icon: '/uploads/2024/11/Medium-Voltage.svg',
href: `/${locale}/products/medium-voltage-cables`,
href: `/${locale}/${productsBase}/${locale === 'de' ? 'mittelspannungskabel' : 'medium-voltage-cables'}`,
},
{
title: t('categories.highVoltage.title'),
desc: t('categories.highVoltage.description'),
img: '/uploads/2024/11/high-voltage-category.webp',
icon: '/uploads/2024/11/High-Voltage.svg',
href: `/${locale}/products/high-voltage-cables`,
href: `/${locale}/${productsBase}/${locale === 'de' ? 'hochspannungskabel' : 'high-voltage-cables'}`,
},
{
title: t('categories.solar.title'),
desc: t('categories.solar.description'),
img: '/uploads/2024/11/solar-category.webp',
icon: '/uploads/2024/11/Solar.svg',
href: `/${locale}/products/solar-cables`,
href: `/${locale}/${productsBase}/${locale === 'de' ? 'solarkabel' : 'solar-cables'}`,
},
];
return (
<Section className="bg-neutral-light py-0 md:py-0 lg:py-0 -mt-px">
<h2 className="sr-only">{t('title')}</h2>
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4">
{categories.map((category, idx) => (
<Link
@@ -53,7 +56,7 @@ export default function ProductCategories() {
alt={category.title}
fill
className="object-cover transition-transform duration-1000 group-hover:scale-110"
sizes="(max-width: 768px) 100vw, (max-width: 1024px) 50vw, 25vw"
sizes="(max-width: 768px) 100vw, (max-width: 1024px) 50vw, 24vw"
/>
<div className="absolute inset-0 bg-primary-dark/40 group-hover:bg-primary-dark/60 transition-all duration-500" />
<div className="absolute inset-0 p-8 md:p-10 flex flex-col justify-end text-white">

View File

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

View File

@@ -1,30 +1,55 @@
'use client';
import React, { useState, useEffect, useRef } from 'react';
import Scribble from '@/components/Scribble';
import { useTranslations } from 'next-intl';
export default function VideoSection() {
const t = useTranslations('Home.video');
const [isVisible, setIsVisible] = useState(false);
const sectionRef = useRef<HTMLElement>(null);
useEffect(() => {
const observer = new IntersectionObserver(
([entry]) => {
if (entry.isIntersecting) {
setIsVisible(true);
observer.disconnect();
}
},
{ rootMargin: '200px' },
);
if (sectionRef.current) {
observer.observe(sectionRef.current);
}
return () => observer.disconnect();
}, []);
return (
<section className="relative h-[70vh] overflow-hidden bg-primary">
<video
className="w-full h-full object-cover opacity-60"
autoPlay
muted
loop
playsInline
>
<source src="/uploads/2024/12/making-of-metal-cable-on-factory-2023-11-27-04-55-16-utc-2.webm" type="video/mp4" />
</video>
<div className="absolute inset-0 bg-gradient-to-b from-primary/60 via-transparent to-primary/60 flex items-center justify-center">
<div className="max-w-5xl px-6 text-center animate-slide-up">
<section ref={sectionRef} className="relative h-[70vh] overflow-hidden bg-primary">
{isVisible && (
<video className="w-full h-full object-cover opacity-60" autoPlay muted loop playsInline>
<source
src="/uploads/2024/12/making-of-metal-cable-on-factory-2023-11-27-04-55-16-utc-2.webm"
type="video/webm"
/>
</video>
)}
<div className="absolute inset-0 bg-gradient-to-b from-primary/60 via-transparent to-primary/60 flex items-center justify-center pointer-events-none">
<div className="max-w-5xl px-6 text-center animate-slide-up pointer-events-auto">
<h2 className="text-3xl md:text-4xl lg:text-5xl font-extrabold text-white leading-[1.1]">
{t.rich('title', {
future: (chunks) => (
<span className="relative inline-block mx-2">
<span className="relative z-10 italic text-accent">{chunks}</span>
<Scribble variant="underline" className="w-full h-4 -bottom-2 left-0 text-accent/40" />
<Scribble
variant="underline"
className="w-full h-4 -bottom-2 left-0 text-accent/40"
/>
</span>
)
),
})}
</h2>
</div>

View File

@@ -17,32 +17,54 @@ export default function WhyChooseUs() {
<p className="text-base md:text-lg text-text-secondary leading-relaxed">
{t('subtitle')}
</p>
<div className="mt-12 space-y-6">
<ul className="mt-12 space-y-6 list-none p-0">
{[0, 1, 2, 3].map((i) => (
<div key={i} className="flex items-center gap-4">
<li key={i} className="flex items-center gap-4">
<div className="flex-shrink-0 w-6 h-6 rounded-full bg-accent flex items-center justify-center">
<svg className="w-4 h-4 text-primary-dark" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={3} d="M5 13l4 4L19 7" />
<svg
className="w-4 h-4 text-primary-dark"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
aria-hidden="true"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={3}
d="M5 13l4 4L19 7"
/>
</svg>
</div>
<span className="font-bold text-primary-dark text-base md:text-base">{t(`features.${i}`)}</span>
</div>
<span className="font-bold text-primary-dark text-base md:text-base">
{t(`features.${i}`)}
</span>
</li>
))}
</div>
</ul>
</div>
</div>
<div className="lg:col-span-8 grid grid-cols-1 md:grid-cols-2 gap-8 order-2 lg:order-1">
<ul className="lg:col-span-8 grid grid-cols-1 md:grid-cols-2 gap-8 order-2 lg:order-1 list-none p-0 m-0">
{[0, 1, 2, 3].map((idx) => (
<div 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">
<span className="text-white font-bold text-lg group-hover:text-primary-dark">0{idx + 1}</span>
<span className="text-white font-bold text-lg group-hover:text-primary-dark">
0{idx + 1}
</span>
</div>
<h3 className="text-xl font-bold mb-4 text-primary-dark">{t(`items.${idx}.title`)}</h3>
<p className="text-text-secondary text-base md:text-base leading-relaxed">{t(`items.${idx}.description`)}</p>
</div>
<h3 className="text-xl font-bold mb-4 text-primary-dark">
{t(`items.${idx}.title`)}
</h3>
<p className="text-text-secondary text-base md:text-base leading-relaxed">
{t(`items.${idx}.description`)}
</p>
</li>
))}
</div>
</ul>
</div>
</Container>
</Section>

View File

@@ -0,0 +1,118 @@
'use client';
import React, { useState, useEffect } from 'react';
import { finder } from '@medv/finder';
export function PickingHelper() {
const [pickingMode, setPickingMode] = useState<'click' | 'scroll' | null>(null);
const [hoveredElement, setHoveredElement] = useState<HTMLElement | null>(null);
useEffect(() => {
const handleMessage = (e: MessageEvent) => {
if (e.data.type === 'START_PICKING') {
setPickingMode(e.data.mode);
} else if (e.data.type === 'STOP_PICKING') {
setPickingMode(null);
setHoveredElement(null);
} else if (e.data.type === 'SET_HOVER_SELECTOR') {
const selector = e.data.selector;
if (selector) {
const el = document.querySelector(selector) as HTMLElement;
setHoveredElement(el || null);
} else {
setHoveredElement(null);
}
}
};
window.addEventListener('message', handleMessage);
return () => window.removeEventListener('message', handleMessage);
}, []);
useEffect(() => {
if (!pickingMode) return;
const handleMouseOver = (e: MouseEvent) => {
const target = e.target as HTMLElement;
if (target.closest('.record-mode-ignore') || target.closest('.feedback-ui-ignore')) return;
setHoveredElement(target);
};
const handleClick = (e: MouseEvent) => {
if (hoveredElement) {
e.preventDefault();
e.stopPropagation();
const selector = finder(hoveredElement, {
root: document.body,
seedMinLength: 3,
optimizedMinLength: 2,
className: (name) =>
!name.startsWith('record-mode-') &&
!name.startsWith('feedback-') &&
!name.includes('[') &&
!name.includes('/') &&
!name.match(/^[a-z]-[0-9]/) &&
!name.match(/[0-9]{4,}/), // Avoid dynamic IDs in classnames
idName: (name) => !name.startsWith('__next') && !name.includes(':') && !name.match(/[0-9]{5,}/),
});
const rect = hoveredElement.getBoundingClientRect();
window.parent.postMessage({
type: 'ELEMENT_SELECTED',
selector,
rect: {
x: rect.left,
y: rect.top,
width: rect.width,
height: rect.height
},
tagName: hoveredElement.tagName.toLowerCase()
}, '*');
setPickingMode(null);
setHoveredElement(null);
}
};
const handleKeyDown = (e: KeyboardEvent) => {
if (e.key === 'Escape') {
setPickingMode(null);
setHoveredElement(null);
window.parent.postMessage({ type: 'PICKING_CANCELLED' }, '*');
}
};
window.addEventListener('mouseover', handleMouseOver);
window.addEventListener('click', handleClick, true);
window.addEventListener('keydown', handleKeyDown);
return () => {
window.removeEventListener('mouseover', handleMouseOver);
window.removeEventListener('click', handleClick, true);
window.removeEventListener('keydown', handleKeyDown);
};
}, [pickingMode, hoveredElement]);
if (!hoveredElement) return null;
// Don't show highlight if we are in picking mode but NOT hovering anything (handled by logic above)
// but DO show if we have a hoveredElement (from message or mouseover)
const rect = hoveredElement.getBoundingClientRect();
return (
<div
className="fixed pointer-events-none border-2 border-[#82ed20] bg-[#82ed20]/15 transition-all z-[9999] shadow-[0_0_20px_rgba(130,237,32,0.3)] rounded-sm"
style={{
top: rect.top,
left: rect.left,
width: rect.width,
height: rect.height,
}}
>
<div className="absolute top-0 right-0 bg-[#82ed20] text-black text-[10px] font-black px-1.5 py-1 transform -translate-y-full uppercase tracking-tighter shadow-xl">
{hoveredElement.tagName.toLowerCase()}
</div>
</div>
);
}

View File

@@ -0,0 +1,92 @@
'use client';
import React, { useEffect, useState } from 'react';
import { m, LazyMotion, domAnimation, AnimatePresence } from 'framer-motion';
import { useRecordMode } from './RecordModeContext';
export function PlaybackCursor() {
const { isPlaying, cursorPosition, isClicking } = useRecordMode();
const [scrollOffset, setScrollOffset] = useState({ x: 0, y: 0 });
// Track scroll so cursor stays locked to the correct element
useEffect(() => {
if (!isPlaying) return;
const handleScroll = () => {
setScrollOffset({ x: window.scrollX, y: window.scrollY });
};
handleScroll(); // Init
window.addEventListener('scroll', handleScroll, { passive: true });
return () => window.removeEventListener('scroll', handleScroll);
}, [isPlaying]);
if (!isPlaying) return null;
return (
<LazyMotion strict features={domAnimation}>
<m.div
className="fixed z-[10000] pointer-events-none"
animate={{
x: cursorPosition.x,
y: cursorPosition.y,
scale: isClicking ? 0.8 : 1,
rotateX: isClicking ? 15 : 0,
rotateY: isClicking ? -15 : 0,
}}
transition={{
x: { type: 'spring', damping: 30, stiffness: 250, mass: 0.5 },
y: { type: 'spring', damping: 30, stiffness: 250, mass: 0.5 },
scale: { type: 'spring', damping: 15, stiffness: 400 },
rotateX: { type: 'spring', damping: 15, stiffness: 400 },
rotateY: { type: 'spring', damping: 15, stiffness: 400 },
}}
style={{ perspective: '1000px' }}
>
<AnimatePresence>
{isClicking && (
<m.div
initial={{ scale: 0.5, opacity: 0 }}
animate={{ scale: 2.5, opacity: 0 }}
exit={{ opacity: 0 }}
transition={{ duration: 0.4, ease: 'easeOut' }}
className="absolute inset-0 rounded-full border-2 border-[#82ed20] blur-[1px]"
/>
)}
</AnimatePresence>
{/* Outer Pulse Ring */}
<div
className={`absolute -inset-3 rounded-full bg-[#82ed20]/10 ${isClicking ? 'scale-150 opacity-0' : 'animate-ping'} transition-all duration-300`}
/>
{/* Visual Cursor */}
<div className="relative">
{/* Soft Glow */}
<div
className={`absolute -inset-2 bg-[#82ed20]/20 rounded-full blur-md transition-all ${isClicking ? 'bg-[#82ed20]/50 blur-xl' : ''}`}
/>
{/* Pointer Arrow */}
<svg
width="24"
height="24"
viewBox="0 0 24 24"
fill="none"
xmlns="http://www.w3.org/2000/svg"
className={`drop-shadow-[0_2px_8px_rgba(130,237,32,0.5)] transition-transform ${isClicking ? 'translate-y-0.5' : ''}`}
>
<path
d="M3 3L10.07 19.97L12.58 12.58L19.97 10.07L3 3Z"
fill={isClicking ? '#82ed20' : 'white'}
stroke="black"
strokeWidth="1.5"
strokeLinejoin="round"
className="transition-colors duration-150"
/>
</svg>
</div>
</m.div>
</LazyMotion>
);
}

View File

@@ -0,0 +1,392 @@
'use client';
import React, { createContext, useContext, useState, useEffect, useRef } from 'react';
import { RecordEvent, RecordingSession } from '@/types/record-mode';
interface RecordModeContextType {
isActive: boolean;
setIsActive: (active: boolean) => void;
events: RecordEvent[];
addEvent: (event: Omit<RecordEvent, 'id' | 'timestamp'>) => void;
updateEvent: (id: string, event: Partial<RecordEvent>) => void;
removeEvent: (id: string) => void;
clearEvents: () => void;
setEvents: (events: RecordEvent[]) => void;
isPlaying: boolean;
playEvents: () => void;
stopPlayback: () => void;
cursorPosition: { x: number; y: number };
zoomLevel: number;
isBlurry: boolean;
currentSession: RecordingSession | null;
saveSession: (name: string) => void;
isFeedbackActive: boolean;
setIsFeedbackActive: (active: boolean) => void;
reorderEvents: (startIndex: number, endIndex: number) => void;
hoveredEventId: string | null;
setHoveredEventId: (id: string | null) => void;
isClicking: boolean;
isEnabled: boolean;
}
const RecordModeContext = createContext<RecordModeContextType | null>(null);
export function useRecordMode(): RecordModeContextType {
const context = useContext(RecordModeContext);
if (!context) {
return {
isActive: false,
setIsActive: () => {},
events: [],
addEvent: () => {},
updateEvent: () => {},
removeEvent: () => {},
clearEvents: () => {},
isPlaying: false,
playEvents: () => {},
stopPlayback: () => {},
cursorPosition: { x: 0, y: 0 },
zoomLevel: 1,
isBlurry: false,
currentSession: null,
isFeedbackActive: false,
setIsFeedbackActive: () => {},
saveSession: () => {},
reorderEvents: () => {},
hoveredEventId: null,
setHoveredEventId: () => {},
setEvents: () => {},
isClicking: false,
isEnabled: false,
};
}
return context;
}
export function RecordModeProvider({
children,
isEnabled = false,
}: {
children: React.ReactNode;
isEnabled?: boolean;
}) {
const [isActive, setIsActiveState] = useState(false);
const [events, setEvents] = useState<RecordEvent[]>([]);
const [isPlaying, setIsPlaying] = useState(false);
const [cursorPosition, setCursorPosition] = useState({ x: 0, y: 0 });
const [zoomLevel, setZoomLevel] = useState(1);
const [isBlurry, setIsBlurry] = useState(false);
const [isFeedbackActive, setIsFeedbackActiveState] = useState(false);
const [hoveredEventId, setHoveredEventId] = useState<string | null>(null);
const [isClicking, setIsClicking] = useState(false);
const [isEmbedded, setIsEmbedded] = useState(false);
useEffect(() => {
console.log('[RecordModeProvider] Mounted with isEnabled:', isEnabled);
}, [isEnabled]);
useEffect(() => {
if (!isEnabled) return;
const embedded =
typeof window !== 'undefined' &&
(window.location.search.includes('embedded=true') ||
window.name === 'record-mode-iframe' ||
window.self !== window.top);
setIsEmbedded(embedded);
}, [isEnabled]);
const setIsActive = (active: boolean) => {
if (!isEnabled) return;
setIsActiveState(active);
if (active) setIsFeedbackActiveState(false);
};
const setIsFeedbackActive = (active: boolean) => {
setIsFeedbackActiveState(active);
if (active && isEnabled) setIsActiveState(false);
};
const isPlayingRef = useRef(false);
const isLoadedRef = useRef(false);
useEffect(() => {
if (!isEnabled) return;
const savedEvents = localStorage.getItem('klz-record-events');
const savedActive = localStorage.getItem('klz-record-active');
if (savedEvents) setEvents(JSON.parse(savedEvents));
if (savedActive) setIsActive(JSON.parse(savedActive));
isLoadedRef.current = true;
}, [isEnabled]);
useEffect(() => {
if (!isEnabled || !isLoadedRef.current) return;
localStorage.setItem('klz-record-events', JSON.stringify(events));
}, [events, isEnabled]);
useEffect(() => {
if (!isEnabled) return;
localStorage.setItem('klz-record-active', JSON.stringify(isActive));
}, [isActive, isEnabled]);
useEffect(() => {
if (!isEnabled) return;
if (isEmbedded) {
const handlePlaybackMessage = (e: MessageEvent) => {
if (e.data.type === 'PLAY_EVENT') {
const { event } = e.data;
const el = event.selector
? (document.querySelector(event.selector) as HTMLElement)
: null;
if (el) {
if (event.type === 'scroll') {
el.scrollIntoView({ behavior: 'smooth', block: 'center' });
} else if (event.type === 'mouse') {
const currentRect = el.getBoundingClientRect();
let targetX = currentRect.left + currentRect.width / 2;
let targetY = currentRect.top + currentRect.height / 2;
if (event.clickOrigin === 'top-left') {
targetX = currentRect.left + 5;
targetY = currentRect.top + 5;
} else if (event.clickOrigin === 'top-right') {
targetX = currentRect.right - 5;
targetY = currentRect.top + 5;
} else if (event.clickOrigin === 'bottom-left') {
targetX = currentRect.left + 5;
targetY = currentRect.bottom - 5;
} else if (event.clickOrigin === 'bottom-right') {
targetX = currentRect.right - 5;
targetY = currentRect.bottom - 5;
}
const eventCoords = { clientX: targetX, clientY: targetY };
const dispatchMouse = (type: string) => {
el.dispatchEvent(
new MouseEvent(type, {
view: window,
bubbles: true,
cancelable: true,
...eventCoords,
}),
);
};
if (event.interactionType === 'click') {
setIsClicking(true);
dispatchMouse('mousedown');
setTimeout(() => {
dispatchMouse('mouseup');
if (event.realClick) {
dispatchMouse('click');
el.click();
}
setIsClicking(false);
}, 150);
} else {
dispatchMouse('mousemove');
dispatchMouse('mouseover');
dispatchMouse('mouseenter');
}
}
}
}
};
window.addEventListener('message', handlePlaybackMessage);
return () => window.removeEventListener('message', handlePlaybackMessage);
}
}, [isEmbedded, isEnabled]);
useEffect(() => {
if (!isEnabled || isEmbedded || !isActive) return;
const event = events.find((e) => e.id === hoveredEventId);
const iframe = document.querySelector('iframe[name="record-mode-iframe"]') as HTMLIFrameElement;
if (iframe?.contentWindow) {
iframe.contentWindow.postMessage(
{ type: 'SET_HOVER_SELECTOR', selector: event?.selector || null },
'*',
);
}
}, [hoveredEventId, events, isActive, isEmbedded, isEnabled]);
const addEvent = (event: Omit<RecordEvent, 'id' | 'timestamp'>) => {
if (!isEnabled) return;
const newEvent: RecordEvent = {
realClick: false,
...event,
id: Math.random().toString(36).substr(2, 9),
timestamp: Date.now(),
};
setEvents((prev) => [...prev, newEvent]);
};
const updateEvent = (id: string, updatedFields: Partial<RecordEvent>) => {
if (!isEnabled) return;
setEvents((prev) =>
prev.map((event) => (event.id === id ? { ...event, ...updatedFields } : event)),
);
};
const reorderEvents = (startIndex: number, endIndex: number) => {
if (!isEnabled) return;
const result = Array.from(events);
const [removed] = result.splice(startIndex, 1);
result.splice(endIndex, 0, removed);
setEvents(result);
};
const removeEvent = (id: string) => {
if (!isEnabled) return;
setEvents((prev) => prev.filter((event) => event.id !== id));
};
const clearEvents = () => {
if (!isEnabled) return;
if (confirm('Clear all recorded events?')) setEvents([]);
};
const currentSession: RecordingSession | null =
events.length > 0
? {
id: 'draft',
name: 'Draft Session',
events,
createdAt: new Date().toISOString(),
}
: null;
const saveSession = (name: string) => {
if (!isEnabled) return;
console.log('Saving session:', name, events);
};
const playEvents = async () => {
if (!isEnabled || events.length === 0 || isPlayingRef.current) return;
setIsPlaying(true);
isPlayingRef.current = true;
const sortedEvents = [...events].sort((a, b) => (a.timestamp || 0) - (b.timestamp || 0));
for (const event of sortedEvents) {
if (!isPlayingRef.current) break;
if (event.rect && !isEmbedded) {
const iframe = document.querySelector(
'iframe[name="record-mode-iframe"]',
) as HTMLIFrameElement;
const iframeRect = iframe?.getBoundingClientRect();
setCursorPosition({
x: (iframeRect?.left || 0) + event.rect.x + event.rect.width / 2,
y: (iframeRect?.top || 0) + event.rect.y + event.rect.height / 2,
});
}
if (event.selector) {
if (!isEmbedded) {
const iframe = document.querySelector(
'iframe[name="record-mode-iframe"]',
) as HTMLIFrameElement;
if (iframe?.contentWindow)
iframe.contentWindow.postMessage({ type: 'PLAY_EVENT', event }, '*');
} else {
const el = document.querySelector(event.selector) as HTMLElement;
if (el) {
if (event.type === 'scroll') el.scrollIntoView({ behavior: 'smooth', block: 'center' });
else if (event.type === 'mouse') {
const currentRect = el.getBoundingClientRect();
let targetX = currentRect.left + currentRect.width / 2;
let targetY = currentRect.top + currentRect.height / 2;
if (event.clickOrigin === 'top-left') {
targetX = currentRect.left + 5;
targetY = currentRect.top + 5;
} else if (event.clickOrigin === 'top-right') {
targetX = currentRect.right - 5;
targetY = currentRect.top + 5;
} else if (event.clickOrigin === 'bottom-left') {
targetX = currentRect.left + 5;
targetY = currentRect.bottom - 5;
} else if (event.clickOrigin === 'bottom-right') {
targetX = currentRect.right - 5;
targetY = currentRect.bottom - 5;
}
const eventCoords = { clientX: targetX, clientY: targetY };
const dispatchMouse = (type: string) => {
el.dispatchEvent(
new MouseEvent(type, {
view: window,
bubbles: true,
cancelable: true,
...eventCoords,
}),
);
};
if (event.interactionType === 'click') {
setIsClicking(true);
dispatchMouse('mousedown');
setTimeout(() => {
dispatchMouse('mouseup');
if (event.realClick) {
dispatchMouse('click');
el.click();
}
setIsClicking(false);
}, 150);
} else {
dispatchMouse('mousemove');
dispatchMouse('mouseover');
dispatchMouse('mouseenter');
}
}
}
}
}
if (event.zoom) setZoomLevel(event.zoom);
if (event.motionBlur) setIsBlurry(true);
await new Promise((resolve) => setTimeout(resolve, event.duration || 1000));
setIsBlurry(false);
}
setIsPlaying(false);
isPlayingRef.current = false;
setZoomLevel(1);
};
const stopPlayback = () => {
setIsPlaying(false);
isPlayingRef.current = false;
setZoomLevel(1);
setIsBlurry(false);
};
return (
<RecordModeContext.Provider
value={{
isActive,
setIsActive,
events,
addEvent,
updateEvent,
removeEvent,
clearEvents,
setEvents,
isPlaying,
playEvents,
stopPlayback,
cursorPosition,
zoomLevel,
isBlurry,
currentSession,
saveSession,
isFeedbackActive,
setIsFeedbackActive,
reorderEvents,
hoveredEventId,
setHoveredEventId,
isClicking,
isEnabled,
}}
>
{children}
</RecordModeContext.Provider>
);
}

View File

@@ -0,0 +1,605 @@
'use client';
import React, { useState, useEffect } from 'react';
import { useRecordMode } from './RecordModeContext';
import { Reorder, AnimatePresence, LazyMotion, domAnimation } from 'framer-motion';
import {
Play,
Square,
MousePointer2,
Scroll,
Plus,
Save,
Trash2,
Eye,
Edit2,
X,
Check,
Download,
Settings2,
GripVertical,
Clock,
Maximize2,
Box,
ExternalLink,
} from 'lucide-react';
import { RecordEvent } from '@/types/record-mode';
import { PlaybackCursor } from './PlaybackCursor';
export function RecordModeOverlay() {
const {
isActive,
setIsActive,
events,
addEvent,
updateEvent,
removeEvent,
isPlaying,
playEvents,
saveSession,
clearEvents,
reorderEvents,
setHoveredEventId,
setEvents, // Added setEvents here
} = useRecordMode();
const [pickingMode, setPickingMode] = useState<'mouse' | 'scroll' | null>(null);
const [lastInteractionType, setLastInteractionType] = useState<'click' | 'hover'>('click');
const [hoveredElement, setHoveredElement] = useState<HTMLElement | null>(null);
const [editingEventId, setEditingEventId] = useState<string | null>(null);
// Edit form state
const [editForm, setEditForm] = useState<Partial<RecordEvent>>({});
const [mounted, setMounted] = useState(false);
useEffect(() => {
setMounted(true);
}, []);
useEffect(() => {
if (!mounted || !isActive) return;
const handleMessage = (e: MessageEvent) => {
if (e.data.type === 'ELEMENT_SELECTED') {
const { selector, rect, tagName } = e.data;
if (pickingMode === 'mouse') {
addEvent({
type: 'mouse',
interactionType: lastInteractionType,
selector,
duration: lastInteractionType === 'click' ? 1000 : 1500,
zoom: 1,
description: `Mouse ${lastInteractionType === 'click' ? '(Click)' : '(Hover)'} on ${tagName}`,
motionBlur: false,
realClick: false,
rect,
});
} else if (pickingMode === 'scroll') {
addEvent({
type: 'scroll',
selector,
duration: 1500,
zoom: 1,
description: `Scroll to ${tagName}`,
motionBlur: false,
rect,
});
}
setPickingMode(null);
} else if (e.data.type === 'PICKING_CANCELLED') {
setPickingMode(null);
}
};
window.addEventListener('message', handleMessage);
if (pickingMode) {
// Find the iframe and signal start picking
const iframe = document.querySelector('iframe');
if (iframe?.contentWindow) {
iframe.contentWindow.postMessage({ type: 'START_PICKING', mode: pickingMode }, '*');
}
} else {
// Signal stop picking
const iframe = document.querySelector('iframe');
if (iframe?.contentWindow) {
iframe.contentWindow.postMessage({ type: 'STOP_PICKING' }, '*');
}
}
return () => {
window.removeEventListener('message', handleMessage);
};
}, [isActive, pickingMode, addEvent, mounted]);
const saveEdit = () => {
if (editingEventId) {
updateEvent(editingEventId, editForm);
setEditingEventId(null);
}
};
const [showEvents, setShowEvents] = useState(true);
if (!mounted) return null;
if (!isActive) {
// Failsafe: Never render host toggle in embedded mode
if (
typeof window !== 'undefined' &&
(window.self !== window.top ||
window.name === 'record-mode-iframe' ||
window.location.search.includes('embedded=true'))
) {
return null;
}
return (
<button
onClick={() => setIsActive(true)}
className="fixed bottom-6 left-6 z-[9999] bg-[#82ed20]/20 hover:bg-[#82ed20]/30 text-[#82ed20] p-4 rounded-full shadow-2xl transition-all hover:scale-110 record-mode-ignore border border-[#82ed20]/30 backdrop-blur-md animate-pulse"
>
<div className="w-5 h-5 rounded-[4px] border-2 border-[#82ed20]" />
</button>
);
}
return (
<LazyMotion strict features={domAnimation}>
<div className="fixed inset-0 z-[9998] pointer-events-none font-sans">
{/* 1. Global Toolbar - Slim Industrial Bar */}
<div className="fixed bottom-6 left-1/2 -translate-x-1/2 z-[10000] pointer-events-auto">
<div className="bg-black/80 backdrop-blur-2xl border border-white/10 p-2 rounded-[24px] shadow-[0_32px_80px_rgba(0,0,0,0.8)] flex items-center gap-2">
{/* Identity Tag */}
<div className="flex items-center gap-3 px-4 py-2 bg-white/5 rounded-[16px] border border-white/5 mx-1">
<div className="w-2 h-2 rounded-full bg-accent animate-pulse" />
<div className="flex flex-col">
<span className="text-[10px] font-bold text-white uppercase tracking-wider leading-none">
Event Builder
</span>
<span className="text-[8px] text-white/30 uppercase tracking-widest mt-1">
Manual Mode
</span>
</div>
</div>
<div className="w-px h-6 bg-white/10 mx-1" />
{/* Action Tools */}
<div className="flex items-center gap-1">
<button
onClick={() => {
setPickingMode('mouse');
setLastInteractionType('click');
}}
className={`flex items-center gap-2 px-4 py-2.5 rounded-[16px] transition-all text-xs font-bold uppercase tracking-wide ${pickingMode === 'mouse' ? 'bg-accent text-primary-dark shadow-lg shadow-accent/20' : 'text-white/40 hover:text-white hover:bg-white/5'}`}
>
<MousePointer2 size={16} />
<span>Mouse</span>
</button>
<button
onClick={() => setPickingMode('scroll')}
className={`flex items-center gap-2 px-4 py-2.5 rounded-[16px] transition-all text-xs font-bold uppercase tracking-wide ${pickingMode === 'scroll' ? 'bg-accent text-primary-dark shadow-lg shadow-accent/20' : 'text-white/40 hover:text-white hover:bg-white/5'}`}
>
<Scroll size={16} />
<span>Scroll</span>
</button>
<button
onClick={() =>
addEvent({
type: 'wait',
duration: 2000,
zoom: 1,
description: 'Wait for 2s',
motionBlur: false,
})
}
className="flex items-center gap-2 px-4 py-2.5 rounded-[16px] text-white/40 hover:text-white hover:bg-white/5 transition-all text-xs font-bold uppercase tracking-wide"
>
<Plus size={16} />
<span>Wait</span>
</button>
</div>
<div className="w-px h-6 bg-white/10 mx-1" />
{/* Sequence Controls */}
<div className="flex items-center gap-1 p-0.5">
<button
onClick={playEvents}
disabled={isPlaying || events.length === 0}
className="p-2.5 text-accent hover:bg-accent/10 rounded-[14px] disabled:opacity-20 transition-all"
title="Preview Sequence"
>
<Play size={18} fill="currentColor" />
</button>
<button
onClick={() => setShowEvents(!showEvents)}
className={`p-2.5 rounded-[14px] transition-all relative ${showEvents ? 'bg-white/10 text-white' : 'text-white/40 hover:text-white hover:bg-white/5'}`}
>
<Edit2 size={18} />
{events.length > 0 && (
<span className="absolute -top-1 -right-1 w-4 h-4 bg-accent text-primary-dark text-[10px] flex items-center justify-center rounded-full font-bold border-2 border-black">
{events.length}
</span>
)}
</button>
<button
onClick={async () => {
const session = {
events,
name: 'Recording',
createdAt: new Date().toISOString(),
};
try {
const res = await fetch('/api/save-session', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(session),
});
if (res.ok) {
// Visual feedback could be improved, but alert is fine for dev tool
alert('Session saved to remotion/session.json');
} else {
const err = await res.json();
alert(`Failed to save: ${err.error}`);
}
} catch (e) {
console.error(e);
alert('Error saving session');
}
}}
disabled={events.length === 0}
className="p-3 bg-white/5 hover:bg-green-500/20 rounded-2xl disabled:opacity-30 transition-all text-green-400"
title="Save to Project (Dev)"
>
<Save size={20} />
</button>
<button
onClick={() => {
const data = JSON.stringify(
{ events, name: 'Recording', createdAt: new Date().toISOString() },
null,
2,
);
const blob = new Blob([data], { type: 'application/json' });
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = 'remotion-session.json';
a.click();
URL.revokeObjectURL(url);
}}
disabled={events.length === 0}
className="p-3 bg-white/5 hover:bg-blue-500/20 rounded-2xl disabled:opacity-30 transition-all text-blue-400"
title="Download JSON"
>
<Download size={20} />
</button>
</div>
<div className="w-px h-6 bg-white/10 mx-1" />
<button
onClick={() => setIsActive(false)}
className="p-2.5 text-red-500 hover:bg-red-500/10 rounded-[14px] transition-all mx-1"
title="Exit Studio"
>
<X size={20} />
</button>
</div>
</div>
{/* 2. Event Timeline Popover */}
{showEvents && (
<div className="fixed bottom-[100px] left-1/2 -translate-x-1/2 w-[400px] pointer-events-auto z-[9999]">
<div className="bg-black/90 backdrop-blur-3xl border border-white/10 rounded-[32px] p-6 shadow-[0_40px_100px_rgba(0,0,0,0.9)] max-h-[500px] overflow-hidden flex flex-col scale-in">
<div className="flex items-center justify-between mb-6">
<div>
<h4 className="text-white font-bold text-lg leading-none">Recording Track</h4>
<p className="text-[10px] text-white/30 uppercase tracking-widest mt-2">
{events.length} Actions Recorded
</p>
</div>
<button
onClick={clearEvents}
disabled={events.length === 0}
className="text-red-400/40 hover:text-red-400 transition-colors p-2 hover:bg-red-500/10 rounded-xl disabled:opacity-10"
>
<Trash2 size={18} />
</button>
</div>
<Reorder.Group
axis="y"
values={events}
onReorder={setEvents}
className="flex-1 overflow-y-auto space-y-2 pr-2 scrollbar-hide"
>
{events.length === 0 ? (
<div className="py-12 flex flex-col items-center justify-center text-white/10">
<Plus size={40} strokeWidth={1} />
<p className="text-xs mt-4">Timeline is empty</p>
</div>
) : (
events.map((event, index) => (
<Reorder.Item
key={event.id}
value={event}
className="group flex items-center gap-3 bg-white/[0.03] border border-white/5 p-3 rounded-[20px] transition-all hover:bg-white/[0.06] hover:border-white/10"
onMouseEnter={() => setHoveredEventId(event.id)}
onMouseLeave={() => setHoveredEventId(null)}
>
<div className="cursor-grab active:cursor-grabbing text-white/10 hover:text-white/30 transition-colors">
<GripVertical size={16} />
</div>
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2">
<span className="text-white text-[10px] font-black uppercase tracking-widest">
{event.type === 'mouse'
? `Mouse (${event.interactionType})`
: event.type}
</span>
{event.clickOrigin &&
event.clickOrigin !== 'center' &&
event.interactionType === 'click' && (
<span className="text-[8px] bg-accent/20 text-accent px-1.5 py-0.5 rounded uppercase font-bold">
{event.clickOrigin}
</span>
)}
<span className="text-[8px] bg-white/10 px-1.5 py-0.5 rounded text-white/40 font-mono italic">
{event.duration}ms
</span>
</div>
<p className="text-[9px] text-white/30 truncate font-mono mt-1 opacity-60">
{event.selector || 'system:wait'}
</p>
</div>
<div className="flex items-center gap-1">
<button
onClick={() => {
setEditingEventId(event.id);
setEditForm(event);
}}
className="p-2 text-white/0 group-hover:text-white/40 hover:text-white hover:bg-white/10 rounded-xl transition-all"
>
<Settings2 size={14} />
</button>
<button
onClick={() => removeEvent(event.id)}
className="p-2 text-white/0 group-hover:text-red-400/60 hover:text-red-400 hover:bg-red-500/10 rounded-xl transition-all"
>
<Trash2 size={14} />
</button>
</div>
</Reorder.Item>
))
)}
</Reorder.Group>
</div>
</div>
)}
{/* Industrial Selector Highlighter - handled inside iframe via PickingHelper */}
{/* Picking Tooltip */}
{pickingMode && (
<div className="fixed top-8 left-1/2 -translate-x-1/2 z-[10000] pointer-events-auto">
<div className="bg-accent text-primary-dark px-6 py-3 rounded-full flex items-center gap-4 shadow-[0_20px_40px_rgba(130,237,32,0.4)] animate-reveal border border-primary-dark/10">
<div className="flex items-center gap-2">
<div className="w-2 h-2 rounded-full bg-primary-dark animate-pulse" />
<span className="font-black uppercase tracking-widest text-xs">
Assigning {pickingMode}
</span>
</div>
<div className="w-px h-6 bg-primary-dark/20" />
<button
onClick={() => {
setPickingMode(null);
setHoveredElement(null);
}}
className="text-[10px] font-bold uppercase tracking-widest opacity-60 hover:opacity-100 transition-opacity bg-primary-dark/10 px-3 py-1.5 rounded-full"
>
ESC to Cancel
</button>
</div>
</div>
)}
<PlaybackCursor />
{/* 3. Event Options Panel (Sidebar-like) */}
<AnimatePresence>
{editingEventId && (
<div className="fixed inset-y-0 right-0 w-[350px] bg-black/95 backdrop-blur-3xl border-l border-white/10 z-[11000] pointer-events-auto p-8 shadow-[-40px_0_100px_rgba(0,0,0,0.9)] flex flex-col">
<div className="flex items-center justify-between mb-8">
<h3 className="text-white font-black uppercase tracking-tighter text-xl">
Event Options
</h3>
<button
onClick={() => setEditingEventId(null)}
className="p-2 text-white/40 hover:text-white transition-colors"
>
<X size={20} />
</button>
</div>
<div className="flex-1 space-y-8 overflow-y-auto pr-2 scrollbar-hide">
{/* Type Display */}
<div className="space-y-3">
<label className="text-[10px] uppercase tracking-[0.2em] font-bold text-white/30 leading-none">
Interaction Type
</label>
<div className="flex gap-2 p-1 bg-white/5 rounded-2xl border border-white/5">
<button
onClick={() =>
setEditForm((prev) => ({
...prev,
type: 'mouse',
interactionType: 'click',
}))
}
className={`flex-1 flex items-center justify-center gap-2 py-3 rounded-xl transition-all border ${editForm.type === 'mouse' && editForm.interactionType === 'click' ? 'bg-accent text-primary-dark border-accent' : 'text-white/40 border-transparent hover:border-white/10'}`}
>
<MousePointer2 size={14} />
<span className="text-[10px] font-black uppercase">Click</span>
</button>
<button
onClick={() =>
setEditForm((prev) => ({
...prev,
type: 'mouse',
interactionType: 'hover',
}))
}
className={`flex-1 flex items-center justify-center gap-2 py-3 rounded-xl transition-all border ${editForm.type === 'mouse' && editForm.interactionType === 'hover' ? 'bg-accent text-primary-dark border-accent' : 'text-white/40 border-transparent hover:border-white/10'}`}
>
<Eye size={14} />
<span className="text-[10px] font-black uppercase">Hover</span>
</button>
<button
onClick={() => setEditForm((prev) => ({ ...prev, type: 'scroll' }))}
className={`flex-1 flex items-center justify-center gap-2 py-3 rounded-xl transition-all border ${editForm.type === 'scroll' ? 'bg-accent text-primary-dark border-accent' : 'text-white/40 border-transparent hover:border-white/10'}`}
>
<Scroll size={14} />
<span className="text-[10px] font-black uppercase">Scroll</span>
</button>
<button
onClick={() => setEditForm((prev) => ({ ...prev, type: 'wait' }))}
className={`flex-1 flex items-center justify-center gap-2 py-3 rounded-xl transition-all border ${editForm.type === 'wait' ? 'bg-accent text-primary-dark border-accent' : 'text-white/40 border-transparent hover:border-white/10'}`}
>
<Clock size={14} />
<span className="text-[10px] font-black uppercase">Wait</span>
</button>
</div>
</div>
{/* Precise Click Origin */}
{editForm.type === 'mouse' && editForm.interactionType === 'click' && (
<div className="space-y-4">
<label className="text-[10px] uppercase tracking-[0.2em] font-bold text-white/30 leading-none">
Click Origin
</label>
<div className="grid grid-cols-3 gap-2 p-2 bg-white/5 rounded-2xl border border-white/5">
{[
{ id: 'top-left', label: 'TL' },
{ id: 'top-right', label: 'TR' },
{ id: 'center', label: 'CTR' },
{ id: 'bottom-left', label: 'BL' },
{ id: 'bottom-right', label: 'BR' },
].map((origin) => (
<button
key={origin.id}
onClick={() =>
setEditForm((prev) => ({ ...prev, clickOrigin: origin.id as any }))
}
className={`py-3 rounded-xl text-[10px] font-black uppercase tracking-tighter transition-all border ${editForm.clickOrigin === origin.id ? 'bg-accent text-primary-dark border-accent' : 'bg-transparent text-white/40 border-white/5 hover:border-white/20'}`}
>
{origin.label}
</button>
))}
</div>
</div>
)}
{/* Timing */}
<div className="space-y-4">
<label className="text-[10px] uppercase tracking-[0.2em] font-bold text-white/30 leading-none flex items-center justify-between">
<span>Timeline Allocation</span>
<span className="text-accent">{editForm.duration}ms</span>
</label>
<input
type="range"
min="0"
max="5000"
step="100"
value={editForm.duration || 1000}
onChange={(e) =>
setEditForm((prev) => ({ ...prev, duration: parseInt(e.target.value) }))
}
className="w-full h-1 bg-white/10 rounded-lg appearance-none cursor-pointer accent-accent"
/>
</div>
{/* Zoom & Effects */}
<div className="space-y-6">
<div className="flex items-center justify-between p-4 bg-white/5 rounded-2xl border border-white/5 group hover:border-white/20 transition-all">
<div className="flex items-center gap-3">
<Maximize2 size={18} className="text-white/40" />
<span className="text-xs font-bold text-white uppercase tracking-wider">
Zoom Shift
</span>
</div>
<input
type="number"
step="0.1"
min="1"
max="3"
value={editForm.zoom || 1}
onChange={(e) =>
setEditForm((prev) => ({ ...prev, zoom: parseFloat(e.target.value) }))
}
className="w-16 bg-white/10 border border-white/10 rounded-lg px-2 py-1 text-xs text-white text-center font-mono"
/>
</div>
<button
onClick={() =>
setEditForm((prev) => ({ ...prev, motionBlur: !prev.motionBlur }))
}
className={`flex items-center justify-between w-full p-4 rounded-2xl border transition-all ${editForm.motionBlur ? 'bg-accent/10 border-accent/30 text-accent' : 'bg-white/5 border-white/5 text-white/40'}`}
>
<div className="flex items-center gap-3">
<Box size={18} />
<span className="text-xs font-bold uppercase tracking-wider">
Motion Blur
</span>
</div>
{editForm.motionBlur ? <Check size={18} /> : <div className="w-[18px]" />}
</button>
{editForm.type === 'mouse' && editForm.interactionType === 'click' && (
<button
onClick={() =>
setEditForm((prev) => ({ ...prev, realClick: !prev.realClick }))
}
className={`flex items-center justify-between w-full p-4 rounded-2xl border transition-all ${editForm.realClick ? 'bg-orange-500/10 border-orange-500/30 text-orange-400' : 'bg-white/5 border-white/5 text-white/40'}`}
>
<div className="flex items-center gap-3">
<ExternalLink size={18} />
<div className="flex flex-col items-start">
<span className="text-xs font-bold uppercase tracking-wider">
Trigger Navigation
</span>
<span className="text-[8px] opacity-60">
Allows URL transitions in Studio
</span>
</div>
</div>
{editForm.realClick ? <Check size={18} /> : <div className="w-[18px]" />}
</button>
)}
</div>
</div>
<button
onClick={saveEdit}
className="mt-8 py-5 bg-accent text-primary-dark text-xs font-black uppercase tracking-[0.2em] rounded-2xl shadow-2xl shadow-accent/20 hover:scale-[1.02] transition-all"
>
Commit Changes
</button>
</div>
)}
</AnimatePresence>
</div>
</LazyMotion>
);
}

View File

@@ -0,0 +1,261 @@
'use client';
import React from 'react';
import { useRecordMode } from './RecordModeContext';
export function RecordModeVisuals({ children }: { children: React.ReactNode }) {
const { isActive, isPlaying, zoomLevel, cursorPosition, isBlurry } = useRecordMode();
const [mounted, setMounted] = React.useState(false);
const [isEmbedded, setIsEmbedded] = React.useState(false);
const [iframeUrl, setIframeUrl] = React.useState<string | null>(null);
React.useEffect(() => {
setMounted(true);
// Explicit non-magical detection
const embedded =
window.location.search.includes('embedded=true') || window.name === 'record-mode-iframe';
setIsEmbedded(embedded);
if (!embedded) {
const url = new URL(window.location.href);
url.searchParams.set('embedded', 'true');
setIframeUrl(url.toString());
}
}, [isEmbedded]);
// Hydration Guard: Match server on first render
if (!mounted) return <>{children}</>;
// Recursion Guard: If we are already in an embedded iframe,
// strictly return just the children to prevent Inception.
if (isEmbedded) {
return (
<>
<style
dangerouslySetInnerHTML={{
__html: `
/* Harder Isolation: Hide ALL potentially duplicate overlays and DEV TOOLS */
#nextjs-portal,
#nextjs-portal-root,
[data-nextjs-toast-wrapper],
.nextjs-static-indicator,
[data-nextjs-indicator],
[class*="nextjs-"],
[id*="nextjs-"],
nextjs-portal,
#feedback-overlay,
.feedback-ui-root,
.feedback-ui-ignore,
[class*="z-[9999]"],
[class*="z-[10000]"],
[style*="z-index: 9999"],
[style*="z-index: 10000"],
.fixed.bottom-6.left-6,
.fixed.bottom-6.left-1/2,
.feedback-ui-overlay,
[id^="feedback-"],
[class^="feedback-"] {
display: none !important;
opacity: 0 !important;
visibility: hidden !important;
pointer-events: none !important;
z-index: -10000 !important;
}
/* Nuclear Option 2.0: Kill ALL scrollbars on ALL elements */
* {
scrollbar-width: none !important;
-ms-overflow-style: none !important;
}
*::-webkit-scrollbar {
display: none !important;
width: 0 !important;
height: 0 !important;
}
html, body {
border-radius: 3rem;
background: #050505 !important;
color: white !important;
overflow-x: hidden !important;
overflow-y: auto !important;
}
`,
}}
/>
{children}
</>
);
}
return (
<>
{/* Global Style for Body Lock */}
{isActive && (
<style
dangerouslySetInnerHTML={{
__html: `
html, body {
overflow: hidden !important;
height: 100vh !important;
position: fixed !important;
width: 100vw !important;
}
/* Kill Next.js Dev tools on host while Studio is active */
#nextjs-portal,
[data-nextjs-toast-wrapper],
.nextjs-static-indicator {
display: none !important;
}
`,
}}
/>
)}
<div
className={`transition-all duration-1000 ${isActive ? 'fixed inset-0 z-[9997] bg-[#020202] flex items-center justify-center p-6 md:p-12 lg:p-20' : 'relative w-full'}`}
>
{/* Studio Background - Only visible when active */}
{isActive && (
<div className="absolute inset-0 z-0 pointer-events-none overflow-hidden">
<div className="absolute inset-0 bg-gradient-to-br from-[#03110a] via-[#020202] to-[#030a11] animate-pulse duration-[10s]" />
<div
className="absolute -top-[60%] -left-[50%] w-[140%] h-[140%] rounded-full opacity-[0.7]"
style={{
background: 'radial-gradient(circle, #10b981 0%, transparent 70%)',
filter: 'blur(160px)',
animation: 'mesh-float-1 18s ease-in-out infinite',
}}
/>
<div
className="absolute -bottom-[60%] -right-[50%] w-[130%] h-[130%] rounded-full opacity-[0.55]"
style={{
background: 'radial-gradient(circle, #06b6d4 0%, transparent 70%)',
filter: 'blur(150px)',
animation: 'mesh-float-2 22s ease-in-out infinite',
}}
/>
<div
className="absolute -top-[30%] -right-[40%] w-[100%] h-[100%] rounded-full opacity-[0.5]"
style={{
background: 'radial-gradient(circle, #82ed20 0%, transparent 70%)',
filter: 'blur(130px)',
animation: 'mesh-float-3 14s ease-in-out infinite',
}}
/>
<div
className="absolute -bottom-[50%] -left-[40%] w-[110%] h-[110%] rounded-full opacity-[0.45]"
style={{
background: 'radial-gradient(circle, #2563eb 0%, transparent 70%)',
filter: 'blur(140px)',
animation: 'mesh-float-4 20s ease-in-out infinite',
}}
/>
<div
className="absolute inset-0 opacity-[0.12] mix-blend-overlay"
style={{
backgroundImage: `url("data:image/svg+xml,%3Csvg viewBox='0 0 256 256' xmlns='http://www.w3.org/2000/svg'%3E%3Cfilter id='noiseFilter'%3E%3CfeTurbulence type='fractalNoise' baseFrequency='0.7' numOctaves='4' stitchTiles='stitch'/%3E%3C/filter%3E%3Crect width='100%25' height='100%25' filter='url(%23noiseFilter)'/%3E%3C/svg%3E")`,
backgroundSize: '128px 128px',
}}
/>
<div
className="absolute inset-0 opacity-[0.06]"
style={{
backgroundImage:
'repeating-linear-gradient(0deg, transparent, transparent 2px, rgba(255,255,255,0.03) 2px, rgba(255,255,255,0.03) 4px)',
}}
/>
</div>
)}
<div
className={`transition-all duration-700 ease-in-out relative z-10 w-full ${isActive ? 'h-full max-h-[1000px] max-w-[1600px] drop-shadow-[0_60px_150px_rgba(0,0,0,1)] scale-in' : 'h-full'}`}
style={{
transform: isPlaying ? `scale(${zoomLevel})` : undefined,
transformOrigin: isPlaying ? `${cursorPosition.x}px ${cursorPosition.y}px` : 'center',
filter: isBlurry ? 'blur(4px)' : 'none',
willChange: 'transform, filter',
WebkitBackfaceVisibility: 'hidden',
backfaceVisibility: 'hidden',
}}
>
<div
className={
isActive
? 'relative h-full w-full rounded-[3rem] overflow-hidden bg-[#050505] isolate'
: 'w-full h-full'
}
style={{ transform: isActive ? 'translateZ(0)' : 'none' }}
>
{isActive && (
<>
<div className="absolute inset-0 rounded-[3rem] border border-white/[0.08] pointer-events-none z-50" />
<div
className="absolute inset-[-2px] rounded-[3rem] pointer-events-none z-20"
style={{
background:
'linear-gradient(135deg, rgba(16,185,129,0.15), rgba(130,237,32,0.15))',
animation: 'pulse-ring 4s ease-in-out infinite',
}}
/>
<div className="absolute inset-0 bg-gradient-to-b from-transparent via-[#82ed20]/[0.05] to-transparent h-[15%] w-full top-[-15%] animate-scan-slow z-50 pointer-events-none opacity-20" />
</>
)}
<div
className={
isActive
? 'w-full h-full rounded-[3rem] overflow-hidden relative'
: 'w-full h-full relative'
}
style={{
WebkitMaskImage: isActive ? '-webkit-radial-gradient(white, black)' : 'none',
transform: isActive ? 'translateZ(0)' : 'none',
}}
>
{isActive && iframeUrl ? (
<iframe
src={iframeUrl}
name="record-mode-iframe"
className="w-full h-full border-0 block"
style={{
backgroundColor: '#050505',
scrollbarWidth: 'none',
msOverflowStyle: 'none',
height: '100%',
width: '100%',
}}
/>
) : (
<div
className={
isActive
? 'blur-2xl opacity-20 pointer-events-none scale-95 transition-all duration-700'
: 'transition-all duration-700'
}
>
{children}
</div>
)}
</div>
</div>
</div>
<style
dangerouslySetInnerHTML={{
__html: `
@keyframes mesh-float-1 { 0%, 100% { transform: translate(0, 0) scale(1) rotate(0deg); } 33% { transform: translate(15%, 10%) scale(1.1) rotate(5deg); } 66% { transform: translate(-10%, 20%) scale(0.9) rotate(-3deg); } }
@keyframes mesh-float-2 { 0%, 100% { transform: translate(0, 0) scale(1) rotate(0deg); } 33% { transform: translate(-20%, -15%) scale(1.2) rotate(-8deg); } 66% { transform: translate(15%, -10%) scale(0.8) rotate(4deg); } }
@keyframes mesh-float-3 { 0%, 100% { transform: translate(0, 0) scale(1.2); } 50% { transform: translate(20%, -25%) scale(0.7); } }
@keyframes mesh-float-4 { 0%, 100% { transform: translate(0, 0) scale(1); } 50% { transform: translate(-15%, 25%) scale(1.1); } }
@keyframes pulse-ring { 0%, 100% { opacity: 0.15; transform: scale(1); } 50% { opacity: 0.4; transform: scale(1.005); } }
@keyframes scan-slow { 0% { transform: translateY(-100%); opacity: 0; } 5% { opacity: 0.2; } 95% { opacity: 0.2; } 100% { transform: translateY(800%); opacity: 0; } }
@keyframes scale-in { 0% { transform: scale(0.95); opacity: 0; } 100% { transform: scale(1); opacity: 1; } }
.scale-in { animation: scale-in 0.7s cubic-bezier(0.16, 1, 0.3, 1) forwards; }
`,
}}
/>
</div>
</>
);
}

View File

@@ -0,0 +1,75 @@
'use client';
import React, { useState, useEffect } from 'react';
import { useRecordMode } from './RecordModeContext';
import dynamic from 'next/dynamic';
const FeedbackOverlay = dynamic(
() => import('@mintel/next-feedback/FeedbackOverlay').then((mod) => mod.FeedbackOverlay),
{ ssr: false },
);
const RecordModeOverlay = dynamic(
() => import('./RecordModeOverlay').then((mod) => mod.RecordModeOverlay),
{ ssr: false },
);
import { PickingHelper } from './PickingHelper';
interface ToolCoordinatorProps {
isEmbedded?: boolean;
feedbackEnabled?: boolean;
}
export function ToolCoordinator({
isEmbedded: isEmbeddedProp,
feedbackEnabled = false,
}: ToolCoordinatorProps) {
const { isActive, setIsActive, isFeedbackActive, setIsFeedbackActive, isEnabled } =
useRecordMode();
const [isEmbedded, setIsEmbedded] = useState(false);
const [mounted, setMounted] = useState(false);
useEffect(() => {
setMounted(true);
const embedded =
isEmbeddedProp ||
window.location.search.includes('embedded=true') ||
window.name === 'record-mode-iframe' ||
window.self !== window.top;
setIsEmbedded(embedded);
}, [isEmbeddedProp]);
if (!mounted) return null;
// Nothing enabled → render nothing
if (!feedbackEnabled && !isEnabled) return null;
// Iframe → only PickingHelper
if (isEmbedded) return <PickingHelper />;
// Record Mode active and enabled
if (isActive && isEnabled) return <RecordModeOverlay />;
// Feedback active and enabled
if (isFeedbackActive && feedbackEnabled) {
return (
<FeedbackOverlay
isActive={isFeedbackActive}
onActiveChange={(active) => setIsFeedbackActive(active)}
/>
);
}
// Baseline: toggle buttons
return (
<div className="feedback-ui-ignore">
{feedbackEnabled && (
<FeedbackOverlay
isActive={false}
onActiveChange={(active) => setIsFeedbackActive(active)}
/>
)}
{isEnabled && <RecordModeOverlay />}
</div>
);
}

View File

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

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.9
}
],
"categories:accessibility": [
"error",
{
"minScore": 0.9
}
],
"categories:best-practices": [
"error",
{
"minScore": 0.9
}
],
"categories:seo": [
"error",
{
"minScore": 0.9
}
],
"first-contentful-paint": [
"warn",
{
"maxNumericValue": 2000
}
],
"interactive": [
"warn",
{
"maxNumericValue": 3500
}
]
}
}
}
}

View File

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

View File

@@ -0,0 +1,65 @@
---
title: 'Herzlich willkommen bei KLZ: Johannes Gleich startet als Senior Key Account Manager durch'
date: '2026-02-20T14:50:00'
featuredImage: /uploads/2026/01/1767353529807.jpg
locale: de
category: Kabel Technologie
excerpt: 'KLZ Cables startet mit einer starken Verstärkung ins neue Jahr: Johannes Gleich übernimmt die Rolle des Senior Key Account Managers. Erfahren Sie mehr über unseren neuen Experten für Infrastruktur und Energieversorger.'
---
# Herzlich willkommen bei KLZ: Johannes Gleich startet als Senior Key Account Manager durch
KLZ Cables startet mit einer starken Verstärkung ins neue Jahr: Seit Januar 2026 übernimmt Johannes Gleich die Rolle des Senior Key Account Managers. Mit ihm gewinnen wir nicht nur zusätzliche Vertriebskraft, sondern auch jahrzehntelange Erfahrung und ein wertvolles Branchennetzwerk.
### **1. Ein bekanntes Gesicht für eine effektive Zusammenarbeit**
Johannes ist für KLZ kein Neuling: Bereits während seiner über zehnjährigen Tätigkeit bei der LAPP Gruppe hat unser Team die Zusammenarbeit mit ihm kennengelernt und sehr geschätzt. Diese bestehende Vertrautheit und das gegenseitige Vertrauen erleichtern den Einstieg enorm und versprechen eine produktive Kooperation von Tag eins an.
### **2. Beruflicher Hintergrund: Erfahrung trifft technische Tiefe**
Mit rund 50 Jahren verbindet Johannes fundierte Berufserfahrung mit frischer Motivation. Seine Basis ist eine technische Ausbildung im Bereich Elektrotechnik. Dieses Fundament ermöglicht es ihm, unsere Produkte nicht nur zu vertreiben, sondern sie in ihrer gesamten technischen Tiefe zu erklären und einzuordnen.
**Sein Werdegang im Überblick:**
<TechnicalGrid
title="Karrierestationen"
items={[
{ label: "Seit Jan. 2026", value: "Senior Key Account Manager bei KLZ Vertriebs GmbH (Remote)" },
{ label: "2015 2026", value: "Projektmanager Infrastrukturbereich Stadtwerke & Energieversorger bei der LAPP Gruppe (Stuttgart)" }
]}
/>
In den vergangenen elf Jahren hat er sich als Experte für die Anforderungen großer Infrastrukturanbieter etabliert. Er kennt die Herausforderungen der Branche technisch, wirtschaftlich und strategisch aus erster Hand.
### **3. Expertise: Ausschreibungen, Normen und Markttrends**
Was Johannes besonders wertvoll für unser Team macht, ist sein spezialisiertes Fachwissen:
<TechnicalGrid
title="Kernkompetenzen"
items={[
{ label: "Tender-Management", value: "Seine umfassende Erfahrung macht ihn zu einem sicheren Partner bei komplexen Ausschreibungen." },
{ label: "Normen & Fertigung", value: "Er verfügt über tiefgehende Kenntnisse im Bereich Kabelnormen und der Kabelfertigung." },
{ label: "Marktkenntnis", value: "Trends, Preisentwicklungen und Beschaffungsstrategien im deutschen Kabelmarkt sind ihm bestens vertraut." },
{ label: "Logistik", value: "Fundierte Kenntnisse in der Lieferkette runden sein Profil ab." }
]}
/>
### **4. Ein verlässlicher Partner auf Augenhöhe**
Johannes genießt bei Kunden eine hohe Wertschätzung als echter „Kümmerer“. Er übernimmt Verantwortung und zeichnet sich durch eine ausgleichende, aber in der Sache klare Verhandlungsführung aus. Seine Fähigkeit, komplexe Anforderungen strukturiert umzusetzen, hat sich bereits in früheren gemeinsamen Projekten mit KLZ bewährt.
### **5. Neue Rolle und Ziele bei KLZ Cables**
In seiner neuen Position wird Johannes den Vertrieb strategisch verstärken und die Geschäftsführung operativ entlasten.
**Seine Kernaufgaben umfassen:**
- **Gezielte Betreuung:** Fokus auf Stadtwerke, Netzbetreiber und Energieversorger.
- **Markterschließung:** Aufbau von Kontakten in den Bereichen Renewables und Tiefbau.
- **Strategische Planung:** Umsetzung von Vertriebsaktivitäten ohne administrative Grenzen, um maximale Dynamik zu entfalten.
### **6. Ausblick**
Wir freuen uns besonders, dass Johannes bei KLZ den Raum findet, sein gesamtes Wissen optimal für unsere Kunden einzusetzen. Mit seiner Kombination aus technischem Know-how, Markterfahrung und menschlicher Integrität ist er genau am richtigen Ort, um das Wachstum von KLZ Cables nachhaltig zu fördern.
Herzlich willkommen im Team, Johannes! Wir freuen uns auf die gemeinsamen Projekte.

View File

@@ -24,15 +24,9 @@ image="https://www.zdf.de/assets/bundestag-berlin-118~1280x720?cb=1741856505967"
### Warum Kabelhersteller jetzt durchstarten sollten
Es wird viel über Subventionen, Fördergelder und deren Verwendung gesprochen. Doch die eigentliche Herausforderung bleibt: Die notwendige Infrastruktur muss geschaffen werden und das gelingt nur mit leistungsfähigen Kabeln.
Die folgenden Trends sind für uns besonders relevant:
- <strong>Ausbau von Stromleitungen und Netzanschlussprojekten:<br />
</strong>Mit dem beschlossenen Milliardenpaket ist klar: Stromleitungen, die erneuerbare Energiequellen wie Onshore-Windparks oder Solaranlagen anbinden, müssen massiv ausgebaut werden. Dabei geht es in erster Linie um die Integration der Stromerzeugung aus Windkraftanlagen ins Netz.Unsere Nieder-, Mittel- und Hochspannungskabel sind dafür ausgelegt, diesen Anforderungen gerecht zu werden.
- <strong>Dezentralisierung der Energieversorgung:<br />
</strong>Ein weiteres zentrales Thema ist der Trend zur [dezentralen Energieversorgung](https://energas-gmbh.de/dezentrale-energieerzeugung/). Immer mehr Energie wird direkt vor Ort erzeugt und muss zuverlässig ins Netz eingespeist werden. Auch hier sind leistungsfähige Erdkabelsysteme gefragt, die sich durch hohe Belastbarkeit und Widerstandsfähigkeit auszeichnen.
- <strong>Klimaschutzmaßnahmen und klimafreundlicher Umbau der Wirtschaft:<br />
</strong>Da 100 Milliarden Euro speziell für den klimafreundlichen Umbau vorgesehen sind, können wir davon ausgehen, dass Projekte zur Elektrifizierung, CO2-Reduktion und zum Ausbau regenerativer Energien massiv gefördert werden.
- <strong>Ausbau von Stromleitungen und Netzanschlussprojekten:<br /></strong>Mit dem beschlossenen Milliardenpaket ist klar: Stromleitungen, die erneuerbare Energiequellen wie Onshore-Windparks oder Solaranlagen anbinden, müssen massiv ausgebaut werden. Dabei geht es in erster Linie um die Integration der Stromerzeugung aus Windkraftanlagen ins Netz.Unsere Nieder-, Mittel- und Hochspannungskabel sind dafür ausgelegt, diesen Anforderungen gerecht zu werden.
- <strong>Dezentralisierung der Energieversorgung:<br /></strong>Ein weiteres zentrales Thema ist der Trend zur [dezentralen Energieversorgung](https://energas-gmbh.de/dezentrale-energieerzeugung/). Immer mehr Energie wird direkt vor Ort erzeugt und muss zuverlässig ins Netz eingespeist werden. Auch hier sind leistungsfähige Erdkabelsysteme gefragt, die sich durch hohe Belastbarkeit und Widerstandsfähigkeit auszeichnen.
- <strong>Klimaschutzmaßnahmen und klimafreundlicher Umbau der Wirtschaft:<br /></strong>Da 100 Milliarden Euro speziell für den klimafreundlichen Umbau vorgesehen sind, können wir davon ausgehen, dass Projekte zur Elektrifizierung, CO2-Reduktion und zum Ausbau regenerativer Energien massiv gefördert werden.
Dies betrifft insbesondere Kabelsysteme, die für hohe Leistung und Stabilität ausgelegt sind so wie die, die wir bei **KLZ** liefern.
### **Die Rolle von KLZ in dieser gigantischen Investitionsoffensive**
Mit diesen milliardenschweren Investitionen wird der Bedarf an Erdkabeln, insbesondere Mittelspannungskabeln, geradezu explodieren. Die Frage ist nicht, **ob** Kabel gebraucht werden sondern **wann und in welchen Mengen**. Und genau da kommen wir ins Spiel.

View File

@@ -24,35 +24,21 @@ There is an often overlooked problem with solar power systems: energy loss due t
But why does this happen? Every cable has a resistance that slows down the flow of electricity. The poorer the quality of the cable, the more energy is lost in the form of heat. This means that less of the electricity produced by the solar system actually reaches you and can be used. And that is obviously a problem, especially when you consider how much is invested in the installation of a solar system.
### Green energy is a central component of our future today &#8230; But it is not enough to simply rely on these energy sources. The infrastructure that brings this energy to us efficiently plays an equally crucial role.
High-quality cables, on the other hand, have better conductivity and lower resistance. This ensures that the **electricity flows more efficiently and less is lost**. This leaves more of the generated energy for you to use which is not only good for your electricity bill, but also helps to maximize the sustainability of your solar system. So it&#8217;s worth paying attention to quality when choosing cables in order to exploit the full potential of green energy.
<VisualLinkPreview
url="
<VisualLinkPreview
url="https://ratedpower.com/blog/utility-scale-pv-losses/"
title="Ultimate guide to utility-scale PV system losses — RatedPower"
summary="What are solar PV system losses and how can you avoid them to maximize the electrical output from your utility-scale plant project?"
image="https://assets.ratedpower.com/1694509274-aerial-view-solar-panels-top-building-eco-building-factory-solar-photovoltaic-cell.jpg?auto=format&fit=crop&h=630&w=1200"
/>
"
title="Ultimate guide to utility-scale PV system losses — RatedPower"
summary="What are solar PV system losses and how can you avoid them to maximize the electrical output from your utility-scale plant project?"
image="https://assets.ratedpower.com/1694509274-aerial-view-solar-panels-top-building-eco-building-factory-solar-photovoltaic-cell.jpg?auto=format&amp;fit=crop&amp;h=630&amp;w=1200"
/>
<h4>Fact 2: Wind farms without energy storage are not that efficient</h4>
Wind farms have a similar problem to solar plants: energy losses due to fluctuating power generation. Imagine a wind farm produces electricity, but the wind does not blow constantly. This means that at certain times the wind turbines generate more electricity than is actually needed, while at other times, when the wind drops, they can supply almost no electricity at all. In both cases, a lot of energy is lost or not used. Without a way to **store surplus energy**, there is a gap between the energy generated and the actual use, which significantly reduces the efficiency of the entire system.The solution to this problem lies in** energy storage systems** such as batteries or pumped storage power plants. These technologies make it possible to store surplus energy when the wind is blowing strongly and therefore more electricity is produced than is required at the moment. This stored energy can then be used on demand when the wind dies down or demand is particularly high. This ensures that all the electricity generated is used efficiently instead of being lost unused. Without these storage technologies, the full potential of wind energy remains untapped and the efficiency of wind farms remains far below their actual value.
However, despite their importance, energy storage systems are associated with challenges. High costs and limited capacity continue to make the development and installation of these storage technologies a difficult endeavor. But technological progress has not stood still: New innovations in storage technologies and increasingly improved scalability are making it more and more realistic to equip wind farms with **effective and cost-efficient storage systems**. This is crucial for the future of wind energy, because only by overcoming these challenges can wind energy fully contribute to ensuring a stable and sustainable energy supply.
<VisualLinkPreview
url="
<VisualLinkPreview
url="https://www.solarenergie.de/stromspeicher/arten/stromspeicher-windkraft"
title="Speicher für Windenergie: Welche Möglichkeiten gibt es?"
summary="Speicher für Windenergie: Welche Möglichkeiten gibt es? Windkraftanlagen mit Speicher im privaten und im öffentlichen Bereich ✓ Wie kann man Windenergie speichern? Lernen Sie hier bereits existente und sich derzeit in der Forschung befindende Verfahren der Zukunft kennen!"
image="https://assets.solarwatt.de/Resources/Persistent/e084aa09af5f0cdef386088bc558a52d81509cc0/Regenerative%20Energie-1200x628.jpg"
/>
"
title="Speicher für Windenergie: Welche Möglichkeiten gibt es?"
summary="Speicher für Windenergie: Welche Möglichkeiten gibt es? Windkraftanlagen mit Speicher im privaten und im öffentlichen Bereich ✓ Wie kann man Windenergie speichern? Lernen Sie hier bereits existente und sich derzeit in der Forschung befindende Verfahren der Zukunft kennen!"
image="https://assets.solarwatt.de/Resources/Persistent/e084aa09af5f0cdef386088bc558a52d81509cc0/Regenerative%20Energie-1200x628.jpg"
/>
<h4>Fact 3: Power lines can be used as habitats for biodiversity</h4>
Did you know that power lines the high-voltage lines that transport electricity from power plants to our homes and businesses can also be used as** habitats for animals and plants**? These areas, which often need to be kept clear to make room for the power lines, provide a valuable opportunity to actively promote biodiversity and contribute to environmental stewardship at the same time.Traditionally, the areas along power lines have often been regarded as “wasteland” with no particular significance. However, innovative approaches to green infrastructure are increasingly creating valuable habitats here. Today, wildflower meadows, bee pastures and shrubs are planted along power lines, providing habitats for many endangered species. These meadows are not only a source of food for bees, butterflies and other pollinators, but also a refuge for birds and small animals that are finding less and less habitat in other parts of the landscape.
In Germany, this is a growing concept known as “**ecological route maintenance**”. Here, care is taken to ensure that the areas along the power lines are designed in a near-natural way so that biodiversity is promoted. This creates flowering meadows and habitats for numerous insect species, which are finding less and less space due to the intensive use of agriculture and urbanization. There are also similar projects in Switzerland in which nature is specifically promoted along power lines.

View File

@@ -31,19 +31,12 @@ Integrating wind farms into the power grid requires a systemic approach. Sound p
Professional planning not only ensures security of supply, but also reduces operating costs in the long term and enables flexible responses to grid requirements.
You can find more information here on how wind energy basically works:
<VisualLinkPreview
url="
<VisualLinkPreview
url="https://www.e-werk-mittelbaden.de/wie-funktioniert-windenergie"
title="Wie funktioniert Windenergie? - Einfach erklärt | E-Werk Mittelbaden"
summary="Erfahren Sie, wie Windenergie funktioniert und wie sie zur nachhaltigen Energieversorgung beiträgt. Jetzt informieren!"
image="https://www.e-werk-mittelbaden.de/sites/default/files/media_image/2024-12/DJI_20231105012629_0029_D-HDR.jpg"
/>
"
title="Wie funktioniert Windenergie?"
summary="- Einfach erklärt | E-Werk MittelbadenErfahren Sie, wie Windenergie funktioniert und wie sie zur nachhaltigen Energieversorgung beiträgt. Jetzt informieren!"
image="https://www.e-werk-mittelbaden.de/sites/default/files/media_image/2024-12/DJI_20231105012629_0029_D-HDR.jpg"
/>
<blockquote>
**Those who want to transport climate-friendly energy must also build in a climate-conscious way.**
</blockquote>
@@ -60,19 +53,12 @@ A well-considered dismantling does not begin with removal, but with a **forward-
This is not only about **ecological aspects** a planned dismantling also makes sense **economically**. Projects that are **systematically designed for dismantling** avoid high **disposal costs** and meet future **regulatory requirements** much more easily.
Overall, it becomes clear: **Sustainability does not end at the grid connection.** It covers the **entire life cycle** right up to the **last recycled cable**. Those who think about **infrastructure holistically** think it through **to the end**.
In the following article, you can find out how, for example, wind turbines are recycled:
<VisualLinkPreview
url="
<VisualLinkPreview
url="https://www.enbw.com/unternehmen/themen/windkraft/windrad-recycling.html"
title="Recycling von Windrädern | EnBW"
summary="Wie funktioniert das Recycling von Windrädern? Erfahren Sie mehr über Herausforderungen und die neuesten Methoden."
image="https://www.enbw.com/media/image-proxy/1600x914,q70,focus60x67,zoom1.45/https://www.enbw.com/media/presse/images/newsroom/windenergie/rueckbau-windpark-hemme-3_1743678993586.jpg"
/>
"
title="Recycling von Windrädern | EnBW"
summary="Wie funktioniert das Recycling von Windrädern? Erfahren Sie mehr über Herausforderungen und die neuesten Methoden."
image="https://www.enbw.com/media/image-proxy/1600x914,q70,focus60x67,zoom1.45/https://www.enbw.com/media/presse/images/newsroom/windenergie/rueckbau-windpark-hemme-3_1743678993586.jpg"
/>
## Reliable Grids Don&#8217;t Happen by Accident
The **requirements** for todays **energy grids** are constantly **increasing**. Especially for wind power projects realized in remote or structurally weak regions, a stable grid design is crucial. It is no longer enough to transmit power from A to B. The **infrastructure** must also work in unforeseen situations during peak loads, maintenance or external disruptions.
This **resilience** cannot be retrofitted. It must be considered right from the planning stage. **Grid architecture** that can flexibly respond to different operating situations is not a technical extra, but a fundamental part of **sustainable project development**. The ability to switch, use alternative routes, or throttle power without causing supply outages is particularly important.

View File

@@ -45,16 +45,9 @@ Those planning sustainable projects dont just need cables they need a cab
Cables are no side note they are the nervous system of the energy transition. They connect **ideas with reality, sources with consumers, visions with feasibility**. And they do so discreetly, reliably, and for decades to come.
Anyone thinking about **renewable energy** today should also consider what keeps that energy moving: **the cable industry.** It doesnt just deliver copper and insulation it provides solutions that make our **green future** possible in the first place.
Find out how you can contribute to a sustainable energy supply in the following article.
<VisualLinkPreview
url="
<VisualLinkPreview
url="https://money-for-future.com/nachhaltige-energieversorgung-erneuerbare-energie"
title="Nachhaltige Energieversor­gung und erneuerbare Energie erklärt"
summary="Nachhaltige Energieversor­gung. Was kann ich tun, um die Energiewende voranzubringen? 7 Schritte zu einer nachhaltigen Lebensweise."
image="https://money-for-future.com/wp-content/uploads/2022/01/Image-153-1.jpg"
/>
"
title="Nachhaltige Energieversor­gung und erneuerbare Energie erklärt"
summary="Nachhaltige Energieversor­gung. Was kann ich tun, um die Energiewende voranzubringen? 7 Schritte zu einer nachhaltigen Lebensweise."
image="https://money-for-future.com/wp-content/uploads/2022/01/Image-153-1.jpg"
/>

View File

@@ -0,0 +1,65 @@
---
title: 'Welcome to KLZ: Johannes Gleich starts as Senior Key Account Manager'
date: '2026-02-20T14:50:00'
featuredImage: /uploads/2026/01/1767353529807.jpg
locale: en
category: Cable Technology
excerpt: 'KLZ Cables kicks off the new year with a strong addition: Johannes Gleich takes on the role of Senior Key Account Manager. Learn more about our new expert for infrastructure and energy suppliers.'
---
# Welcome to KLZ: Johannes Gleich starts as Senior Key Account Manager
KLZ Cables kicks off the new year with a strong addition to the team: Since January 2026, Johannes Gleich has taken on the role of Senior Key Account Manager. With him, we gain not only additional sales power, but also decades of experience and a valuable industry network.
### **1. A familiar face for effective collaboration**
Johannes is no stranger to KLZ: During his more than ten years at the LAPP Group, our team had the pleasure of working with him and greatly appreciated the collaboration. This existing familiarity and mutual trust make his start enormously easier and promise productive cooperation from day one.
### **2. Professional background: Experience meets technical depth**
At around 50 years of age, Johannes combines solid professional experience with fresh motivation. His foundation is a technical education in electrical engineering. This basis enables him not only to sell our products, but also to explain and classify them in their full technical depth.
**His career at a glance:**
<TechnicalGrid
title="Career Stations"
items={[
{ label: "Since Jan. 2026", value: "Senior Key Account Manager at KLZ Vertriebs GmbH (Remote)" },
{ label: "2015 2026", value: "Project Manager Infrastructure Municipal Utilities & Energy Suppliers at the LAPP Group (Stuttgart)" }
]}
/>
Over the past eleven years, he has established himself as an expert in the requirements of large infrastructure providers. He knows the industry's challenges—technical, economic, and strategic—firsthand.
### **3. Expertise: Tenders, standards, and market trends**
What makes Johannes particularly valuable to our team is his specialized expertise:
<TechnicalGrid
title="Core Competencies"
items={[
{ label: "Tender Management", value: "His extensive experience makes him a reliable partner for complex tenders." },
{ label: "Standards & Production", value: "He has deeply rooted knowledge in cable standards and cable manufacturing." },
{ label: "Market Knowledge", value: "He is highly familiar with trends, price developments, and procurement strategies in the German cable market." },
{ label: "Logistics", value: "Solid knowledge of the supply chain rounds out his profile." }
]}
/>
### **4. A reliable partner at eye level**
Johannes is highly valued by customers as a true "caretaker". He takes responsibility and stands out for his balanced yet clear negotiation skills. His ability to implement complex requirements in a structured manner has already proven itself in past joint projects with KLZ.
### **5. New role and goals at KLZ Cables**
In his new position, Johannes will strategically strengthen sales and operatively relieve the management.
**His core responsibilities include:**
- **Targeted Support:** Focus on municipal utilities, grid operators, and energy suppliers.
- **Market Penetration:** Building contacts in the renewables and civil engineering sectors.
- **Strategic Planning:** Implementing sales activities without administrative boundaries to unfold maximum dynamism.
### **6. Outlook**
We are especially pleased that Johannes has found the space at KLZ to optimally use all his knowledge for our customers. With his combination of technical know-how, market experience, and personal integrity, he is exactly in the right place to sustainably promote the growth of KLZ Cables.
Welcome to the team, Johannes! We look forward to our future projects together.

View File

@@ -9,26 +9,12 @@ category: Kabel Technologie
### Whats the Directory of Wind Energy?
The <em>Directory of Wind Energy 2025</em> is the ultimate reference guide for the wind energy industry. With over 200 pages of insights, company listings, and industry contacts, its the resource planners, developers, and decision-makers use to connect with trusted suppliers and service providers. Covering everything from turbine manufacturers to certification companies, its a compact treasure trove of knowledge, both in print and online.
Now, KLZ is part of this trusted network, making it even easier for industry professionals to find us.
<VisualLinkPreview
url="
<VisualLinkPreview
<VisualLinkPreview
url="https://www.erneuerbareenergien.de/"
title="Erneuerbare Energien - Das Magazin für die Energiewende mit Wind-, Solar- und Bioenergie"
summary="Heft 01-2025"
image="https://www.erneuerbareenergien.de/sites/default/files/styles/teaser_standard__xs/public/aurora/2024/12/414535.jpeg?itok=WJmtgX-q"
/>
"
title="Erneuerbare Energien - Das Magazin für die Energiewende mit Wind-, Solar- und Bioenergie"
summary="Heft 01-2025"
image="
<VisualLinkPreview
url="https://www.erneuerbareenergien.de/"
title="Erneuerbare Energien - Das Magazin für die Energiewende mit Wind-, Solar- und Bioenergie"
summary="Heft 01-2025"
image="https://www.erneuerbareenergien.de/sites/default/files/styles/teaser_standard__xs/public/aurora/2024/12/414535.jpeg?itok=WJmtgX-q"
/>
sites/default/files/styles/teaser_standard__xs/public/aurora/2024/12/414535.jpeg?itok=WJmtgX-q"
/>
### Why were included
Our medium voltage cables, like the **NA2XS(F)2Y**, have become essential in wind parks throughout Germany and the Netherlands. These cables play a critical role in transmitting electricity from wind turbines to substations, ensuring safe and reliable energy flow under the most demanding conditions.
What sets us apart is more than the cables:

View File

@@ -7,18 +7,11 @@ category: Kabel Technologie
---
# Securing the future with H1Z2Z2-K: Our solar cable for Intersolar 2025
Around [Intersolar Europe](https://www.intersolar.de/start), the topic of photovoltaics is once again moving into the spotlight. A great reason to take a closer look at a special solar cable developed specifically for use in PV systems robust, weather-resistant, and compliant with current standards.
<VisualLinkPreview
url="
<VisualLinkPreview
url="https://youtu.be/YbtdyvQFoVM"
title="Intersolar Europe 2025 | Save The Date | May 79, 2025"
summary="As the worlds leading exhibition for the solar industry, Intersolar Europe demonstrates the enormous vitality of the solar market. For more than 30 years, i…"
image="https://i.ytimg.com/vi/YbtdyvQFoVM/maxresdefault.jpg?sqp=-oaymwEmCIAKENAF8quKqQMa8AEB-AH-CYAC0AWKAgwIABABGEQgSyhyMA8=&rs=AOn4CLBx90qdBxgYcyMttgdOGs3-m0udZQ"
/>
"
title="Intersolar Europe 2025 | Save The Date | May 79, 2025"
summary="As the worlds leading exhibition for the solar industry, Intersolar Europe demonstrates the enormous vitality of the solar market. For more than 30 years, i…"
image="https://i.ytimg.com/vi/YbtdyvQFoVM/maxresdefault.jpg?sqp=-oaymwEmCIAKENAF8quKqQMa8AEB-AH-CYAC0AWKAgwIABABGEQgSyhyMA8=&amp;rs=AOn4CLBx90qdBxgYcyMttgdOGs3-m0udZQ"
image="https://i.ytimg.com/vi/YbtdyvQFoVM/maxresdefault.jpg?sqp=-oaymwEmCIAKENAF8quKqQMa8AEB-AH-CYAC0AWKAgwIABABGEQgSyhyMA8=&amp;rs=AOn4CLBx90qdBxgYcyMttgdOGs3-m0udZQ"
/>
What lies behind the design, which selection criteria matter for solar cables, and why every detail counts in photovoltaic projects thats exactly what this article is about.
## What is the H1Z2Z2-K 6mm² solar cable?
@@ -113,16 +106,9 @@ The H1Z2Z2-K 6mm² stands for technical maturity and a consistent focus on profe
What really stands out is its versatility: whether on rooftops, in underground installations, or in large-scale PV plants the H1Z2Z2-K delivers reliability and an impressive service life. It makes a direct contribution to the economic efficiency and sustainability of solar systems.
Further information, technical details, and ordering options can be found on the product page: 👉 [To the H1Z2Z2-K at KLZ](/products/solar-cables/h1z2z2-k/)
All the key details about Intersolar Europe can be found here:
<VisualLinkPreview
url="
<VisualLinkPreview
url="https://www.intersolar.de/messe-kompakt?ref=m5f53a666f3a2cb2fee160554-s65eec4739108db093b003a02-t1746004197-cf3c592e7"
title="Intersolar Europe at a Glance"
summary="Intersolar Europe | Exhibition Quick Facts | Date, Venue, Opening Hours, Exhibitors"
image="https://www.intersolar.de/media/image/6311c9ee98bbc414b66305e2/750"
/>
"
title="Intersolar Europe at a Glance"
summary="Intersolar Europe | Exhibition Quick Facts | Date, Venue, Opening Hours, Exhibitors"
image="https://www.intersolar.de/media/image/6311c9ee98bbc414b66305e2/750"
/>

View File

@@ -42,19 +42,12 @@ Choosing the right cable is a long-term investment that pays off in safety, cost
<h4>The relevance of high-quality cables for a sustainable future</h4>
In a world that is increasingly moving towards a carbon-neutral energy supply, first-class cables are helping to achieve these goals. Sustainable cables are made from recyclable materials that minimize environmental impact. They also support the integration of renewable energies into the power grid by ensuring that the electricity generated is transported efficiently and without losses.
The right choice of cable is therefore not just a technical decision it is a contribution to a more sustainable future. By using high-quality cables, the carbon footprint of infrastructure projects can be significantly reduced. This is an important step towards an environmentally friendly and energy-efficient society.A first-class cable is therefore more than just a technical component it is a key to a more stable, greener and more efficient energy supply.
<VisualLinkPreview
url="
<VisualLinkPreview
url="https://www.konnworld.com/why-cable-quality-matters-the-impact-on-energy-efficiency-and-longevity"
title="Why Cable Quality Matters: The Impact on Energy Efficiency and Longevity"
summary="In the electrical systems that we have today, theres no denying that cable quality is important in ensuring optimal performance and safety."
image="https://www.konnworld.com/wp-content/uploads/2018/08/konn-b-logo.png"
/>
"
title="Why Cable Quality Matters: The Impact on Energy Efficiency and Longevity"
summary="In the electrical systems that we have today, theres no denying that cable quality is important in ensuring optimal performance and safety."
image="https://www.konnworld.com/wp-content/uploads/2018/08/konn-b-logo.png"
/>
<h4>Materials: What makes a cable durable and efficient?</h4>
Choosing the right materials is crucial to making cables both durable and efficient. Two of the most common and important materials used in cables are copper and aluminum. They play a central role in the electrical conductivity and durability of cables.
**The role of copper and aluminum**
@@ -67,37 +60,23 @@ The demand for environmentally friendly materials is growing as more and more co
- Aluminum recycling: Aluminum is also a highly recyclable material. The aluminum recycling process requires only about 5% of the energy needed to produce new aluminum. Many manufacturers are turning to recycled aluminum to improve their environmental footprint while increasing the efficiency of their cable products.
- Biodegradable insulation: Another trend is the development of biodegradable or more environmentally friendly insulation materials. These materials not only reduce the use of toxic substances, but also help to minimize waste after the cable&#8217;s lifetime.In summary, choosing the right materials for cables is not only a factor in their longevity and efficiency, but also crucial for a sustainable future. Copper and aluminum offer excellent performance, but the focus on recycling and the search for more environmentally friendly alternatives is making the cable industry increasingly greener and more resource-efficient. So not only can a cable work efficiently today, it can also leave a smaller environmental footprint in the future.
<VisualLinkPreview
url="
<VisualLinkPreview
url="https://insights.regencysupply.com/pros-and-cons-of-copper-and-aluminum-wire"
title="Pros and Cons of Copper and Aluminum Wire"
summary="Copper wire and aluminum wire — which option is better? We weight the pros and cons."
image="https://insights.regencysupply.com/hubfs/copper%20wire.jpg"
/>
"
title="Pros and Cons of Copper and Aluminum Wire"
summary="Copper wire and aluminum wire — which option is better? We weight the pros and cons."
image="https://insights.regencysupply.com/hubfs/copper%20wire.jpg"
/>
<h4>Technology: Advanced designs for optimal performance</h4>
Advanced cable technologies are critical to maximize the performance and safety of electrical systems, especially with regard to renewable energy sources. Two key technologies are insulation and sheathing, and specialized cables for renewable energy.
**Insulation and sheathing: quality meets safety**<br />
The insulation of a cable protects against short circuits and external influences such as moisture or mechanical damage. Materials such as PVC, PE and XLPE offer excellent protection and guarantee safe power transmission. The protective sheath protects the cable from UV radiation and extreme temperatures, which significantly extends its service life and increases safety.**Cables for renewable energy sources**<br />
Solar and wind energy require specialized cables that can withstand extreme weather conditions and high loads. Solar cables must be UV resistant and suitable for DC systems, while wind power cables must be flexible and corrosion resistant to withstand the constant movement of rotor blades. These advanced cables enable the efficient and safe transportation of energy, which is crucial for the sustainability and economic viability of renewable energy.
Overall, these technologies help maximize the efficiency and safety of cables and support a sustainable energy future.
<VisualLinkPreview
url="
<VisualLinkPreview
url="https://www.cables-unlimited.com/renewable-energy-cable-assemblies-weve-got-you-covered/"
title="Renewable Energy Cable Assemblies — Weve Got You Covered - Cables Unlimited Inc."
summary="Cable assemblies used in renewable energy installations, what they are, their benefits and cable systems for renewable energy design factors."
image="http://www.cables-unlimited.com/wp-content/uploads/2023/02/Cables-Unlimited_Featured-Renewable-Energy-Cable-Assemblies-%E2%80%94-Weve-Got-You-Covered.jpg"
/>
"
title="Renewable Energy Cable Assemblies — Weve Got You Covered - Cables Unlimited Inc."
summary="Cable assemblies used in renewable energy installations, what they are, their benefits and cable systems for renewable energy design factors."
image="http://www.cables-unlimited.com/wp-content/uploads/2023/02/Cables-Unlimited_Featured-Renewable-Energy-Cable-Assemblies-%E2%80%94-Weve-Got-You-Covered.jpg"
image="http://www.cables-unlimited.com/wp-content/uploads/2023/02/Cables-Unlimited_Featured-Renewable-Energy-Cable-Assemblies-%E2%80%94-Weve-Got You-Covered.jpg"
/>
<h4>Certifications and standards: a guarantee of quality</h4>
The quality of cables is not only determined by their materials and technologies, but also by compliance with certifications and standards. These guarantee that cables are safe, efficient and durable. They play a decisive role in ensuring product quality and are an important criterion when selecting cables for various applications, particularly with regard to sustainability and environmental protection.**Important standards and seals for first-class cables**
@@ -129,34 +108,20 @@ In a world that is increasingly focusing on sustainability, sustainability certi
- Environmentally friendly production: Certificates such as ISO 14001 prove that manufacturers use environmentally friendly production processes that minimize energy consumption and waste.
The increasing importance of sustainability certificates is not only a response to legal requirements, but also a response to the growing awareness of consumers and companies who are looking for environmentally friendly products. In an industry increasingly dominated by green technologies, such certificates provide companies with a competitive advantage and consumers with the assurance that they are choosing responsibly produced products.
<VisualLinkPreview
url="
<VisualLinkPreview
url="https://www.flukenetworks.com/blog/cabling-chronicles/cabling-certification"
title="Three Reasons Cabling Certification Is More Important Than Ever"
summary="Every time you complete the installation of a structured cabling system, you can choose whether to certify it. All links in the system should be tested in some way to make sure that theyre connected properly, but is it necessary to measure and document the performance of every link?"
image="https://www.flukenetworks.com/sites/default/files/blog/fn-dsx-8000_14a_w.jpg"
/>
"
title="Three Reasons Cabling Certification Is More Important Than Ever"
summary="Every time you complete the installation of a structured cabling system, you can choose whether to certify it. All links in the system should be tested in some way to make sure that theyre connected properly, but is it necessary to measure and document the performance of every link?"
image="https://www.flukenetworks.com/sites/default/files/blog/fn-dsx-8000_14a_w.jpg"
/>
<h4>Conclusion: What really makes a first-class cable</h4>
A first-class cable is characterized by a perfect balance between quality, efficiency and sustainability. Choosing the right cable is not just a question of technical requirements, but also an important step towards a more sustainable future. A high-quality cable ensures reliable performance and maximum efficiency, reduces energy loss and at the same time offers a high standard of safety.**Quality and efficiency**<br />
A good cable is designed to work efficiently over the long term. Materials such as copper and aluminum ensure excellent conductivity, while modern insulation materials and protective layers extend the cable&#8217;s service life and protect it against external influences such as moisture and corrosion. This is particularly important in applications such as power transmission and the use of renewable energy, where efficiency and reliability are top priorities.
**Sustainability**<br />
In a world that is increasingly focusing on sustainability, environmental protection is playing an ever greater role when choosing a cable. Recyclability, sustainable production processes and certifications such as RoHS or recycling seals are decisive factors that determine the ecological footprint of a cable. Integrating these elements into cable production helps to minimize resource consumption and reduce waste, which contributes to a more environmentally friendly and resource-efficient future.
<VisualLinkPreview
url="
<VisualLinkPreview
url="https://sustainablebrands.com/read/evolving-infrastructure-wire-cable-prioritize-sustainability"
title="Evolving Our Infrastructure Means the Wire and Cable Industry Must Prioritize Sustainability | Sustainable Brands"
summary="To sustainably support the tremendous global demand for connectivity, collaboration is needed across the value chain to create solutions that enable more inf…"
image="https://sb-web-assets.s3.amazonaws.com/production/46426/conversions/keyart-fbimg.jpg"
/>
"
title="Evolving Our Infrastructure Means the Wire and Cable Industry Must Prioritize Sustainability | Sustainable Brands"
summary="To sustainably support the tremendous global demand for connectivity, collaboration is needed across the value chain to create solutions that enable more inf…"
image="https://sb-web-assets.s3.amazonaws.com/production/46426/conversions/keyart-fbimg.jpg"
/>

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,83 @@
version: 1
directus: 11.14.1
vendor: postgres
collections:
- collection: contact_submissions
meta:
accountability: all
archive_app_filter: true
collapse: open
collection: contact_submissions
color: '#002b49'
display_template: '{{name}} | {{email}}'
hidden: false
icon: contact_mail
singleton: false
schema:
name: contact_submissions
fields:
- collection: contact_submissions
field: id
type: uuid
meta:
collection: contact_submissions
field: id
hidden: true
sort: 1
schema:
name: id
table: contact_submissions
data_type: uuid
is_nullable: false
is_primary_key: true
- collection: contact_submissions
field: name
type: string
meta:
collection: contact_submissions
field: name
interface: input
sort: 2
schema:
name: name
table: contact_submissions
data_type: character varying
- collection: contact_submissions
field: email
type: string
meta:
collection: contact_submissions
field: email
interface: input
sort: 3
schema:
name: email
table: contact_submissions
data_type: character varying
- collection: contact_submissions
field: message
type: text
meta:
collection: contact_submissions
field: message
interface: textarea
sort: 4
schema:
name: message
table: contact_submissions
data_type: text
- collection: contact_submissions
field: date_created
type: timestamp
meta:
collection: contact_submissions
field: date_created
interface: datetime
readonly: true
sort: 5
schema:
name: date_created
table: contact_submissions
data_type: timestamp with time zone
default_value: CURRENT_TIMESTAMP
relations: []

View File

@@ -6,51 +6,210 @@ collections:
meta:
accountability: all
archive_app_filter: true
archive_field: null
archive_value: null
collapse: open
collection: contact_submissions
color: '#002b49'
display_template: '{{first_name}} {{last_name}} | {{subject}}'
group: null
display_template: '{{name}} | {{email}}'
hidden: false
icon: contact_mail
item_duplication_fields: null
note: null
preview_url: null
singleton: false
sort: null
sort_field: null
translations: null
unarchive_value: null
versioning: false
schema:
name: contact_submissions
- collection: product_requests
meta:
accountability: all
archive_app_filter: true
archive_field: null
archive_value: null
collapse: open
collection: product_requests
color: '#002b49'
display_template: null
group: null
display_template: '{{product_name}} | {{email}}'
hidden: false
icon: inventory
item_duplication_fields: null
note: null
preview_url: null
singleton: false
sort: null
sort_field: null
translations: null
unarchive_value: null
versioning: false
schema:
name: product_requests
fields: []
- collection: products
meta:
accountability: all
collection: products
icon: inventory_2
singleton: false
schema:
name: products
- collection: products_translations
meta:
accountability: all
collection: products_translations
hidden: true
schema:
name: products_translations
fields:
# contact_submissions
- collection: contact_submissions
field: id
type: uuid
meta:
collection: contact_submissions
field: id
hidden: true
sort: 1
schema:
name: id
table: contact_submissions
data_type: uuid
is_nullable: false
is_primary_key: true
- collection: contact_submissions
field: name
type: string
meta:
collection: contact_submissions
field: name
interface: input
sort: 2
schema:
name: name
table: contact_submissions
data_type: character varying
- collection: contact_submissions
field: email
type: string
meta:
collection: contact_submissions
field: email
interface: input
sort: 3
schema:
name: email
table: contact_submissions
data_type: character varying
- collection: contact_submissions
field: message
type: text
meta:
collection: contact_submissions
field: message
interface: textarea
sort: 4
schema:
name: message
table: contact_submissions
data_type: text
- collection: contact_submissions
field: date_created
type: timestamp
meta:
collection: contact_submissions
field: date_created
interface: datetime
readonly: true
sort: 5
schema:
name: date_created
table: contact_submissions
data_type: timestamp with time zone
default_value: CURRENT_TIMESTAMP
# product_requests
- collection: product_requests
field: id
type: uuid
meta:
collection: product_requests
field: id
hidden: true
sort: 1
schema:
name: id
table: product_requests
data_type: uuid
is_nullable: false
is_primary_key: true
- collection: product_requests
field: product_name
type: string
meta:
collection: product_requests
field: product_name
interface: input
sort: 2
schema:
name: product_name
table: product_requests
data_type: character varying
- collection: product_requests
field: email
type: string
meta:
collection: product_requests
field: email
interface: input
sort: 3
schema:
name: email
table: product_requests
data_type: character varying
- collection: product_requests
field: message
type: text
meta:
collection: product_requests
field: message
interface: textarea
sort: 4
schema:
name: message
table: product_requests
data_type: text
- collection: product_requests
field: date_created
type: timestamp
meta:
collection: product_requests
field: date_created
interface: datetime
readonly: true
sort: 5
schema:
name: date_created
table: product_requests
data_type: timestamp with time zone
default_value: CURRENT_TIMESTAMP
# products
- collection: products
field: id
type: uuid
meta:
collection: products
field: id
hidden: true
sort: 1
schema:
name: id
table: products
data_type: uuid
is_nullable: false
is_primary_key: true
# products_translations
- collection: products_translations
field: id
type: integer
meta:
collection: products_translations
field: id
hidden: true
schema:
name: id
table: products_translations
data_type: integer
is_primary_key: true
has_auto_increment: true
systemFields:
- collection: directus_activity
field: timestamp
@@ -64,4 +223,7 @@ systemFields:
field: parent
schema:
is_indexed: true
relations: []

View File

@@ -0,0 +1,45 @@
services:
klz-app:
build:
context: .
dockerfile: Dockerfile
target: development
volumes:
- .:/app
- /app/node_modules
- /app/.next
environment:
- WATCHPACK_POLLING=true # Useful for Docker volume mounting issues on some systems
restart: "no"
container_name: klz-app-dev
labels:
- "traefik.enable=true"
# Clear any production middlewares/headers redirect
- "traefik.http.routers.${PROJECT_NAME:-klz-cables}-web.middlewares="
- "traefik.http.routers.${PROJECT_NAME:-klz-cables}-web.rule=Host(`${TRAEFIK_HOST:-klz.localhost}`)"
- "traefik.http.routers.${PROJECT_NAME:-klz-cables}-web.entrypoints=web"
# Configure main router for local HTTP without auth
- "traefik.http.routers.${PROJECT_NAME:-klz-cables}.rule=Host(`${TRAEFIK_HOST:-klz.localhost}`)"
- "traefik.http.routers.${PROJECT_NAME:-klz-cables}.entrypoints=web"
- "traefik.http.routers.${PROJECT_NAME:-klz-cables}.middlewares="
- "traefik.http.routers.${PROJECT_NAME:-klz-cables}.tls=false"
- "traefik.docker.network=infra"
klz-cms:
container_name: klz-cms-dev
restart: "no"
ports:
- "8055:8055"
labels:
- "traefik.enable=true"
- "traefik.http.routers.${PROJECT_NAME:-klz-cables}-cms.rule=Host(`${DIRECTUS_HOST:-cms.klz.localhost}`)"
- "traefik.http.routers.${PROJECT_NAME:-klz-cables}-cms.entrypoints=${TRAEFIK_ENTRYPOINT:-web}"
- "traefik.http.routers.${PROJECT_NAME:-klz-cables}-cms.service=${PROJECT_NAME:-klz-cables}-cms"
- "traefik.http.services.${PROJECT_NAME:-klz-cables}-cms.loadbalancer.server.port=8055"
- "traefik.docker.network=infra"
klz-db:
restart: "no"
klz-gatekeeper:
restart: "no"

View File

@@ -1,70 +1,82 @@
services:
klz-app:
build:
context: .
dockerfile: Dockerfile
args:
NEXT_PUBLIC_BASE_URL: ${NEXT_PUBLIC_BASE_URL}
DIRECTUS_URL: "${DIRECTUS_URL}"
image: registry.infra.mintel.me/mintel/klz-cables.com:${IMAGE_TAG:-latest}
restart: always
restart: unless-stopped
networks:
- default
- infra
default:
infra:
aliases:
- klz.localhost
env_file:
- ${ENV_FILE:-.env}
environment:
IMGPROXY_URL: ${IMGPROXY_URL:-http://klz-imgproxy:8080}
labels:
- "traefik.enable=true"
# HTTP ⇒ HTTPS redirect
- "traefik.http.routers.${PROJECT_NAME:-klz-cables}-web.rule=${TRAEFIK_HOST_RULE:-Host(\"klz-cables.com\")}"
- "traefik.http.routers.${PROJECT_NAME:-klz-cables}-web.entrypoints=web"
- "traefik.http.routers.${PROJECT_NAME:-klz-cables}-web.middlewares=redirect-https"
- "traefik.http.routers.${PROJECT_NAME:-klz}-web.rule=${TRAEFIK_HOST_RULE:-Host(`${TRAEFIK_HOST:-klz-cables.com}`)}"
- "traefik.http.routers.${PROJECT_NAME:-klz}-web.entrypoints=web"
- "traefik.http.routers.${PROJECT_NAME:-klz}-web.middlewares=redirect-https"
# HTTPS router (Standard)
- "traefik.http.routers.${PROJECT_NAME:-klz-cables}.rule=${TRAEFIK_HOST_RULE:-Host(\"klz-cables.com\")}"
- "traefik.http.routers.${PROJECT_NAME:-klz-cables}.entrypoints=websecure"
- "traefik.http.routers.${PROJECT_NAME:-klz-cables}.tls.certresolver=le"
- "traefik.http.routers.${PROJECT_NAME:-klz-cables}.tls=true"
- "traefik.http.routers.${PROJECT_NAME:-klz-cables}.service=${PROJECT_NAME:-klz-cables}"
- "traefik.http.routers.${PROJECT_NAME:-klz-cables}.middlewares=${AUTH_MIDDLEWARE:-${PROJECT_NAME:-klz-cables}-ratelimit,${PROJECT_NAME:-klz-cables}-forward,${PROJECT_NAME:-klz-cables}-compress}"
- "traefik.http.routers.${PROJECT_NAME:-klz}.rule=${TRAEFIK_HOST_RULE:-Host(`${TRAEFIK_HOST:-klz-cables.com}`)}"
- "traefik.http.routers.${PROJECT_NAME:-klz}.entrypoints=${TRAEFIK_ENTRYPOINT:-web}"
- "traefik.http.routers.${PROJECT_NAME:-klz}.tls.certresolver=${TRAEFIK_CERT_RESOLVER:-}"
- "traefik.http.routers.${PROJECT_NAME:-klz}.tls=${TRAEFIK_TLS:-false}"
- "traefik.http.routers.${PROJECT_NAME:-klz}.service=${PROJECT_NAME:-klz}-app-svc"
- "traefik.http.routers.${PROJECT_NAME:-klz}.middlewares=${AUTH_MIDDLEWARE:-klz-ratelimit,klz-forward,klz-compress}"
# Public Router (Whitelist for OG Images, Sitemaps, Health)
- "traefik.http.routers.${PROJECT_NAME:-klz-cables}-public.rule=(${TRAEFIK_HOST_RULE:-Host(\"klz-cables.com\")}) && (PathPrefix(\"/health\", \"/sitemap.xml\", \"/robots.txt\", \"/manifest.webmanifest\", \"/api/og\") || PathPrefix(\"/de/opengraph-image\", \"/en/opengraph-image\", \"/de/blog/opengraph-image\", \"/en/blog/opengraph-image\", \"/de/products/opengraph-image\", \"/en/products/opengraph-image\") || PathRegexp(\"^/.*opengraph-image.*$\"))"
- "traefik.http.routers.${PROJECT_NAME:-klz-cables}-public.entrypoints=websecure"
- "traefik.http.routers.${PROJECT_NAME:-klz-cables}-public.tls.certresolver=le"
- "traefik.http.routers.${PROJECT_NAME:-klz-cables}-public.tls=true"
- "traefik.http.routers.${PROJECT_NAME:-klz-cables}-public.service=${PROJECT_NAME:-klz-cables}"
- "traefik.http.routers.${PROJECT_NAME:-klz-cables}-public.middlewares=${AUTH_MIDDLEWARE_UNPROTECTED:-${PROJECT_NAME:-klz-cables}-ratelimit,${PROJECT_NAME:-klz-cables}-forward,${PROJECT_NAME:-klz-cables}-compress}"
- "traefik.http.routers.${PROJECT_NAME:-klz-cables}-public.priority=2000"
- "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(`/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.tls.certresolver=${TRAEFIK_CERT_RESOLVER:-}"
- "traefik.http.routers.${PROJECT_NAME:-klz}-public.tls=${TRAEFIK_TLS:-false}"
- "traefik.http.routers.${PROJECT_NAME:-klz}-public.service=${PROJECT_NAME:-klz}-app-svc"
- "traefik.http.routers.${PROJECT_NAME:-klz}-public.middlewares=${AUTH_MIDDLEWARE_UNPROTECTED:-klz-ratelimit,klz-forward,klz-compress}"
- "traefik.http.routers.${PROJECT_NAME:-klz}-public.priority=2000"
- "traefik.http.services.${PROJECT_NAME:-klz-cables}.loadbalancer.server.port=3000"
- "traefik.http.services.${PROJECT_NAME:-klz-cables}.loadbalancer.server.scheme=http"
- "traefik.http.services.${PROJECT_NAME:-klz}-app-svc.loadbalancer.server.scheme=http"
- "traefik.http.services.${PROJECT_NAME:-klz}-app-svc.loadbalancer.server.port=3000"
- "traefik.docker.network=infra"
- "caddy=http://${TRAEFIK_HOST:-klz.localhost}"
- "caddy.reverse_proxy={{upstreams 3000}}"
# Middleware Definitions
- "traefik.http.middlewares.${PROJECT_NAME:-klz-cables}-compress.compress=true"
- "traefik.http.middlewares.${PROJECT_NAME:-klz}-compress.compress=true"
# Forwarded Headers
- "traefik.http.middlewares.${PROJECT_NAME:-klz-cables}-forward.headers.customrequestheaders.X-Forwarded-Proto=https"
- "traefik.http.middlewares.${PROJECT_NAME:-klz-cables}-forward.headers.customrequestheaders.X-Forwarded-Ssl=on"
- "traefik.http.middlewares.${PROJECT_NAME:-klz}-forward.headers.customrequestheaders.X-Forwarded-Proto=https"
- "traefik.http.middlewares.${PROJECT_NAME:-klz}-forward.headers.customrequestheaders.X-Forwarded-Ssl=on"
# Authentication Middleware (ForwardAuth)
- "traefik.http.middlewares.${PROJECT_NAME:-klz-cables}-auth.forwardauth.address=http://${PROJECT_NAME:-klz-cables}-gatekeeper:3000/gatekeeper/api/verify"
- "traefik.http.middlewares.${PROJECT_NAME:-klz-cables}-auth.forwardauth.trustForwardHeader=true"
- "traefik.http.middlewares.${PROJECT_NAME:-klz-cables}-auth.forwardauth.authRequestHeaders=X-Forwarded-Host,X-Forwarded-Proto,X-Forwarded-For,Cookie"
- "traefik.http.middlewares.${PROJECT_NAME:-klz-cables}-auth.forwardauth.authResponseHeaders=X-Auth-User"
- "traefik.http.middlewares.${PROJECT_NAME:-klz}-auth.forwardauth.address=http://${PROJECT_NAME:-klz}-gatekeeper:3000/gatekeeper/api/verify"
- "traefik.http.middlewares.${PROJECT_NAME:-klz}-auth.forwardauth.trustForwardHeader=true"
- "traefik.http.middlewares.${PROJECT_NAME:-klz}-auth.forwardauth.authRequestHeaders=X-Forwarded-Host,X-Forwarded-Proto,X-Forwarded-For,Cookie"
- "traefik.http.middlewares.${PROJECT_NAME:-klz}-auth.forwardauth.authResponseHeaders=X-Auth-User"
# Middleware Definitions
- "traefik.http.middlewares.${PROJECT_NAME:-klz-cables}-ratelimit.ratelimit.average=100"
- "traefik.http.middlewares.${PROJECT_NAME:-klz-cables}-ratelimit.ratelimit.burst=50"
# Rate Limit Middleware
- "traefik.http.middlewares.${PROJECT_NAME:-klz}-ratelimit.ratelimit.average=100"
- "traefik.http.middlewares.${PROJECT_NAME:-klz}-ratelimit.ratelimit.burst=50"
healthcheck:
test: [ "CMD", "curl", "-f", "http://127.0.0.1:3000/" ]
interval: 10s
timeout: 5s
retries: 5
start_period: 30s
test: [ "CMD", "curl", "-f", "http://127.0.0.1:3000/health" ]
interval: 15s
timeout: 10s
retries: 3
start_period: 45s
gatekeeper:
klz-gatekeeper:
profiles: [ "gatekeeper" ]
image: registry.infra.mintel.me/mintel/gatekeeper:v1.7.12
restart: always
restart: unless-stopped
networks:
infra:
aliases:
- ${PROJECT_NAME:-klz-cables}-gatekeeper
- ${PROJECT_NAME:-klz}-gatekeeper
env_file:
- ${ENV_FILE:-.env}
environment:
@@ -78,22 +90,18 @@ services:
labels:
- "traefik.enable=true"
- "traefik.docker.network=infra"
- "traefik.http.routers.${PROJECT_NAME:-klz-cables}-gatekeeper.rule=(Host(\"${TRAEFIK_HOST:-testing.klz-cables.com}\") && PathPrefix(\"/gatekeeper\"))"
- "traefik.http.routers.${PROJECT_NAME:-klz-cables}-gatekeeper.entrypoints=websecure"
- "traefik.http.routers.${PROJECT_NAME:-klz-cables}-gatekeeper.tls.certresolver=le"
- "traefik.http.routers.${PROJECT_NAME:-klz-cables}-gatekeeper.tls=true"
- "traefik.http.routers.${PROJECT_NAME:-klz-cables}-gatekeeper.service=${PROJECT_NAME:-klz-cables}-gatekeeper"
- "traefik.http.services.${PROJECT_NAME:-klz-cables}-gatekeeper.loadbalancer.server.port=3000"
- "traefik.http.routers.${PROJECT_NAME:-klz}-gatekeeper.rule=(Host(`${TRAEFIK_HOST:-testing.klz-cables.com}`) && PathPrefix(`/gatekeeper`))"
- "traefik.http.routers.${PROJECT_NAME:-klz}-gatekeeper.entrypoints=${TRAEFIK_ENTRYPOINT:-web}"
- "traefik.http.routers.${PROJECT_NAME:-klz}-gatekeeper.tls.certresolver=${TRAEFIK_CERT_RESOLVER:-}"
- "traefik.http.routers.${PROJECT_NAME:-klz}-gatekeeper.tls=${TRAEFIK_TLS:-false}"
- "traefik.http.routers.${PROJECT_NAME:-klz}-gatekeeper.service=${PROJECT_NAME:-klz}-gatekeeper-svc"
- "traefik.http.services.${PROJECT_NAME:-klz}-gatekeeper-svc.loadbalancer.server.port=3000"
- "traefik.docker.network=infra"
directus:
image: directus/directus:11
restart: always
networks:
default:
infra:
aliases:
- ${PROJECT_NAME:-klz-cables}-directus
klz-cms:
image: registry.infra.mintel.me/mintel/directus:latest
restart: unless-stopped
command: [ "node", "cli.js", "start" ]
env_file:
- ${ENV_FILE:-.env}
environment:
@@ -102,45 +110,96 @@ services:
ADMIN_EMAIL: ${DIRECTUS_ADMIN_EMAIL}
ADMIN_PASSWORD: ${DIRECTUS_ADMIN_PASSWORD}
DB_CLIENT: 'pg'
DB_HOST: 'directus-db'
DB_HOST: 'klz-db'
DB_PORT: '5432'
DB_DATABASE: ${DIRECTUS_DB_NAME:-directus}
DB_USER: ${DIRECTUS_DB_USER:-directus}
DB_PASSWORD: ${DIRECTUS_DB_PASSWORD:-directus}
DB_PASSWORD: ${DIRECTUS_DB_PASSWORD:-120in09oenaoinsd9iaidon}
WEBSOCKETS_ENABLED: 'true'
PUBLIC_URL: ${DIRECTUS_URL:-https://cms.klz-cables.com}
# Error Tracking
SENTRY_DSN: ${SENTRY_DSN}
SENTRY_ENVIRONMENT: ${TARGET:-development}
LOGGER_LEVEL: ${LOG_LEVEL:-info}
HOST: '0.0.0.0'
networks:
- default
- infra
volumes:
- ./directus/uploads:/directus/uploads
- ./directus/extensions:/directus/extensions
- ./directus/schema:/directus/schema
- ./directus/migrations:/directus/migrations
healthcheck:
disable: true
labels:
- "traefik.enable=true"
- "traefik.http.routers.${PROJECT_NAME:-klz-cables}-directus.rule=Host(\"${DIRECTUS_HOST}\")"
- "traefik.http.routers.${PROJECT_NAME:-klz-cables}-directus.entrypoints=websecure"
- "traefik.http.routers.${PROJECT_NAME:-klz-cables}-directus.tls.certresolver=le"
- "traefik.http.routers.${PROJECT_NAME:-klz-cables}-directus.tls=true"
- "traefik.http.routers.${PROJECT_NAME:-klz-cables}-directus.middlewares=${PROJECT_NAME:-klz-cables}-forward,compress"
- "traefik.http.services.${PROJECT_NAME:-klz-cables}-directus.loadbalancer.server.port=8055"
- "traefik.http.routers.${PROJECT_NAME:-klz}-cms.rule=Host(`${DIRECTUS_HOST:-cms.klz-cables.com}`)"
- "traefik.http.routers.${PROJECT_NAME:-klz}-cms.entrypoints=websecure"
- "traefik.http.routers.${PROJECT_NAME:-klz}-cms.priority=5000"
- "traefik.http.routers.${PROJECT_NAME:-klz}-cms.tls=true"
- "traefik.http.routers.${PROJECT_NAME:-klz}-cms.tls.certresolver=le"
- "traefik.http.routers.${PROJECT_NAME:-klz}-cms.service=${PROJECT_NAME:-klz}-cms-svc"
- "traefik.http.services.${PROJECT_NAME:-klz}-cms-svc.loadbalancer.server.port=8055"
- "traefik.docker.network=infra"
directus-db:
- "caddy=http://${DIRECTUS_HOST:-cms.klz-cables.com}"
- "caddy.reverse_proxy={{upstreams 8055}}"
klz-db:
image: postgres:15-alpine
restart: always
networks:
- default
restart: unless-stopped
env_file:
- ${ENV_FILE:-.env}
environment:
POSTGRES_DB: ${DIRECTUS_DB_NAME:-directus}
POSTGRES_USER: ${DIRECTUS_DB_USER:-directus}
POSTGRES_PASSWORD: ${DIRECTUS_DB_PASSWORD:-directus}
POSTGRES_PASSWORD: ${DIRECTUS_DB_PASSWORD:-120in09oenaoinsd9iaidon}
volumes:
- directus-db-data:/var/lib/postgresql/data
networks:
- default
klz-imgproxy:
image: darthsim/imgproxy:latest
restart: unless-stopped
networks:
- default
- infra
extra_hosts:
- "klz.localhost:host-gateway"
- "cms.klz.localhost:host-gateway"
- "host.docker.internal:host-gateway"
environment:
IMGPROXY_URL_MAPPING: "${NEXT_PUBLIC_BASE_URL}:http://klz-app:3000,${DIRECTUS_URL}:http://klz-cms:8055"
IMGPROXY_USE_ETAG: "true"
IMGPROXY_MAX_SRC_RESOLUTION: 20
IMGPROXY_IGNORE_SSL_ERRORS: "true"
IMGPROXY_LOG_LEVEL: debug
IMGPROXY_ALLOW_LOCAL_NETWORKS: "true"
labels:
- "traefik.enable=true"
# Existing Local HTTP Router
- "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"
# NEW: Direct Public Staging Router for /_img (Bypasses Next.js rewrites)
# This fixes the Next.js URL-decoding bug on dynamic image proxy paths
- "traefik.http.routers.${PROJECT_NAME:-klz}-img.rule=(Host(`${TRAEFIK_HOST:-klz.localhost}`) || Host(`staging.klz-cables.com`) || Host(`testing.klz-cables.com`)) && PathPrefix(`/_img`)"
- "traefik.http.routers.${PROJECT_NAME:-klz}-img.priority=99999"
- "traefik.http.routers.${PROJECT_NAME:-klz}-img.entrypoints=websecure"
- "traefik.http.routers.${PROJECT_NAME:-klz}-img.tls=true"
- "traefik.http.routers.${PROJECT_NAME:-klz}-img.tls.certresolver=${TRAEFIK_CERT_RESOLVER:-le}"
- "traefik.http.routers.${PROJECT_NAME:-klz}-img.service=${PROJECT_NAME:-klz}-imgproxy-svc"
- "traefik.http.services.${PROJECT_NAME:-klz}-imgproxy-svc.loadbalancer.server.port=8080"
- "traefik.http.routers.${PROJECT_NAME:-klz}-img.middlewares=${PROJECT_NAME:-klz}-img-strip"
- "traefik.http.middlewares.${PROJECT_NAME:-klz}-img-strip.stripprefix.prefixes=/_img"
# HTTPS router (staging/prod)
- "traefik.http.routers.${PROJECT_NAME:-klz}-imgproxy-secure.rule=Host(`img.${TRAEFIK_HOST:-klz.localhost}`)"
- "traefik.http.routers.${PROJECT_NAME:-klz}-imgproxy-secure.entrypoints=${TRAEFIK_ENTRYPOINT:-web}"
- "traefik.http.routers.${PROJECT_NAME:-klz}-imgproxy-secure.tls=${TRAEFIK_TLS:-false}"
- "traefik.http.routers.${PROJECT_NAME:-klz}-imgproxy-secure.tls.certresolver=${TRAEFIK_CERT_RESOLVER:-}"
- "traefik.http.routers.${PROJECT_NAME:-klz}-imgproxy-secure.service=${PROJECT_NAME:-klz}-imgproxy-svc"
- "traefik.http.services.${PROJECT_NAME:-klz}-imgproxy-svc.loadbalancer.server.port=8080"
- "traefik.docker.network=infra"
- "caddy=http://img.${TRAEFIK_HOST:-klz.localhost}"
- "caddy.reverse_proxy={{upstreams 8080}}"
networks:
default:
@@ -150,3 +209,5 @@ networks:
volumes:
directus-db-data:
external: true
name: klz-cablescom_directus-db-data

View File

@@ -1,11 +0,0 @@
> klz-cables-nextjs@1.0.0 lint /Users/marcmintel/Projects/klz-2026
> eslint .
/Users/marcmintel/Projects/klz-2026/components/home/GallerySection.tsx
3:17 warning 'useState' is defined but never used @typescript-eslint/no-unused-vars
3:27 warning 'useEffect' is defined but never used @typescript-eslint/no-unused-vars
✖ 2 problems (0 errors, 2 warnings)

View File

@@ -9,11 +9,9 @@ export default getRequestConfig(async ({ requestLocale }) => {
const locale =
typeof rawLocale === 'string' && supportedLocales.includes(rawLocale) ? rawLocale : 'en';
// Log to Sentry if we had to fallback, as it might indicate a routing issue
// Silent fallback for missing locales to support internal requests (e.g. OG generation)
if (!rawLocale || !supportedLocales.includes(rawLocale as string)) {
console.warn(
`[i18n] Invalid or missing requestLocale received: "${rawLocale}". Falling back to "en".`,
);
// console.debug(`[i18n] Fallback to "en" for locale: "${rawLocale}"`);
}
return {

View File

@@ -15,6 +15,11 @@ function createConfig() {
const target = env.NEXT_PUBLIC_TARGET || env.TARGET;
console.log('[Config] Initializing Toggles:', {
feedbackEnabled: env.NEXT_PUBLIC_FEEDBACK_ENABLED,
recordModeEnabled: env.NEXT_PUBLIC_RECORD_MODE_ENABLED,
});
return {
env: env.NODE_ENV,
target,
@@ -23,15 +28,16 @@ function createConfig() {
isTesting: target === 'testing',
isDevelopment: target === 'development',
feedbackEnabled: env.NEXT_PUBLIC_FEEDBACK_ENABLED,
recordModeEnabled: env.NEXT_PUBLIC_RECORD_MODE_ENABLED,
gatekeeperUrl: env.GATEKEEPER_URL,
baseUrl: env.NEXT_PUBLIC_BASE_URL,
analytics: {
umami: {
websiteId: env.UMAMI_WEBSITE_ID,
apiEndpoint: env.UMAMI_API_ENDPOINT,
enabled: Boolean(env.UMAMI_WEBSITE_ID),
websiteId: env.NEXT_PUBLIC_UMAMI_WEBSITE_ID || env.UMAMI_WEBSITE_ID,
apiEndpoint: env.UMAMI_API_ENDPOINT || 'https://analytics.infra.mintel.me',
enabled: typeof window !== 'undefined' || Boolean(env.UMAMI_WEBSITE_ID),
},
},
@@ -50,7 +56,7 @@ function createConfig() {
},
logging: {
level: env.LOG_LEVEL,
level: env.LOG_LEVEL || 'info',
},
mail: {
@@ -144,6 +150,9 @@ export const config = {
get feedbackEnabled() {
return getConfig().feedbackEnabled;
},
get recordModeEnabled() {
return getConfig().recordModeEnabled;
},
get infraCMS() {
return getConfig().infraCMS;
},

View File

@@ -1,6 +1,18 @@
import { z } from 'zod';
import { validateMintelEnv, mintelEnvSchema, withMintelRefinements } from '@mintel/next-utils';
/**
* Robust boolean preprocessor for environment variables.
* Handles strings 'true'/'false' and actual booleans.
*/
const booleanSchema = z.preprocess((val) => {
if (typeof val === 'string') {
if (val.toLowerCase() === 'true') return true;
if (val.toLowerCase() === 'false') return false;
}
return val;
}, z.boolean());
/**
* Environment variable schema.
* Extends the default Mintel environment schema.
@@ -11,20 +23,15 @@ const envExtension = {
// Gatekeeper specifics not in base
GATEKEEPER_URL: z.string().url().default('http://gatekeeper:3000'),
GATEKEEPER_BYPASS_ENABLED: z.preprocess(
(val) => val === 'true' || val === true,
z.boolean().default(false),
),
NEXT_PUBLIC_FEEDBACK_ENABLED: z.preprocess(
(val) => val === 'true' || val === true,
z.boolean().default(false),
),
GATEKEEPER_BYPASS_ENABLED: booleanSchema.default(false),
NEXT_PUBLIC_FEEDBACK_ENABLED: booleanSchema.default(false),
NEXT_PUBLIC_RECORD_MODE_ENABLED: booleanSchema.default(false),
INFRA_DIRECTUS_URL: z.string().url().optional(),
INFRA_DIRECTUS_TOKEN: z.string().optional(),
// Analytics
UMAMI_WEBSITE_ID: z.string().optional(),
NEXT_PUBLIC_UMAMI_WEBSITE_ID: z.string().optional(),
UMAMI_API_ENDPOINT: z.string().optional(),
// Mail Configuration

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: 'sm', // Use smart gravity (content-aware) instead of face detection (requires ML)
});
}

72
lib/imgproxy.ts Normal file
View File

@@ -0,0 +1,72 @@
/**
* 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 Base64 encoding for the source URL.
// This completely eliminates any risk of intermediate proxies (Traefik/Next.js)
// URL-decoding the path, which corrupts the double-slash (// to /) and causes 403 errors.
// Imgproxy expects URL-safe Base64 (RFC 4648) without padding.
const b64 =
typeof window === 'undefined'
? Buffer.from(absoluteSrc).toString('base64')
: btoa(unescape(encodeURIComponent(absoluteSrc)));
const urlSafeB64 = b64.replace(/\+/g, '-').replace(/\//g, '_').replace(/=/g, '');
const suffix = extension ? `.${extension}` : '';
return `${baseUrl}/unsafe/${processingOptions}/${urlSafeB64}${suffix}`;
}

View File

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

View File

@@ -1,4 +1,3 @@
import * as Sentry from '@sentry/nextjs';
import type {
ErrorReportingLevel,
ErrorReportingService,
@@ -7,32 +6,66 @@ import type {
import type { NotificationService } from '../notifications/notification-service';
import type { LoggerService } from '../logging/logger-service';
type SentryLike = typeof Sentry;
export type GlitchtipErrorReportingServiceOptions = {
enabled: boolean;
};
// GlitchTip speaks the Sentry protocol; @sentry/nextjs can send to GlitchTip via DSN.
// Sentry is dynamically imported to avoid a ~100KB main-thread execution penalty on initial load.
export class GlitchtipErrorReportingService implements ErrorReportingService {
private logger: LoggerService;
private sentryPromise: Promise<typeof import('@sentry/nextjs')> | null = null;
constructor(
private readonly options: GlitchtipErrorReportingServiceOptions,
logger: LoggerService,
private readonly notifications?: NotificationService,
private readonly sentry: SentryLike = Sentry,
) {
this.logger = logger.child({ component: 'error-reporting-glitchtip' });
if (this.options.enabled) {
if (typeof window !== 'undefined') {
// On client-side, wait until idle before fetching Sentry
if ('requestIdleCallback' in window) {
window.requestIdleCallback(() => {
this.getSentry();
});
} else {
setTimeout(() => {
this.getSentry();
}, 3000);
}
} else {
// Pre-fetch on server-side
this.getSentry();
}
}
}
private getSentry(): Promise<typeof import('@sentry/nextjs')> {
if (!this.sentryPromise) {
this.sentryPromise = import('@sentry/nextjs').then((Sentry) => {
// Client-side initialization must happen here since sentry.client.config.ts is empty
if (typeof window !== 'undefined') {
Sentry.init({
dsn: 'https://public@errors.infra.mintel.me/1',
tunnel: '/errors/api/relay',
enabled: true,
tracesSampleRate: 0,
replaysOnErrorSampleRate: 1.0,
replaysSessionSampleRate: 0.1,
});
}
return Sentry;
});
}
return this.sentryPromise;
}
async captureException(error: unknown, context?: Record<string, unknown>) {
if (!this.options.enabled) return undefined;
const result = this.sentry.captureException(error, context as any) as any;
// Send to Gotify if it's considered critical or if we just want all exceptions there
// For now, let's send all exceptions to Gotify as requested "notify me via gotify about critical error messages"
// We'll treat all captureException calls as potentially critical or at least noteworthy
if (this.notifications) {
const errorMessage = error instanceof Error ? error.message : String(error);
const contextStr = context ? `\nContext: ${JSON.stringify(context, null, 2)}` : '';
@@ -44,34 +77,33 @@ export class GlitchtipErrorReportingService implements ErrorReportingService {
});
}
return result;
const Sentry = await this.getSentry();
return Sentry.captureException(error, context as any) as any;
}
captureMessage(message: string, level: ErrorReportingLevel = 'error') {
async captureMessage(message: string, level: ErrorReportingLevel = 'error') {
if (!this.options.enabled) return undefined;
return this.sentry.captureMessage(message, level as any) as any;
const Sentry = await this.getSentry();
return Sentry.captureMessage(message, level as any) as any;
}
setUser(user: ErrorReportingUser | null) {
if (!this.options.enabled) return;
this.sentry.setUser(user as any);
this.getSentry().then((Sentry) => Sentry.setUser(user as any));
}
setTag(key: string, value: string) {
if (!this.options.enabled) return;
this.sentry.setTag(key, value);
this.getSentry().then((Sentry) => Sentry.setTag(key, value));
}
withScope<T>(fn: () => T, context?: Record<string, unknown>) {
withScope<T>(fn: () => T, context?: Record<string, unknown>): T {
if (!this.options.enabled) return fn();
return this.sentry.withScope((scope) => {
if (context) {
for (const [key, value] of Object.entries(context)) {
scope.setExtra(key, value);
}
}
return fn();
});
// Since withScope mandates executing fn() synchronously to return T,
// and Sentry load is async, if context mapping is absolutely required
// for this feature we would need an async API.
// For now we degrade gracefully by just executing the function.
return fn();
}
}

View File

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

View File

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

View File

@@ -1,132 +0,0 @@
> klz-cables-nextjs@1.0.0 lint /Users/marcmintel/Projects/klz-2026
> eslint .
(node:66439) ESLintEnvWarning: /* eslint-env */ comments are no longer recognized when linting with flat config and will be reported as errors as of v10.0.0. Replace them with /* global */ comments or define globals in your config file. See https://eslint.org/docs/latest/use/configure/migration-guide#eslint-env-configuration-comments for details. Found in /Users/marcmintel/Projects/klz-2026/.lintstagedrc.cjs at line 2.
(Use `node --trace-warnings ...` to show where the warning was created)
(node:66439) ESLintEnvWarning: /* eslint-env */ comments are no longer recognized when linting with flat config and will be reported as errors as of v10.0.0. Replace them with /* global */ comments or define globals in your config file. See https://eslint.org/docs/latest/use/configure/migration-guide#eslint-env-configuration-comments for details. Found in /Users/marcmintel/Projects/klz-2026/commitlint.config.cjs at line 2.
(node:66439) ESLintEnvWarning: /* eslint-env */ comments are no longer recognized when linting with flat config and will be reported as errors as of v10.0.0. Replace them with /* global */ comments or define globals in your config file. See https://eslint.org/docs/latest/use/configure/migration-guide#eslint-env-configuration-comments for details. Found in /Users/marcmintel/Projects/klz-2026/postcss.config.cjs at line 2.
(node:66439) ESLintEnvWarning: /* eslint-env */ comments are no longer recognized when linting with flat config and will be reported as errors as of v10.0.0. Replace them with /* global */ comments or define globals in your config file. See https://eslint.org/docs/latest/use/configure/migration-guide#eslint-env-configuration-comments for details. Found in /Users/marcmintel/Projects/klz-2026/tailwind.config.cjs at line 2.
/Users/marcmintel/Projects/klz-2026/.lintstagedrc.cjs
3:14 error A `require()` style import is forbidden @typescript-eslint/no-require-imports
/Users/marcmintel/Projects/klz-2026/app/[locale]/blog/[slug]/page.tsx
2:8 warning 'Script' is defined but never used @typescript-eslint/no-unused-vars
4:10 warning 'getBreadcrumbSchema' is defined but never used @typescript-eslint/no-unused-vars
4:41 warning 'LOGO_URL' is defined but never used @typescript-eslint/no-unused-vars
/Users/marcmintel/Projects/klz-2026/app/[locale]/blog/page.tsx
63:15 warning Using `<img>` could result in slower LCP and higher bandwidth. Consider using `<Image />` from `next/image` or a custom image loader to automatically optimize images. This may incur additional usage or cost from your provider. See: https://nextjs.org/docs/messages/no-img-element @next/next/no-img-element
148:25 warning Using `<img>` could result in slower LCP and higher bandwidth. Consider using `<Image />` from `next/image` or a custom image loader to automatically optimize images. This may incur additional usage or cost from your provider. See: https://nextjs.org/docs/messages/no-img-element @next/next/no-img-element
/Users/marcmintel/Projects/klz-2026/app/[locale]/layout.tsx
81:12 warning 'e' is defined but never used @typescript-eslint/no-unused-vars
/Users/marcmintel/Projects/klz-2026/app/[locale]/page.tsx
70:12 warning 'err' is defined but never used @typescript-eslint/no-unused-vars
74:14 warning 'e' is defined but never used @typescript-eslint/no-unused-vars
75:12 warning 'key' is defined but never used. Allowed unused args must match /^_/u @typescript-eslint/no-unused-vars
/Users/marcmintel/Projects/klz-2026/app/[locale]/products/[...slug]/page.tsx
1:8 warning 'Script' is defined but never used @typescript-eslint/no-unused-vars
3:10 warning 'getBreadcrumbSchema' is defined but never used @typescript-eslint/no-unused-vars
/Users/marcmintel/Projects/klz-2026/app/errors/api/relay/route.ts
28:11 warning 'header' is assigned a value but never used @typescript-eslint/no-unused-vars
/Users/marcmintel/Projects/klz-2026/components/CMSConnectivityNotice.tsx
4:34 warning 'Database' is defined but never used @typescript-eslint/no-unused-vars
8:10 warning 'status' is assigned a value but never used @typescript-eslint/no-unused-vars
35:16 warning 'err' is defined but never used @typescript-eslint/no-unused-vars
/Users/marcmintel/Projects/klz-2026/components/Header.tsx
36:7 warning Error: Calling setState synchronously within an effect can trigger cascading renders
Effects are intended to synchronize state between React and external systems such as manually updating the DOM, state management libraries, or other platform APIs. In general, the body of an effect should do one or both of the following:
* Update external systems with the latest state from React.
* Subscribe for updates from some external system, calling setState in a callback function when external state changes.
Calling setState synchronously within an effect body causes cascading renders that can hurt performance, and is not recommended. (https://react.dev/learn/you-might-not-need-an-effect).
/Users/marcmintel/Projects/klz-2026/components/Header.tsx:36:7
34 | useEffect(() => {
35 | if (isMobileMenuOpen) {
> 36 | setIsMobileMenuOpen(false);
| ^^^^^^^^^^^^^^^^^^^ Avoid calling setState() directly within an effect
37 | }
38 | }, [pathname, isMobileMenuOpen]);
39 | react-hooks/set-state-in-effect
116:37 warning 'idx' is defined but never used. Allowed unused args must match /^_/u @typescript-eslint/no-unused-vars
/Users/marcmintel/Projects/klz-2026/components/Lightbox.tsx
24:5 warning Error: Calling setState synchronously within an effect can trigger cascading renders
Effects are intended to synchronize state between React and external systems such as manually updating the DOM, state management libraries, or other platform APIs. In general, the body of an effect should do one or both of the following:
* Update external systems with the latest state from React.
* Subscribe for updates from some external system, calling setState in a callback function when external state changes.
Calling setState synchronously within an effect body causes cascading renders that can hurt performance, and is not recommended. (https://react.dev/learn/you-might-not-need-an-effect).
/Users/marcmintel/Projects/klz-2026/components/Lightbox.tsx:24:5
22 |
23 | useEffect(() => {
> 24 | setMounted(true);
| ^^^^^^^^^^ Avoid calling setState() directly within an effect
25 | return () => setMounted(false);
26 | }, []);
27 | react-hooks/set-state-in-effect
62:9 warning Error: Calling setState synchronously within an effect can trigger cascading renders
Effects are intended to synchronize state between React and external systems such as manually updating the DOM, state management libraries, or other platform APIs. In general, the body of an effect should do one or both of the following:
* Update external systems with the latest state from React.
* Subscribe for updates from some external system, calling setState in a callback function when external state changes.
Calling setState synchronously within an effect body causes cascading renders that can hurt performance, and is not recommended. (https://react.dev/learn/you-might-not-need-an-effect).
/Users/marcmintel/Projects/klz-2026/components/Lightbox.tsx:62:9
60 | const index = parseInt(photoParam, 10);
61 | if (!isNaN(index) && index >= 0 && index < images.length) {
> 62 | setCurrentIndex(index);
| ^^^^^^^^^^^^^^^ Avoid calling setState() directly within an effect
63 | }
64 | }
65 | }, [searchParams, images.length]); react-hooks/set-state-in-effect
/Users/marcmintel/Projects/klz-2026/components/OGImageTemplate.tsx
49:11 warning Using `<img>` could result in slower LCP and higher bandwidth. Consider using `<Image />` from `next/image` or a custom image loader to automatically optimize images. This may incur additional usage or cost from your provider. See: https://nextjs.org/docs/messages/no-img-element @next/next/no-img-element
/Users/marcmintel/Projects/klz-2026/components/home/GallerySection.tsx
30:38 warning Error: Calling setState synchronously within an effect can trigger cascading renders
Effects are intended to synchronize state between React and external systems such as manually updating the DOM, state management libraries, or other platform APIs. In general, the body of an effect should do one or both of the following:
* Update external systems with the latest state from React.
* Subscribe for updates from some external system, calling setState in a callback function when external state changes.
Calling setState synchronously within an effect body causes cascading renders that can hurt performance, and is not recommended. (https://react.dev/learn/you-might-not-need-an-effect).
/Users/marcmintel/Projects/klz-2026/components/home/GallerySection.tsx:30:38
28 | const index = parseInt(photoParam, 10);
29 | if (!isNaN(index) && index >= 0 && index < images.length) {
> 30 | if (lightboxIndex !== index) setLightboxIndex(index);
| ^^^^^^^^^^^^^^^^ Avoid calling setState() directly within an effect
31 | if (!lightboxOpen) setLightboxOpen(true);
32 | }
33 | } react-hooks/set-state-in-effect
/Users/marcmintel/Projects/klz-2026/components/home/RecentPosts.tsx
37:21 warning Using `<img>` could result in slower LCP and higher bandwidth. Consider using `<Image />` from `next/image` or a custom image loader to automatically optimize images. This may incur additional usage or cost from your provider. See: https://nextjs.org/docs/messages/no-img-element @next/next/no-img-element
/Users/marcmintel/Projects/klz-2026/lib/config.ts
5:10 warning 'env' is defined but never used @typescript-eslint/no-unused-vars
/Users/marcmintel/Projects/klz-2026/lib/mail/mailer.ts
4:10 warning 'ReactElement' is defined but never used @typescript-eslint/no-unused-vars
/Users/marcmintel/Projects/klz-2026/middleware.ts
2:10 warning 'NextResponse' is defined but never used @typescript-eslint/no-unused-vars
33:12 warning 'publicHostname' is assigned a value but never used @typescript-eslint/no-unused-vars
✖ 27 problems (1 error, 26 warnings)
ELIFECYCLE Command failed with exit code 1.

View File

@@ -1,65 +0,0 @@
> klz-cables-nextjs@1.0.0 lint /Users/marcmintel/Projects/klz-2026
> eslint .
/Users/marcmintel/Projects/klz-2026/app/[locale]/layout.tsx
81:12 warning '_e' is defined but never used @typescript-eslint/no-unused-vars
/Users/marcmintel/Projects/klz-2026/app/[locale]/page.tsx
70:12 warning '_err' is defined but never used @typescript-eslint/no-unused-vars
74:14 warning '_e' is defined but never used @typescript-eslint/no-unused-vars
/Users/marcmintel/Projects/klz-2026/app/errors/api/relay/route.ts
28:11 warning '_header' is assigned a value but never used @typescript-eslint/no-unused-vars
/Users/marcmintel/Projects/klz-2026/components/CMSConnectivityNotice.tsx
8:10 warning '_status' is assigned a value but never used @typescript-eslint/no-unused-vars
35:16 warning '_err' is defined but never used @typescript-eslint/no-unused-vars
/Users/marcmintel/Projects/klz-2026/components/Lightbox.tsx
24:5 warning Error: Calling setState synchronously within an effect can trigger cascading renders
Effects are intended to synchronize state between React and external systems such as manually updating the DOM, state management libraries, or other platform APIs. In general, the body of an effect should do one or both of the following:
* Update external systems with the latest state from React.
* Subscribe for updates from some external system, calling setState in a callback function when external state changes.
Calling setState synchronously within an effect body causes cascading renders that can hurt performance, and is not recommended. (https://react.dev/learn/you-might-not-need-an-effect).
/Users/marcmintel/Projects/klz-2026/components/Lightbox.tsx:24:5
22 |
23 | useEffect(() => {
> 24 | setMounted(true);
| ^^^^^^^^^^ Avoid calling setState() directly within an effect
25 | return () => setMounted(false);
26 | }, []); // eslint-disable-line react-hooks/set-state-in-effect
27 | react-hooks/set-state-in-effect
26:11 warning Unused eslint-disable directive (no problems were reported from 'react-hooks/set-state-in-effect')
62:9 warning Error: Calling setState synchronously within an effect can trigger cascading renders
Effects are intended to synchronize state between React and external systems such as manually updating the DOM, state management libraries, or other platform APIs. In general, the body of an effect should do one or both of the following:
* Update external systems with the latest state from React.
* Subscribe for updates from some external system, calling setState in a callback function when external state changes.
Calling setState synchronously within an effect body causes cascading renders that can hurt performance, and is not recommended. (https://react.dev/learn/you-might-not-need-an-effect).
/Users/marcmintel/Projects/klz-2026/components/Lightbox.tsx:62:9
60 | const index = parseInt(photoParam, 10);
61 | if (!isNaN(index) && index >= 0 && index < images.length) {
> 62 | setCurrentIndex(index);
| ^^^^^^^^^^^^^^^ Avoid calling setState() directly within an effect
63 | }
64 | }
65 | }, [searchParams, images.length]); // eslint-disable-line react-hooks/set-state-in-effect react-hooks/set-state-in-effect
65:38 warning Unused eslint-disable directive (no problems were reported from 'react-hooks/set-state-in-effect')
/Users/marcmintel/Projects/klz-2026/components/home/GallerySection.tsx
3:17 warning 'useState' is defined but never used @typescript-eslint/no-unused-vars
3:27 warning 'useEffect' is defined but never used @typescript-eslint/no-unused-vars
/Users/marcmintel/Projects/klz-2026/middleware.ts
33:12 warning '_publicHostname' is assigned a value but never used @typescript-eslint/no-unused-vars
✖ 13 problems (0 errors, 13 warnings)
0 errors and 2 warnings potentially fixable with the `--fix` option.

View File

@@ -58,12 +58,14 @@
}
},
"Navigation": {
"home": "Start",
"menu": "Menü",
"home": "Startseite",
"team": "Team",
"products": "Produkte",
"blog": "Blog",
"contact": "Kontakt",
"toggleMenu": "Menü umschalten"
"toggleMenu": "Menü umschalten",
"skipToContent": "Zum Inhalt springen"
},
"Footer": {
"legal": "Rechtliches",
@@ -120,7 +122,7 @@
"quote": "Manchmal braucht es nur einen klaren Kopf und das richtige Kabel, um die Welt ein Stück besser zu machen.",
"description": "Klaus ist der Fels in der Brandung selbst wenn das Kabelchaos überhandnimmt. Mit jahrzehntelanger Erfahrung und einem stabilen Netzwerk sorgt er dafür, dass alles glatt läuft. Er denkt nicht nur in Lösungen, sondern bringt auch Humor und den nötigen Weitblick mit, um selbst komplexe Themen locker auf den Punkt zu bringen.",
"linkedin": "Klaus' LinkedIn",
"role": "Gründer & Visionär"
"role": "Geschäftsführer"
},
"manifesto": {
"title": "Unser Manifest",
@@ -207,6 +209,7 @@
"description": "Entdecken Sie unser umfassendes Sortiment an zertifizierten Kabeln: von Niederspannung über Mittel- und Hochspannung bis hin zu spezialisierten Solarkabeln."
},
"title": "Unsere <green>Produkte</green>",
"breadcrumb": "Produkte",
"subtitle": "Entdecken Sie unser umfassendes Sortiment an hochwertigen Kabeln für jede Anwendung.",
"heroSubtitle": "Produktportfolio",
"categoryLabel": "Kategorie",

View File

@@ -58,12 +58,14 @@
}
},
"Navigation": {
"menu": "Menu",
"home": "Home",
"team": "Team",
"products": "Products",
"blog": "Blog",
"contact": "Contact",
"toggleMenu": "Toggle Menu"
"toggleMenu": "Toggle Menu",
"skipToContent": "Skip to content"
},
"Footer": {
"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.",
"description": "Klaus is the man with the experience, bringing perspective and calm to the table—even when cable chaos threatens to take over. With impressive industry knowledge and a network as solid as our cables, he ensures everything runs smoothly. Klaus isnt just a problem solver; hes a strategic thinker who knows how to get to the point with a touch of humor.",
"linkedin": "Check Klaus' LinkedIn",
"role": "Founder & Visionary"
"role": "Managing Director"
},
"manifesto": {
"title": "Our manifesto",
@@ -207,6 +209,7 @@
"description": "Explore our comprehensive range of certified cables: from low voltage to medium and high voltage, as well as specialized solar cables."
},
"title": "Our <green>Products</green>",
"breadcrumb": "Products",
"subtitle": "Explore our comprehensive range of high-quality cables designed for every application.",
"heroSubtitle": "Product Portfolio",
"categoryLabel": "Category",

View File

@@ -1,5 +1,5 @@
import createMiddleware from 'next-intl/middleware';
import { NextRequest } from 'next/server';
import { NextRequest, NextResponse } from 'next/server';
// Create the internationalization middleware
const intlMiddleware = createMiddleware({
@@ -19,10 +19,12 @@ export default function middleware(request: NextRequest) {
pathname.startsWith('/stats') ||
pathname.startsWith('/errors') ||
pathname.startsWith('/health') ||
pathname.startsWith('/api/og') ||
pathname.includes('opengraph-image')
pathname.includes('/api/og') ||
pathname.includes('opengraph-image') ||
pathname.endsWith('sitemap.xml') ||
pathname.endsWith('manifest.webmanifest')
) {
return;
return NextResponse.next();
}
// Build header object for logging
@@ -42,11 +44,8 @@ export default function middleware(request: NextRequest) {
// Prioritize x-forwarded-host (passed by Traefik) over the local Host header
const hostHeader =
headers.get('x-forwarded-host') || headers.get('host') || 'testing.klz-cables.com';
hostHeader.split(':');
urlObj.protocol = proto;
// urlObj.hostname = publicHostname; // Don't rewrite hostname yet as it breaks internal fetches in dev
// urlObj.port = ''; // DON'T clear internal port (3000) anymore
effectiveRequest = new NextRequest(urlObj, {
headers: request.headers,
@@ -55,13 +54,35 @@ export default function middleware(request: NextRequest) {
});
console.log(
`🛡️ Middleware: Fixed internal URL leak: ${url} -> ${urlObj.toString()} | Proto: ${proto} | Host: ${hostHeader}`,
`🛡️ Proxy: Fixed internal URL leak: ${url} -> ${urlObj.toString()} | Proto: ${proto} | Host: ${hostHeader}`,
);
}
try {
// Apply internationalization middleware
const response = intlMiddleware(effectiveRequest);
// Upgrade 307 (Temporary Redirect) to 308 (Permanent Redirect)
// This improves compatibility with scanners (Website Carbon, PageSpeed) and SEO.
if (response.status === 307) {
const location = response.headers.get('Location');
if (location) {
const url = new URL(location, request.url);
return Response.redirect(url, 308);
}
}
// Allow iframe embedding from recorder domains
const referer = headers.get('referer') || '';
const recorderDomains = ['recorder.localhost', 'recorder.mintel.me'];
const isRecorderRequest = recorderDomains.some((domain) => referer.includes(domain));
if (isRecorderRequest) {
response.headers.delete('x-frame-options');
response.headers.delete('content-security-policy');
response.headers.set('Access-Control-Allow-Origin', '*');
}
return response;
} catch (error) {
console.error(
@@ -73,6 +94,9 @@ export default function middleware(request: NextRequest) {
}
export const config = {
// Match only internationalized pathnames
matcher: ['/((?!api|_next|_vercel|.*\\..*).*)', '/', '/(de|en)/:path*'],
matcher: [
'/((?!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*',
],
};

View File

@@ -1,10 +1,25 @@
import createNextIntlPlugin from 'next-intl/plugin';
import withMintelConfig from '@mintel/next-config';
import withBundleAnalyzer from '@next/bundle-analyzer';
import { withSentryConfig } from '@sentry/nextjs';
const withNextIntl = createNextIntlPlugin();
console.log('🚀 NEXT CONFIG LOADED FROM:', process.cwd());
/** @type {import('next').NextConfig} */
const nextConfig = {
onDemandEntries: {
// Make sure entries are not disposed too quickly
maxInactiveAge: 60 * 1000,
},
experimental: {
optimizeCss: true,
optimizePackageImports: ['lucide-react', 'framer-motion', '@/components/ui'],
},
productionBrowserSourceMaps: false,
logging: {
fetches: {
fullUrl: true,
},
},
output: 'standalone',
async redirects() {
return [
@@ -309,16 +324,10 @@ const nextConfig = {
];
},
images: {
remotePatterns: [
{
protocol: 'https',
hostname: 'klz-cables.com',
port: '',
pathname: '/wp-content/uploads/**',
},
],
loader: 'custom',
loaderFile: './lib/imgproxy-loader.ts',
dangerouslyAllowSVG: true,
contentDispositionType: 'attachment',
contentDispositionType: "attachment",
contentSecurityPolicy: "default-src 'self'; script-src 'none'; sandbox;",
},
async rewrites() {
@@ -333,15 +342,28 @@ const nextConfig = {
const directusUrl = process.env.INTERNAL_DIRECTUS_URL || process.env.DIRECTUS_URL || 'https://cms.klz-cables.com';
let imgproxyUrl = process.env.IMGPROXY_URL || 'https://img.infra.mintel.me';
if (!imgproxyUrl.startsWith('http')) {
imgproxyUrl = `https://${imgproxyUrl}`;
}
return [
{
source: '/cms/:path*',
destination: `${directusUrl}/:path*`,
},
{
source: '/_img/:path*',
destination: `${imgproxyUrl}/:path*`,
},
];
},
};
const nextIntlConfig = withNextIntl(nextConfig);
const withAnalyzer = withBundleAnalyzer({
enabled: process.env.ANALYZE === 'true',
});
export default withMintelConfig(nextIntlConfig);
export default withAnalyzer(withMintelConfig(nextConfig, {
hideSourceMaps: true,
}));

View File

@@ -4,13 +4,15 @@
"private": true,
"dependencies": {
"@directus/sdk": "^21.0.0",
"@mintel/mail": "1.7.12",
"@mintel/next-config": "1.7.12",
"@mintel/next-feedback": "1.7.12",
"@medv/finder": "^4.0.2",
"@mintel/mail": "1.8.3",
"@mintel/next-config": "1.8.3",
"@mintel/next-feedback": "1.8.10",
"@mintel/next-utils": "^1.7.15",
"@react-email/components": "^1.0.7",
"@react-pdf/renderer": "^4.3.2",
"@sentry/nextjs": "^10.38.0",
"@types/recharts": "^2.0.1",
"axios": "^1.13.5",
"clsx": "^2.1.1",
"framer-motion": "^12.34.0",
@@ -19,7 +21,6 @@
"import-in-the-middle": "^1.11.0",
"jsdom": "^27.4.0",
"leaflet": "^1.9.4",
"lucide-react": "^0.562.0",
"next": "16.1.6",
"next-i18next": "^15.4.3",
"next-intl": "^4.8.2",
@@ -32,6 +33,7 @@
"react-dom": "^19.2.4",
"react-email": "^5.2.5",
"react-leaflet": "^4.2.1",
"recharts": "^3.7.0",
"require-in-the-middle": "^8.0.1",
"resend": "^3.5.0",
"schema-dts": "^1.1.5",
@@ -45,8 +47,13 @@
"@commitlint/cli": "^20.4.0",
"@commitlint/config-conventional": "^20.4.0",
"@lhci/cli": "^0.15.1",
"@mintel/eslint-config": "1.7.12",
"@mintel/tsconfig": "1.7.12",
"@mintel/eslint-config": "1.8.3",
"@mintel/tsconfig": "1.8.3",
"@next/bundle-analyzer": "^16.1.6",
"@remotion/cli": "^4.0.421",
"@remotion/google-fonts": "^4.0.421",
"@remotion/player": "^4.0.421",
"@remotion/renderer": "^4.0.421",
"@tailwindcss/cli": "^4.1.18",
"@tailwindcss/postcss": "^4.1.18",
"@types/geojson": "^7946.0.16",
@@ -59,20 +66,28 @@
"@vitejs/plugin-react": "^5.1.4",
"@vitest/ui": "^4.0.16",
"autoprefixer": "^10.4.23",
"cheerio": "^1.2.0",
"critters": "^0.0.25",
"eslint": "^9.18.0",
"happy-dom": "^20.6.1",
"husky": "^9.1.7",
"lint-staged": "^16.2.7",
"lucide-react": "^0.563.0",
"pa11y-ci": "^4.0.1",
"postcss": "^8.5.6",
"prettier": "^3.8.1",
"puppeteer": "^24.37.3",
"remotion": "^4.0.421",
"sass": "^1.97.1",
"start-server-and-test": "^2.1.3",
"tailwindcss": "^4.1.18",
"tsx": "^4.21.0",
"typescript": "^5.7.2",
"vitest": "^4.0.16"
},
"scripts": {
"dev": "docker network create infra 2>/dev/null || true && echo '\\n🚀 Development Environment Starting...\\n\\n📱 App: http://klz.localhost\\n🗄 CMS: http://cms.klz.localhost/admin\\n🚦 Traefik: http://localhost:8080\\n\\n(Press Ctrl+C to stop)\\n' && docker-compose down --remove-orphans && docker-compose up klz-app directus directus-db gatekeeper",
"dev": "docker network create infra 2>/dev/null || true && echo '\\n🚀 Development Environment Starting...\\n\\n📱 App (Next.js): http://localhost:3000\\n📱 App (Traefik): http://klz.localhost\\n🗄 CMS: http://cms.klz.localhost/admin\\n🚦 Traefik: http://localhost:8080\\n\\n(Press Ctrl+C to stop)\\n' && docker-compose down --remove-orphans && docker-compose up --build klz-app klz-cms klz-db klz-gatekeeper",
"dev:infra": "docker network create infra 2>/dev/null || true && docker-compose up -d klz-cms klz-db klz-gatekeeper",
"dev:local": "next dev",
"build": "next build",
"start": "next start",
@@ -81,7 +96,10 @@
"test": "vitest run --passWithNoTests",
"test:og": "vitest run tests/og-image.test.ts",
"check:og": "tsx scripts/check-og-images.ts",
"cms:branding:local": "DIRECTUS_URL=http://localhost:8055 npx tsx --env-file=.env scripts/setup-directus-branding.ts",
"check:mdx": "node scripts/validate-mdx.mjs",
"check:a11y": "start-server-and-test start http://localhost:3000 'pa11y-ci'",
"check:wcag": "tsx ./scripts/wcag-sitemap.ts",
"cms:branding:local": "DIRECTUS_URL=${DIRECTUS_URL:-http://cms.klz.localhost} npx tsx --env-file=.env scripts/setup-directus-branding.ts",
"cms:branding:testing": "DIRECTUS_URL=https://cms.testing.klz-cables.com npx tsx --env-file=.env scripts/setup-directus-branding.ts",
"cms:branding:staging": "DIRECTUS_URL=https://cms.staging.klz-cables.com npx tsx --env-file=.env scripts/setup-directus-branding.ts",
"cms:branding:prod": "DIRECTUS_URL=https://cms.klz-cables.com npx tsx --env-file=.env scripts/setup-directus-branding.ts",
@@ -100,7 +118,10 @@
"cms:push:testing:DANGER": "./scripts/sync-directus.sh push testing",
"cms:push:prod:DANGER": "./scripts/sync-directus.sh push production",
"pagespeed:test": "tsx ./scripts/pagespeed-sitemap.ts",
"pagespeed:audit": "./scripts/audit-local.sh",
"pagespeed:urls": "tsx -e \"import sitemap from './app/sitemap'; sitemap().then(urls => console.log(urls.map(u => u.url).join('\\n')))\"",
"remotion:render": "remotion render WebsiteVideo remotion/index.ts out.mp4",
"remotion:preview": "remotion preview remotion/index.ts",
"prepare": "husky",
"preinstall": "npx only-allow pnpm"
},
@@ -109,5 +130,18 @@
"overrides": {
"next": "16.1.6"
}
},
"browserslist": [
"last 3 versions",
"not dead",
"not ie 11"
],
"peerDependencies": {
"@remotion/cli": "^4.0.421",
"@remotion/google-fonts": "^4.0.421",
"@remotion/player": "^4.0.421",
"@remotion/renderer": "^4.0.421",
"lucide-react": "^0.563.0",
"remotion": "^4.0.421"
}
}

2078
pnpm-lock.yaml generated

File diff suppressed because it is too large Load Diff

Binary file not shown.

After

Width:  |  Height:  |  Size: 316 KiB

Some files were not shown because too many files have changed in this diff Show More