Compare commits

...

39 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
75 changed files with 3726 additions and 1560 deletions

View File

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

View File

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

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

5
.gitignore vendored
View File

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

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

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

View File

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

View File

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

View File

@@ -1,15 +1,16 @@
import Footer from '@/components/Footer'; import Footer from '@/components/Footer';
import Header from '@/components/Header'; import Header from '@/components/Header';
import JsonLd from '@/components/JsonLd'; import JsonLd from '@/components/JsonLd';
import AnalyticsProvider from '@/components/analytics/AnalyticsProvider'; import SkipLink from '@/components/SkipLink';
import ScrollDepthTracker from '@/components/analytics/ScrollDepthTracker';
import CMSConnectivityNotice from '@/components/CMSConnectivityNotice'; import CMSConnectivityNotice from '@/components/CMSConnectivityNotice';
import { RecordModeProvider } from '@/components/record-mode/RecordModeContext'; import { RecordModeProvider } from '@/components/record-mode/RecordModeContext';
import { RecordModeVisuals } from '@/components/record-mode/RecordModeVisuals'; import { RecordModeVisuals } from '@/components/record-mode/RecordModeVisuals';
import { ToolCoordinator } from '@/components/record-mode/ToolCoordinator'; import { ToolCoordinator } from '@/components/record-mode/ToolCoordinator';
import AnalyticsShell from '@/components/analytics/AnalyticsShell';
import { Metadata, Viewport } from 'next'; import { Metadata, Viewport } from 'next';
import { NextIntlClientProvider } from 'next-intl'; import { NextIntlClientProvider } from 'next-intl';
import { getMessages } from 'next-intl/server'; import { getMessages } from 'next-intl/server';
import dynamic from 'next/dynamic';
import { Suspense } from 'react'; import { Suspense } from 'react';
import '../../styles/globals.css'; import '../../styles/globals.css';
import { SITE_URL } from '@/lib/schema'; import { SITE_URL } from '@/lib/schema';
@@ -23,22 +24,37 @@ const inter = Inter({
variable: '--font-inter', variable: '--font-inter',
}); });
export const metadata: Metadata = { export async function generateMetadata(props: {
metadataBase: new URL(SITE_URL), params: Promise<{ locale: string }>;
icons: { }): Promise<Metadata> {
icon: [ const params = await props.params;
{ url: '/favicon.ico', sizes: 'any' }, const { locale } = params;
{ url: '/logo-blue.svg', type: 'image/svg+xml' },
], return {
apple: [{ url: '/apple-touch-icon.png', sizes: '180x180', type: 'image/png' }], metadataBase: new URL(SITE_URL),
}, manifest: '/manifest.webmanifest',
}; alternates: {
canonical: locale === 'en' ? '/' : `/${locale}`,
languages: {
de: '/de',
en: '/en',
},
},
icons: {
icon: [
{ url: '/favicon.ico', sizes: 'any' },
{ url: '/logo-blue.svg', type: 'image/svg+xml' },
],
apple: [{ url: '/apple-touch-icon.png', sizes: '180x180', type: 'image/png' }],
},
};
}
export const viewport: Viewport = { export const viewport: Viewport = {
width: 'device-width', width: 'device-width',
initialScale: 1, initialScale: 1,
maximumScale: 1, maximumScale: 5,
userScalable: false, userScalable: true,
viewportFit: 'cover', viewportFit: 'cover',
themeColor: '#001a4d', themeColor: '#001a4d',
}; };
@@ -56,7 +72,7 @@ export default async function Layout(props: {
setRequestLocale(safeLocale); setRequestLocale(safeLocale);
let messages = {}; let messages: Record<string, any> = {};
try { try {
messages = await getMessages(); messages = await getMessages();
} catch (error) { } catch (error) {
@@ -64,6 +80,15 @@ export default async function Layout(props: {
messages = {}; messages = {};
} }
// 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 { getServerAppServices } = await import('@/lib/services/create-services.server');
const serverServices = getServerAppServices(); const serverServices = getServerAppServices();
@@ -80,7 +105,8 @@ export default async function Layout(props: {
}); });
} }
serverServices.analytics.trackPageview(); // Server-side analytics tracking removed to prevent duplicate/empty events.
// Client-side AnalyticsProvider handles all pageviews.
} catch { } catch {
if (process.env.NODE_ENV !== 'production' || !process.env.CI) { if (process.env.NODE_ENV !== 'production' || !process.env.CI) {
console.warn( console.warn(
@@ -95,23 +121,31 @@ export default async function Layout(props: {
return ( return (
<html lang={safeLocale} className={`scroll-smooth overflow-x-hidden ${inter.variable}`}> <html lang={safeLocale} className={`scroll-smooth overflow-x-hidden ${inter.variable}`}>
<head></head> <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"> <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}> <NextIntlClientProvider messages={clientMessages} locale={safeLocale}>
<RecordModeProvider isEnabled={recordModeEnabled}> <RecordModeProvider isEnabled={recordModeEnabled}>
<RecordModeVisuals> <RecordModeVisuals>
<SkipLink />
<JsonLd /> <JsonLd />
<Header /> <Header />
<main className="flex-grow animate-fade-in overflow-visible">{children}</main> <main
id="main-content"
className="flex-grow animate-fade-in overflow-visible"
tabIndex={-1}
>
{children}
</main>
<Footer /> <Footer />
</RecordModeVisuals> </RecordModeVisuals>
<CMSConnectivityNotice /> <CMSConnectivityNotice />
<Suspense fallback={null}> <AnalyticsShell />
<AnalyticsProvider />
<ScrollDepthTracker />
</Suspense>
<ToolCoordinator feedbackEnabled={feedbackEnabled} /> <ToolCoordinator feedbackEnabled={feedbackEnabled} />
</RecordModeProvider> </RecordModeProvider>
</NextIntlClientProvider> </NextIntlClientProvider>

View File

@@ -1,11 +1,12 @@
import Hero from '@/components/home/Hero'; import Hero from '@/components/home/Hero';
import JsonLd from '@/components/JsonLd'; import JsonLd from '@/components/JsonLd';
import { getBreadcrumbSchema, SITE_URL } from '@/lib/schema'; 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 dynamic from 'next/dynamic';
import Reveal from '@/components/Reveal'; 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 RecentPosts = dynamic(() => import('@/components/home/RecentPosts'));
const Experience = dynamic(() => import('@/components/home/Experience')); const Experience = dynamic(() => import('@/components/home/Experience'));
const WhyChooseUs = dynamic(() => import('@/components/home/WhyChooseUs')); const WhyChooseUs = dynamic(() => import('@/components/home/WhyChooseUs'));
@@ -79,7 +80,9 @@ export async function generateMetadata({
} }
const title = t('title') || 'KLZ Cables'; const title = t('title') || 'KLZ Cables';
const description = t('description') || ''; const description =
t('description') ||
'Ihr Experte für hochwertige Stromkabel, Mittelspannungslösungen und Solarkabel. Zuverlässige Infrastruktur für eine grüne Energiezukunft.';
return { return {
title, title,

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -2,11 +2,11 @@
import Link from 'next/link'; import Link from 'next/link';
import Image from 'next/image'; import Image from 'next/image';
import { motion } from 'framer-motion'; import { m, LazyMotion, domAnimation } from 'framer-motion';
import { useTranslations } from 'next-intl'; import { useTranslations } from 'next-intl';
import { usePathname } from 'next/navigation'; import { usePathname } from 'next/navigation';
import { Button } from './ui'; import { Button } from './ui';
import { useEffect, useState } from 'react'; import { useEffect, useState, useRef } from 'react';
import { cn } from './ui'; import { cn } from './ui';
import { useAnalytics } from './analytics/useAnalytics'; import { useAnalytics } from './analytics/useAnalytics';
import { AnalyticsEvents } from './analytics/analytics-events'; import { AnalyticsEvents } from './analytics/analytics-events';
@@ -17,6 +17,8 @@ export default function Header() {
const { trackEvent } = useAnalytics(); const { trackEvent } = useAnalytics();
const [isScrolled, setIsScrolled] = useState(false); const [isScrolled, setIsScrolled] = useState(false);
const [isMobileMenuOpen, setIsMobileMenuOpen] = useState(false); const [isMobileMenuOpen, setIsMobileMenuOpen] = useState(false);
const mobileMenuRef = useRef<HTMLDivElement>(null);
const closeButtonRef = useRef<HTMLButtonElement>(null);
// Extract locale from pathname // Extract locale from pathname
const currentLocale = pathname.split('/')[1] || 'en'; const currentLocale = pathname.split('/')[1] || 'en';
@@ -34,9 +36,52 @@ export default function Header() {
}, []); }, []);
// Prevent scroll when mobile menu is open // Prevent scroll when mobile menu is open
// Prevent scroll when mobile menu is open and handle focus trap
useEffect(() => { useEffect(() => {
if (isMobileMenuOpen) { if (isMobileMenuOpen) {
document.body.style.overflow = 'hidden'; document.body.style.overflow = 'hidden';
// Focus trap logic
const focusableElements = mobileMenuRef.current?.querySelectorAll(
'button, [href], input, select, textarea, [tabindex]:not([tabindex="-1"])',
);
if (focusableElements && focusableElements.length > 0) {
const firstElement = focusableElements[0] as HTMLElement;
const lastElement = focusableElements[focusableElements.length - 1] as HTMLElement;
const handleTabKey = (e: KeyboardEvent) => {
if (e.key === 'Tab') {
if (e.shiftKey) {
if (document.activeElement === firstElement) {
e.preventDefault();
lastElement.focus();
}
} else {
if (document.activeElement === lastElement) {
e.preventDefault();
firstElement.focus();
}
}
}
};
const handleEscapeKey = (e: KeyboardEvent) => {
if (e.key === 'Escape') {
setIsMobileMenuOpen(false);
}
};
document.addEventListener('keydown', handleTabKey);
document.addEventListener('keydown', handleEscapeKey);
// Focus the first element when menu opens
setTimeout(() => firstElement.focus(), 100);
return () => {
document.removeEventListener('keydown', handleTabKey);
document.removeEventListener('keydown', handleEscapeKey);
};
}
} else { } else {
document.body.style.overflow = 'unset'; document.body.style.overflow = 'unset';
} }
@@ -69,55 +114,264 @@ export default function Header() {
return ( return (
<> <>
<motion.header <LazyMotion strict features={domAnimation}>
className={headerClass} <m.header
initial={{ y: -100, opacity: 0 }} className={headerClass}
animate={{ y: 0, opacity: 1 }} initial={{ y: -100, opacity: 0 }}
transition={{ duration: 0.8, ease: 'easeOut' }} 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 <div className="container mx-auto px-4 md:px-12 lg:px-16 max-w-7xl flex items-center justify-between">
className="flex-shrink-0 group touch-target" <m.div
initial={{ scale: 0.8, opacity: 0 }} className="flex-shrink-0 group touch-target"
animate={{ scale: 1, opacity: 1 }} initial={{ scale: 0.8, opacity: 0 }}
transition={{ duration: 0.6, ease: 'easeOut', delay: 0.1 }} 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 <Link
src={logoSrc} href={`/${currentLocale}`}
alt={t('home')} onClick={() =>
width={120} trackEvent(AnalyticsEvents.BUTTON_CLICK, {
height={120} target: 'home_logo',
className="h-10 md:h-14 w-auto transition-all duration-500 group-hover:scale-110" location: 'header',
priority })
/> }
</Link> >
</motion.div> <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>
<motion.div <m.div
className="flex items-center gap-4 md:gap-12" className="flex items-center gap-4 md:gap-12"
initial="hidden" initial="hidden"
animate="visible" animate="visible"
variants={{ variants={{
visible: { visible: {
transition: { transition: {
staggerChildren: 0.08, staggerChildren: 0.08,
delayChildren: 0.3, 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',
});
}}
>
<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 }}
>
{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>
{/* 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}
> >
<motion.nav className="hidden lg:flex items-center space-x-10" variants={navVariants}> <m.nav
{menuItems.map((item, _idx) => ( className="flex-grow flex flex-col justify-center items-center p-8 space-y-8"
<motion.div key={item.href} variants={navLinkVariants}> initial="closed"
animate={isMobileMenuOpen ? 'open' : 'closed'}
variants={{
open: {
transition: {
staggerChildren: 0.1,
delayChildren: 0.2,
},
},
}}
>
{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 <Link
href={`/${currentLocale}${item.href === '/' ? '' : item.href}`} href={`/${currentLocale}${item.href === '/' ? '' : item.href}`}
onClick={() => { onClick={() => {
@@ -125,295 +379,95 @@ export default function Header() {
trackEvent(AnalyticsEvents.LINK_CLICK, { trackEvent(AnalyticsEvents.LINK_CLICK, {
label: item.label, label: item.label,
href: item.href, href: item.href,
location: 'header_nav', location: 'mobile_menu',
}); });
}} }}
className={cn( className="text-2xl md:text-3xl font-extrabold text-white hover:text-accent transition-all transform block py-4"
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} {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> </Link>
</motion.div> </m.div>
))} ))}
</motion.nav>
<motion.div <m.div
className={cn('hidden lg:flex items-center space-x-8', textColorClass)} className="pt-8 border-t border-white/10 w-full flex flex-col items-center space-y-8"
variants={headerRightVariants} initial={{ opacity: 0, y: 30 }}
> animate={isMobileMenuOpen ? { opacity: 1, y: 0 } : { opacity: 0, y: 30 }}
<motion.div transition={{ duration: 0.5, delay: 0.8 }}
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 <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 }} initial={{ opacity: 0, scale: 0.8 }}
animate={{ opacity: 1, scale: 1 }} animate={{ opacity: 1, scale: 1 }}
transition={{ duration: 0.4, delay: 0.65 }} transition={{ duration: 0.4, delay: 0.9 }}
> >
<Link <m.div
href={getPathForLocale('en')} initial={{ opacity: 0 }}
onClick={() => animate={{ opacity: 1 }}
trackEvent(AnalyticsEvents.TOGGLE_SWITCH, { transition={{ duration: 0.3, delay: 1.0 }}
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
</Link> href={getPathForLocale('en')}
</motion.div> className={`hover:text-accent transition-colors ${currentLocale === 'en' ? 'text-accent' : 'opacity-60'}`}
<motion.div >
className="w-px h-4 bg-current opacity-20" EN
initial={{ scaleY: 0 }} </Link>
animate={{ scaleY: 1 }} </m.div>
transition={{ duration: 0.4, delay: 0.7 }} <m.div
/> className="w-px h-6 bg-white/20"
<motion.div initial={{ scaleX: 0 }}
initial={{ opacity: 0, scale: 0.8 }} animate={{ scaleX: 1 }}
animate={{ opacity: 1, scale: 1 }} transition={{ duration: 0.4, delay: 1.05 }}
transition={{ duration: 0.4, delay: 0.75 }} />
> <m.div
<Link initial={{ opacity: 0 }}
href={getPathForLocale('de')} animate={{ opacity: 1 }}
onClick={() => transition={{ duration: 0.3, delay: 1.1 }}
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
</Link> href={getPathForLocale('de')}
</motion.div> className={`hover:text-accent transition-colors ${currentLocale === 'de' ? 'text-accent' : 'opacity-60'}`}
</motion.div> >
DE
</Link>
</m.div>
</m.div>
<motion.div <m.div
initial={{ scale: 0.9, opacity: 0 }} initial={{ scale: 0.9, opacity: 0, y: 20 }}
animate={{ scale: 1, opacity: 1 }} animate={{ scale: 1, opacity: 1, y: 0 }}
transition={{ duration: 0.6, type: 'spring', stiffness: 400, delay: 0.7 }} transition={{ type: 'spring', stiffness: 400, damping: 20, delay: 1.2 }}
>
<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
</Button> href={`/${currentLocale}/contact`}
</motion.div> variant="accent"
</motion.div> size="lg"
className="w-full max-w-xs py-6 text-lg md:text-xl shadow-2xl"
>
{t('contact')}
</Button>
</m.div>
</m.div>
{/* Mobile Menu Button */} {/* Bottom Branding */}
<motion.button <m.div
className={cn( className="p-12 flex justify-center opacity-20"
'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={() => {
const newState = !isMobileMenuOpen;
setIsMobileMenuOpen(newState);
trackEvent(AnalyticsEvents.BUTTON_CLICK, {
type: 'mobile_menu',
action: newState ? 'open' : 'close',
});
}}
>
<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',
)}
>
<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,
},
},
}}
>
<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>
</motion.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 }}
>
<motion.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 }} initial={{ opacity: 0, scale: 0.8 }}
animate={{ opacity: 1, scale: 1 }} animate={isMobileMenuOpen ? { opacity: 0.2, scale: 1 } : { opacity: 0, scale: 0.8 }}
transition={{ duration: 0.4, delay: 0.9 }} transition={{ duration: 0.5, delay: 1.4 }}
> >
<motion.div <m.div
initial={{ opacity: 0 }} initial={{ scale: 0.5 }}
animate={{ opacity: 1 }} animate={{ scale: 1 }}
transition={{ duration: 0.3, delay: 1.0 }} transition={{ type: 'spring', stiffness: 300, delay: 1.5 }}
> >
<Link <Image src="/logo-white.svg" alt={t('home')} width={80} height={80} unoptimized />
href={getPathForLocale('en')} </m.div>
className={`hover:text-accent transition-colors ${currentLocale === 'en' ? 'text-accent' : 'opacity-60'}`} </m.div>
> </m.nav>
EN </div>
</Link> </m.header>
</motion.div> </LazyMotion>
<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>
</> </>
); );
} }

View File

@@ -1,9 +1,9 @@
'use client'; 'use client';
import React, { useEffect, useState, useCallback } from 'react'; import React, { useEffect, useState, useCallback, useRef } from 'react';
import Image from 'next/image'; import Image from 'next/image';
import { createPortal } from 'react-dom'; import { createPortal } from 'react-dom';
import { motion, AnimatePresence } from 'framer-motion'; import { m, LazyMotion, domAnimation, AnimatePresence } from 'framer-motion';
import { useRouter, useSearchParams, usePathname } from 'next/navigation'; import { useRouter, useSearchParams, usePathname } from 'next/navigation';
interface LightboxProps { interface LightboxProps {
@@ -19,6 +19,8 @@ export default function Lightbox({ isOpen, images, initialIndex, onClose }: Ligh
const pathname = usePathname(); const pathname = usePathname();
const [currentIndex, setCurrentIndex] = useState(initialIndex); const [currentIndex, setCurrentIndex] = useState(initialIndex);
const [mounted, setMounted] = useState(false); const [mounted, setMounted] = useState(false);
const closeButtonRef = useRef<HTMLButtonElement>(null);
const previousFocusRef = useRef<HTMLElement | null>(null);
useEffect(() => { useEffect(() => {
setMounted(true); // eslint-disable-line react-hooks/set-state-in-effect setMounted(true); // eslint-disable-line react-hooks/set-state-in-effect
@@ -76,12 +78,50 @@ export default function Lightbox({ isOpen, images, initialIndex, onClose }: Ligh
}, [isOpen, currentIndex, updateUrl]); }, [isOpen, currentIndex, updateUrl]);
useEffect(() => { useEffect(() => {
if (!isOpen) return; if (!isOpen) {
if (previousFocusRef.current) {
previousFocusRef.current.focus();
}
return;
}
// Capture previous focus
previousFocusRef.current = document.activeElement as HTMLElement;
// Focus close button on open
setTimeout(() => closeButtonRef.current?.focus(), 100);
const handleKeyDown = (e: KeyboardEvent) => { const handleKeyDown = (e: KeyboardEvent) => {
if (e.key === 'Escape') handleClose(); if (e.key === 'Escape') handleClose();
if (e.key === 'ArrowLeft') prevImage(); if (e.key === 'ArrowLeft') prevImage();
if (e.key === 'ArrowRight') nextImage(); if (e.key === 'ArrowRight') nextImage();
// Focus Trap
if (e.key === 'Tab') {
const focusableElements = document.querySelectorAll(
'button, [href], input, select, textarea, [tabindex]:not([tabindex="-1"])',
);
const modalElements = Array.from(focusableElements).filter((el) =>
document.querySelector('[role="dialog"]')?.contains(el),
);
if (modalElements.length === 0) return;
const firstElement = modalElements[0] as HTMLElement;
const lastElement = modalElements[modalElements.length - 1] as HTMLElement;
if (e.shiftKey) {
if (document.activeElement === firstElement) {
lastElement.focus();
e.preventDefault();
}
} else {
if (document.activeElement === lastElement) {
firstElement.focus();
e.preventDefault();
}
}
}
}; };
// Lock scroll // Lock scroll
@@ -99,113 +139,120 @@ export default function Lightbox({ isOpen, images, initialIndex, onClose }: Ligh
if (!mounted) return null; if (!mounted) return null;
return createPortal( return createPortal(
<AnimatePresence> <LazyMotion strict features={domAnimation}>
{isOpen && ( <AnimatePresence>
<div className="fixed inset-0 z-[99999] flex items-center justify-center"> {isOpen && (
<motion.div <div
initial={{ opacity: 0 }} className="fixed inset-0 z-[99999] flex items-center justify-center"
animate={{ opacity: 1 }} role="dialog"
exit={{ opacity: 0 }} aria-modal="true"
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"
> >
<div className="relative w-full h-full flex items-center justify-center group-hover:rotate-90 transition-transform duration-500"> <m.div
<span className="text-3xl font-extralight leading-none mb-1">×</span> initial={{ opacity: 0 }}
</div> animate={{ opacity: 1 }}
</motion.button> exit={{ opacity: 0 }}
transition={{ duration: 0.5 }}
className="absolute inset-0 bg-primary/95 backdrop-blur-xl"
onClick={handleClose}
/>
<motion.button <m.button
initial={{ opacity: 0, x: -20 }} initial={{ opacity: 0, scale: 0.5 }}
animate={{ opacity: 1, x: 0 }} animate={{ opacity: 1, scale: 1 }}
exit={{ opacity: 0, x: -20 }} exit={{ opacity: 0, scale: 0.5 }}
transition={{ delay: 0.2, duration: 0.4 }} transition={{ delay: 0.1, duration: 0.4 }}
onClick={prevImage} ref={closeButtonRef}
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" onClick={handleClose}
aria-label="Previous image" 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"
<span className="text-4xl font-extralight group-hover:-translate-x-1 transition-transform duration-500"> >
<div className="relative w-full h-full flex items-center justify-center group-hover:rotate-90 transition-transform duration-500">
</span> <span className="text-3xl font-extralight leading-none mb-1">×</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" />
</div> </div>
</m.button>
<motion.div <m.button
initial={{ opacity: 0, y: 10 }} initial={{ opacity: 0, x: -20 }}
animate={{ opacity: 1, y: 0 }} animate={{ opacity: 1, x: 0 }}
exit={{ opacity: 0, y: 10 }} exit={{ opacity: 0, x: -20 }}
transition={{ delay: 0.3, duration: 0.4 }} transition={{ delay: 0.2, duration: 0.4 }}
className="mt-8 flex items-center gap-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"
<div className="h-px w-12 bg-white/20" /> aria-label="Previous image"
<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} <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>
<div className="h-px w-12 bg-white/20" />
</motion.div> <m.div
</div> initial={{ opacity: 0, y: 10 }}
</motion.div> animate={{ opacity: 1, y: 0 }}
</div> exit={{ opacity: 0, y: 10 }}
)} transition={{ delay: 0.3, duration: 0.4 }}
</AnimatePresence>, 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, document.body,
); );
} }

View File

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

View File

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

View File

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

View File

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

View File

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

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

View File

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

View File

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

View File

@@ -2,7 +2,7 @@
import Scribble from '@/components/Scribble'; import Scribble from '@/components/Scribble';
import { Button, Container, Heading, Section } from '@/components/ui'; import { Button, Container, Heading, Section } from '@/components/ui';
import { motion } from 'framer-motion'; import { m, LazyMotion, domAnimation } from 'framer-motion';
import { useTranslations, useLocale } from 'next-intl'; import { useTranslations, useLocale } from 'next-intl';
import dynamic from 'next/dynamic'; import dynamic from 'next/dynamic';
import { useAnalytics } from '../analytics/useAnalytics'; import { useAnalytics } from '../analytics/useAnalytics';
@@ -16,111 +16,113 @@ export default function Hero() {
return ( 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"> <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"> <LazyMotion strict features={domAnimation}>
<motion.div <Container className="relative z-10 text-center md:text-left text-white w-full order-2 md:order-none">
className="max-w-5xl mx-auto md:mx-0" <m.div
initial="hidden" className="max-w-5xl mx-auto md:mx-0"
animate="visible" initial="hidden"
variants={containerVariants} 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}
> >
<motion.div variants={buttonVariants}> <m.div variants={headingVariants}>
<Button <Heading
href="/contact" level={1}
variant="accent" 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]"
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')} {t.rich('title', {
<span className="transition-transform group-hover/btn:translate-x-1">&rarr;</span> green: (chunks) => (
</Button> <span className="relative inline-block">
</motion.div> <m.span
<motion.div variants={buttonVariants}> className="relative z-10 text-accent italic"
<Button variants={accentVariants}
href={`/${locale}/${locale === 'de' ? 'produkte' : 'products'}`} >
variant="white" {chunks}
size="lg" </m.span>
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" <m.div
onClick={() => variants={scribbleVariants}
trackEvent(AnalyticsEvents.BUTTON_CLICK, { className="w-[140%] h-[140%] -top-[20%] -left-[20%] text-accent/30 hidden md:block absolute -z-10"
label: t('exploreProducts'), >
location: 'home_hero_secondary', <Scribble variant="circle" />
}) </m.div>
} </span>
> ),
{t('exploreProducts')} })}
</Button> </Heading>
</motion.div> </m.div>
</motion.div> <m.div variants={subtitleVariants}>
</motion.div> <p className="text-lg md:text-xl text-white/90 leading-relaxed max-w-2xl mb-10 md:mb-12">
</Container> {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 <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" 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)' }} initial={{ opacity: 0, scale: 0.95, filter: 'blur(20px)' }}
animate={{ opacity: 1, scale: 1, filter: 'blur(0px)' }} animate={{ opacity: 1, scale: 1, filter: 'blur(0px)' }}
transition={{ duration: 2.2, ease: 'easeOut', delay: 0.05 }} transition={{ duration: 2.2, ease: 'easeOut', delay: 0.05 }}
> >
<HeroIllustration /> <HeroIllustration />
</motion.div> </m.div>
<motion.div <m.div
className="absolute bottom-6 md:bottom-10 left-1/2 -translate-x-1/2 hidden sm:block" className="absolute bottom-6 md:bottom-10 left-1/2 -translate-x-1/2 hidden sm:block"
initial={{ opacity: 0, y: 16 }} initial={{ opacity: 0, y: 16 }}
animate={{ opacity: 1, y: 0 }} animate={{ opacity: 1, y: 0 }}
transition={{ duration: 1, ease: 'easeOut', delay: 3 }} 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"> <div className="w-6 h-10 border-2 border-white/30 rounded-full flex justify-center p-1">
<motion.div <m.div
className="w-1 h-2 bg-white rounded-full" className="w-1 h-2 bg-white rounded-full"
animate={{ y: [0, -10, 0] }} animate={{ y: [0, -10, 0] }}
transition={{ transition={{
duration: 1.5, duration: 1.5,
repeat: Infinity, repeat: Infinity,
ease: 'easeInOut', ease: 'easeInOut',
}} }}
/> />
</div> </div>
</motion.div> </m.div>
</LazyMotion>
</Section> </Section>
); );
} }
@@ -130,19 +132,19 @@ const containerVariants = {
visible: { visible: {
opacity: 1, opacity: 1,
transition: { transition: {
staggerChildren: 0.12, staggerChildren: 0.1,
delayChildren: 0.4, delayChildren: 0.1,
}, },
}, },
} as const; } as const;
const headingVariants = { const headingVariants = {
hidden: { opacity: 0, y: 60, scale: 0.85 }, hidden: { opacity: 1, y: 10, scale: 0.98 },
visible: { visible: {
opacity: 1, opacity: 1,
y: 0, y: 0,
scale: 1, scale: 1,
transition: { duration: 1.2, ease: [0.25, 0.46, 0.45, 0.94] }, transition: { duration: 0.4, ease: [0.25, 0.46, 0.45, 0.94] },
}, },
} as const; } as const;
@@ -167,17 +169,17 @@ const scribbleVariants = {
} as const; } as const;
const subtitleVariants = { const subtitleVariants = {
hidden: { opacity: 0, y: 40, scale: 0.95 }, hidden: { opacity: 1, y: 20, scale: 0.98 },
visible: { visible: {
opacity: 1, opacity: 1,
y: 0, y: 0,
scale: 1, 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; } as const;
const buttonContainerVariants = { const buttonContainerVariants = {
hidden: { opacity: 0 }, hidden: { opacity: 1 },
visible: { visible: {
opacity: 1, opacity: 1,
transition: { transition: {
@@ -188,7 +190,7 @@ const buttonContainerVariants = {
} as const; } as const;
const buttonVariants = { const buttonVariants = {
hidden: { opacity: 0, y: 30, scale: 0.9 }, hidden: { opacity: 1, y: 30, scale: 0.9 },
visible: { visible: {
opacity: 1, opacity: 1,
y: 0, y: 0,

View File

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

View File

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

View File

@@ -1,30 +1,55 @@
'use client';
import React, { useState, useEffect, useRef } from 'react';
import Scribble from '@/components/Scribble'; import Scribble from '@/components/Scribble';
import { useTranslations } from 'next-intl'; import { useTranslations } from 'next-intl';
export default function VideoSection() { export default function VideoSection() {
const t = useTranslations('Home.video'); 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 ( return (
<section className="relative h-[70vh] overflow-hidden bg-primary"> <section ref={sectionRef} className="relative h-[70vh] overflow-hidden bg-primary">
<video {isVisible && (
className="w-full h-full object-cover opacity-60" <video className="w-full h-full object-cover opacity-60" autoPlay muted loop playsInline>
autoPlay <source
muted src="/uploads/2024/12/making-of-metal-cable-on-factory-2023-11-27-04-55-16-utc-2.webm"
loop type="video/webm"
playsInline />
> </video>
<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 pointer-events-none">
<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 pointer-events-auto">
<div className="max-w-5xl px-6 text-center animate-slide-up">
<h2 className="text-3xl md:text-4xl lg:text-5xl font-extrabold text-white leading-[1.1]"> <h2 className="text-3xl md:text-4xl lg:text-5xl font-extrabold text-white leading-[1.1]">
{t.rich('title', { {t.rich('title', {
future: (chunks) => ( future: (chunks) => (
<span className="relative inline-block mx-2"> <span className="relative inline-block mx-2">
<span className="relative z-10 italic text-accent">{chunks}</span> <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> </span>
) ),
})} })}
</h2> </h2>
</div> </div>

View File

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

View File

@@ -1,7 +1,7 @@
'use client'; 'use client';
import React, { useEffect, useState } from 'react'; import React, { useEffect, useState } from 'react';
import { motion, AnimatePresence } from 'framer-motion'; import { m, LazyMotion, domAnimation, AnimatePresence } from 'framer-motion';
import { useRecordMode } from './RecordModeContext'; import { useRecordMode } from './RecordModeContext';
export function PlaybackCursor() { export function PlaybackCursor() {
@@ -24,67 +24,69 @@ export function PlaybackCursor() {
if (!isPlaying) return null; if (!isPlaying) return null;
return ( return (
<motion.div <LazyMotion strict features={domAnimation}>
className="fixed z-[10000] pointer-events-none" <m.div
animate={{ className="fixed z-[10000] pointer-events-none"
x: cursorPosition.x, animate={{
y: cursorPosition.y, x: cursorPosition.x,
scale: isClicking ? 0.8 : 1, y: cursorPosition.y,
rotateX: isClicking ? 15 : 0, scale: isClicking ? 0.8 : 1,
rotateY: isClicking ? -15 : 0, rotateX: isClicking ? 15 : 0,
}} rotateY: isClicking ? -15 : 0,
transition={{ }}
x: { type: 'spring', damping: 30, stiffness: 250, mass: 0.5 }, transition={{
y: { type: 'spring', damping: 30, stiffness: 250, mass: 0.5 }, x: { type: 'spring', damping: 30, stiffness: 250, mass: 0.5 },
scale: { type: 'spring', damping: 15, stiffness: 400 }, y: { type: 'spring', damping: 30, stiffness: 250, mass: 0.5 },
rotateX: { type: 'spring', damping: 15, stiffness: 400 }, scale: { type: 'spring', damping: 15, stiffness: 400 },
rotateY: { type: 'spring', damping: 15, stiffness: 400 }, rotateX: { type: 'spring', damping: 15, stiffness: 400 },
}} rotateY: { type: 'spring', damping: 15, stiffness: 400 },
style={{ perspective: '1000px' }} }}
> style={{ perspective: '1000px' }}
<AnimatePresence> >
{isClicking && ( <AnimatePresence>
<motion.div {isClicking && (
initial={{ scale: 0.5, opacity: 0 }} <m.div
animate={{ scale: 2.5, opacity: 0 }} initial={{ scale: 0.5, opacity: 0 }}
exit={{ opacity: 0 }} animate={{ scale: 2.5, opacity: 0 }}
transition={{ duration: 0.4, ease: 'easeOut' }} exit={{ opacity: 0 }}
className="absolute inset-0 rounded-full border-2 border-[#82ed20] blur-[1px]" transition={{ duration: 0.4, ease: 'easeOut' }}
/> className="absolute inset-0 rounded-full border-2 border-[#82ed20] blur-[1px]"
)} />
</AnimatePresence> )}
</AnimatePresence>
{/* Outer Pulse Ring */} {/* 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 <div
className={`absolute -inset-2 bg-[#82ed20]/20 rounded-full blur-md transition-all ${isClicking ? 'bg-[#82ed20]/50 blur-xl' : ''}`} className={`absolute -inset-3 rounded-full bg-[#82ed20]/10 ${isClicking ? 'scale-150 opacity-0' : 'animate-ping'} transition-all duration-300`}
/> />
{/* Pointer Arrow */} {/* Visual Cursor */}
<svg <div className="relative">
width="24" {/* Soft Glow */}
height="24" <div
viewBox="0 0 24 24" className={`absolute -inset-2 bg-[#82ed20]/20 rounded-full blur-md transition-all ${isClicking ? 'bg-[#82ed20]/50 blur-xl' : ''}`}
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> {/* Pointer Arrow */}
</motion.div> <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

@@ -2,7 +2,7 @@
import React, { useState, useEffect } from 'react'; import React, { useState, useEffect } from 'react';
import { useRecordMode } from './RecordModeContext'; import { useRecordMode } from './RecordModeContext';
import { Reorder, AnimatePresence } from 'framer-motion'; import { Reorder, AnimatePresence, LazyMotion, domAnimation } from 'framer-motion';
import { import {
Play, Play,
Square, Square,
@@ -146,438 +146,460 @@ export function RecordModeOverlay() {
} }
return ( return (
<div className="fixed inset-0 z-[9998] pointer-events-none font-sans"> <LazyMotion strict features={domAnimation}>
{/* 1. Global Toolbar - Slim Industrial Bar */} <div className="fixed inset-0 z-[9998] pointer-events-none font-sans">
<div className="fixed bottom-6 left-1/2 -translate-x-1/2 z-[10000] pointer-events-auto"> {/* 1. Global Toolbar - Slim Industrial Bar */}
<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"> <div className="fixed bottom-6 left-1/2 -translate-x-1/2 z-[10000] pointer-events-auto">
{/* Identity Tag */} <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">
<div className="flex items-center gap-3 px-4 py-2 bg-white/5 rounded-[16px] border border-white/5 mx-1"> {/* Identity Tag */}
<div className="w-2 h-2 rounded-full bg-accent animate-pulse" /> <div className="flex items-center gap-3 px-4 py-2 bg-white/5 rounded-[16px] border border-white/5 mx-1">
<div className="flex flex-col"> <div className="w-2 h-2 rounded-full bg-accent animate-pulse" />
<span className="text-[10px] font-bold text-white uppercase tracking-wider leading-none"> <div className="flex flex-col">
Event Builder <span className="text-[10px] font-bold text-white uppercase tracking-wider leading-none">
</span> Event Builder
<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> </span>
)} <span className="text-[8px] text-white/30 uppercase tracking-widest mt-1">
</button> Manual Mode
</span>
</div>
</div>
<button <div className="w-px h-6 bg-white/10 mx-1" />
onClick={async () => {
const session = { events, name: 'Recording', createdAt: new Date().toISOString() }; {/* Action Tools */}
try { <div className="flex items-center gap-1">
const res = await fetch('/api/save-session', { <button
method: 'POST', onClick={() => {
headers: { 'Content-Type': 'application/json' }, setPickingMode('mouse');
body: JSON.stringify(session), setLastInteractionType('click');
}); }}
if (res.ok) { 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'}`}
// Visual feedback could be improved, but alert is fine for dev tool >
alert('Session saved to remotion/session.json'); <MousePointer2 size={16} />
} else { <span>Mouse</span>
const err = await res.json(); </button>
alert(`Failed to save: ${err.error}`);
} <button
} catch (e) { onClick={() => setPickingMode('scroll')}
console.error(e); 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'}`}
alert('Error saving session'); >
<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"
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} /> <Plus size={16} />
<span>Wait</span>
</button> </button>
</div> </div>
<Reorder.Group <div className="w-px h-6 bg-white/10 mx-1" />
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"> {/* Sequence Controls */}
<div className="flex items-center gap-2"> <div className="flex items-center gap-1 p-0.5">
<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 <button
onClick={() => setEditingEventId(null)} onClick={playEvents}
className="p-2 text-white/40 hover:text-white transition-colors" 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"
> >
<X size={20} /> <Play size={18} fill="currentColor" />
</button> </button>
</div>
<div className="flex-1 space-y-8 overflow-y-auto pr-2 scrollbar-hide"> <button
{/* Type Display */} onClick={() => setShowEvents(!showEvents)}
<div className="space-y-3"> 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'}`}
<label className="text-[10px] uppercase tracking-[0.2em] font-bold text-white/30 leading-none"> >
Interaction Type <Edit2 size={18} />
</label> {events.length > 0 && (
<div className="flex gap-2 p-1 bg-white/5 rounded-2xl border border-white/5"> <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">
<button {events.length}
onClick={() => </span>
setEditForm((prev) => ({ ...prev, type: 'mouse', interactionType: 'click' })) )}
</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}`);
} }
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'}`} } catch (e) {
> console.error(e);
<MousePointer2 size={14} /> alert('Error saving session');
<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" }}
/> 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> </div>
{/* Zoom & Effects */} <Reorder.Group
<div className="space-y-6"> axis="y"
<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"> values={events}
<div className="flex items-center gap-3"> onReorder={setEvents}
<Maximize2 size={18} className="text-white/40" /> className="flex-1 overflow-y-auto space-y-2 pr-2 scrollbar-hide"
<span className="text-xs font-bold text-white uppercase tracking-wider"> >
Zoom Shift {events.length === 0 ? (
</span> <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> </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 <input
type="number" type="range"
step="0.1" min="0"
min="1" max="5000"
max="3" step="100"
value={editForm.zoom || 1} value={editForm.duration || 1000}
onChange={(e) => onChange={(e) =>
setEditForm((prev) => ({ ...prev, zoom: parseFloat(e.target.value) })) setEditForm((prev) => ({ ...prev, duration: parseInt(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" className="w-full h-1 bg-white/10 rounded-lg appearance-none cursor-pointer accent-accent"
/> />
</div> </div>
<button {/* Zoom & Effects */}
onClick={() => setEditForm((prev) => ({ ...prev, motionBlur: !prev.motionBlur }))} <div className="space-y-6">
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 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">
<div className="flex items-center gap-3"> <Maximize2 size={18} className="text-white/40" />
<Box size={18} /> <span className="text-xs font-bold text-white uppercase tracking-wider">
<span className="text-xs font-bold uppercase tracking-wider">Motion Blur</span> 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> </div>
{editForm.motionBlur ? <Check size={18} /> : <div className="w-[18px]" />}
</button>
{editForm.type === 'mouse' && editForm.interactionType === 'click' && (
<button <button
onClick={() => setEditForm((prev) => ({ ...prev, realClick: !prev.realClick }))} onClick={() =>
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'}`} 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"> <div className="flex items-center gap-3">
<ExternalLink size={18} /> <Box size={18} />
<div className="flex flex-col items-start"> <span className="text-xs font-bold uppercase tracking-wider">
<span className="text-xs font-bold uppercase tracking-wider"> Motion Blur
Trigger Navigation </span>
</span>
<span className="text-[8px] opacity-60">
Allows URL transitions in Studio
</span>
</div>
</div> </div>
{editForm.realClick ? <Check size={18} /> : <div className="w-[18px]" />} {editForm.motionBlur ? <Check size={18} /> : <div className="w-[18px]" />}
</button> </button>
)}
</div>
</div>
<button {editForm.type === 'mouse' && editForm.interactionType === 'click' && (
onClick={saveEdit} <button
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" onClick={() =>
> setEditForm((prev) => ({ ...prev, realClick: !prev.realClick }))
Commit Changes }
</button> 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> >
)} <div className="flex items-center gap-3">
</AnimatePresence> <ExternalLink size={18} />
</div> <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

@@ -2,8 +2,17 @@
import React, { useState, useEffect } from 'react'; import React, { useState, useEffect } from 'react';
import { useRecordMode } from './RecordModeContext'; import { useRecordMode } from './RecordModeContext';
import { FeedbackOverlay } from '@mintel/next-feedback/FeedbackOverlay'; import dynamic from 'next/dynamic';
import { RecordModeOverlay } from './RecordModeOverlay';
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'; import { PickingHelper } from './PickingHelper';
interface ToolCoordinatorProps { interface ToolCoordinatorProps {

View File

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

52
config/lighthouserc.json Normal file
View File

@@ -0,0 +1,52 @@
{
"ci": {
"collect": {
"numberOfRuns": 1,
"settings": {
"preset": "desktop",
"onlyCategories": ["performance", "accessibility", "best-practices", "seo"],
"chromeFlags": "--no-sandbox --disable-setuid-sandbox"
}
},
"assert": {
"assertions": {
"categories:performance": [
"error",
{
"minScore": 0.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 featuredImage: /uploads/2025/02/image_fx_-6.webp
locale: de locale: de
category: Kabel Technologie category: Kabel Technologie
excerpt: Die Energiewende braucht leistungsfähige Netze. Erfahren Sie, warum Investitionen in die Kabelinfrastruktur der Schlüssel zu 100 % erneuerbarer Energie sind.
--- ---
# 100 % erneuerbare Energie? Nur mit der richtigen Kabelinfrastruktur! # 100 % erneuerbare Energie? Nur mit der richtigen Kabelinfrastruktur!
Die Vision ist klar: Ein Europa, das seinen Strom zu 100 % aus erneuerbaren Energien gewinnt. Doch während Solar- und Windparks boomen, hinkt der Ausbau der Stromnetze hinterher. Die Ursache? Eine Infrastruktur, die für fossile Kraftwerke gebaut wurde und mit den neuen Anforderungen nicht Schritt hält. Die Vision ist klar: Ein Europa, das seinen Strom zu 100 % aus erneuerbaren Energien gewinnt. Doch während Solar- und Windparks boomen, hinkt der Ausbau der Stromnetze hinterher. Die Ursache? Eine Infrastruktur, die für fossile Kraftwerke gebaut wurde und mit den neuen Anforderungen nicht Schritt hält.

View File

@@ -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 ### 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. 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: Die folgenden Trends sind für uns besonders relevant:
- <strong>Ausbau von Stromleitungen und Netzanschlussprojekten:<br /> - <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>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>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>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. 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** ### **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. 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. 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. ### 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. 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 <VisualLinkPreview
url="https://ratedpower.com/blog/utility-scale-pv-losses/" url="https://ratedpower.com/blog/utility-scale-pv-losses/"
title="Ultimate guide to utility-scale PV system losses — RatedPower" 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?" 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" 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> <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. 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. 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 <VisualLinkPreview
url="https://www.solarenergie.de/stromspeicher/arten/stromspeicher-windkraft" url="https://www.solarenergie.de/stromspeicher/arten/stromspeicher-windkraft"
title="Speicher für Windenergie: Welche Möglichkeiten gibt es?" 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!" 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" 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> <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. 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. 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. 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: You can find more information here on how wind energy basically works:
<VisualLinkPreview
url="
<VisualLinkPreview <VisualLinkPreview
url="https://www.e-werk-mittelbaden.de/wie-funktioniert-windenergie" url="https://www.e-werk-mittelbaden.de/wie-funktioniert-windenergie"
title="Wie funktioniert Windenergie? - Einfach erklärt | E-Werk Mittelbaden" 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!" 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" 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> <blockquote>
**Those who want to transport climate-friendly energy must also build in a climate-conscious way.** **Those who want to transport climate-friendly energy must also build in a climate-conscious way.**
</blockquote> </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. 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**. 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: In the following article, you can find out how, for example, wind turbines are recycled:
<VisualLinkPreview
url="
<VisualLinkPreview <VisualLinkPreview
url="https://www.enbw.com/unternehmen/themen/windkraft/windrad-recycling.html" url="https://www.enbw.com/unternehmen/themen/windkraft/windrad-recycling.html"
title="Recycling von Windrädern | EnBW" title="Recycling von Windrädern | EnBW"
summary="Wie funktioniert das Recycling von Windrädern? Erfahren Sie mehr über Herausforderungen und die neuesten Methoden." 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" 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 ## 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. 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. 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. 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. 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. Find out how you can contribute to a sustainable energy supply in the following article.
<VisualLinkPreview
url="
<VisualLinkPreview <VisualLinkPreview
url="https://money-for-future.com/nachhaltige-energieversorgung-erneuerbare-energie" url="https://money-for-future.com/nachhaltige-energieversorgung-erneuerbare-energie"
title="Nachhaltige Energieversor­gung und erneuerbare Energie erklärt" 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." 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" 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? ### 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. 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. Now, KLZ is part of this trusted network, making it even easier for industry professionals to find us.
<VisualLinkPreview <VisualLinkPreview
url="
<VisualLinkPreview
url="https://www.erneuerbareenergien.de/" url="https://www.erneuerbareenergien.de/"
title="Erneuerbare Energien - Das Magazin für die Energiewende mit Wind-, Solar- und Bioenergie" title="Erneuerbare Energien - Das Magazin für die Energiewende mit Wind-, Solar- und Bioenergie"
summary="Heft 01-2025" 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" 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 ### 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. 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: 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 # 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. 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 <VisualLinkPreview
url="https://youtu.be/YbtdyvQFoVM" url="https://youtu.be/YbtdyvQFoVM"
title="Intersolar Europe 2025 | Save The Date | May 79, 2025" 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…" 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" image="https://i.ytimg.com/vi/YbtdyvQFoVM/maxresdefault.jpg?sqp=-oaymwEmCIAKENAF8quKqQMa8AEB-AH-CYAC0AWKAgwIABABGEQgSyhyMA8=&amp;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"
/> />
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 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? ## 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. 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/) 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: All the key details about Intersolar Europe can be found here:
<VisualLinkPreview
url="
<VisualLinkPreview <VisualLinkPreview
url="https://www.intersolar.de/messe-kompakt?ref=m5f53a666f3a2cb2fee160554-s65eec4739108db093b003a02-t1746004197-cf3c592e7" url="https://www.intersolar.de/messe-kompakt?ref=m5f53a666f3a2cb2fee160554-s65eec4739108db093b003a02-t1746004197-cf3c592e7"
title="Intersolar Europe at a Glance" title="Intersolar Europe at a Glance"
summary="Intersolar Europe | Exhibition Quick Facts | Date, Venue, Opening Hours, Exhibitors" summary="Intersolar Europe | Exhibition Quick Facts | Date, Venue, Opening Hours, Exhibitors"
image="https://www.intersolar.de/media/image/6311c9ee98bbc414b66305e2/750" 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> <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. 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. 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 <VisualLinkPreview
url="https://www.konnworld.com/why-cable-quality-matters-the-impact-on-energy-efficiency-and-longevity" 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" 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." 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" 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> <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. 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** **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. - 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. - 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 <VisualLinkPreview
url="https://insights.regencysupply.com/pros-and-cons-of-copper-and-aluminum-wire" url="https://insights.regencysupply.com/pros-and-cons-of-copper-and-aluminum-wire"
title="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." 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" 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> <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. 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 /> **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 /> 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. 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. Overall, these technologies help maximize the efficiency and safety of cables and support a sustainable energy future.
<VisualLinkPreview
url="
<VisualLinkPreview <VisualLinkPreview
url="https://www.cables-unlimited.com/renewable-energy-cable-assemblies-weve-got-you-covered/" 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." 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." 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"
/>
"
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"
/> />
<h4>Certifications and standards: a guarantee of quality</h4> <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** 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. - 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. 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 <VisualLinkPreview
url="https://www.flukenetworks.com/blog/cabling-chronicles/cabling-certification" url="https://www.flukenetworks.com/blog/cabling-chronicles/cabling-certification"
title="Three Reasons Cabling Certification Is More Important Than Ever" 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?" 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" 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> <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 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. 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 /> **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. 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 <VisualLinkPreview
url="https://sustainablebrands.com/read/evolving-infrastructure-wire-cable-prioritize-sustainability" 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" 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…" 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" 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"
/>

View File

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

View File

@@ -5,7 +5,7 @@ services:
dockerfile: Dockerfile dockerfile: Dockerfile
args: args:
NEXT_PUBLIC_BASE_URL: ${NEXT_PUBLIC_BASE_URL} NEXT_PUBLIC_BASE_URL: ${NEXT_PUBLIC_BASE_URL}
DIRECTUS_URL: ${DIRECTUS_URL} DIRECTUS_URL: "${DIRECTUS_URL}"
image: registry.infra.mintel.me/mintel/klz-cables.com:${IMAGE_TAG:-latest} image: registry.infra.mintel.me/mintel/klz-cables.com:${IMAGE_TAG:-latest}
restart: unless-stopped restart: unless-stopped
networks: networks:
@@ -15,6 +15,8 @@ services:
- klz.localhost - klz.localhost
env_file: env_file:
- ${ENV_FILE:-.env} - ${ENV_FILE:-.env}
environment:
IMGPROXY_URL: ${IMGPROXY_URL:-http://klz-imgproxy:8080}
labels: labels:
- "traefik.enable=true" - "traefik.enable=true"
# HTTP ⇒ HTTPS redirect # HTTP ⇒ HTTPS redirect
@@ -30,7 +32,7 @@ services:
- "traefik.http.routers.${PROJECT_NAME:-klz}.middlewares=${AUTH_MIDDLEWARE:-klz-ratelimit,klz-forward,klz-compress}" - "traefik.http.routers.${PROJECT_NAME:-klz}.middlewares=${AUTH_MIDDLEWARE:-klz-ratelimit,klz-forward,klz-compress}"
# Public Router (Whitelist for OG Images, Sitemaps, Health) # Public Router (Whitelist for OG Images, Sitemaps, Health)
- "traefik.http.routers.${PROJECT_NAME:-klz}-public.rule=(${TRAEFIK_HOST_RULE:-Host(`${TRAEFIK_HOST:-klz-cables.com}`)}) && (PathPrefix(`/health`) || PathPrefix(`/sitemap.xml`) || PathPrefix(`/robots.txt`) || PathPrefix(`/manifest.webmanifest`) || PathRegexp(`^/([a-z]{2}/)?api/og`) || PathRegexp(`^/([a-z]{2}/)?opengraph-image$`) || PathRegexp(`^/([a-z]{2}/)?blog/opengraph-image$`) || PathRegexp(`^/sitemap(-[0-9]+)?\\.xml$`))" - "traefik.http.routers.${PROJECT_NAME:-klz}-public.rule=(${TRAEFIK_HOST_RULE:-Host(`${TRAEFIK_HOST:-klz-cables.com}`)}) && (PathPrefix(`/health`) || PathPrefix(`/sitemap.xml`) || PathPrefix(`/robots.txt`) || PathPrefix(`/manifest.webmanifest`) || PathPrefix(`/api/og`) || PathPrefix(`/de/api/og`) || PathPrefix(`/en/api/og`) || PathPrefix(`/logo-white.svg`) || PathPrefix(`/icon-white.svg`) || PathPrefix(`/opengraph-image`) || PathPrefix(`/de/opengraph-image`) || PathPrefix(`/en/opengraph-image`) || PathPrefix(`/blog/opengraph-image`) || PathPrefix(`/de/blog/opengraph-image`) || PathPrefix(`/en/blog/opengraph-image`) || PathRegexp(`^/sitemap(-[0-9]+)?\\.xml$`) || PathRegexp(`.*\\.(svg|png|jpg|jpeg|gif|webp|ico|webm|mp4|map)$`))"
- "traefik.http.routers.${PROJECT_NAME:-klz}-public.entrypoints=${TRAEFIK_ENTRYPOINT:-web}" - "traefik.http.routers.${PROJECT_NAME:-klz}-public.entrypoints=${TRAEFIK_ENTRYPOINT:-web}"
- "traefik.http.routers.${PROJECT_NAME:-klz}-public.tls.certresolver=${TRAEFIK_CERT_RESOLVER:-}" - "traefik.http.routers.${PROJECT_NAME:-klz}-public.tls.certresolver=${TRAEFIK_CERT_RESOLVER:-}"
- "traefik.http.routers.${PROJECT_NAME:-klz}-public.tls=${TRAEFIK_TLS:-false}" - "traefik.http.routers.${PROJECT_NAME:-klz}-public.tls=${TRAEFIK_TLS:-false}"
@@ -117,6 +119,7 @@ services:
PUBLIC_URL: ${DIRECTUS_URL:-https://cms.klz-cables.com} PUBLIC_URL: ${DIRECTUS_URL:-https://cms.klz-cables.com}
HOST: '0.0.0.0' HOST: '0.0.0.0'
networks: networks:
- default
- infra - infra
volumes: volumes:
- ./directus/uploads:/directus/uploads - ./directus/uploads:/directus/uploads
@@ -149,7 +152,54 @@ services:
volumes: volumes:
- directus-db-data:/var/lib/postgresql/data - directus-db-data:/var/lib/postgresql/data
networks: networks:
- default
klz-imgproxy:
image: darthsim/imgproxy:latest
restart: unless-stopped
networks:
- default
- infra - infra
extra_hosts:
- "klz.localhost:host-gateway"
- "cms.klz.localhost:host-gateway"
- "host.docker.internal:host-gateway"
environment:
IMGPROXY_URL_MAPPING: "${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: networks:
default: default:
@@ -159,3 +209,5 @@ networks:
volumes: volumes:
directus-db-data: directus-db-data:
external: true
name: klz-cablescom_directus-db-data

View File

@@ -35,9 +35,9 @@ function createConfig() {
analytics: { analytics: {
umami: { umami: {
websiteId: env.UMAMI_WEBSITE_ID, websiteId: env.NEXT_PUBLIC_UMAMI_WEBSITE_ID || env.UMAMI_WEBSITE_ID,
apiEndpoint: env.UMAMI_API_ENDPOINT, apiEndpoint: env.UMAMI_API_ENDPOINT || 'https://analytics.infra.mintel.me',
enabled: Boolean(env.UMAMI_WEBSITE_ID), enabled: typeof window !== 'undefined' || Boolean(env.UMAMI_WEBSITE_ID),
}, },
}, },

View File

@@ -31,7 +31,7 @@ const envExtension = {
INFRA_DIRECTUS_TOKEN: z.string().optional(), INFRA_DIRECTUS_TOKEN: z.string().optional(),
// Analytics // Analytics
UMAMI_WEBSITE_ID: z.string().optional(), NEXT_PUBLIC_UMAMI_WEBSITE_ID: z.string().optional(),
UMAMI_API_ENDPOINT: z.string().optional(), UMAMI_API_ENDPOINT: z.string().optional(),
// Mail Configuration // 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 // Add a timeout to prevent hanging requests
const controller = new AbortController(); const controller = new AbortController();
const timeoutId = setTimeout(() => controller.abort(), 10000); // 10s timeout const timeoutId = setTimeout(() => controller.abort(), 2000); // 2s timeout
const headers: Record<string, string> = { const headers: Record<string, string> = {
'Content-Type': 'application/json', 'Content-Type': 'application/json',

View File

@@ -1,4 +1,3 @@
import * as Sentry from '@sentry/nextjs';
import type { import type {
ErrorReportingLevel, ErrorReportingLevel,
ErrorReportingService, ErrorReportingService,
@@ -7,32 +6,66 @@ import type {
import type { NotificationService } from '../notifications/notification-service'; import type { NotificationService } from '../notifications/notification-service';
import type { LoggerService } from '../logging/logger-service'; import type { LoggerService } from '../logging/logger-service';
type SentryLike = typeof Sentry;
export type GlitchtipErrorReportingServiceOptions = { export type GlitchtipErrorReportingServiceOptions = {
enabled: boolean; enabled: boolean;
}; };
// GlitchTip speaks the Sentry protocol; @sentry/nextjs can send to GlitchTip via DSN. // 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 { export class GlitchtipErrorReportingService implements ErrorReportingService {
private logger: LoggerService; private logger: LoggerService;
private sentryPromise: Promise<typeof import('@sentry/nextjs')> | null = null;
constructor( constructor(
private readonly options: GlitchtipErrorReportingServiceOptions, private readonly options: GlitchtipErrorReportingServiceOptions,
logger: LoggerService, logger: LoggerService,
private readonly notifications?: NotificationService, private readonly notifications?: NotificationService,
private readonly sentry: SentryLike = Sentry,
) { ) {
this.logger = logger.child({ component: 'error-reporting-glitchtip' }); 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>) { async captureException(error: unknown, context?: Record<string, unknown>) {
if (!this.options.enabled) return undefined; 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 // 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) { if (this.notifications) {
const errorMessage = error instanceof Error ? error.message : String(error); const errorMessage = error instanceof Error ? error.message : String(error);
const contextStr = context ? `\nContext: ${JSON.stringify(context, null, 2)}` : ''; 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; 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) { setUser(user: ErrorReportingUser | null) {
if (!this.options.enabled) return; 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) { setTag(key: string, value: string) {
if (!this.options.enabled) return; 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(); if (!this.options.enabled) return fn();
return this.sentry.withScope((scope) => { // Since withScope mandates executing fn() synchronously to return T,
if (context) { // and Sentry load is async, if context mapping is absolutely required
for (const [key, value] of Object.entries(context)) { // for this feature we would need an async API.
scope.setExtra(key, value); // For now we degrade gracefully by just executing the function.
} return fn();
}
return fn();
});
} }
} }

View File

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

View File

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

View File

@@ -58,12 +58,14 @@
} }
}, },
"Navigation": { "Navigation": {
"home": "Start", "menu": "Menü",
"home": "Startseite",
"team": "Team", "team": "Team",
"products": "Produkte", "products": "Produkte",
"blog": "Blog", "blog": "Blog",
"contact": "Kontakt", "contact": "Kontakt",
"toggleMenu": "Menü umschalten" "toggleMenu": "Menü umschalten",
"skipToContent": "Zum Inhalt springen"
}, },
"Footer": { "Footer": {
"legal": "Rechtliches", "legal": "Rechtliches",
@@ -120,7 +122,7 @@
"quote": "Manchmal braucht es nur einen klaren Kopf und das richtige Kabel, um die Welt ein Stück besser zu machen.", "quote": "Manchmal braucht es nur einen klaren Kopf und das richtige Kabel, um die Welt ein Stück besser zu machen.",
"description": "Klaus ist der Fels in der Brandung selbst wenn das Kabelchaos überhandnimmt. Mit jahrzehntelanger Erfahrung und einem stabilen Netzwerk sorgt er dafür, dass alles glatt läuft. Er denkt nicht nur in Lösungen, sondern bringt auch Humor und den nötigen Weitblick mit, um selbst komplexe Themen locker auf den Punkt zu bringen.", "description": "Klaus ist der Fels in der Brandung selbst wenn das Kabelchaos überhandnimmt. Mit jahrzehntelanger Erfahrung und einem stabilen Netzwerk sorgt er dafür, dass alles glatt läuft. Er denkt nicht nur in Lösungen, sondern bringt auch Humor und den nötigen Weitblick mit, um selbst komplexe Themen locker auf den Punkt zu bringen.",
"linkedin": "Klaus' LinkedIn", "linkedin": "Klaus' LinkedIn",
"role": "Gründer & Visionär" "role": "Geschäftsführer"
}, },
"manifesto": { "manifesto": {
"title": "Unser Manifest", "title": "Unser Manifest",
@@ -394,4 +396,4 @@
"cta": "Zurück zur Sicherheit" "cta": "Zurück zur Sicherheit"
} }
} }
} }

View File

@@ -58,12 +58,14 @@
} }
}, },
"Navigation": { "Navigation": {
"menu": "Menu",
"home": "Home", "home": "Home",
"team": "Team", "team": "Team",
"products": "Products", "products": "Products",
"blog": "Blog", "blog": "Blog",
"contact": "Contact", "contact": "Contact",
"toggleMenu": "Toggle Menu" "toggleMenu": "Toggle Menu",
"skipToContent": "Skip to content"
}, },
"Footer": { "Footer": {
"legal": "Legal", "legal": "Legal",
@@ -120,7 +122,7 @@
"quote": "Sometimes all it takes is a clear head and a good cable to make the world a little better.", "quote": "Sometimes all it takes is a clear head and a good cable to make the world a little better.",
"description": "Klaus is the man with the experience, bringing perspective and calm to the table—even when cable chaos threatens to take over. With impressive industry knowledge and a network as solid as our cables, he ensures everything runs smoothly. Klaus isnt just a problem solver; hes a strategic thinker who knows how to get to the point with a touch of humor.", "description": "Klaus is the man with the experience, bringing perspective and calm to the table—even when cable chaos threatens to take over. With impressive industry knowledge and a network as solid as our cables, he ensures everything runs smoothly. Klaus isnt just a problem solver; hes a strategic thinker who knows how to get to the point with a touch of humor.",
"linkedin": "Check Klaus' LinkedIn", "linkedin": "Check Klaus' LinkedIn",
"role": "Founder & Visionary" "role": "Managing Director"
}, },
"manifesto": { "manifesto": {
"title": "Our manifesto", "title": "Our manifesto",
@@ -394,4 +396,4 @@
"cta": "Back to Safety" "cta": "Back to Safety"
} }
} }
} }

View File

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

2
next-env.d.ts vendored
View File

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

View File

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

View File

@@ -12,6 +12,7 @@
"@react-email/components": "^1.0.7", "@react-email/components": "^1.0.7",
"@react-pdf/renderer": "^4.3.2", "@react-pdf/renderer": "^4.3.2",
"@sentry/nextjs": "^10.38.0", "@sentry/nextjs": "^10.38.0",
"@types/recharts": "^2.0.1",
"axios": "^1.13.5", "axios": "^1.13.5",
"clsx": "^2.1.1", "clsx": "^2.1.1",
"framer-motion": "^12.34.0", "framer-motion": "^12.34.0",
@@ -32,6 +33,7 @@
"react-dom": "^19.2.4", "react-dom": "^19.2.4",
"react-email": "^5.2.5", "react-email": "^5.2.5",
"react-leaflet": "^4.2.1", "react-leaflet": "^4.2.1",
"recharts": "^3.7.0",
"require-in-the-middle": "^8.0.1", "require-in-the-middle": "^8.0.1",
"resend": "^3.5.0", "resend": "^3.5.0",
"schema-dts": "^1.1.5", "schema-dts": "^1.1.5",
@@ -47,6 +49,7 @@
"@lhci/cli": "^0.15.1", "@lhci/cli": "^0.15.1",
"@mintel/eslint-config": "1.8.3", "@mintel/eslint-config": "1.8.3",
"@mintel/tsconfig": "1.8.3", "@mintel/tsconfig": "1.8.3",
"@next/bundle-analyzer": "^16.1.6",
"@remotion/cli": "^4.0.421", "@remotion/cli": "^4.0.421",
"@remotion/google-fonts": "^4.0.421", "@remotion/google-fonts": "^4.0.421",
"@remotion/player": "^4.0.421", "@remotion/player": "^4.0.421",
@@ -63,15 +66,20 @@
"@vitejs/plugin-react": "^5.1.4", "@vitejs/plugin-react": "^5.1.4",
"@vitest/ui": "^4.0.16", "@vitest/ui": "^4.0.16",
"autoprefixer": "^10.4.23", "autoprefixer": "^10.4.23",
"cheerio": "^1.2.0",
"critters": "^0.0.25",
"eslint": "^9.18.0", "eslint": "^9.18.0",
"happy-dom": "^20.6.1", "happy-dom": "^20.6.1",
"husky": "^9.1.7", "husky": "^9.1.7",
"lint-staged": "^16.2.7", "lint-staged": "^16.2.7",
"lucide-react": "^0.563.0", "lucide-react": "^0.563.0",
"pa11y-ci": "^4.0.1",
"postcss": "^8.5.6", "postcss": "^8.5.6",
"prettier": "^3.8.1", "prettier": "^3.8.1",
"puppeteer": "^24.37.3",
"remotion": "^4.0.421", "remotion": "^4.0.421",
"sass": "^1.97.1", "sass": "^1.97.1",
"start-server-and-test": "^2.1.3",
"tailwindcss": "^4.1.18", "tailwindcss": "^4.1.18",
"tsx": "^4.21.0", "tsx": "^4.21.0",
"typescript": "^5.7.2", "typescript": "^5.7.2",
@@ -88,6 +96,9 @@
"test": "vitest run --passWithNoTests", "test": "vitest run --passWithNoTests",
"test:og": "vitest run tests/og-image.test.ts", "test:og": "vitest run tests/og-image.test.ts",
"check:og": "tsx scripts/check-og-images.ts", "check:og": "tsx scripts/check-og-images.ts",
"check:mdx": "node scripts/validate-mdx.mjs",
"check:a11y": "start-server-and-test start http://localhost:3000 'pa11y-ci'",
"check:wcag": "tsx ./scripts/wcag-sitemap.ts",
"cms:branding:local": "DIRECTUS_URL=${DIRECTUS_URL:-http://cms.klz.localhost} npx tsx --env-file=.env scripts/setup-directus-branding.ts", "cms:branding:local": "DIRECTUS_URL=${DIRECTUS_URL:-http://cms.klz.localhost} npx tsx --env-file=.env scripts/setup-directus-branding.ts",
"cms:branding:testing": "DIRECTUS_URL=https://cms.testing.klz-cables.com npx tsx --env-file=.env scripts/setup-directus-branding.ts", "cms:branding:testing": "DIRECTUS_URL=https://cms.testing.klz-cables.com npx tsx --env-file=.env scripts/setup-directus-branding.ts",
"cms:branding:staging": "DIRECTUS_URL=https://cms.staging.klz-cables.com npx tsx --env-file=.env scripts/setup-directus-branding.ts", "cms:branding:staging": "DIRECTUS_URL=https://cms.staging.klz-cables.com npx tsx --env-file=.env scripts/setup-directus-branding.ts",
@@ -107,6 +118,7 @@
"cms:push:testing:DANGER": "./scripts/sync-directus.sh push testing", "cms:push:testing:DANGER": "./scripts/sync-directus.sh push testing",
"cms:push:prod:DANGER": "./scripts/sync-directus.sh push production", "cms:push:prod:DANGER": "./scripts/sync-directus.sh push production",
"pagespeed:test": "tsx ./scripts/pagespeed-sitemap.ts", "pagespeed:test": "tsx ./scripts/pagespeed-sitemap.ts",
"pagespeed:audit": "./scripts/audit-local.sh",
"pagespeed:urls": "tsx -e \"import sitemap from './app/sitemap'; sitemap().then(urls => console.log(urls.map(u => u.url).join('\\n')))\"", "pagespeed:urls": "tsx -e \"import sitemap from './app/sitemap'; sitemap().then(urls => console.log(urls.map(u => u.url).join('\\n')))\"",
"remotion:render": "remotion render WebsiteVideo remotion/index.ts out.mp4", "remotion:render": "remotion render WebsiteVideo remotion/index.ts out.mp4",
"remotion:preview": "remotion preview remotion/index.ts", "remotion:preview": "remotion preview remotion/index.ts",
@@ -119,6 +131,11 @@
"next": "16.1.6" "next": "16.1.6"
} }
}, },
"browserslist": [
"last 3 versions",
"not dead",
"not ie 11"
],
"peerDependencies": { "peerDependencies": {
"@remotion/cli": "^4.0.421", "@remotion/cli": "^4.0.421",
"@remotion/google-fonts": "^4.0.421", "@remotion/google-fonts": "^4.0.421",

1049
pnpm-lock.yaml generated

File diff suppressed because it is too large Load Diff

Binary file not shown.

After

Width:  |  Height:  |  Size: 316 KiB

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

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

View File

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

View File

@@ -0,0 +1,21 @@
const fs = require('fs');
const files = [
'/Users/marcmintel/Projects/klz-2026/components/Header.tsx',
'/Users/marcmintel/Projects/klz-2026/components/Scribble.tsx',
'/Users/marcmintel/Projects/klz-2026/components/Lightbox.tsx',
'/Users/marcmintel/Projects/klz-2026/components/record-mode/RecordModeOverlay.tsx',
'/Users/marcmintel/Projects/klz-2026/components/record-mode/PlaybackCursor.tsx'
];
for (const file of files) {
let content = fs.readFileSync(file, 'utf8');
content = content.replace(/import { motion } from 'framer-motion';/g, "import { m, LazyMotion, domAnimation } from 'framer-motion';");
content = content.replace(/import { motion, Variants } from 'framer-motion';/g, "import { m, LazyMotion, domAnimation, Variants } from 'framer-motion';");
content = content.replace(/import { motion, AnimatePresence } from 'framer-motion';/g, "import { m, LazyMotion, domAnimation, AnimatePresence } from 'framer-motion';");
content = content.replace(/<motion\./g, '<m.');
content = content.replace(/<\/motion\./g, '</m.');
fs.writeFileSync(file, content);
}
console.log('Replaced motion with m in ' + files.length + ' files');

55
scripts/validate-mdx.mjs Normal file
View File

@@ -0,0 +1,55 @@
import { compile } from '@mdx-js/mdx';
import fs from 'node:fs';
import path from 'node:path';
import matter from 'gray-matter';
const TARGET_DIRS = ['./data/blog', './data/products', './data/pages'];
function getAllFiles(dirPath, arrayOfFiles) {
if (!fs.existsSync(dirPath)) return arrayOfFiles || [];
const files = fs.readdirSync(dirPath);
arrayOfFiles = arrayOfFiles || [];
files.forEach(function (file) {
const fullPath = path.join(dirPath, file);
if (fs.statSync(fullPath).isDirectory()) {
arrayOfFiles = getAllFiles(fullPath, arrayOfFiles);
} else {
arrayOfFiles.push(fullPath);
}
});
return arrayOfFiles;
}
const allMdxFiles = TARGET_DIRS.flatMap(dir => getAllFiles(dir)).filter(file => file.endsWith('.mdx'));
console.log(`Found ${allMdxFiles.length} MDX files to validate...`);
let errorCount = 0;
for (const file of allMdxFiles) {
const fileContent = fs.readFileSync(file, 'utf8');
const { content } = matter(fileContent);
try {
// Attempt to compile MDX content
await compile(content);
} catch (err) {
console.error(`\x1b[31mError in ${file}:\x1b[0m`);
console.error(err.message);
if (err.line && err.column) {
console.error(`At line ${err.line}, column ${err.column}`);
}
errorCount++;
}
}
if (errorCount > 0) {
console.error(`\n\x1b[31mValidation failed: ${errorCount} errors found.\x1b[0m`);
process.exit(1);
} else {
console.log('\n\x1b[32mAll MDX files are valid.\x1b[0m');
}

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

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

View File

@@ -1,19 +1,4 @@
import * as Sentry from '@sentry/nextjs'; // Sentry initialization move to GlitchtipErrorReportingService to allow lazy-loading
// for PageSpeed 100 optimizations. This file is now empty to prevent the SDK
// We use a placeholder DSN on the client because the real DSN is injected // from being included in the initial JS bundle.
// by our server-side relay at /errors/api/relay. export {};
// This keeps our environment clean of NEXT_PUBLIC_ variables.
const CLIENT_DSN = 'https://public@errors.infra.mintel.me/1';
Sentry.init({
dsn: CLIENT_DSN,
// Relay events through our own server to hide the real DSN and bypass ad-blockers
tunnel: '/errors/api/relay',
// Enable even if no DSN is provided, because we have the tunnel
enabled: true,
tracesSampleRate: 0,
replaysOnErrorSampleRate: 1.0,
replaysSessionSampleRate: 0.1,
});

View File

@@ -43,11 +43,11 @@
--animate-slide-up: slide-up 0.6s ease-out; --animate-slide-up: slide-up 0.6s ease-out;
--animate-slow-zoom: slow-zoom 20s linear infinite; --animate-slow-zoom: slow-zoom 20s linear infinite;
--animate-reveal: reveal 0.8s cubic-bezier(0.16, 1, 0.3, 1) forwards; --animate-reveal: reveal 0.8s cubic-bezier(0.16, 1, 0.3, 1) forwards;
--animate-slight-fade-in-from-bottom: slight-fade-in-from-bottom 0.8s cubic-bezier(0.16, 1, 0.3, 1) forwards; --animate-slight-fade-in-from-bottom: slight-fade-in-from-bottom 0.8s
cubic-bezier(0.16, 1, 0.3, 1) forwards;
--animate-gradient-x: gradient-x 15s ease infinite; --animate-gradient-x: gradient-x 15s ease infinite;
@keyframes gradient-x { @keyframes gradient-x {
0%, 0%,
100% { 100% {
background-position: 0% 50%; background-position: 0% 50%;
@@ -135,10 +135,31 @@
transform: translate(0, 0) scale(1); transform: translate(0, 0) scale(1);
} }
} }
@keyframes spin-slow {
to {
transform: rotate(360deg);
}
}
@keyframes flow {
to {
stroke-dashoffset: 0;
}
}
@keyframes solar-pulse {
0%,
100% {
fill-opacity: 0.2;
}
50% {
fill-opacity: 0.5;
}
}
} }
@layer base { @layer base {
.bg-primary a, .bg-primary a,
.bg-primary-dark a { .bg-primary-dark a {
@apply text-white/90 hover:text-white transition-colors; @apply text-white/90 hover:text-white transition-colors;
@@ -321,4 +342,4 @@
@utility content-visibility-auto { @utility content-visibility-auto {
content-visibility: auto; content-visibility: auto;
contain-intrinsic-size: 1px 1000px; contain-intrinsic-size: 1px 1000px;
} }