Compare commits

...

328 Commits

Author SHA1 Message Date
205880b41a ci: branch deploys 2026-02-10 13:50:57 +01:00
84555d11ed fix: deploy
Some checks failed
CI - Lint, Typecheck & Test / quality-assurance (push) Successful in 2m24s
Build & Deploy KLZ Cables / 🔍 Prepare Environment (push) Successful in 11s
Build & Deploy KLZ Cables / 🧪 Quality Assurance (push) Successful in 1m23s
Build & Deploy KLZ Cables / 🏗️ Build App (push) Failing after 1m42s
Build & Deploy KLZ Cables / 🚀 Deploy (push) Has been skipped
Build & Deploy KLZ Cables / ⚡ PageSpeed (push) Has been skipped
Build & Deploy KLZ Cables / 🔔 Notifications (push) Successful in 2s
2026-02-10 13:45:13 +01:00
1dce82b74e fix: synchronize next.js version via pnpm overrides
Some checks failed
CI - Lint, Typecheck & Test / quality-assurance (push) Has been cancelled
2026-02-10 13:44:11 +01:00
3be4939ff5 fix: optimize ci pipeline and dynamic registry auth
Some checks failed
CI - Lint, Typecheck & Test / quality-assurance (push) Failing after 2m30s
2026-02-10 13:39:41 +01:00
e054bb3490 fix: deploy
Some checks failed
CI - Lint, Typecheck & Test / quality-assurance (push) Failing after 7m28s
2026-02-10 13:31:55 +01:00
75234095b7 fix: deploy
Some checks failed
CI - Lint, Typecheck & Test / quality-assurance (push) Failing after 1m21s
2026-02-10 13:23:51 +01:00
4bdd4efdc3 fix: deploy
Some checks failed
CI - Lint, Typecheck & Test / quality-assurance (push) Failing after 47s
2026-02-10 13:09:55 +01:00
47ca58a85a fix: deploy
Some checks failed
CI - Lint, Typecheck & Test / quality-assurance (push) Failing after 43s
2026-02-10 12:21:48 +01:00
d5d39a218a fix: deploy issues
Some checks failed
CI - Lint, Typecheck & Test / quality-assurance (push) Failing after 17s
2026-02-10 11:59:43 +01:00
ae7a45a911 fix: deploy issues
Some checks failed
CI - Lint, Typecheck & Test / quality-assurance (push) Failing after 34s
2026-02-10 11:52:10 +01:00
cb51c37207 feat: improved analytics
Some checks failed
CI - Lint, Typecheck & Test / quality-assurance (push) Failing after 8s
Build & Deploy KLZ Cables / 🔍 Prepare Environment (push) Successful in 9s
Build & Deploy KLZ Cables / 🧪 Quality Assurance (push) Failing after 13s
Build & Deploy KLZ Cables / 🏗️ Build App (push) Failing after 25s
Build & Deploy KLZ Cables / 🚀 Deploy (push) Has been skipped
Build & Deploy KLZ Cables / ⚡ PageSpeed (push) Has been skipped
Build & Deploy KLZ Cables / 🔔 Notifications (push) Successful in 2s
2026-02-09 23:48:49 +01:00
8872d2424a feat: improved analytics 2026-02-09 23:47:56 +01:00
eb388610de chore: align ecosystem to Next.js 16.1.6 and v1.6.0, migrate to ESLint 9 Flat Config
Some checks failed
CI - Lint, Typecheck & Test / quality-assurance (push) Failing after 30s
2026-02-09 23:23:31 +01:00
6451a9e28e chore: platform docs 2026-02-09 11:56:56 +01:00
7ec826dae3 feat: integrate feedback module 2026-02-08 21:48:55 +01:00
453a603392 fix: build, typecheck, eslint
All checks were successful
Build & Deploy KLZ Cables / 🔍 Prepare Environment (push) Successful in 12s
Build & Deploy KLZ Cables / 🧪 Quality Assurance (push) Successful in 1m36s
Build & Deploy KLZ Cables / 🏗️ Build App (push) Successful in 3m50s
Build & Deploy KLZ Cables / 🚀 Deploy (push) Successful in 29s
Build & Deploy KLZ Cables / ⚡ PageSpeed (push) Successful in 4m23s
Build & Deploy KLZ Cables / 🔔 Notifications (push) Successful in 2s
2026-02-07 10:03:31 +01:00
5cfcc16dc2 refactor: move umami and sentry to server side
Some checks failed
Build & Deploy KLZ Cables / 🔍 Prepare Environment (push) Successful in 7s
Build & Deploy KLZ Cables / 🧪 Quality Assurance (push) Failing after 1m39s
Build & Deploy KLZ Cables / 🏗️ Build App (push) Failing after 2m54s
Build & Deploy KLZ Cables / 🚀 Deploy (push) Has been skipped
Build & Deploy KLZ Cables / ⚡ PageSpeed (push) Has been skipped
Build & Deploy KLZ Cables / 🔔 Notifications (push) Successful in 2s
2026-02-07 09:58:31 +01:00
5b43349205 fix: prevent backtick expansion in env generation and fix traefik rules
All checks were successful
Build & Deploy KLZ Cables / 🔍 Prepare Environment (push) Successful in 7s
Build & Deploy KLZ Cables / 🧪 Quality Assurance (push) Successful in 1m34s
Build & Deploy KLZ Cables / 🏗️ Build App (push) Successful in 4m53s
Build & Deploy KLZ Cables / 🚀 Deploy (push) Successful in 32s
Build & Deploy KLZ Cables / ⚡ PageSpeed (push) Successful in 4m13s
Build & Deploy KLZ Cables / 🔔 Notifications (push) Successful in 2s
2026-02-07 00:49:36 +01:00
96b296da12 fix: traefik routing rules and define missing compress middleware
Some checks failed
Build & Deploy KLZ Cables / 🔍 Prepare Environment (push) Successful in 10s
Build & Deploy KLZ Cables / 🧪 Quality Assurance (push) Successful in 1m37s
Build & Deploy KLZ Cables / 🏗️ Build App (push) Successful in 4m36s
Build & Deploy KLZ Cables / 🚀 Deploy (push) Successful in 32s
Build & Deploy KLZ Cables / ⚡ PageSpeed (push) Failing after 1m44s
Build & Deploy KLZ Cables / 🔔 Notifications (push) Successful in 2s
2026-02-06 23:57:37 +01:00
d5eb20a341 fix(analytics): bypass gatekeeper and middleware for tracking endpoints
Some checks failed
Build & Deploy KLZ Cables / 🔍 Prepare Environment (push) Successful in 7s
Build & Deploy KLZ Cables / 🧪 Quality Assurance (push) Successful in 1m37s
Build & Deploy KLZ Cables / 🏗️ Build App (push) Successful in 9m13s
Build & Deploy KLZ Cables / 🚀 Deploy (push) Successful in 28s
Build & Deploy KLZ Cables / ⚡ PageSpeed (push) Failing after 2m44s
Build & Deploy KLZ Cables / 🔔 Notifications (push) Successful in 2s
2026-02-06 23:16:52 +01:00
333111f03b chore: cms branding scripts 2026-02-06 23:11:57 +01:00
698141f70b fix: eslint and tests
All checks were successful
Build & Deploy KLZ Cables / 🔍 Prepare Environment (push) Successful in 7s
Build & Deploy KLZ Cables / 🧪 Quality Assurance (push) Successful in 1m31s
Build & Deploy KLZ Cables / 🏗️ Build App (push) Successful in 3m53s
Build & Deploy KLZ Cables / 🚀 Deploy (push) Successful in 31s
Build & Deploy KLZ Cables / ⚡ PageSpeed (push) Successful in 5m24s
Build & Deploy KLZ Cables / 🔔 Notifications (push) Successful in 2s
2026-02-06 22:46:53 +01:00
e179e8162c refactor: Standardize Umami analytics environment variables to non-public names with fallbacks to NEXT_PUBLIC_ prefixed versions.
Some checks failed
Build & Deploy KLZ Cables / 🔍 Prepare Environment (push) Successful in 7s
Build & Deploy KLZ Cables / 🧪 Quality Assurance (push) Failing after 1m31s
Build & Deploy KLZ Cables / 🏗️ Build App (push) Successful in 3m51s
Build & Deploy KLZ Cables / 🚀 Deploy (push) Has been skipped
Build & Deploy KLZ Cables / ⚡ PageSpeed (push) Has been skipped
Build & Deploy KLZ Cables / 🔔 Notifications (push) Successful in 2s
2026-02-06 22:35:49 +01:00
259d712105 fix: Update Gitea workflow to use environment-specific mail and Directus secrets, and refactor Directus branding script for improved CSS management and button styling.
Some checks failed
Build & Deploy KLZ Cables / 🔍 Prepare Environment (push) Successful in 8s
Build & Deploy KLZ Cables / 🏗️ Build App (push) Has been cancelled
Build & Deploy KLZ Cables / 🚀 Deploy (push) Has been cancelled
Build & Deploy KLZ Cables / ⚡ PageSpeed (push) Has been cancelled
Build & Deploy KLZ Cables / 🔔 Notifications (push) Has been cancelled
Build & Deploy KLZ Cables / 🧪 Quality Assurance (push) Has been cancelled
2026-02-06 21:43:53 +01:00
0178e828d6 chore: ensure traefik network labels are applied
All checks were successful
Build & Deploy KLZ Cables / 🔍 Prepare Environment (push) Successful in 8s
Build & Deploy KLZ Cables / 🧪 Quality Assurance (push) Has been skipped
Build & Deploy KLZ Cables / 🏗️ Build App (push) Has been skipped
Build & Deploy KLZ Cables / 🚀 Deploy (push) Has been skipped
Build & Deploy KLZ Cables / ⚡ PageSpeed (push) Has been skipped
Build & Deploy KLZ Cables / 🔔 Notifications (push) Successful in 1s
2026-02-06 19:19:40 +01:00
e3f7344daf chore: traefik labels
Some checks failed
Build & Deploy KLZ Cables / 🔍 Prepare Environment (push) Successful in 10s
Build & Deploy KLZ Cables / 🏗️ Build App (push) Successful in 26s
Build & Deploy KLZ Cables / 🧪 Quality Assurance (push) Failing after 1m34s
Build & Deploy KLZ Cables / 🚀 Deploy (push) Has been skipped
Build & Deploy KLZ Cables / ⚡ PageSpeed (push) Has been skipped
Build & Deploy KLZ Cables / 🔔 Notifications (push) Successful in 2s
2026-02-06 18:36:34 +01:00
21a7b0ade2 feat: Implement replyTo for contact form emails and refine success/error message layout. 2026-02-06 18:24:58 +01:00
d027fbeac2 chore: Standardize error message extraction in contact form actions and mailer, and add Directus restart to the Directus sync script.
All checks were successful
Build & Deploy KLZ Cables / 🔍 Prepare Environment (push) Successful in 14s
Build & Deploy KLZ Cables / 🧪 Quality Assurance (push) Has been skipped
Build & Deploy KLZ Cables / 🏗️ Build App (push) Has been skipped
Build & Deploy KLZ Cables / 🚀 Deploy (push) Has been skipped
Build & Deploy KLZ Cables / ⚡ PageSpeed (push) Has been skipped
Build & Deploy KLZ Cables / 🔔 Notifications (push) Successful in 2s
2026-02-06 17:55:43 +01:00
8a751998eb feat: Add COOKIE_DOMAIN to .env and improve project name fallback logic in sync-directus script
Some checks failed
Build & Deploy KLZ Cables / 🔍 Prepare Environment (push) Successful in 11s
Build & Deploy KLZ Cables / 🧪 Quality Assurance (push) Successful in 1m40s
Build & Deploy KLZ Cables / 🏗️ Build App (push) Failing after 17m34s
Build & Deploy KLZ Cables / 🚀 Deploy (push) Has been cancelled
Build & Deploy KLZ Cables / ⚡ PageSpeed (push) Has been cancelled
Build & Deploy KLZ Cables / 🔔 Notifications (push) Has been cancelled
2026-02-06 17:37:53 +01:00
48c3e1d013 chore: rename scripts 2026-02-06 17:19:57 +01:00
3df4b44b8d fix: center success and error messages within the RequestQuoteForm component.
All checks were successful
Build & Deploy KLZ Cables / 🔍 Prepare Environment (push) Successful in 7s
Build & Deploy KLZ Cables / 🧪 Quality Assurance (push) Successful in 1m31s
Build & Deploy KLZ Cables / 🏗️ Build App (push) Successful in 3m53s
Build & Deploy KLZ Cables / 🚀 Deploy (push) Successful in 34s
Build & Deploy KLZ Cables / ⚡ PageSpeed (push) Successful in 9m46s
Build & Deploy KLZ Cables / 🔔 Notifications (push) Successful in 2s
2026-02-06 17:08:39 +01:00
07e0f237b9 feat: Introduce metadata-only retrieval functions for posts, products, and pages to optimize sitemap generation.
Some checks failed
Build & Deploy KLZ Cables / 🔍 Prepare Environment (push) Successful in 35s
Build & Deploy KLZ Cables / 🧪 Quality Assurance (push) Successful in 1m30s
Build & Deploy KLZ Cables / 🏗️ Build App (push) Successful in 4m39s
Build & Deploy KLZ Cables / 🚀 Deploy (push) Successful in 46s
Build & Deploy KLZ Cables / 🔔 Notifications (push) Has been cancelled
Build & Deploy KLZ Cables / ⚡ PageSpeed (push) Has been cancelled
2026-02-06 17:01:25 +01:00
57a3944301 feat: Update gatekeeper image to latest, add new environment variables, and allow gatekeeper's own paths to prevent redirect loops.
Some checks failed
Build & Deploy KLZ Cables / 🔍 Prepare Environment (push) Successful in 9s
Build & Deploy KLZ Cables / 🏗️ Build App (push) Successful in 18s
Build & Deploy KLZ Cables / 🧪 Quality Assurance (push) Successful in 1m45s
Build & Deploy KLZ Cables / 🚀 Deploy (push) Successful in 27s
Build & Deploy KLZ Cables / ⚡ PageSpeed (push) Failing after 4m9s
Build & Deploy KLZ Cables / 🔔 Notifications (push) Successful in 1s
2026-02-06 15:26:21 +01:00
5fe0a8d83e chore: Hardcode 'compress' Traefik middleware for Directus, removing the dynamic AUTH_MIDDLEWARE variable.
All checks were successful
Build & Deploy KLZ Cables / 🔍 Prepare Environment (push) Successful in 7s
Build & Deploy KLZ Cables / 🧪 Quality Assurance (push) Has been skipped
Build & Deploy KLZ Cables / 🏗️ Build App (push) Has been skipped
Build & Deploy KLZ Cables / 🏗️ Build Gatekeeper (push) Has been skipped
Build & Deploy KLZ Cables / 🚀 Deploy (push) Has been skipped
Build & Deploy KLZ Cables / ⚡ PageSpeed (push) Has been skipped
Build & Deploy KLZ Cables / 🔔 Notifications (push) Successful in 2s
2026-02-06 14:31:39 +01:00
8062d33f35 fix: Validate server-only environment variables exclusively on the server to prevent browser validation errors.
Some checks failed
Build & Deploy KLZ Cables / 🔍 Prepare Environment (push) Successful in 12s
Build & Deploy KLZ Cables / 🧪 Quality Assurance (push) Successful in 1m39s
Build & Deploy KLZ Cables / 🏗️ Build Gatekeeper (push) Successful in 29s
Build & Deploy KLZ Cables / 🏗️ Build App (push) Successful in 3m56s
Build & Deploy KLZ Cables / 🚀 Deploy (push) Successful in 47s
Build & Deploy KLZ Cables / 🔔 Notifications (push) Has been cancelled
Build & Deploy KLZ Cables / ⚡ PageSpeed (push) Has been cancelled
2026-02-06 14:19:29 +01:00
ebe67afd73 feat: Broaden middleware's internal URL correction to include hosts like klz-app and localhost, and update Varnish's health check URL to /health.
All checks were successful
Build & Deploy KLZ Cables / 🔍 Prepare Environment (push) Successful in 7s
Build & Deploy KLZ Cables / 🧪 Quality Assurance (push) Successful in 1m31s
Build & Deploy KLZ Cables / 🏗️ Build Gatekeeper (push) Successful in 23s
Build & Deploy KLZ Cables / 🏗️ Build App (push) Successful in 5m7s
Build & Deploy KLZ Cables / 🚀 Deploy (push) Successful in 50s
Build & Deploy KLZ Cables / ⚡ PageSpeed (push) Successful in 9m7s
Build & Deploy KLZ Cables / 🔔 Notifications (push) Successful in 2s
2026-02-06 13:23:26 +01:00
b74f6b6f9e fix(middleware): strip port 3000 from reconstructed URL to prevent hydration mismatch
All checks were successful
Build & Deploy KLZ Cables / 🔍 Prepare Environment (push) Successful in 7s
Build & Deploy KLZ Cables / 🧪 Quality Assurance (push) Successful in 1m30s
Build & Deploy KLZ Cables / 🏗️ Build Gatekeeper (push) Successful in 21s
Build & Deploy KLZ Cables / 🏗️ Build App (push) Successful in 4m38s
Build & Deploy KLZ Cables / 🚀 Deploy (push) Successful in 50s
Build & Deploy KLZ Cables / ⚡ PageSpeed (push) Successful in 7m12s
Build & Deploy KLZ Cables / 🔔 Notifications (push) Successful in 2s
2026-02-06 12:35:33 +01:00
24eea9a2fe fix(middleware): resolve 0.0.0.0 hydration issues and add header logging
Some checks failed
Build & Deploy KLZ Cables / 🔍 Prepare Environment (push) Successful in 7s
Build & Deploy KLZ Cables / 🧪 Quality Assurance (push) Successful in 1m29s
Build & Deploy KLZ Cables / 🏗️ Build Gatekeeper (push) Successful in 20s
Build & Deploy KLZ Cables / 🏗️ Build App (push) Successful in 4m32s
Build & Deploy KLZ Cables / 🚀 Deploy (push) Successful in 48s
Build & Deploy KLZ Cables / 🔔 Notifications (push) Has been cancelled
Build & Deploy KLZ Cables / ⚡ PageSpeed (push) Has been cancelled
2026-02-06 12:22:32 +01:00
c70288bba7 feat: Enhance Directus URL resolution for internal and proxy paths, and adjust Traefik host variable interpolation.
Some checks failed
Build & Deploy KLZ Cables / 🔍 Prepare Environment (push) Successful in 39s
Build & Deploy KLZ Cables / 🧪 Quality Assurance (push) Successful in 1m33s
Build & Deploy KLZ Cables / 🏗️ Build Gatekeeper (push) Successful in 22s
Build & Deploy KLZ Cables / 🏗️ Build App (push) Successful in 4m47s
Build & Deploy KLZ Cables / 🚀 Deploy (push) Successful in 48s
Build & Deploy KLZ Cables / ⚡ PageSpeed (push) Failing after 3m50s
Build & Deploy KLZ Cables / 🔔 Notifications (push) Successful in 2s
2026-02-06 11:29:10 +01:00
d438dbdc9d feat: Add Varnish backend health check to deploy workflow and set APP_VERSION in Varnish service.
All checks were successful
Build & Deploy KLZ Cables / 🔍 Prepare Environment (push) Successful in 10s
Build & Deploy KLZ Cables / 🧪 Quality Assurance (push) Successful in 1m36s
Build & Deploy KLZ Cables / 🏗️ Build Gatekeeper (push) Successful in 21s
Build & Deploy KLZ Cables / 🏗️ Build App (push) Successful in 4m41s
Build & Deploy KLZ Cables / 🚀 Deploy (push) Successful in 45s
Build & Deploy KLZ Cables / ⚡ PageSpeed (push) Successful in 4m18s
Build & Deploy KLZ Cables / 🔔 Notifications (push) Successful in 1s
2026-02-06 00:21:39 +01:00
e0c4aaf298 feat: Add PROJECT_NAME and COOKIE_DOMAIN environment variables to the deploy workflow.
Some checks failed
Build & Deploy KLZ Cables / 🔍 Prepare Environment (push) Successful in 7s
Build & Deploy KLZ Cables / 🧪 Quality Assurance (push) Successful in 1m32s
Build & Deploy KLZ Cables / 🏗️ Build Gatekeeper (push) Successful in 24s
Build & Deploy KLZ Cables / 🏗️ Build App (push) Successful in 4m33s
Build & Deploy KLZ Cables / 🚀 Deploy (push) Successful in 44s
Build & Deploy KLZ Cables / ⚡ PageSpeed (push) Failing after 3m56s
Build & Deploy KLZ Cables / 🔔 Notifications (push) Successful in 2s
2026-02-05 23:58:03 +01:00
f44487eeac feat: Add configurable cookie domain to gatekeeper and enhance Varnish backend configuration with health probes and increased timeouts.
Some checks failed
Build & Deploy KLZ Cables / 🔍 Prepare Environment (push) Successful in 11s
Build & Deploy KLZ Cables / 🧪 Quality Assurance (push) Successful in 1m41s
Build & Deploy KLZ Cables / 🏗️ Build Gatekeeper (push) Successful in 28s
Build & Deploy KLZ Cables / 🏗️ Build App (push) Successful in 3m56s
Build & Deploy KLZ Cables / 🚀 Deploy (push) Successful in 46s
Build & Deploy KLZ Cables / ⚡ PageSpeed (push) Failing after 3m7s
Build & Deploy KLZ Cables / 🔔 Notifications (push) Successful in 3s
2026-02-05 23:29:34 +01:00
a82b95a28f feat: Adjust backend timeouts, add /cms to cache bypass, and include X-Backend-IP in responses.
Some checks failed
Build & Deploy KLZ Cables / 🔍 Prepare Environment (push) Successful in 9s
Build & Deploy KLZ Cables / 🧪 Quality Assurance (push) Successful in 1m33s
Build & Deploy KLZ Cables / 🏗️ Build Gatekeeper (push) Successful in 20s
Build & Deploy KLZ Cables / 🏗️ Build App (push) Successful in 4m29s
Build & Deploy KLZ Cables / 🚀 Deploy (push) Successful in 45s
Build & Deploy KLZ Cables / ⚡ PageSpeed (push) Failing after 2m7s
Build & Deploy KLZ Cables / 🔔 Notifications (push) Successful in 2s
2026-02-05 22:41:26 +01:00
ab688a3dab refactor: rename app service to klz-app and establish a new internal Docker network for service communication.
Some checks failed
Build & Deploy KLZ Cables / 🔍 Prepare Environment (push) Successful in 34s
Build & Deploy KLZ Cables / 🧪 Quality Assurance (push) Successful in 1m31s
Build & Deploy KLZ Cables / 🏗️ Build Gatekeeper (push) Successful in 20s
Build & Deploy KLZ Cables / 🏗️ Build App (push) Successful in 9m37s
Build & Deploy KLZ Cables / 🚀 Deploy (push) Successful in 1m41s
Build & Deploy KLZ Cables / ⚡ PageSpeed (push) Failing after 3m6s
Build & Deploy KLZ Cables / 🔔 Notifications (push) Successful in 2s
2026-02-05 21:54:46 +01:00
a0ce37708e fix(ci): fix shell compatibility and scp order in deployment workflow
Some checks failed
Build & Deploy KLZ Cables / 🔍 Prepare Environment (push) Successful in 6s
Build & Deploy KLZ Cables / 🧪 Quality Assurance (push) Successful in 1m29s
Build & Deploy KLZ Cables / 🏗️ Build Gatekeeper (push) Successful in 23s
Build & Deploy KLZ Cables / 🏗️ Build App (push) Successful in 4m34s
Build & Deploy KLZ Cables / 🚀 Deploy (push) Successful in 44s
Build & Deploy KLZ Cables / ⚡ PageSpeed (push) Failing after 2m21s
Build & Deploy KLZ Cables / 🔔 Notifications (push) Successful in 2s
2026-02-05 19:25:53 +01:00
0379d1f05d fix: restore critical build/deploy fixes and standardize destructive UI state
Some checks failed
Build & Deploy KLZ Cables / 🔍 Prepare Environment (push) Successful in 9s
Build & Deploy KLZ Cables / 🧪 Quality Assurance (push) Successful in 1m35s
Build & Deploy KLZ Cables / 🏗️ Build Gatekeeper (push) Successful in 21s
Build & Deploy KLZ Cables / 🏗️ Build App (push) Successful in 4m38s
Build & Deploy KLZ Cables / 🚀 Deploy (push) Failing after 12s
Build & Deploy KLZ Cables / ⚡ PageSpeed (push) Has been skipped
Build & Deploy KLZ Cables / 🔔 Notifications (push) Successful in 2s
2026-02-05 14:16:28 +01:00
50347d049d style: add danger button variant and fix contact form error state
Some checks failed
Build & Deploy KLZ Cables / 🔍 Prepare Environment (push) Successful in 6s
Build & Deploy KLZ Cables / 🧪 Quality Assurance (push) Successful in 2m3s
Build & Deploy KLZ Cables / 🏗️ Build Gatekeeper (push) Successful in 33s
Build & Deploy KLZ Cables / 🏗️ Build App (push) Successful in 3m59s
Build & Deploy KLZ Cables / 🚀 Deploy (push) Failing after 9s
Build & Deploy KLZ Cables / ⚡ PageSpeed (push) Has been skipped
Build & Deploy KLZ Cables / 🔔 Notifications (push) Successful in 3s
2026-02-05 14:08:06 +01:00
9678181927 fix: copy varnish config and ensure server directories in deployment
Some checks failed
Build & Deploy KLZ Cables / 🔍 Prepare Environment (push) Successful in 11s
Build & Deploy KLZ Cables / 🧪 Quality Assurance (push) Successful in 1m45s
Build & Deploy KLZ Cables / 🏗️ Build Gatekeeper (push) Successful in 33s
Build & Deploy KLZ Cables / 🏗️ Build App (push) Successful in 3m54s
Build & Deploy KLZ Cables / 🚀 Deploy (push) Failing after 9s
Build & Deploy KLZ Cables / ⚡ PageSpeed (push) Has been skipped
Build & Deploy KLZ Cables / 🔔 Notifications (push) Successful in 1s
2026-02-05 14:03:36 +01:00
3ffaafefe5 build: use --legacy-peer-deps in CI workflow for peer dependency resolution
Some checks failed
Build & Deploy KLZ Cables / 🔍 Prepare Environment (push) Successful in 7s
Build & Deploy KLZ Cables / 🧪 Quality Assurance (push) Successful in 1m29s
Build & Deploy KLZ Cables / 🏗️ Build Gatekeeper (push) Successful in 21s
Build & Deploy KLZ Cables / 🏗️ Build App (push) Successful in 4m37s
Build & Deploy KLZ Cables / 🚀 Deploy (push) Failing after 36s
Build & Deploy KLZ Cables / ⚡ PageSpeed (push) Has been skipped
Build & Deploy KLZ Cables / 🔔 Notifications (push) Successful in 2s
2026-02-05 13:40:21 +01:00
e5bf8c861c build: fix peer dependency conflict in Dockerfile
Some checks failed
Build & Deploy KLZ Cables / 🔍 Prepare Environment (push) Successful in 10s
Build & Deploy KLZ Cables / 🧪 Quality Assurance (push) Failing after 13s
Build & Deploy KLZ Cables / 🏗️ Build Gatekeeper (push) Successful in 21s
Build & Deploy KLZ Cables / 🏗️ Build App (push) Successful in 11m56s
Build & Deploy KLZ Cables / 🚀 Deploy (push) Has been skipped
Build & Deploy KLZ Cables / ⚡ PageSpeed (push) Has been skipped
Build & Deploy KLZ Cables / 🔔 Notifications (push) Successful in 2s
2026-02-05 13:24:28 +01:00
651e14d665 build: update @mintel/mail to 1.2.3
Some checks failed
Build & Deploy KLZ Cables / 🔍 Prepare Environment (push) Successful in 7s
Build & Deploy KLZ Cables / 🧪 Quality Assurance (push) Failing after 12s
Build & Deploy KLZ Cables / 🏗️ Build Gatekeeper (push) Successful in 20s
Build & Deploy KLZ Cables / 🏗️ Build App (push) Failing after 35s
Build & Deploy KLZ Cables / 🚀 Deploy (push) Has been skipped
Build & Deploy KLZ Cables / ⚡ PageSpeed (push) Has been skipped
Build & Deploy KLZ Cables / 🔔 Notifications (push) Successful in 2s
2026-02-05 13:12:54 +01:00
580cd6789c feat: Allow skipping MAIL_HOST environment variable validation during build processes using SKIP_RUNTIME_ENV_VALIDATION.
Some checks failed
Build & Deploy KLZ Cables / 🔍 Prepare Environment (push) Successful in 8s
Build & Deploy KLZ Cables / 🧪 Quality Assurance (push) Successful in 1m35s
Build & Deploy KLZ Cables / 🏗️ Build Gatekeeper (push) Successful in 20s
Build & Deploy KLZ Cables / 🏗️ Build App (push) Failing after 5m36s
Build & Deploy KLZ Cables / 🚀 Deploy (push) Has been skipped
Build & Deploy KLZ Cables / ⚡ PageSpeed (push) Has been skipped
Build & Deploy KLZ Cables / 🔔 Notifications (push) Successful in 2s
2026-02-05 12:48:56 +01:00
db4cf354ff feat: Add conditional MAIL_HOST validation, lazy-load mailer, and update Gitea workflow to use vars for mail and Sentry environment variables.
Some checks failed
Build & Deploy KLZ Cables / 🔍 Prepare Environment (push) Successful in 14s
Build & Deploy KLZ Cables / 🧪 Quality Assurance (push) Successful in 1m44s
Build & Deploy KLZ Cables / 🏗️ Build App (push) Failing after 1m54s
Build & Deploy KLZ Cables / 🏗️ Build Gatekeeper (push) Successful in 31s
Build & Deploy KLZ Cables / 🚀 Deploy (push) Has been skipped
Build & Deploy KLZ Cables / ⚡ PageSpeed (push) Has been skipped
Build & Deploy KLZ Cables / 🔔 Notifications (push) Successful in 2s
2026-02-05 12:39:51 +01:00
e8957e0672 feat: Add Varnish caching service and configuration, adjusting Traefik routing and implementing a rate limit middleware.
Some checks failed
Build & Deploy KLZ Cables / 🔍 Prepare Environment (push) Successful in 7s
Build & Deploy KLZ Cables / 🧪 Quality Assurance (push) Successful in 1m34s
Build & Deploy KLZ Cables / 🏗️ Build Gatekeeper (push) Successful in 21s
Build & Deploy KLZ Cables / 🏗️ Build App (push) Failing after 4m59s
Build & Deploy KLZ Cables / 🚀 Deploy (push) Has been skipped
Build & Deploy KLZ Cables / ⚡ PageSpeed (push) Has been skipped
Build & Deploy KLZ Cables / 🔔 Notifications (push) Successful in 2s
2026-02-05 01:54:17 +01:00
7ef0bca9f6 feat: Implement dual email sending for contact form submissions, including a customer confirmation and internal notification, by refactoring email rendering to accept pre-rendered HTML. 2026-02-05 01:54:01 +01:00
198944649a feat: Add new 1x1200/35 product configuration to data files and datasheets, including scripts to manage its technical and ampacity values.
All checks were successful
Build & Deploy KLZ Cables / 🔍 Prepare Environment (push) Successful in 7s
Build & Deploy KLZ Cables / 🧪 Quality Assurance (push) Successful in 1m37s
Build & Deploy KLZ Cables / 🏗️ Build Gatekeeper (push) Successful in 21s
Build & Deploy KLZ Cables / 🏗️ Build App (push) Successful in 4m7s
Build & Deploy KLZ Cables / 🚀 Deploy (push) Successful in 44s
Build & Deploy KLZ Cables / ⚡ PageSpeed (push) Successful in 1m18s
Build & Deploy KLZ Cables / 🔔 Notifications (push) Successful in 2s
2026-02-04 17:52:21 +01:00
6aa741ab0a refactor: remove npm cache restoration steps and update Gotify failure notification conditions
All checks were successful
Build & Deploy KLZ Cables / 🔍 Prepare Environment (push) Successful in 8s
Build & Deploy KLZ Cables / 🧪 Quality Assurance (push) Successful in 1m43s
Build & Deploy KLZ Cables / 🏗️ Build Gatekeeper (push) Successful in 22s
Build & Deploy KLZ Cables / 🏗️ Build App (push) Successful in 4m21s
Build & Deploy KLZ Cables / 🚀 Deploy (push) Successful in 44s
Build & Deploy KLZ Cables / ⚡ PageSpeed (push) Successful in 6m6s
Build & Deploy KLZ Cables / 🔔 Notifications (push) Successful in 2s
2026-02-02 18:43:21 +01:00
f69952a5da refactor: Enable pino-pretty transport exclusively for development environments.
All checks were successful
Build & Deploy KLZ Cables / 🔍 Prepare Environment (push) Successful in 7s
Build & Deploy KLZ Cables / 🏗️ Build App (push) Successful in 4m6s
Build & Deploy KLZ Cables / 🏗️ Build Gatekeeper (push) Successful in 22s
Build & Deploy KLZ Cables / 🧪 Quality Assurance (push) Successful in 11m52s
Build & Deploy KLZ Cables / 🚀 Deploy (push) Successful in 42s
Build & Deploy KLZ Cables / ⚡ PageSpeed (push) Successful in 4m40s
Build & Deploy KLZ Cables / 🔔 Notifications (push) Successful in 1s
2026-02-02 17:43:54 +01:00
81af9bf3dd feat: Add Gotify notification service and integrate it with error reporting and contact form actions, making error reporting methods asynchronous.
Some checks failed
Build & Deploy KLZ Cables / 🔍 Prepare Environment (push) Successful in 7s
Build & Deploy KLZ Cables / 🏗️ Build App (push) Successful in 4m3s
Build & Deploy KLZ Cables / 🏗️ Build Gatekeeper (push) Successful in 20s
Build & Deploy KLZ Cables / 🚀 Deploy (push) Has been cancelled
Build & Deploy KLZ Cables / ⚡ PageSpeed (push) Has been cancelled
Build & Deploy KLZ Cables / 🔔 Notifications (push) Has been cancelled
Build & Deploy KLZ Cables / 🧪 Quality Assurance (push) Has been cancelled
2026-02-02 17:38:15 +01:00
f1b617e967 feat: add contact form submission status translations 2026-02-02 17:34:18 +01:00
d6be9beebf fix: directus
Some checks failed
Build & Deploy KLZ Cables / 🔍 Prepare Environment (push) Successful in 8s
Build & Deploy KLZ Cables / 🏗️ Build App (push) Successful in 6m36s
Build & Deploy KLZ Cables / 🏗️ Build Gatekeeper (push) Successful in 23s
Build & Deploy KLZ Cables / 🧪 Quality Assurance (push) Successful in 11m59s
Build & Deploy KLZ Cables / 🚀 Deploy (push) Successful in 43s
Build & Deploy KLZ Cables / ⚡ PageSpeed (push) Failing after 7m22s
Build & Deploy KLZ Cables / 🔔 Notifications (push) Successful in 2s
2026-02-02 16:46:26 +01:00
0a797260e3 feat: Introduce NEXT_PUBLIC_TARGET build argument and abstract server-side error reporting to a dedicated service.
Some checks failed
Build & Deploy KLZ Cables / 🔍 Prepare Environment (push) Successful in 7s
Build & Deploy KLZ Cables / 🏗️ Build App (push) Successful in 4m2s
Build & Deploy KLZ Cables / 🏗️ Build Gatekeeper (push) Successful in 21s
Build & Deploy KLZ Cables / 🧪 Quality Assurance (push) Successful in 11m56s
Build & Deploy KLZ Cables / 🚀 Deploy (push) Successful in 43s
Build & Deploy KLZ Cables / ⚡ PageSpeed (push) Failing after 2m7s
Build & Deploy KLZ Cables / 🔔 Notifications (push) Successful in 1s
2026-02-02 15:27:04 +01:00
2a4cc76292 feat: Disable Lighthouse CI report uploading and generate a local summary table of PageSpeed scores instead. 2026-02-02 15:20:33 +01:00
f87eb27f41 ci: tighten docker maintenance window and fix yaml syntax
Some checks failed
Build & Deploy KLZ Cables / 🔍 Prepare Environment (push) Successful in 19s
Build & Deploy KLZ Cables / 🧪 Quality Assurance (push) Successful in 1m37s
Build & Deploy KLZ Cables / 🏗️ Build Gatekeeper (push) Successful in 22s
Build & Deploy KLZ Cables / 🏗️ Build App (push) Successful in 4m23s
Build & Deploy KLZ Cables / 🚀 Deploy (push) Successful in 43s
Build & Deploy KLZ Cables / ⚡ PageSpeed (push) Failing after 2m23s
Build & Deploy KLZ Cables / 🔔 Notifications (push) Successful in 2s
2026-02-02 14:57:14 +01:00
acd86099e5 ci: add proactive docker cleanup and tighten prune filters
Some checks failed
Build & Deploy KLZ Cables / 🧪 Quality Assurance (push) Has been cancelled
Build & Deploy KLZ Cables / 🏗️ Build App (push) Has been cancelled
Build & Deploy KLZ Cables / 🏗️ Build Gatekeeper (push) Has been cancelled
Build & Deploy KLZ Cables / 🚀 Deploy (push) Has been cancelled
Build & Deploy KLZ Cables / ⚡ PageSpeed (push) Has been cancelled
Build & Deploy KLZ Cables / 🔔 Notifications (push) Has been cancelled
Build & Deploy KLZ Cables / 🔍 Prepare Environment (push) Has been cancelled
2026-02-02 14:49:15 +01:00
5ab9791c72 ci: use shallow clones to resolve 'No space left on device' errors
Some checks failed
Build & Deploy KLZ Cables / 🔍 Prepare Environment (push) Successful in 7s
Build & Deploy KLZ Cables / 🏗️ Build App (push) Failing after 10s
Build & Deploy KLZ Cables / 🏗️ Build Gatekeeper (push) Failing after 9s
Build & Deploy KLZ Cables / 🚀 Deploy (push) Has been cancelled
Build & Deploy KLZ Cables / ⚡ PageSpeed (push) Has been cancelled
Build & Deploy KLZ Cables / 🔔 Notifications (push) Has been cancelled
Build & Deploy KLZ Cables / 🧪 Quality Assurance (push) Has been cancelled
2026-02-02 14:45:15 +01:00
8152ccd5df ci: standardize runner containers and harden script output logic
Some checks failed
Build & Deploy KLZ Cables / 🔍 Prepare Environment (push) Failing after 57s
Build & Deploy KLZ Cables / 🧪 Quality Assurance (push) Has been skipped
Build & Deploy KLZ Cables / 🏗️ Build App (push) Has been skipped
Build & Deploy KLZ Cables / 🏗️ Build Gatekeeper (push) Has been skipped
Build & Deploy KLZ Cables / 🚀 Deploy (push) Has been skipped
Build & Deploy KLZ Cables / ⚡ PageSpeed (push) Has been skipped
Build & Deploy KLZ Cables / 🔔 Notifications (push) Successful in 2s
2026-02-02 14:35:49 +01:00
8eeb571c2d feat: Add deployment target configuration for environment-specific settings, Sentry integration, and CMS notice logic.
Some checks failed
Build & Deploy KLZ Cables / 🔍 Prepare Environment (push) Failing after 56s
Build & Deploy KLZ Cables / 🧪 Quality Assurance (push) Has been skipped
Build & Deploy KLZ Cables / 🏗️ Build App (push) Has been skipped
Build & Deploy KLZ Cables / 🏗️ Build Gatekeeper (push) Has been skipped
Build & Deploy KLZ Cables / 🚀 Deploy (push) Has been skipped
Build & Deploy KLZ Cables / ⚡ PageSpeed (push) Has been skipped
Build & Deploy KLZ Cables / 🔔 Notifications (push) Successful in 2s
2026-02-02 14:31:03 +01:00
b1854d5255 ci: optimize builds with parallelism, change detection, and registry caching
Some checks failed
Build & Deploy KLZ Cables / 🔍 Prepare Environment (push) Successful in 22s
Build & Deploy KLZ Cables / 🧪 Quality Assurance (push) Successful in 1m32s
Build & Deploy KLZ Cables / 🏗️ Build App (push) Failing after 5m24s
Build & Deploy KLZ Cables / 🏗️ Build Gatekeeper (push) Failing after 12s
Build & Deploy KLZ Cables / 🚀 Deploy (push) Has been skipped
Build & Deploy KLZ Cables / ⚡ PageSpeed (push) Has been skipped
Build & Deploy KLZ Cables / 🔔 Notifications (push) Successful in 1s
2026-02-02 14:18:01 +01:00
7f4f970a38 feat: Configure deploy workflow concurrency to dynamically group by environment and enable in-progress cancellation. 2026-02-02 14:17:04 +01:00
e5908c757c ci: ensure docker cli is available in build job
Some checks failed
Build & Deploy KLZ Cables / 🔍 Prepare Environment (push) Successful in 27s
Build & Deploy KLZ Cables / 🧪 Quality Assurance (push) Successful in 1m32s
Build & Deploy KLZ Cables / 🏗️ Build & Push (push) Successful in 5m7s
Build & Deploy KLZ Cables / 🚀 Deploy (push) Successful in 43s
Build & Deploy KLZ Cables / ⚡ PageSpeed (push) Failing after 7m52s
Build & Deploy KLZ Cables / 🔔 Notifications (push) Successful in 2s
2026-02-02 14:11:09 +01:00
70efb0c593 ci: update Gitea deployment workflow configuration
Some checks failed
Build & Deploy KLZ Cables / 🔍 Prepare Environment (push) Successful in 25s
Build & Deploy KLZ Cables / 🏗️ Build & Push (push) Failing after 34s
Build & Deploy KLZ Cables / 🧪 Quality Assurance (push) Successful in 1m34s
Build & Deploy KLZ Cables / 🚀 Deploy (push) Has been skipped
Build & Deploy KLZ Cables / ⚡ PageSpeed (push) Has been skipped
Build & Deploy KLZ Cables / 🔔 Notifications (push) Successful in 2s
2026-02-02 14:04:05 +01:00
479a36f1d0 feat: Improve CMS health check error reporting and limit connectivity notice display to developer and debug environments.
Some checks failed
Build & Deploy KLZ Cables / 🔍 Prepare Environment (push) Successful in 22s
Build & Deploy KLZ Cables / 🧪 Quality Assurance (push) Successful in 1m32s
Build & Deploy KLZ Cables / 🚀 Deploy (push) Has been cancelled
Build & Deploy KLZ Cables / ⚡ PageSpeed (push) Has been cancelled
Build & Deploy KLZ Cables / 🔔 Notifications (push) Has been cancelled
Build & Deploy KLZ Cables / 🏗️ Build & Push (push) Has been cancelled
2026-02-02 13:42:20 +01:00
372a0c5cfa feat: Add new logo and configure application icons in metadata and manifest.
All checks were successful
Build & Deploy KLZ Cables / 🔍 Prepare Environment (push) Successful in 21s
Build & Deploy KLZ Cables / 🧪 Quality Assurance (push) Successful in 1m33s
Build & Deploy KLZ Cables / 🏗️ Build & Push (push) Successful in 2m59s
Build & Deploy KLZ Cables / 🚀 Deploy (push) Successful in 45s
Build & Deploy KLZ Cables / ⚡ PageSpeed (push) Successful in 4m56s
Build & Deploy KLZ Cables / 🔔 Notifications (push) Successful in 2s
2026-02-02 13:08:19 +01:00
42b06e1ef8 refactor: Replace hardcoded domain with SITE_URL constant across metadata and schema definitions for improved configurability.
All checks were successful
Build & Deploy KLZ Cables / 🔍 Prepare Environment (push) Successful in 20s
Build & Deploy KLZ Cables / 🧪 Quality Assurance (push) Successful in 1m30s
Build & Deploy KLZ Cables / 🏗️ Build & Push (push) Successful in 3m14s
Build & Deploy KLZ Cables / 🚀 Deploy (push) Successful in 42s
Build & Deploy KLZ Cables / ⚡ PageSpeed (push) Successful in 5m0s
Build & Deploy KLZ Cables / 🔔 Notifications (push) Successful in 2s
2026-02-02 12:10:09 +01:00
b25fdd877a fix(ci): use correct GPG key ID and multi-method fetch for Chromium PPA on Ubuntu Noble
Some checks failed
Build & Deploy KLZ Cables / 🔍 Prepare Environment (push) Successful in 21s
Build & Deploy KLZ Cables / 🧪 Quality Assurance (push) Successful in 1m31s
Build & Deploy KLZ Cables / 🏗️ Build & Push (push) Successful in 2m47s
Build & Deploy KLZ Cables / 🚀 Deploy (push) Successful in 39s
Build & Deploy KLZ Cables / ⚡ PageSpeed (push) Failing after 2m36s
Build & Deploy KLZ Cables / 🔔 Notifications (push) Successful in 2s
2026-02-02 01:21:16 +01:00
dd23310ac4 fix(ci): prioritize PPA chromium over snap wrapper and pass explicit chrome path to lhci
Some checks failed
Build & Deploy KLZ Cables / 🔍 Prepare Environment (push) Successful in 21s
Build & Deploy KLZ Cables / 🧪 Quality Assurance (push) Successful in 1m30s
Build & Deploy KLZ Cables / 🏗️ Build & Push (push) Successful in 2m52s
Build & Deploy KLZ Cables / 🚀 Deploy (push) Successful in 40s
Build & Deploy KLZ Cables / ⚡ PageSpeed (push) Failing after 1m6s
Build & Deploy KLZ Cables / 🔔 Notifications (push) Successful in 2s
2026-02-01 22:50:33 +01:00
f6f28a4529 fix(ci): robust chromium install for arm64 with os detection and gpg retries
Some checks failed
Build & Deploy KLZ Cables / 🔍 Prepare Environment (push) Successful in 21s
Build & Deploy KLZ Cables / 🧪 Quality Assurance (push) Successful in 1m32s
Build & Deploy KLZ Cables / 🏗️ Build & Push (push) Successful in 2m50s
Build & Deploy KLZ Cables / 🚀 Deploy (push) Successful in 40s
Build & Deploy KLZ Cables / ⚡ PageSpeed (push) Failing after 2m32s
Build & Deploy KLZ Cables / 🔔 Notifications (push) Successful in 2s
2026-02-01 22:30:22 +01:00
fc3635db86 fix(ci): replace deprecated apt-key with gpg dearmor and use robust keyserver fetch
Some checks failed
Build & Deploy KLZ Cables / 🔍 Prepare Environment (push) Successful in 20s
Build & Deploy KLZ Cables / 🧪 Quality Assurance (push) Successful in 1m31s
Build & Deploy KLZ Cables / 🏗️ Build & Push (push) Successful in 2m51s
Build & Deploy KLZ Cables / 🚀 Deploy (push) Successful in 42s
Build & Deploy KLZ Cables / ⚡ PageSpeed (push) Failing after 1m5s
Build & Deploy KLZ Cables / 🔔 Notifications (push) Successful in 2s
2026-02-01 21:41:21 +01:00
fc000353a9 feat: Add support for an internal Directus URL for server-side communication and enhance the health check with schema validation for the products collection.
Some checks failed
Build & Deploy KLZ Cables / 🔍 Prepare Environment (push) Successful in 21s
Build & Deploy KLZ Cables / 🧪 Quality Assurance (push) Successful in 1m33s
Build & Deploy KLZ Cables / 🏗️ Build & Push (push) Successful in 2m53s
Build & Deploy KLZ Cables / 🚀 Deploy (push) Successful in 40s
Build & Deploy KLZ Cables / ⚡ PageSpeed (push) Failing after 1m5s
Build & Deploy KLZ Cables / 🔔 Notifications (push) Successful in 1s
2026-02-01 21:22:30 +01:00
73c32c6d31 fix(ci): robust chromium installation to avoid snap issues on arm64
Some checks failed
Build & Deploy KLZ Cables / 🔍 Prepare Environment (push) Successful in 21s
Build & Deploy KLZ Cables / 🧪 Quality Assurance (push) Successful in 1m30s
Build & Deploy KLZ Cables / 🏗️ Build & Push (push) Successful in 2m51s
Build & Deploy KLZ Cables / 🚀 Deploy (push) Successful in 39s
Build & Deploy KLZ Cables / ⚡ PageSpeed (push) Failing after 1m6s
Build & Deploy KLZ Cables / 🔔 Notifications (push) Successful in 2s
2026-02-01 19:46:41 +01:00
381e0b121f fix(ci): install chromium instead of google-chrome-stable for arm64 support
Some checks failed
Build & Deploy KLZ Cables / 🔍 Prepare Environment (push) Successful in 21s
Build & Deploy KLZ Cables / 🧪 Quality Assurance (push) Successful in 1m29s
Build & Deploy KLZ Cables / 🏗️ Build & Push (push) Successful in 3m22s
Build & Deploy KLZ Cables / 🚀 Deploy (push) Successful in 39s
Build & Deploy KLZ Cables / ⚡ PageSpeed (push) Failing after 2m28s
Build & Deploy KLZ Cables / 🔔 Notifications (push) Successful in 2s
2026-02-01 18:13:51 +01:00
829c074c7f fix(ci): use bash and explicit if conditions to fix skip logic
Some checks failed
Build & Deploy KLZ Cables / 🔍 Prepare Environment (push) Successful in 20s
Build & Deploy KLZ Cables / 🧪 Quality Assurance (push) Successful in 1m34s
Build & Deploy KLZ Cables / 🏗️ Build & Push (push) Successful in 7m23s
Build & Deploy KLZ Cables / 🚀 Deploy (push) Successful in 40s
Build & Deploy KLZ Cables / ⚡ PageSpeed (push) Failing after 1m10s
Build & Deploy KLZ Cables / 🔔 Notifications (push) Successful in 2s
2026-02-01 17:58:18 +01:00
9189e813b2 fix(ci): skip build and deploy jobs when target is skip (chore commits) 2026-02-01 17:53:11 +01:00
249313cc37 chore: Configure Husky, Commitlint, and Lint-staged to enforce Git commit message conventions and run pre-commit checks.
Some checks failed
Build & Deploy KLZ Cables / 🔍 Prepare Environment (push) Successful in 21s
Build & Deploy KLZ Cables / 🧪 Quality Assurance (push) Has been skipped
Build & Deploy KLZ Cables / 🏗️ Build & Push (push) Failing after 6s
Build & Deploy KLZ Cables / 🚀 Deploy (push) Has been skipped
Build & Deploy KLZ Cables / ⚡ PageSpeed (push) Has been skipped
Build & Deploy KLZ Cables / 🔔 Notifications (push) Successful in 1s
2026-02-01 17:41:00 +01:00
8232971419 ci: Update Gitea deploy workflow.
Some checks failed
Build & Deploy KLZ Cables / 🔍 Prepare Environment (push) Successful in 21s
Build & Deploy KLZ Cables / 🧪 Quality Assurance (push) Successful in 1m33s
Build & Deploy KLZ Cables / 🚀 Deploy (push) Has been cancelled
Build & Deploy KLZ Cables / ⚡ PageSpeed (push) Has been cancelled
Build & Deploy KLZ Cables / 🔔 Notifications (push) Has been cancelled
Build & Deploy KLZ Cables / 🏗️ Build & Push (push) Has been cancelled
2026-02-01 17:37:38 +01:00
f41260e1db feat: implement automated Lighthouse CI testing for sitemap URLs with dedicated configuration and scripts. 2026-02-01 17:35:31 +01:00
950ef9d463 refactor: Replace custom X-Auth-Redirect header with standard HTTP redirect for unauthorized access.
All checks were successful
Build & Deploy KLZ Cables / 🔍 Prepare Environment (push) Successful in 20s
Build & Deploy KLZ Cables / 🧪 Quality Assurance (push) Successful in 1m25s
Build & Deploy KLZ Cables / 🏗️ Build & Push (push) Successful in 2m47s
Build & Deploy KLZ Cables / 🚀 Deploy (push) Successful in 30s
Build & Deploy KLZ Cables / 🔔 Notifications (push) Successful in 2s
2026-02-01 16:52:57 +01:00
fcb3169d04 feat: Implement a gatekeeper service for access control and add CMS health monitoring with a connectivity notice.
Some checks failed
Build & Deploy KLZ Cables / 🔍 Prepare Environment (push) Successful in 21s
Build & Deploy KLZ Cables / 🧪 Quality Assurance (push) Successful in 1m24s
Build & Deploy KLZ Cables / 🏗️ Build & Push (push) Failing after 3m8s
Build & Deploy KLZ Cables / 🚀 Deploy (push) Has been skipped
Build & Deploy KLZ Cables / 🔔 Notifications (push) Successful in 2s
2026-02-01 16:27:52 +01:00
9e87720494 chore: update .env.example with current environment variable examples.
All checks were successful
Build & Deploy KLZ Cables / 🔍 Prepare Environment (push) Successful in 20s
Build & Deploy KLZ Cables / 🧪 Quality Assurance (push) Successful in 1m24s
Build & Deploy KLZ Cables / 🏗️ Build & Push (push) Successful in 2m58s
Build & Deploy KLZ Cables / 🚀 Deploy (push) Successful in 29s
Build & Deploy KLZ Cables / 🔔 Notifications (push) Successful in 2s
2026-02-01 15:16:46 +01:00
6a403f47a0 feat: Add script and package.json commands to synchronize Directus data and uploads between local and staging environments.
Some checks failed
Build & Deploy KLZ Cables / 🔍 Prepare Environment (push) Successful in 20s
Build & Deploy KLZ Cables / 🧪 Quality Assurance (push) Successful in 1m23s
Build & Deploy KLZ Cables / 🏗️ Build & Push (push) Successful in 5m32s
Build & Deploy KLZ Cables / 🚀 Deploy (push) Failing after 8s
Build & Deploy KLZ Cables / 🔔 Notifications (push) Successful in 2s
2026-02-01 14:55:38 +01:00
2d8df53e36 refactor: Replace hardcoded Traefik service names with a dynamic project name variable.
All checks were successful
Build & Deploy KLZ Cables / 🔍 Prepare Environment (push) Successful in 21s
Build & Deploy KLZ Cables / 🧪 Quality Assurance (push) Successful in 1m25s
Build & Deploy KLZ Cables / 🏗️ Build & Push (push) Successful in 2m52s
Build & Deploy KLZ Cables / 🚀 Deploy (push) Successful in 29s
Build & Deploy KLZ Cables / 🔔 Notifications (push) Successful in 2s
2026-02-01 14:26:44 +01:00
6f49dbc56c ci: Update Gitea deploy workflow configuration.
All checks were successful
Build & Deploy KLZ Cables / 🔍 Prepare Environment (push) Successful in 20s
Build & Deploy KLZ Cables / 🧪 Quality Assurance (push) Successful in 1m24s
Build & Deploy KLZ Cables / 🏗️ Build & Push (push) Successful in 2m55s
Build & Deploy KLZ Cables / 🚀 Deploy (push) Successful in 30s
Build & Deploy KLZ Cables / 🔔 Notifications (push) Successful in 1s
2026-02-01 14:17:09 +01:00
ad2a477636 perf: Add Docker build cache mounts for npm ci and npm run build commands.
Some checks failed
Build & Deploy KLZ Cables / 🔍 Prepare Environment (push) Successful in 21s
Build & Deploy KLZ Cables / 🧪 Quality Assurance (push) Successful in 1m24s
Build & Deploy KLZ Cables / 🏗️ Build & Push (push) Successful in 5m48s
Build & Deploy KLZ Cables / 🚀 Deploy (push) Failing after 12s
Build & Deploy KLZ Cables / 🔔 Notifications (push) Successful in 3s
2026-02-01 14:00:44 +01:00
77a1067820 build: include .next/cache in Docker build context.
All checks were successful
Build & Deploy KLZ Cables / 🔍 Prepare Environment (push) Successful in 21s
Build & Deploy KLZ Cables / 🧪 Quality Assurance (push) Successful in 1m22s
Build & Deploy KLZ Cables / 🏗️ Build & Push (push) Successful in 5m42s
Build & Deploy KLZ Cables / 🚀 Deploy (push) Successful in 44s
Build & Deploy KLZ Cables / 🔔 Notifications (push) Successful in 2s
2026-02-01 13:43:42 +01:00
ea3076b4ec fix: Remove quotes from environment variables in Traefik host rules within docker-compose.yml.
Some checks failed
Build & Deploy KLZ Cables / 🔍 Prepare Environment (push) Successful in 21s
Build & Deploy KLZ Cables / 🧪 Quality Assurance (push) Successful in 1m28s
Build & Deploy KLZ Cables / 🚀 Deploy (push) Has been cancelled
Build & Deploy KLZ Cables / 🔔 Notifications (push) Has been cancelled
Build & Deploy KLZ Cables / 🏗️ Build & Push (push) Has been cancelled
2026-02-01 13:38:35 +01:00
17fe0d7107 fix: Update Traefik host rules in docker-compose.yml to use properly quoted environment variables.
All checks were successful
Build & Deploy KLZ Cables / 🔍 Prepare Environment (push) Successful in 21s
Build & Deploy KLZ Cables / 🧪 Quality Assurance (push) Successful in 2m32s
Build & Deploy KLZ Cables / 🏗️ Build & Push (push) Successful in 4m26s
Build & Deploy KLZ Cables / 🚀 Deploy (push) Successful in 49s
Build & Deploy KLZ Cables / 🔔 Notifications (push) Successful in 1s
2026-02-01 13:26:25 +01:00
38b512973b ci: update Gitea deploy workflow configuration.
Some checks failed
Build & Deploy KLZ Cables / 🔍 Prepare Environment (push) Successful in 21s
Build & Deploy KLZ Cables / 🧪 Quality Assurance (push) Successful in 1m17s
Build & Deploy KLZ Cables / 🏗️ Build & Push (push) Successful in 4m52s
Build & Deploy KLZ Cables / 🔔 Notifications (push) Has been cancelled
Build & Deploy KLZ Cables / 🚀 Deploy (push) Has been cancelled
2026-02-01 13:18:06 +01:00
4f73838c21 ci: update Gitea deploy workflow configuration.
Some checks failed
Build & Deploy KLZ Cables / 🧪 Quality Assurance (push) Has been cancelled
Build & Deploy KLZ Cables / 🏗️ Build & Push (push) Has been cancelled
Build & Deploy KLZ Cables / 🚀 Deploy (push) Has been cancelled
Build & Deploy KLZ Cables / 🔔 Notifications (push) Has been cancelled
Build & Deploy KLZ Cables / 🔍 Prepare Environment (push) Has been cancelled
2026-02-01 13:15:34 +01:00
2f8ce42409 refactor: Remove automatic CMS bootstrapping and wait commands from the dev script.
All checks were successful
Build & Deploy KLZ Cables / 🔍 Prepare Environment (push) Successful in 21s
Build & Deploy KLZ Cables / 🧪 Quality Assurance (push) Successful in 1m17s
Build & Deploy KLZ Cables / 🏗️ Build & Push (push) Successful in 3m28s
Build & Deploy KLZ Cables / 🚀 Deploy (push) Successful in 43s
Build & Deploy KLZ Cables / 🔔 Notifications (push) Successful in 1s
2026-02-01 13:04:11 +01:00
cf7af73b72 feat: Automate Directus CMS bootstrapping in the dev script and update gitignore rules.
Some checks failed
Build & Deploy KLZ Cables / 🔍 Prepare Environment (push) Successful in 20s
Build & Deploy KLZ Cables / 🧪 Quality Assurance (push) Successful in 1m15s
Build & Deploy KLZ Cables / 🏗️ Build & Push (push) Successful in 3m32s
Build & Deploy KLZ Cables / 🚀 Deploy (push) Failing after 1m0s
Build & Deploy KLZ Cables / 🔔 Notifications (push) Successful in 2s
2026-02-01 12:47:46 +01:00
d526bfe56f chore: Remove various unused uploaded files from Directus. 2026-02-01 12:47:30 +01:00
4cb7d438a0 feat: make Directus CMS URL configurable via environment variable with a default.
All checks were successful
Build & Deploy KLZ Cables / 🔍 Prepare Environment (push) Successful in 21s
Build & Deploy KLZ Cables / 🧪 Quality Assurance (push) Successful in 1m17s
Build & Deploy KLZ Cables / 🏗️ Build & Push (push) Successful in 3m42s
Build & Deploy KLZ Cables / 🚀 Deploy (push) Successful in 56s
Build & Deploy KLZ Cables / 🔔 Notifications (push) Successful in 2s
2026-02-01 11:17:24 +01:00
03e597442b feat: Centralize OG image font loading and sizing, simplify product page OG generation, and refine template styling.
Some checks failed
Build & Deploy KLZ Cables / 🔍 Prepare Environment (push) Successful in 21s
Build & Deploy KLZ Cables / 🧪 Quality Assurance (push) Successful in 1m36s
Build & Deploy KLZ Cables / 🏗️ Build & Push (push) Failing after 1m31s
Build & Deploy KLZ Cables / 🚀 Deploy (push) Has been skipped
Build & Deploy KLZ Cables / 🔔 Notifications (push) Successful in 3s
2026-02-01 11:05:37 +01:00
5f9ee7d976 chore: resolve merge conflict in deploy workflow and sync docker-compose
Some checks failed
Build & Deploy KLZ Cables / 🔍 Prepare Environment (push) Successful in 24s
Build & Deploy KLZ Cables / 🧪 Quality Assurance (push) Failing after 1m16s
Build & Deploy KLZ Cables / 🏗️ Build & Push (push) Has been skipped
Build & Deploy KLZ Cables / 🚀 Deploy (push) Has been skipped
Build & Deploy KLZ Cables / 🔔 Notifications (push) Successful in 2s
2026-02-01 02:18:25 +01:00
a4ea42a043 directus 2026-02-01 02:10:24 +01:00
ee04d2422c directus 2026-02-01 02:02:03 +01:00
26fc34299e directus 2026-02-01 00:58:54 +01:00
6d13611a16 directus 2026-02-01 00:55:33 +01:00
4a9246be5e directus 2026-01-31 23:52:51 +01:00
2ed038174d directus 2026-01-31 23:32:01 +01:00
c1304403a1 ci cd
Some checks failed
Build & Deploy KLZ Cables / 🔍 Prepare Environment (push) Successful in 22s
Build & Deploy KLZ Cables / 🧪 Quality Assurance (push) Failing after 1m34s
Build & Deploy KLZ Cables / 🏗️ Build & Push (push) Has been skipped
Build & Deploy KLZ Cables / 🚀 Deploy (push) Has been skipped
Build & Deploy KLZ Cables / 🔔 Notifications (push) Successful in 1s
2026-01-31 22:32:32 +01:00
5036c5fe28 deploy
All checks were successful
Build & Deploy KLZ Cables / build-and-deploy (push) Successful in 4m13s
2026-01-31 21:10:12 +01:00
50a524c515 gitea
Some checks failed
Build & Deploy KLZ Cables / build-and-deploy (push) Failing after 3m54s
2026-01-31 19:21:53 +01:00
57886a01d6 traefik
All checks were successful
Build & Deploy KLZ Cables / build-and-deploy (push) Successful in 3m58s
2026-01-31 18:36:34 +01:00
c89bd8e80f staging deploy
All checks were successful
Build & Deploy KLZ Cables / build-and-deploy (push) Successful in 3m54s
2026-01-31 18:07:28 +01:00
9c54322654 datasheet download
All checks were successful
Build & Deploy KLZ Cables / build-and-deploy (push) Successful in 4m1s
2026-01-31 10:40:17 +01:00
8a80eb7b9a og
All checks were successful
Build & Deploy KLZ Cables / build-and-deploy (push) Successful in 3m55s
2026-01-31 10:35:07 +01:00
c1773a7072 datasheets as downloads
All checks were successful
Build & Deploy KLZ Cables / build-and-deploy (push) Successful in 3m59s
2026-01-31 10:29:39 +01:00
33ed13d255 og
Some checks failed
Build & Deploy KLZ Cables / build-and-deploy (push) Failing after 3m28s
2026-01-31 10:25:25 +01:00
0f5811edb9 og
Some checks failed
Build & Deploy KLZ Cables / build-and-deploy (push) Failing after 1m47s
2026-01-31 10:21:24 +01:00
06bbed8c21 strapi 2026-01-31 10:11:45 +01:00
f5a879fa60 Merge branch 'main' into feature/strapi 2026-01-30 22:10:16 +01:00
e4eabd7a86 sheets
Some checks failed
Build & Deploy KLZ Cables / build-and-deploy (push) Failing after 17m33s
2026-01-30 22:10:01 +01:00
757df76f36 terms
All checks were successful
Build & Deploy KLZ Cables / build-and-deploy (push) Successful in 4m1s
2026-01-30 19:31:22 +01:00
14b2f83971 sheets 2026-01-30 00:17:46 +01:00
51565fdf41 sheets 2026-01-29 22:51:56 +01:00
be9f9cf483 strapi 2026-01-29 19:47:55 +01:00
506c8682fe umami
All checks were successful
Build & Deploy KLZ Cables / build-and-deploy (push) Successful in 3m52s
2026-01-29 17:43:06 +01:00
a909de30f3 filter products
All checks were successful
Build & Deploy KLZ Cables / build-and-deploy (push) Successful in 3m52s
2026-01-29 17:34:15 +01:00
a2f94f15bc og
All checks were successful
Build & Deploy KLZ Cables / build-and-deploy (push) Successful in 4m48s
2026-01-29 17:26:02 +01:00
13e56a88bc headings
All checks were successful
Build & Deploy KLZ Cables / build-and-deploy (push) Successful in 4m3s
2026-01-29 16:51:11 +01:00
bb7d17001b og
All checks were successful
Build & Deploy KLZ Cables / build-and-deploy (push) Successful in 3m57s
2026-01-29 16:33:04 +01:00
920efa0083 pdf sheets 2026-01-29 16:27:09 +01:00
0b81d1a4cb og
Some checks failed
Build & Deploy KLZ Cables / build-and-deploy (push) Failing after 14s
2026-01-29 16:18:37 +01:00
1d5bdeba26 spacing 2026-01-29 15:59:53 +01:00
a0c3fbbc7e application field to mdx 2026-01-29 15:45:35 +01:00
8101a9f156 reveal
All checks were successful
Build & Deploy KLZ Cables / build-and-deploy (push) Successful in 3m57s
2026-01-29 15:11:04 +01:00
7b6f4b5ea4 deploy
All checks were successful
Build & Deploy KLZ Cables / build-and-deploy (push) Successful in 4m2s
2026-01-29 14:47:43 +01:00
658057cdb1 deploy
Some checks failed
Build & Deploy KLZ Cables / build-and-deploy (push) Failing after 5m55s
2026-01-29 13:57:35 +01:00
2aa5d5b00e deploy
Some checks failed
Build & Deploy KLZ Cables / build-and-deploy (push) Failing after 3m53s
2026-01-29 13:52:37 +01:00
7f2f6f5aca deploy
Some checks failed
Build & Deploy KLZ Cables / build-and-deploy (push) Failing after 5m48s
2026-01-29 11:33:30 +01:00
4e50482769 remove redis
Some checks failed
Build & Deploy KLZ Cables / build-and-deploy (push) Failing after 5m50s
2026-01-29 02:23:41 +01:00
1da1f05cdd remove varnish
Some checks failed
Build & Deploy KLZ Cables / build-and-deploy (push) Failing after 4m55s
2026-01-29 02:12:39 +01:00
15cfb314b1 fixes
Some checks failed
Build & Deploy KLZ Cables / build-and-deploy (push) Failing after 3m53s
2026-01-29 01:05:57 +01:00
a3da6192e3 remove smoke test 2026-01-29 00:34:41 +01:00
af33c6225d deploy
Some checks failed
Build & Deploy KLZ Cables / build-and-deploy (push) Failing after 5m32s
2026-01-29 00:09:15 +01:00
9ee09bbe4b deploy
Some checks failed
Build & Deploy KLZ Cables / build-and-deploy (push) Failing after 4m27s
2026-01-28 23:46:51 +01:00
3f0858a1ba smoke tests
Some checks failed
Build & Deploy KLZ Cables / build-and-deploy (push) Failing after 4m17s
2026-01-28 21:39:16 +01:00
a85fe64ccb build
All checks were successful
Build & Deploy KLZ Cables / build-and-deploy (push) Successful in 4m14s
2026-01-28 19:41:01 +01:00
21b16a5e6c deploy
Some checks failed
Build & Deploy KLZ Cables / build-and-deploy (push) Failing after 1m51s
2026-01-28 19:05:20 +01:00
6115e0e0d4 deploy
Some checks failed
Build & Deploy KLZ Cables / build-and-deploy (push) Failing after 1m51s
2026-01-28 17:27:43 +01:00
859d034ed7 env
Some checks failed
Build & Deploy KLZ Cables / build-and-deploy (push) Failing after 1m17s
2026-01-28 15:26:36 +01:00
91ebc54571 env
All checks were successful
Build & Deploy KLZ Cables / build-and-deploy (push) Successful in 4m12s
2026-01-28 15:14:01 +01:00
d6c1d6bae6 env
Some checks failed
Build & Deploy KLZ Cables / build-and-deploy (push) Failing after 4m2s
2026-01-28 15:06:21 +01:00
407b2227b3 env
All checks were successful
Build & Deploy KLZ Cables / build-and-deploy (push) Successful in 3m53s
2026-01-28 01:00:30 +01:00
2896556659 env
All checks were successful
Build & Deploy KLZ Cables / build-and-deploy (push) Successful in 3m52s
2026-01-28 00:41:29 +01:00
8242687b07 env
All checks were successful
Build & Deploy KLZ Cables / build-and-deploy (push) Successful in 3m46s
2026-01-28 00:34:40 +01:00
dab4f3f5b5 logging 2026-01-28 00:13:19 +01:00
b18ee8d7a0 envs
All checks were successful
Build & Deploy KLZ Cables / build-and-deploy (push) Successful in 3m52s
2026-01-28 00:02:24 +01:00
cbca29cbcf env
All checks were successful
Build & Deploy KLZ Cables / build-and-deploy (push) Successful in 3m52s
2026-01-27 23:51:48 +01:00
5a5c10ca36 env
Some checks failed
Build & Deploy KLZ Cables / build-and-deploy (push) Failing after 1m46s
2026-01-27 23:43:14 +01:00
ad6bfe1457 404
All checks were successful
Build & Deploy KLZ Cables / build-and-deploy (push) Successful in 3m48s
2026-01-27 23:36:12 +01:00
b5c3fc6649 404
All checks were successful
Build & Deploy KLZ Cables / build-and-deploy (push) Successful in 3m43s
2026-01-27 23:09:56 +01:00
03609f113d 404
Some checks failed
Build & Deploy KLZ Cables / build-and-deploy (push) Failing after 2m31s
2026-01-27 22:38:05 +01:00
622180c483 404s
Some checks failed
Build & Deploy KLZ Cables / build-and-deploy (push) Failing after 1m40s
2026-01-27 22:27:04 +01:00
41865ab9ab deploy
All checks were successful
Build & Deploy KLZ Cables / build-and-deploy (push) Successful in 3m45s
2026-01-27 20:32:32 +01:00
986fcd067e deploy
All checks were successful
Build & Deploy KLZ Cables / build-and-deploy (push) Successful in 3m40s
2026-01-27 19:09:43 +01:00
d27616ed43 deploy
All checks were successful
Build & Deploy KLZ Cables / build-and-deploy (push) Successful in 3m46s
2026-01-27 18:46:02 +01:00
b9a3e47662 deploy
All checks were successful
Build & Deploy KLZ Cables / build-and-deploy (push) Successful in 3m43s
2026-01-27 18:30:54 +01:00
a5e34053f7 gotify 2026-01-27 18:22:55 +01:00
e6f9ad36d3 deploy
Some checks failed
Build & Deploy KLZ Cables / build-and-deploy (push) Failing after 3m33s
2026-01-27 18:16:26 +01:00
2e0456b081 gotify
Some checks failed
Build & Deploy KLZ Cables / build-and-deploy (push) Failing after 11s
2026-01-27 17:55:38 +01:00
ad37a372a7 deploy
Some checks failed
Build & Deploy KLZ Cables / build-and-deploy (push) Failing after 10s
2026-01-27 12:37:49 +01:00
8490b691e1 contact 2026-01-27 12:36:00 +01:00
334c76935e env
All checks were successful
Build & Deploy KLZ Cables / build-and-deploy (push) Successful in 3m39s
2026-01-27 11:32:01 +01:00
6624cfc3ad sentry
All checks were successful
Build & Deploy KLZ Cables / build-and-deploy (push) Successful in 3m37s
2026-01-27 10:31:36 +01:00
20d7d8405a toc
All checks were successful
Build & Deploy KLZ Cables / build-and-deploy (push) Successful in 3m39s
2026-01-27 10:26:17 +01:00
12501ea51a fixes 2026-01-27 10:12:36 +01:00
70f1813e33 fix 404
All checks were successful
Build & Deploy KLZ Cables / build-and-deploy (push) Successful in 3m38s
2026-01-27 09:23:15 +01:00
69e39b06cf fix redirects
All checks were successful
Build & Deploy KLZ Cables / build-and-deploy (push) Successful in 3m39s
2026-01-27 09:17:55 +01:00
3b5174cd12 lightbox
All checks were successful
Build & Deploy KLZ Cables / build-and-deploy (push) Successful in 3m46s
2026-01-27 08:57:43 +01:00
875cf1bd07 buttons 2026-01-27 08:47:52 +01:00
e284bb94af hero
All checks were successful
Build & Deploy KLZ Cables / build-and-deploy (push) Successful in 3m39s
2026-01-27 08:30:18 +01:00
8eea94ceda hero
All checks were successful
Build & Deploy KLZ Cables / build-and-deploy (push) Successful in 3m35s
2026-01-27 08:21:55 +01:00
05b10018a6 hero center
All checks were successful
Build & Deploy KLZ Cables / build-and-deploy (push) Successful in 3m39s
2026-01-27 08:07:25 +01:00
3b493abb3d env
All checks were successful
Build & Deploy KLZ Cables / build-and-deploy (push) Successful in 3m34s
2026-01-27 00:50:52 +01:00
f54d8277b3 hero on mobile 2026-01-27 00:49:19 +01:00
5c71e9a064 env
All checks were successful
Build & Deploy KLZ Cables / build-and-deploy (push) Successful in 3m36s
2026-01-27 00:30:12 +01:00
3cab376cd1 lightbox
All checks were successful
Build & Deploy KLZ Cables / build-and-deploy (push) Successful in 3m31s
2026-01-27 00:21:03 +01:00
3c45e5563e build
All checks were successful
Build & Deploy KLZ Cables / build-and-deploy (push) Successful in 3m34s
2026-01-27 00:14:10 +01:00
13ab4bde75 og image
Some checks failed
Build & Deploy KLZ Cables / build-and-deploy (push) Failing after 13s
2026-01-27 00:10:10 +01:00
a805c7b8de titles
Some checks failed
Build & Deploy KLZ Cables / build-and-deploy (push) Has been cancelled
2026-01-27 00:07:00 +01:00
b8fdbfb10b lightbox
All checks were successful
Build & Deploy KLZ Cables / build-and-deploy (push) Successful in 3m39s
2026-01-26 23:59:10 +01:00
081adb02be fix
All checks were successful
Build & Deploy KLZ Cables / build-and-deploy (push) Successful in 13s
2026-01-26 23:45:37 +01:00
3f17d08b04 deploy
All checks were successful
Build & Deploy KLZ Cables / build-and-deploy (push) Successful in 3m40s
2026-01-26 23:28:23 +01:00
d40f4544ea deploy
All checks were successful
Build & Deploy KLZ Cables / build-and-deploy (push) Successful in 3m30s
2026-01-26 23:12:22 +01:00
041b5534c9 build
All checks were successful
Build & Deploy KLZ Cables / build-and-deploy (push) Successful in 3m37s
2026-01-26 23:00:45 +01:00
dea3b57627 forms 2026-01-26 22:55:13 +01:00
baec7cc94a reveals
Some checks failed
Build & Deploy KLZ Cables / build-and-deploy (push) Failing after 2m4s
2026-01-26 22:51:11 +01:00
b43b1c4314 fix 2026-01-26 22:19:13 +01:00
615757963c hero entrance 2026-01-26 22:04:49 +01:00
0094871358 ssr 2026-01-26 22:00:48 +01:00
8fef3b6c7f fix 2026-01-26 21:52:30 +01:00
cbb7855804 frontend fixes
Some checks failed
Build & Deploy KLZ Cables / build-and-deploy (push) Failing after 1m37s
2026-01-26 20:21:17 +01:00
a8f7c5370b colors
All checks were successful
Build & Deploy KLZ Cables / build-and-deploy (push) Successful in 11s
2026-01-26 17:42:43 +01:00
f459bf230d colors
Some checks failed
Build & Deploy KLZ Cables / build-and-deploy (push) Has been cancelled
2026-01-26 17:40:33 +01:00
570a4977dd deploy
Some checks failed
Build & Deploy KLZ Cables / build-and-deploy (push) Has been cancelled
2026-01-26 17:38:24 +01:00
da04c108ef deploy
Some checks failed
Build & Deploy KLZ Cables / build-and-deploy (push) Has been cancelled
2026-01-26 17:37:25 +01:00
cb207d6a01 deploy
All checks were successful
Build & Deploy KLZ Cables / build-and-deploy (push) Successful in 10s
2026-01-26 16:57:20 +01:00
6dc0ff4644 deploy
Some checks failed
Build & Deploy KLZ Cables / build-and-deploy (push) Failing after 3m33s
2026-01-26 15:56:29 +01:00
e648d30767 deploy
All checks were successful
Build & Deploy KLZ Cables / build-and-deploy (push) Successful in 3m30s
2026-01-26 15:36:46 +01:00
9c26ddddbf deploy
All checks were successful
Build & Deploy KLZ Cables / build-and-deploy (push) Successful in 3m28s
2026-01-26 14:08:17 +01:00
61b4b37111 build
All checks were successful
Build & Deploy KLZ Cables / build-and-deploy (push) Successful in 3m29s
2026-01-26 13:38:48 +01:00
feedf30be1 env vars
Some checks failed
Build & Deploy KLZ Cables / build-and-deploy (push) Failing after 3m30s
2026-01-26 12:57:06 +01:00
b596c22011 logs 2026-01-26 12:15:34 +01:00
377f583ff1 deploy
All checks were successful
Build & Deploy KLZ Cables / build-and-deploy (push) Successful in 3m29s
2026-01-26 11:08:51 +01:00
4d72a5bf86 colors
All checks were successful
Build & Deploy KLZ Cables / build-and-deploy (push) Successful in 3m35s
2026-01-26 02:39:22 +01:00
f50f41530d deploy
Some checks failed
Build & Deploy KLZ Cables / build-and-deploy (push) Has been cancelled
2026-01-26 02:36:58 +01:00
f8274cad1b logs 2026-01-26 02:34:54 +01:00
f2dd76a7a6 build
All checks were successful
Build & Deploy KLZ Cables / build-and-deploy (push) Successful in 3m30s
2026-01-26 02:30:19 +01:00
574d5a8a9a deploy
Some checks failed
Build & Deploy KLZ Cables / build-and-deploy (push) Failing after 1m32s
2026-01-26 02:19:48 +01:00
ac1e22017e logging
All checks were successful
Build & Deploy KLZ Cables / build-and-deploy (push) Successful in 3m46s
2026-01-26 02:02:46 +01:00
a503d3e539 colors 2026-01-26 01:59:40 +01:00
c2eeeafd56 deploy
All checks were successful
Build & Deploy KLZ Cables / build-and-deploy (push) Successful in 3m43s
2026-01-26 01:45:10 +01:00
78cb7207e6 deploy 2026-01-26 01:41:07 +01:00
ca4c36ad01 deploy 2026-01-26 01:35:12 +01:00
c9dcf73021 deploy
Some checks failed
Build & Deploy KLZ Cables / build-and-deploy (push) Failing after 3m49s
2026-01-26 01:28:04 +01:00
8146ee78fa deploy
Some checks failed
Build & Deploy KLZ Cables / build-and-deploy (push) Has been cancelled
2026-01-26 01:25:52 +01:00
58878e9f64 deploy
Some checks failed
Build & Deploy KLZ Cables / build-and-deploy (push) Failing after 3m21s
2026-01-26 01:20:41 +01:00
f43c97e712 deploy
Some checks failed
Build & Deploy KLZ Cables / build-and-deploy (push) Failing after 3m23s
2026-01-26 01:14:54 +01:00
888b46eed0 deploy
Some checks failed
Build & Deploy KLZ Cables / build-and-deploy (push) Failing after 3m19s
2026-01-26 01:03:16 +01:00
f1c64f000c deploy
Some checks failed
Build & Deploy KLZ Cables / build-and-deploy (push) Failing after 3m22s
2026-01-26 00:57:43 +01:00
9b561f2176 deploy 2026-01-26 00:48:28 +01:00
f7930503d5 deploy 2026-01-26 00:34:50 +01:00
bb98014ea1 deploy
Some checks failed
Build & Deploy KLZ Cables / build-and-deploy (push) Failing after 4m43s
2026-01-25 23:44:44 +01:00
defde86fc9 build
All checks were successful
Build & Deploy KLZ Cables / build-and-deploy (push) Successful in 4m45s
2026-01-25 23:20:39 +01:00
69141175cf deploy
Some checks failed
Build & Deploy KLZ Cables / build-and-deploy (push) Failing after 3m51s
2026-01-25 23:11:03 +01:00
4fdbc0f5cf deploy
Some checks failed
Build & Deploy KLZ Cables / build-and-deploy (push) Failing after 17s
2026-01-25 18:07:19 +01:00
97950d574e deploy
Some checks failed
Build & Deploy KLZ Cables / deploy (push) Failing after 17s
2026-01-25 17:56:04 +01:00
c04a134eca deploy
Some checks failed
Build & Deploy KLZ Cables / deploy (push) Has been cancelled
2026-01-25 17:52:57 +01:00
3b03572fb0 deploy
Some checks failed
Build & Deploy KLZ Cables / deploy (push) Has been cancelled
2026-01-25 17:51:28 +01:00
a4c926ceb1 deploy
Some checks failed
Build & Deploy KLZ Cables / deploy (push) Failing after 28s
2026-01-25 17:40:04 +01:00
f5edc08e9d deploy 2026-01-25 17:38:56 +01:00
28a1cb4b4c deploy
Some checks failed
Build & Deploy KLZ Cables / deploy (push) Failing after 36s
2026-01-25 17:30:54 +01:00
8dcc27ffcd deploy
Some checks failed
Build & Deploy KLZ Cables / deploy (push) Failing after 36s
2026-01-25 17:23:54 +01:00
a9d256ea55 deploy
Some checks failed
Build & Deploy KLZ Cables / deploy (push) Failing after 36s
2026-01-25 17:13:55 +01:00
97b1a94012 simple deploy
Some checks failed
Build & Deploy KLZ Cables / deploy (push) Failing after 35s
2026-01-25 17:10:47 +01:00
831c8be588 deploy
Some checks failed
Build & Deploy KLZ Cables / deploy (push) Failing after 29s
2026-01-25 17:01:00 +01:00
c121398381 deploy
Some checks failed
Build & Deploy KLZ Cables / deploy (push) Has been cancelled
2026-01-25 17:00:02 +01:00
a831bed335 build
All checks were successful
Build & Deploy KLZ Cables / deploy (push) Successful in 5m45s
2026-01-25 13:53:27 +01:00
5659073f95 build
Some checks failed
Build & Deploy KLZ Cables / deploy (push) Failing after 36s
2026-01-25 13:51:08 +01:00
2d93321a91 build
Some checks failed
Build & Deploy KLZ Cables / deploy (push) Failing after 37s
2026-01-25 13:50:09 +01:00
fb6af84a42 interactive map
Some checks failed
Build & Deploy KLZ Cables / deploy (push) Failing after 39s
2026-01-25 13:46:31 +01:00
8affb7878f deploy
Some checks failed
Build & Deploy KLZ Cables / deploy (push) Has been cancelled
2026-01-25 13:44:09 +01:00
c074a5d935 logging 2026-01-25 13:42:28 +01:00
4dbf566f0c hero on mobile 2026-01-25 13:38:14 +01:00
a0cfa8ef62 mailing
All checks were successful
Build & Deploy KLZ Cables / deploy (push) Successful in 5m8s
2026-01-25 13:36:25 +01:00
ae1e0ad8a9 fix 2026-01-25 13:33:09 +01:00
5ba3afc393 error pages 2026-01-25 13:29:11 +01:00
1380d40b4d proxy urls
Some checks failed
Build & Deploy KLZ Cables / deploy (push) Failing after 2m21s
2026-01-25 13:25:37 +01:00
cf5df1b46b proxy urls
Some checks failed
Build & Deploy KLZ Cables / deploy (push) Has been cancelled
2026-01-25 13:22:19 +01:00
dd9f427ad5 deploy
All checks were successful
Build & Deploy KLZ Cables / deploy (push) Successful in 3m53s
2026-01-25 13:13:47 +01:00
2c4345a7bd env 2026-01-25 12:34:57 +01:00
6fdf9c3464 deploy
Some checks failed
Build & Deploy KLZ Cables / deploy (push) Failing after 4m22s
2026-01-25 12:28:18 +01:00
aac2cb2041 deploy
Some checks failed
Build & Deploy KLZ Cables / deploy (push) Failing after 3m49s
2026-01-25 12:20:31 +01:00
b3827b95c2 vcf + terms 2026-01-25 12:15:10 +01:00
339a272105 deploy
Some checks failed
Build & Deploy KLZ Cables / deploy (push) Failing after 3m48s
2026-01-25 11:47:00 +01:00
3582370449 font sizes
Some checks failed
Build & Deploy KLZ Cables / deploy (push) Failing after 3m50s
2026-01-25 11:34:57 +01:00
3288bbd745 deploy
Some checks failed
Build & Deploy KLZ Cables / deploy (push) Failing after 3m51s
2026-01-25 11:28:23 +01:00
c2d6e082e8 umami migration
All checks were successful
Build & Deploy KLZ Cables / deploy (push) Successful in 3m50s
2026-01-25 11:23:34 +01:00
4777091d8e deploy
All checks were successful
Build & Deploy KLZ Cables / deploy (push) Successful in 3m43s
2026-01-25 00:14:30 +01:00
5afa5395d4 deploy
Some checks failed
Build & Deploy KLZ Cables / deploy (push) Failing after 29s
2026-01-24 23:42:28 +01:00
1568ecdf7d deploy
All checks were successful
Build & Deploy KLZ Cables / deploy (push) Successful in 5m11s
2026-01-24 23:13:09 +01:00
807a604e39 font sizes
Some checks failed
Build & Deploy KLZ Cables / deploy (push) Failing after 1m1s
2026-01-24 23:09:00 +01:00
72711c74ba umami
Some checks failed
Build & Deploy KLZ Cables / deploy (push) Failing after 13m20s
2026-01-24 22:03:06 +01:00
7e94feaf19 sheets 2026-01-24 21:44:14 +01:00
d90d7502c3 sheets 2026-01-23 13:59:35 +01:00
e5e2b646a0 pdf sheets from new excel 2026-01-23 13:10:08 +01:00
899b3c7ed4 schema 2026-01-23 12:16:18 +01:00
1a646282a0 json 2026-01-23 12:09:23 +01:00
84438f1492 json schema 2026-01-23 12:07:11 +01:00
dd5636450c excel 2026-01-22 14:16:18 +01:00
ae60037708 deploy
All checks were successful
Build & Deploy KLZ Cables / deploy (push) Successful in 11m33s
2026-01-21 17:13:34 +01:00
41dd897f08 fix
All checks were successful
Build & Deploy KLZ Cables / deploy (push) Successful in 9m53s
2026-01-21 16:56:06 +01:00
1d472062b1 i18n slugs fix
Some checks failed
Build & Deploy KLZ Cables / deploy (push) Failing after 17m15s
2026-01-21 16:33:48 +01:00
656852e983 deploy 2026-01-21 16:26:24 +01:00
a668dc60a9 deploy
All checks were successful
Build & Deploy KLZ Cables / deploy (push) Successful in 3m18s
2026-01-21 16:12:09 +01:00
83411789a5 deploy
Some checks failed
Build & Deploy KLZ Cables / deploy (push) Failing after 3m3s
2026-01-21 15:56:59 +01:00
33f188c16a deploy
Some checks failed
Build & Deploy KLZ Cables / deploy (push) Failing after 3m30s
2026-01-21 13:22:10 +01:00
22bd212c11 deploy
Some checks failed
Build & Deploy KLZ Cables / deploy (push) Failing after 3m21s
2026-01-21 13:14:42 +01:00
e74c309461 redirects
All checks were successful
Build & Deploy / deploy (push) Successful in 3m20s
2026-01-21 13:10:32 +01:00
2fcca45e4a fix 2026-01-21 12:42:00 +01:00
846319b2c7 deploy
All checks were successful
Build & Deploy / deploy (push) Successful in 3m20s
2026-01-21 11:52:41 +01:00
ded9150df6 add glitchtip
All checks were successful
Build & Deploy / deploy (push) Successful in 3m21s
2026-01-21 11:43:03 +01:00
d16c28edb1 deploy
All checks were successful
Build & Deploy / deploy (push) Successful in 3m11s
2026-01-21 00:45:57 +01:00
d8b268fc23 deploy
Some checks failed
Build & Deploy / deploy (push) Failing after 19m0s
2026-01-21 00:09:52 +01:00
a7f5c1c16d redirects
All checks were successful
Build & Deploy / deploy (push) Successful in 3m44s
2026-01-20 23:51:13 +01:00
abf283c9ab slug i18n 2026-01-20 23:43:01 +01:00
f62485a67d fix bug
All checks were successful
Build & Deploy / deploy (push) Successful in 3m39s
2026-01-20 21:36:35 +01:00
1293adbef2 deploy
All checks were successful
Build & Deploy / deploy (push) Successful in 3m22s
2026-01-19 22:54:36 +01:00
2ffbe8b449 fix
Some checks failed
Build & Deploy / deploy (push) Failing after 3m47s
2026-01-19 22:47:19 +01:00
45234c844d deploy
All checks were successful
Build & Deploy / deploy (push) Successful in 3m39s
2026-01-19 21:40:15 +01:00
a62a29b1ad deploy
Some checks failed
Build & Deploy / deploy (push) Failing after 3m47s
2026-01-19 21:32:16 +01:00
ef47fa914c deploy
Some checks failed
Build & Deploy / deploy (push) Failing after 3m35s
2026-01-19 21:26:15 +01:00
6e34392976 build
Some checks failed
Build & Deploy / deploy (push) Failing after 3m39s
2026-01-19 20:46:39 +01:00
6adf97a096 wip
Some checks failed
Build & Deploy / deploy (push) Failing after 20m37s
2026-01-19 20:15:14 +01:00
4abcc3fdf5 wip 2026-01-19 19:31:53 +01:00
6797303628 wip 2026-01-19 19:29:44 +01:00
2feb73b982 wip 2026-01-19 19:22:01 +01:00
b99258f886 wip 2026-01-19 19:16:42 +01:00
5c9b2e3f5a wip 2026-01-19 19:09:32 +01:00
e8b6b13a3b wip 2026-01-19 18:07:39 +01:00
1f624f3d7f wip 2026-01-19 17:43:23 +01:00
40c553d6f6 wip 2026-01-19 14:11:41 +01:00
a32c12692c wip 2026-01-19 02:23:42 +01:00
79016fbe97 wip 2026-01-19 02:19:11 +01:00
4f6264f2e2 wip 2026-01-19 02:05:30 +01:00
46266a7bbc components 2026-01-19 01:46:07 +01:00
ac2add1984 deploy
Some checks failed
Build & Deploy / deploy (push) Failing after 3m42s
2026-01-18 22:28:37 +01:00
c57cf1daa1 deploy 2026-01-18 21:48:54 +01:00
c14556816e build
Some checks failed
Build & Deploy / deploy (push) Failing after 3m24s
2026-01-18 15:57:17 +01:00
b05a21350c umami, glitchtip, redis
Some checks failed
Build & Deploy / deploy (push) Failing after 3m45s
2026-01-18 15:37:51 +01:00
619b699f14 build
All checks were successful
Build & Deploy / deploy (push) Successful in 2m29s
2026-01-18 00:59:42 +01:00
21ce9b2ca7 domains
All checks were successful
Build & Deploy / deploy (push) Successful in 2m21s
2026-01-17 17:13:21 +01:00
9f62c0a7bf deploy
All checks were successful
Build & Deploy / deploy (push) Successful in 2m21s
2026-01-17 17:00:41 +01:00
f6cd5ec5aa deploy
Some checks failed
Build & Deploy / deploy (push) Failing after 2m10s
2026-01-17 16:57:52 +01:00
cfc16593f6 build
Some checks failed
Build & Deploy / deploy (push) Failing after 2m50s
2026-01-17 16:53:20 +01:00
440 changed files with 194183 additions and 7666 deletions

View File

@@ -1,10 +1,10 @@
node_modules
.next
!.next/cache
.git
.DS_Store
.env
*.md
docs
reference
scripts
public/datasheets/*.pdf

39
.env
View File

@@ -1,4 +1,35 @@
WOOCOMMERCE_URL=https://klz-cables.com
WOOCOMMERCE_CONSUMER_KEY=ck_38d97df86880e8fefbd54ab5cdf47a9c5a9e5b39
WOOCOMMERCE_CONSUMER_SECRET=cs_d675ee2ac2ec7c22de84ae5451c07e42b1717759
WORDPRESS_APP_PASSWORD=DlJH 49dp fC3a Itc3 Sl7Z Wz0k
# Application
NODE_ENV=production
NEXT_PUBLIC_BASE_URL=https://klz-cables.com
UMAMI_API_ENDPOINT=https://analytics.infra.mintel.me
SENTRY_DSN=https://c10957d0182245b1a2a806ac2d34a197@errors.infra.mintel.me/1
LOG_LEVEL=info
NEXT_PUBLIC_FEEDBACK_ENABLED=true
# SMTP Configuration
MAIL_HOST=smtp.eu.mailgun.org
MAIL_PORT=587
MAIL_USERNAME=postmaster@mg.mintel.me
MAIL_PASSWORD=4592fcb94599ee1a45b4ac2386fd0a64-102c75d8-ca2870e6
MAIL_FROM="KLZ Cables <postmaster@mg.mintel.me>"
MAIL_RECIPIENTS=marc@cablecreations.de,info@klz-cables.com
# Directus
DIRECTUS_URL=https://cms.klz-cables.com
DIRECTUS_KEY=59fb8f4c1a51b18fe28ad947f713914e
DIRECTUS_SECRET=7459038d41401dfb11254cf7f1ef2d0f
DIRECTUS_ADMIN_EMAIL=marc@mintel.me
DIRECTUS_ADMIN_PASSWORD=Tim300493.
DIRECTUS_API_TOKEN=59fb8f4c1a51b18fe28ad947f713914e
DIRECTUS_DB_NAME=directus
DIRECTUS_DB_USER=directus
DIRECTUS_DB_PASSWORD=directus
# Local Development
PROJECT_NAME=klz-cables
GATEKEEPER_BYPASS_ENABLED=true
TRAEFIK_HOST=klz.localhost
DIRECTUS_HOST=cms.klz.localhost
GATEKEEPER_PASSWORD=klz2026
COOKIE_DOMAIN=localhost
INFRA_DIRECTUS_URL=http://localhost:8059
INFRA_DIRECTUS_TOKEN=59fb8f4c1a51b18fe28ad947f713914e

93
.env.example Normal file
View File

@@ -0,0 +1,93 @@
# ============================================================================
# KLZ Cables - Environment Configuration
# ============================================================================
# Copy this file to .env for local development
# For production, use .env.production as a template
# ============================================================================
# ────────────────────────────────────────────────────────────────────────────
# Application Configuration
# ────────────────────────────────────────────────────────────────────────────
NODE_ENV=development
NEXT_PUBLIC_BASE_URL=http://localhost:3000
DIRECTUS_PORT=8055
# TARGET is used to differentiate between environments (testing, staging, production)
# NEXT_PUBLIC_TARGET makes this information available to the frontend
TARGET=development
NEXT_PUBLIC_FEEDBACK_ENABLED=false
# ────────────────────────────────────────────────────────────────────────────
# Analytics (Umami)
# ────────────────────────────────────────────────────────────────────────────
# Optional: Leave empty to disable analytics
UMAMI_WEBSITE_ID=
UMAMI_API_ENDPOINT=https://analytics.infra.mintel.me
# ────────────────────────────────────────────────────────────────────────────
# Error Tracking (GlitchTip/Sentry)
# ────────────────────────────────────────────────────────────────────────────
# Optional: Leave empty to disable error tracking
SENTRY_DSN=
# ────────────────────────────────────────────────────────────────────────────
# Email Configuration (SMTP)
# ────────────────────────────────────────────────────────────────────────────
# Required for contact form functionality
MAIL_HOST=smtp.eu.mailgun.org
MAIL_PORT=587
MAIL_USERNAME=
MAIL_PASSWORD=
MAIL_FROM=KLZ Cables <noreply@klz-cables.com>
MAIL_RECIPIENTS=info@klz-cables.com
# ────────────────────────────────────────────────────────────────────────────
# Logging
# ────────────────────────────────────────────────────────────────────────────
LOG_LEVEL=info
GATEKEEPER_PASSWORD=klz2026
SENTRY_DSN=
# For Directus Error Tracking
# SENTRY_ENVIRONMENT is set automatically by CI
# ────────────────────────────────────────────────────────────────────────────
# Deployment Configuration (CI/CD only)
# ────────────────────────────────────────────────────────────────────────────
# These are typically set by the CI/CD workflow
IMAGE_TAG=latest
TRAEFIK_HOST=klz-cables.com
ENV_FILE=.env
# ────────────────────────────────────────────────────────────────────────────
# Varnish Configuration
# ────────────────────────────────────────────────────────────────────────────
VARNISH_CACHE_SIZE=256M
# ============================================================================
# IMPORTANT NOTES
# ============================================================================
#
# BUILD-TIME vs RUNTIME Variables:
# ─────────────────────────────────
# • NEXT_PUBLIC_* variables are baked into the client bundle at BUILD time
# They must be provided as --build-arg when building the Docker image
#
# • All other variables are used at RUNTIME only
# They are loaded from the .env file by docker-compose
#
# Docker Deployment:
# ──────────────────
# 1. Build-time: Only NEXT_PUBLIC_* vars are needed (via --build-arg)
# 2. Runtime: All vars are loaded from .env file on the server
# 3. Branch Deployments:
# - main branch uses .env.prod
# - staging branch uses .env.staging
# - CI/CD supports STAGING_ prefix for all secrets to override defaults
# - TRAEFIK_HOST is automatically derived from NEXT_PUBLIC_BASE_URL
#
# Security:
# ─────────
# • NEVER commit .env files with real credentials to git
# • Use Gitea/GitHub secrets for CI/CD workflows
# • Store production .env file securely on the server only
#
# ============================================================================

30
.env.production Normal file
View File

@@ -0,0 +1,30 @@
# ============================================================================
# KLZ Cables - Production Environment Configuration
# ============================================================================
# This file contains runtime environment variables for the production deployment.
# It should be placed on the production server at: /home/deploy/sites/klz-cables.com/.env
#
# IMPORTANT: This file contains sensitive data and should NEVER be committed to git.
# ============================================================================
# Application
NODE_ENV=production
NEXT_PUBLIC_BASE_URL=https://klz-cables.com
# Analytics (Umami)
UMAMI_WEBSITE_ID=
UMAMI_API_ENDPOINT=https://analytics.infra.mintel.me
# Error Tracking (GlitchTip/Sentry)
SENTRY_DSN=
# Email Configuration (Mailgun)
MAIL_HOST=smtp.eu.mailgun.org
MAIL_PORT=587
MAIL_USERNAME=
MAIL_PASSWORD=
MAIL_FROM=KLZ Cables <noreply@klz-cables.com>
MAIL_RECIPIENTS=info@klz-cables.com
# Varnish Cache Size (optional)
VARNISH_CACHE_SIZE=256m

42
.gitea/workflows/ci.yml Normal file
View File

@@ -0,0 +1,42 @@
name: CI - Lint, Typecheck & Test
on:
pull_request:
jobs:
quality-assurance:
runs-on: docker
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
run_install: false
- name: 🔐 Configure Private Registry
run: |
REGISTRY="${{ vars.REGISTRY_HOST || 'npm.infra.mintel.me' }}"
echo "@mintel:registry=https://$REGISTRY" > .npmrc
echo "//$REGISTRY/:_authToken=${{ secrets.REGISTRY_PASS }}" >> .npmrc
- name: Install dependencies
run: pnpm install
env:
NPM_TOKEN: ${{ secrets.REGISTRY_PASS }}
- name: 🔍 Lint
run: npm run lint
- name: 🏗️ Typecheck
run: npm run typecheck
- name: 🧪 Test
run: npm run test

View File

@@ -1,74 +1,568 @@
name: Build & Deploy
name: Build & Deploy KLZ Cables
on:
push:
branches:
- main
- '**'
tags:
- 'v*'
workflow_dispatch:
inputs:
skip_long_checks:
description: 'Skip tests? (true/false)'
required: false
default: 'false'
concurrency:
group: ${{ github.workflow }}-${{ (github.ref_type == 'tag' && !contains(github.ref_name, '-')) && 'prod' || (github.ref_type == 'tag' && 'staging' || (github.ref_name == 'main' && 'testing' || github.ref_name)) }}
cancel-in-progress: true
jobs:
deploy:
# ──────────────────────────────────────────────────────────────────────────────
# JOB 1: Prepare & Determine Environment
# ──────────────────────────────────────────────────────────────────────────────
prepare:
name: 🔍 Prepare Environment
runs-on: docker
outputs:
target: ${{ steps.determine.outputs.target }}
image_tag: ${{ steps.determine.outputs.image_tag }}
env_file: ${{ steps.determine.outputs.env_file }}
traefik_host: ${{ steps.determine.outputs.traefik_host }}
traefik_host_rule: ${{ steps.determine.outputs.traefik_host_rule }}
primary_host: ${{ steps.determine.outputs.primary_host }}
next_public_base_url: ${{ steps.determine.outputs.next_public_base_url }}
directus_url: ${{ steps.determine.outputs.directus_url }}
directus_host: ${{ steps.determine.outputs.directus_host }}
project_name: ${{ steps.determine.outputs.project_name }}
is_prod: ${{ steps.determine.outputs.is_prod }}
gotify_title: ${{ steps.determine.outputs.gotify_title }}
gotify_priority: ${{ steps.determine.outputs.gotify_priority }}
short_sha: ${{ steps.determine.outputs.short_sha }}
commit_msg: ${{ steps.determine.outputs.commit_msg }}
container:
image: catthehacker/ubuntu:act-latest
steps:
# --- Tools ---
- name: Install tools
- name: 🧹 Maintenance (High Density Cleanup)
shell: bash
run: |
echo "Purging old build layers and dangling images..."
docker image prune -f
docker builder prune -f --filter "until=6h"
- name: Checkout repository
uses: actions/checkout@v4
with:
fetch-depth: 2
- name: 🔍 Environment & Version ermitteln
id: determine
shell: bash
run: |
TAG="${{ github.ref_name }}"
SHORT_SHA=$(echo "${{ github.sha }}" | cut -c1-9)
IMAGE_TAG="sha-${SHORT_SHA}"
COMMIT_MSG=$(git log -1 --pretty=%s || echo "No commit message available")
if [[ "${{ github.ref_type }}" == "branch" && "$TAG" == "main" ]]; then
if [[ "$COMMIT_MSG" =~ ^chore: ]]; then
TARGET="skip"
GOTIFY_TITLE=" Skip Deploy (Chore)"
GOTIFY_PRIORITY=2
else
TARGET="testing"
IMAGE_TAG="main-${SHORT_SHA}"
ENV_FILE=".env.testing"
TRAEFIK_HOST="testing.klz-cables.com"
NEXT_PUBLIC_BASE_URL="https://testing.klz-cables.com"
DIRECTUS_URL="https://cms.testing.klz-cables.com"
DIRECTUS_HOST="cms.testing.klz-cables.com"
PROJECT_NAME="klz-cables-testing"
IS_PROD="false"
GOTIFY_TITLE="🧪 Testing-Deploy"
GOTIFY_PRIORITY=4
fi
elif [[ "${{ github.ref_type }}" == "tag" ]]; then
if [[ "$TAG" =~ ^v[0-9]+\.[0-9]+\.[0-9]+$ ]]; then
TARGET="production"
IMAGE_TAG="$TAG"
ENV_FILE=".env.prod"
TRAEFIK_HOST="klz-cables.com, www.klz-cables.com"
NEXT_PUBLIC_BASE_URL="https://klz-cables.com"
DIRECTUS_URL="https://cms.klz-cables.com"
DIRECTUS_HOST="cms.klz-cables.com"
PROJECT_NAME="klz-cables-prod"
IS_PROD="true"
GOTIFY_TITLE="🚀 Production-Release"
GOTIFY_PRIORITY=6
elif [[ "$TAG" =~ -rc || "$TAG" =~ -beta || "$TAG" =~ -alpha ]]; then
TARGET="staging"
IMAGE_TAG="$TAG"
ENV_FILE=".env.staging"
TRAEFIK_HOST="staging.klz-cables.com"
NEXT_PUBLIC_BASE_URL="https://staging.klz-cables.com"
DIRECTUS_URL="https://cms.staging.klz-cables.com"
DIRECTUS_HOST="cms.staging.klz-cables.com"
PROJECT_NAME="klz-cables-staging"
IS_PROD="false"
GOTIFY_TITLE="🧪 Staging-Deploy (Pre-Release)"
GOTIFY_PRIORITY=5
else
TARGET="skip"
GOTIFY_TITLE="❓ Unbekannter Tag"
GOTIFY_PRIORITY=3
fi
elif [[ "${{ github.ref_type }}" == "branch" ]]; then
TARGET="branch"
# Slugify branch name: lowercase, replace non-alphanumeric with -, remove leading/trailing -
SLUG=$(echo "$TAG" | tr '[:upper:]' '[:lower:]' | sed 's/[^a-z0-9]/-/g' | sed 's/--*/-/g' | sed 's/^-//;s/-$//')
IMAGE_TAG="branch-${SLUG}-${SHORT_SHA}"
ENV_FILE=".env.branch-${SLUG}"
TRAEFIK_HOST="${SLUG}.branch.mintel.me"
NEXT_PUBLIC_BASE_URL="https://${SLUG}.branch.mintel.me"
DIRECTUS_URL="https://cms.${SLUG}.branch.mintel.me"
DIRECTUS_HOST="cms.${SLUG}.branch.mintel.me"
PROJECT_NAME="klz-cables-br-${SLUG}"
IS_PROD="false"
GOTIFY_TITLE="🌿 Branch-Deploy ($TAG)"
GOTIFY_PRIORITY=4
else
TARGET="skip"
fi
if [[ "$TRAEFIK_HOST" == *","* ]]; then
# Multi-domain: Host(`a.com`) || Host(`b.com`)
TRAEFIK_HOST_RULE=$(echo "$TRAEFIK_HOST" | sed 's/,/ /g' | awk '{for(i=1;i<=NF;i++) printf "Host(`%s`)%s", $i, (i==NF?"":" || ")}')
PRIMARY_HOST=$(echo "$TRAEFIK_HOST" | cut -d',' -f1 | sed 's/ //g')
else
# Single domain: Host(`domain.com`)
TRAEFIK_HOST_RULE="Host(\`$TRAEFIK_HOST\`)"
PRIMARY_HOST="$TRAEFIK_HOST"
fi
{
echo "target=$TARGET"
echo "image_tag=$IMAGE_TAG"
echo "env_file=$ENV_FILE"
echo "traefik_host=$TRAEFIK_HOST"
echo "traefik_host_rule=$TRAEFIK_HOST_RULE"
echo "primary_host=$PRIMARY_HOST"
echo "next_public_base_url=$NEXT_PUBLIC_BASE_URL"
echo "directus_url=$DIRECTUS_URL"
echo "directus_host=$DIRECTUS_HOST"
echo "project_name=$PROJECT_NAME"
echo "is_prod=$IS_PROD"
echo "gotify_title=$GOTIFY_TITLE"
echo "gotify_priority=$GOTIFY_PRIORITY"
echo "short_sha=$SHORT_SHA"
echo "commit_msg=$COMMIT_MSG"
} >> "$GITHUB_OUTPUT"
# ──────────────────────────────────────────────────────────────────────────────
# JOB 2: Quality Assurance (Lint & Test)
# ──────────────────────────────────────────────────────────────────────────────
qa:
name: 🧪 Quality Assurance
needs: prepare
if: needs.prepare.outputs.target != 'skip'
runs-on: docker
container:
image: catthehacker/ubuntu:act-latest
steps:
- name: Checkout repository
uses: actions/checkout@v4
with:
fetch-depth: 1
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: 20
- name: Setup pnpm
uses: pnpm/action-setup@v3
with:
version: 10
run_install: false
- name: 🔐 Configure Private Registry
run: |
REGISTRY="${{ vars.REGISTRY_HOST || 'npm.infra.mintel.me' }}"
echo "@mintel:registry=https://$REGISTRY" > .npmrc
echo "//$REGISTRY/:_authToken=${{ secrets.REGISTRY_PASS }}" >> .npmrc
- name: Install dependencies
run: pnpm install
env:
NPM_TOKEN: ${{ secrets.REGISTRY_PASS }}
- name: 🧪 Run Checks in Parallel
if: github.event.inputs.skip_long_checks != 'true'
run: |
pnpm lint &
LINT_PID=$!
pnpm typecheck &
TYPE_PID=$!
pnpm test &
TEST_PID=$!
# Wait for all and fail if any fail
wait $LINT_PID || exit 1
wait $TYPE_PID || exit 1
wait $TEST_PID || exit 1
# ──────────────────────────────────────────────────────────────────────────────
# JOB 3: Build & Push Docker Image
# ──────────────────────────────────────────────────────────────────────────────
build-app:
name: 🏗️ Build App
needs: prepare
if: ${{ needs.prepare.outputs.target != 'skip' }}
runs-on: docker
container:
image: catthehacker/ubuntu:act-latest
steps:
- name: Checkout repository
uses: actions/checkout@v4
with:
fetch-depth: 1
- name: 🐳 Set up Docker Buildx
uses: docker/setup-buildx-action@v3
- name: 🔐 Registry Login
run: |
echo "${{ secrets.REGISTRY_PASS }}" | docker login registry.infra.mintel.me -u "${{ secrets.REGISTRY_USER }}" --password-stdin
- name: 🏗️ App bauen & pushen
env:
IMAGE_TAG: ${{ needs.prepare.outputs.image_tag }}
TARGET: ${{ needs.prepare.outputs.target }}
NEXT_PUBLIC_BASE_URL: ${{ needs.prepare.outputs.next_public_base_url }}
DIRECTUS_URL: ${{ needs.prepare.outputs.directus_url }}
run: |
docker buildx build \
--pull \
--platform linux/arm64 \
--build-arg NEXT_PUBLIC_BASE_URL="$NEXT_PUBLIC_BASE_URL" \
--build-arg NEXT_PUBLIC_TARGET="$TARGET" \
--build-arg DIRECTUS_URL="$DIRECTUS_URL" \
--build-arg REGISTRY_HOST="${{ vars.REGISTRY_HOST || 'npm.infra.mintel.me' }}" \
--build-arg NPM_TOKEN="${{ secrets.REGISTRY_PASS }}" \
-t registry.infra.mintel.me/mintel/klz-cables.com:$IMAGE_TAG \
--cache-from type=registry,ref=registry.infra.mintel.me/mintel/klz-cables.com:buildcache \
--cache-to type=registry,ref=registry.infra.mintel.me/mintel/klz-cables.com:buildcache,mode=max \
--push .
# ──────────────────────────────────────────────────────────────────────────────
# JOB 4: Deploy via SSH
# ──────────────────────────────────────────────────────────────────────────────
deploy:
name: 🚀 Deploy
needs: [prepare, build-app, qa]
if: ${{ needs.prepare.outputs.target != 'skip' }}
runs-on: docker
container:
image: catthehacker/ubuntu:act-latest
env:
TARGET: ${{ needs.prepare.outputs.target }}
IMAGE_TAG: ${{ needs.prepare.outputs.image_tag }}
ENV_FILE: ${{ needs.prepare.outputs.env_file }}
TRAEFIK_HOST: ${{ needs.prepare.outputs.primary_host }}
NEXT_PUBLIC_BASE_URL: ${{ needs.prepare.outputs.next_public_base_url }}
UMAMI_WEBSITE_ID: ${{ needs.prepare.outputs.target == 'production' && secrets.NEXT_PUBLIC_UMAMI_WEBSITE_ID || (needs.prepare.outputs.target == 'staging' && secrets.STAGING_NEXT_PUBLIC_UMAMI_WEBSITE_ID || secrets.TESTING_NEXT_PUBLIC_UMAMI_WEBSITE_ID || secrets.NEXT_PUBLIC_UMAMI_WEBSITE_ID) }}
UMAMI_API_ENDPOINT: ${{ needs.prepare.outputs.target == 'production' && secrets.NEXT_PUBLIC_UMAMI_SCRIPT_URL || (needs.prepare.outputs.target == 'staging' && secrets.STAGING_NEXT_PUBLIC_UMAMI_SCRIPT_URL || secrets.TESTING_NEXT_PUBLIC_UMAMI_SCRIPT_URL || secrets.NEXT_PUBLIC_UMAMI_SCRIPT_URL) }}
SENTRY_DSN: ${{ secrets.SENTRY_DSN || vars.SENTRY_DSN }}
MAIL_HOST: ${{ secrets.MAIL_HOST || vars.MAIL_HOST || (needs.prepare.outputs.target == 'production' && (secrets.MAIL_HOST || vars.MAIL_HOST) || (needs.prepare.outputs.target == 'staging' && (secrets.STAGING_MAIL_HOST || vars.STAGING_MAIL_HOST) || (secrets.TESTING_MAIL_HOST || vars.TESTING_MAIL_HOST) || (secrets.MAIL_HOST || vars.MAIL_HOST))) }}
MAIL_PORT: ${{ secrets.MAIL_PORT || vars.MAIL_PORT || (needs.prepare.outputs.target == 'production' && (secrets.MAIL_PORT || vars.MAIL_PORT) || (needs.prepare.outputs.target == 'staging' && (secrets.STAGING_MAIL_PORT || vars.STAGING_MAIL_PORT) || (secrets.TESTING_MAIL_PORT || vars.TESTING_MAIL_PORT) || (secrets.MAIL_PORT || vars.MAIL_PORT))) }}
MAIL_USERNAME: ${{ secrets.MAIL_USERNAME || vars.MAIL_USERNAME || (needs.prepare.outputs.target == 'production' && (secrets.MAIL_USERNAME || vars.MAIL_USERNAME) || (needs.prepare.outputs.target == 'staging' && (secrets.STAGING_MAIL_USERNAME || vars.STAGING_MAIL_USERNAME) || (secrets.TESTING_MAIL_USERNAME || vars.TESTING_MAIL_USERNAME) || (secrets.MAIL_USERNAME || vars.MAIL_USERNAME))) }}
MAIL_PASSWORD: ${{ secrets.MAIL_PASSWORD || (needs.prepare.outputs.target == 'production' && secrets.MAIL_PASSWORD || (needs.prepare.outputs.target == 'staging' && secrets.STAGING_MAIL_PASSWORD || secrets.TESTING_MAIL_PASSWORD || secrets.MAIL_PASSWORD)) }}
MAIL_FROM: ${{ secrets.MAIL_FROM || vars.MAIL_FROM || (needs.prepare.outputs.target == 'production' && (secrets.MAIL_FROM || vars.MAIL_FROM) || (needs.prepare.outputs.target == 'staging' && (secrets.STAGING_MAIL_FROM || vars.STAGING_MAIL_FROM) || (secrets.TESTING_MAIL_FROM || vars.TESTING_MAIL_FROM) || (secrets.MAIL_FROM || vars.MAIL_FROM))) }}
MAIL_RECIPIENTS: ${{ secrets.MAIL_RECIPIENTS || vars.MAIL_RECIPIENTS || (needs.prepare.outputs.target == 'production' && (secrets.MAIL_RECIPIENTS || vars.MAIL_RECIPIENTS) || (needs.prepare.outputs.target == 'staging' && (secrets.STAGING_MAIL_RECIPIENTS || vars.STAGING_MAIL_RECIPIENTS) || (secrets.TESTING_MAIL_RECIPIENTS || vars.TESTING_MAIL_RECIPIENTS) || (secrets.MAIL_RECIPIENTS || vars.MAIL_RECIPIENTS))) }}
DIRECTUS_URL: ${{ needs.prepare.outputs.directus_url }}
DIRECTUS_HOST: ${{ needs.prepare.outputs.directus_host }}
PROJECT_NAME: ${{ needs.prepare.outputs.project_name }}
DIRECTUS_KEY: ${{ secrets.DIRECTUS_KEY || (needs.prepare.outputs.target == 'production' && secrets.DIRECTUS_KEY || (needs.prepare.outputs.target == 'staging' && secrets.STAGING_DIRECTUS_KEY || secrets.TESTING_DIRECTUS_KEY || secrets.DIRECTUS_KEY)) }}
DIRECTUS_SECRET: ${{ secrets.DIRECTUS_SECRET || (needs.prepare.outputs.target == 'production' && secrets.DIRECTUS_SECRET || (needs.prepare.outputs.target == 'staging' && secrets.STAGING_DIRECTUS_SECRET || secrets.TESTING_DIRECTUS_SECRET || secrets.DIRECTUS_SECRET)) }}
DIRECTUS_ADMIN_EMAIL: ${{ secrets.DIRECTUS_ADMIN_EMAIL || (needs.prepare.outputs.target == 'production' && secrets.DIRECTUS_ADMIN_EMAIL || (needs.prepare.outputs.target == 'staging' && secrets.STAGING_DIRECTUS_ADMIN_EMAIL || secrets.TESTING_DIRECTUS_ADMIN_EMAIL || secrets.DIRECTUS_ADMIN_EMAIL)) }}
DIRECTUS_ADMIN_PASSWORD: ${{ secrets.DIRECTUS_ADMIN_PASSWORD || (needs.prepare.outputs.target == 'production' && secrets.DIRECTUS_ADMIN_PASSWORD || (needs.prepare.outputs.target == 'staging' && secrets.STAGING_DIRECTUS_ADMIN_PASSWORD || secrets.TESTING_DIRECTUS_ADMIN_PASSWORD || secrets.DIRECTUS_ADMIN_PASSWORD)) }}
DIRECTUS_DB_NAME: ${{ secrets.DIRECTUS_DB_NAME || 'directus' }}
DIRECTUS_DB_USER: ${{ secrets.DIRECTUS_DB_USER || 'directus' }}
DIRECTUS_DB_PASSWORD: ${{ secrets.DIRECTUS_DB_PASSWORD || (needs.prepare.outputs.target == 'production' && secrets.DIRECTUS_DB_PASSWORD || (needs.prepare.outputs.target == 'staging' && secrets.STAGING_DIRECTUS_DB_PASSWORD || secrets.TESTING_DIRECTUS_DB_PASSWORD || secrets.DIRECTUS_DB_PASSWORD)) }}
DIRECTUS_API_TOKEN: ${{ secrets.DIRECTUS_API_TOKEN || (needs.prepare.outputs.target == 'production' && secrets.DIRECTUS_API_TOKEN || (needs.prepare.outputs.target == 'staging' && secrets.STAGING_DIRECTUS_API_TOKEN || secrets.TESTING_DIRECTUS_API_TOKEN || secrets.DIRECTUS_API_TOKEN)) }}
GATEKEEPER_PASSWORD: ${{ secrets.GATEKEEPER_PASSWORD || 'klz2026' }}
steps:
- name: Checkout repository
uses: actions/checkout@v4
with:
fetch-depth: 1
- name: 🚀 Deploy to ${{ env.TARGET }}
shell: bash
run: |
echo "Deploying $TARGET → $IMAGE_TAG"
mkdir -p ~/.ssh
echo "${{ secrets.ALPHA_SSH_KEY }}" > ~/.ssh/id_ed25519
chmod 600 ~/.ssh/id_ed25519
ssh-keyscan -H alpha.mintel.me >> ~/.ssh/known_hosts 2>/dev/null
# Generated by CI - $TARGET - $(date -u)
# Determine dynamic values before writing the file
LOG_LEVEL=$( [[ "$TARGET" == "testing" || "$TARGET" == "development" ]] && echo "debug" || echo "info" )
COOKIE_DOMAIN=.$(echo $NEXT_PUBLIC_BASE_URL | sed 's|https://||')
cat > /tmp/klz-cables.env << EOF
# Generated by CI - $TARGET - $(date -u)
IMAGE_TAG=$IMAGE_TAG
NEXT_PUBLIC_BASE_URL=$NEXT_PUBLIC_BASE_URL
UMAMI_WEBSITE_ID=$UMAMI_WEBSITE_ID
UMAMI_API_ENDPOINT=$UMAMI_API_ENDPOINT
SENTRY_DSN=$SENTRY_DSN
LOG_LEVEL=$LOG_LEVEL
MAIL_HOST=$MAIL_HOST
MAIL_PORT=$MAIL_PORT
MAIL_USERNAME=$MAIL_USERNAME
MAIL_PASSWORD=$MAIL_PASSWORD
MAIL_FROM=$MAIL_FROM
MAIL_RECIPIENTS=$MAIL_RECIPIENTS
# Directus
DIRECTUS_URL=$DIRECTUS_URL
DIRECTUS_HOST=$DIRECTUS_HOST
DIRECTUS_KEY=$DIRECTUS_KEY
DIRECTUS_SECRET=$DIRECTUS_SECRET
DIRECTUS_ADMIN_EMAIL=$DIRECTUS_ADMIN_EMAIL
DIRECTUS_ADMIN_PASSWORD=$DIRECTUS_ADMIN_PASSWORD
DIRECTUS_DB_NAME=$DIRECTUS_DB_NAME
DIRECTUS_DB_USER=$DIRECTUS_DB_USER
DIRECTUS_DB_PASSWORD=$DIRECTUS_DB_PASSWORD
DIRECTUS_API_TOKEN=$DIRECTUS_API_TOKEN
INTERNAL_DIRECTUS_URL=http://directus:8055
GATEKEEPER_PASSWORD=$GATEKEEPER_PASSWORD
TARGET=$TARGET
SENTRY_ENVIRONMENT=$TARGET
PROJECT_NAME=$PROJECT_NAME
COOKIE_DOMAIN=$COOKIE_DOMAIN
TRAEFIK_HOST=$TRAEFIK_HOST
EOF
# Append complex variables that contain backticks using printf to avoid shell expansion hits
printf "AUTH_MIDDLEWARE=%s\n" "$( [[ "$TARGET" == "production" ]] && echo "${PROJECT_NAME}-compress" || echo "${PROJECT_NAME}-auth,${PROJECT_NAME}-compress" )" >> /tmp/klz-cables.env
printf "TRAEFIK_HOST_RULE='%s'\n" '${{ needs.prepare.outputs.traefik_host_rule }}' >> /tmp/klz-cables.env
# 1. Cleanup and Create Directories on server BEFORE SCP
ssh -o StrictHostKeyChecking=accept-new root@alpha.mintel.me bash << 'EOF'
set -e
mkdir -p /home/deploy/sites/klz-cables.com/varnish
mkdir -p /home/deploy/sites/klz-cables.com/directus/uploads \
/home/deploy/sites/klz-cables.com/directus/extensions \
/home/deploy/sites/klz-cables.com/directus/schema
if [ -d "/home/deploy/sites/klz-cables.com/varnish/default.vcl" ]; then
echo "🧹 Removing directory 'varnish/default.vcl' created by Docker..."
rm -rf /home/deploy/sites/klz-cables.com/varnish/default.vcl
fi
chown -R deploy:deploy /home/deploy/sites/klz-cables.com/directus /home/deploy/sites/klz-cables.com/varnish
EOF
# 2. Transfer files
scp -o StrictHostKeyChecking=accept-new /tmp/klz-cables.env root@alpha.mintel.me:/home/deploy/sites/klz-cables.com/$ENV_FILE
scp -o StrictHostKeyChecking=accept-new docker-compose.yml root@alpha.mintel.me:/home/deploy/sites/klz-cables.com/docker-compose.yml
scp -r -o StrictHostKeyChecking=accept-new directus/schema root@alpha.mintel.me:/home/deploy/sites/klz-cables.com/directus/
scp -r -o StrictHostKeyChecking=accept-new varnish root@alpha.mintel.me:/home/deploy/sites/klz-cables.com/
ssh -o StrictHostKeyChecking=accept-new root@alpha.mintel.me IMAGE_TAG="$IMAGE_TAG" ENV_FILE="$ENV_FILE" PROJECT_NAME="$PROJECT_NAME" bash << 'EOF'
set -e
cd /home/deploy/sites/klz-cables.com
chmod 600 "$ENV_FILE"
chown deploy:deploy "$ENV_FILE"
echo "${{ secrets.REGISTRY_PASS }}" | docker login registry.infra.mintel.me -u "${{ secrets.REGISTRY_USER }}" --password-stdin
echo "→ Pulling image: $IMAGE_TAG"
docker compose -p "$PROJECT_NAME" --env-file "$ENV_FILE" pull
echo "→ Starting containers..."
docker compose -p "$PROJECT_NAME" --env-file "$ENV_FILE" up -d --remove-orphans
docker system prune -f --filter "until=24h"
echo "→ Waiting 15s for warmup..."
sleep 15
echo "→ Container status:"
docker compose -p "$PROJECT_NAME" --env-file "$ENV_FILE" ps
if ! docker compose -p "$PROJECT_NAME" --env-file "$ENV_FILE" ps | grep -q "Up"; then
echo "❌ Fehler: Container nicht Up!"
docker compose -p "$PROJECT_NAME" --env-file "$ENV_FILE" logs --tail=150
exit 1
fi
echo "→ Applying Directus Schema Snapshot..."
# Note: We check if snapshot exists first to avoid failing if no snapshot is committed yet
if docker compose -p "$PROJECT_NAME" --env-file "$ENV_FILE" exec -T directus ls /directus/schema/snapshot.yaml >/dev/null 2>&1; then
docker compose -p "$PROJECT_NAME" --env-file "$ENV_FILE" exec -T directus npx directus schema apply /directus/schema/snapshot.yaml --yes
else
echo " No snapshot.yaml found, skipping schema apply."
fi
echo "→ Verifying Varnish Backend Health..."
docker compose -p "$PROJECT_NAME" --env-file "$ENV_FILE" exec -T varnish varnishadm backend.list
if ! docker compose -p "$PROJECT_NAME" --env-file "$ENV_FILE" exec -T varnish varnishadm backend.list | grep -q "healthy"; then
echo "❌ Fehler: Varnish Backend ist SICK!"
docker compose -p "$PROJECT_NAME" --env-file "$ENV_FILE" logs varnish
exit 1
fi
echo "✅ Varnish Backend ist Healthy."
# ──────────────────────────────────────────────────────────────────────────────
# JOB 5: PageSpeed Test
# ──────────────────────────────────────────────────────────────────────────────
pagespeed:
name: ⚡ PageSpeed
needs: [prepare, deploy]
if: |
always() &&
needs.prepare.outputs.target != 'skip' &&
needs.deploy.result == 'success' &&
github.event.inputs.skip_long_checks != 'true'
runs-on: docker
container:
image: catthehacker/ubuntu:act-latest
# outputs:
# report_url: ${{ steps.save.outputs.report_url }}
steps:
- name: Checkout repository
uses: actions/checkout@v4
with:
fetch-depth: 1
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: 20
- name: Setup pnpm
uses: pnpm/action-setup@v3
with:
version: 10
run_install: false
- name: 🔐 Configure Private Registry
run: |
REGISTRY="${{ vars.REGISTRY_HOST || 'npm.infra.mintel.me' }}"
echo "@mintel:registry=https://$REGISTRY" > .npmrc
echo "//$REGISTRY/:_authToken=${{ secrets.REGISTRY_PASS }}" >> .npmrc
- name: Install dependencies
run: pnpm install
- name: 🔍 Install Chromium (Native & ARM64)
run: |
apt-get update
apt-get install -y \
git \
docker.io \
openssh-client \
rsync
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"
# Multi-method Key Fetch
SUCCESS=false
echo "Fetching key $KEY_ID..."
# Method 1: gpg --recv-keys (standard)
for server in "hkp://keyserver.ubuntu.com:80" "hkp://keyserver.ubuntu.com:11371"; do
if gpg --no-default-keyring --keyring /tmp/xtradeb.gpg --keyserver "$server" --recv-keys "$KEY_ID"; then
gpg --no-default-keyring --keyring /tmp/xtradeb.gpg --export > /etc/apt/keyrings/xtradeb.gpg
SUCCESS=true && break
fi
done
# Method 2: Direct wget (fallback)
if [ "$SUCCESS" = false ]; then
wget -qO- "https://keyserver.ubuntu.com/pks/lookup?op=get&search=0x$KEY_ID" | gpg --dearmor > /etc/apt/keyrings/xtradeb.gpg && SUCCESS=true
fi
if [ "$SUCCESS" = true ]; then
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
else
echo "⚠️ GPG fetch failed, using legacy apt-key as last resort..."
apt-key adv --keyserver keyserver.ubuntu.com --recv-keys "$KEY_ID" || true
echo "deb http://ppa.launchpad.net/xtradeb/apps/ubuntu $CODENAME main" > /etc/apt/sources.list.d/xtradeb-ppa.list
fi
# 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 || apt-get install -y chromium-browser
fi
# Force clean paths (remove existing dead links/files if they are snap wrappers)
rm -f /usr/bin/google-chrome /usr/bin/chromium-browser
[ -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
echo "✅ Binary check:"
ls -l /usr/bin/chromium* /usr/bin/google-chrome || true
continue-on-error: true
# --- Checkout ---
- name: Checkout repo
run: |
git clone https://git.infra.mintel.me/mmintel/klz-cables.com.git .
git checkout main
# --- Docker registry login ---
- name: Login to registry
- name: 🧪 Run PageSpeed (Lighthouse)
env:
REGISTRY_USER: ${{ secrets.REGISTRY_USER }}
REGISTRY_PASS: ${{ secrets.REGISTRY_PASS }}
run: |
echo "$REGISTRY_PASS" | docker login registry.infra.mintel.me \
-u "$REGISTRY_USER" \
--password-stdin
NEXT_PUBLIC_BASE_URL: ${{ needs.prepare.outputs.next_public_base_url }}
GATEKEEPER_PASSWORD: ${{ secrets.GATEKEEPER_PASSWORD || 'klz2026' }}
PAGESPEED_LIMIT: 8
PUPPETEER_EXECUTABLE_PATH: /usr/bin/chromium
CHROME_PATH: /usr/bin/chromium
run: pnpm pagespeed:test
# --- Build image ---
- name: Build image
run: |
docker build \
-t registry.infra.mintel.me/mintel/klz-cables.com:latest .
# --- Push image ---
- name: Push image
run: |
docker push registry.infra.mintel.me/mintel/klz-cables.com:latest
# --- SSH setup ---
- name: Setup SSH
env:
ALPHA_SSH_KEY: ${{ secrets.ALPHA_SSH_KEY }}
# ──────────────────────────────────────────────────────────────────────────────
# JOB 6: Notifications
# ──────────────────────────────────────────────────────────────────────────────
notifications:
name: 🔔 Notifications
needs: [prepare, qa, build-app, deploy, pagespeed]
if: always()
runs-on: docker
container:
image: catthehacker/ubuntu:act-latest
steps:
- name: 📊 Deployment Summary
run: |
mkdir -p ~/.ssh
echo "$ALPHA_SSH_KEY" > ~/.ssh/id_ed25519
chmod 600 ~/.ssh/id_ed25519
ssh-keyscan -H alpha.mintel.me >> ~/.ssh/known_hosts
echo "┌──────────────────────────────┐"
echo "│ Deployment Summary │"
echo "├──────────────────────────────┤"
echo "│ Status: ${{ needs.deploy.result }} │"
echo "│ Umgebung: ${{ needs.prepare.outputs.target || 'skipped' }} │"
echo "│ Version: ${{ needs.prepare.outputs.image_tag }} │"
echo "│ Commit: ${{ needs.prepare.outputs.short_sha }} │"
echo "│ Message: ${{ needs.prepare.outputs.commit_msg }} │"
echo "└──────────────────────────────┘"
# --- Sync compose (yml OR yaml) ---
- name: Sync compose file
- name: 🔔 Gotify - Success
if: needs.deploy.result == 'success'
run: |
rsync -av ./docker-compose.y*ml \
deploy@alpha.mintel.me:/home/deploy/sites/klz-cables.com/
curl -s -k -X POST "${{ secrets.GOTIFY_URL }}/message?token=${{ secrets.GOTIFY_TOKEN }}" \
-F "title=${{ needs.prepare.outputs.gotify_title }}" \
-F "message=Erfolgreich deployt auf **${{ needs.prepare.outputs.target }}**\n\nVersion: **${{ needs.prepare.outputs.image_tag }}**\nCommit: ${{ needs.prepare.outputs.short_sha }} (${{ needs.prepare.outputs.commit_msg }})\nVon: ${{ github.actor }}\nRun: ${{ github.run_id }}" \
-F "priority=4" || true
# --- Deploy ---
- name: Deploy on server
- name: 🔔 Gotify - Failure
if: |
needs.prepare.result == 'failure' ||
needs.qa.result == 'failure' ||
needs.build-app.result == 'failure' ||
needs.deploy.result == 'failure' ||
needs.pagespeed.result == 'failure'
run: |
ssh deploy@alpha.mintel.me '
cd /home/deploy/sites/klz-cables.com &&
docker compose -f docker-compose.yml pull 2>/dev/null ||
docker compose -f docker-compose.yaml pull &&
docker compose up -d
'
curl -s -k -X POST "${{ secrets.GOTIFY_URL }}/message?token=${{ secrets.GOTIFY_TOKEN }}" \
-F "title=❌ Deployment FEHLGESCHLAGEN ${{ needs.prepare.outputs.target || 'unknown' }}" \
-F "message=**Fehler beim Deploy auf ${{ needs.prepare.outputs.target }}**\n\nVersion: ${{ needs.prepare.outputs.image_tag || '?' }}\nCommit: ${{ needs.prepare.outputs.short_sha || '?' }}\nVon: ${{ github.actor }}\nRun: ${{ github.run_id }}\n\nBitte Logs prüfen!" \
-F "priority=8" || true

9
.gitignore vendored
View File

@@ -1,2 +1,9 @@
node_modules
.next
.next
.DS_Store
# Directus
directus/uploads
!directus/extensions/
!directus/schema/
!directus/migrations/

1
.husky/commit-msg Executable file
View File

@@ -0,0 +1 @@
npx --no -- commitlint --edit "$1"

1
.husky/pre-commit Executable file
View File

@@ -0,0 +1 @@
npx lint-staged

9
.lintstagedrc.js Normal file
View File

@@ -0,0 +1,9 @@
const path = require('path');
const buildEslintCommand = (filenames) =>
`eslint --fix ${filenames.map((f) => path.relative(process.cwd(), f)).join(' ')}`;
module.exports = {
'*.{js,jsx,ts,tsx}': [buildEslintCommand, 'prettier --write'],
'*.{json,md,css,scss}': ['prettier --write'],
};

7
.prettierrc Normal file
View File

@@ -0,0 +1,7 @@
{
"semi": true,
"singleQuote": true,
"tabWidth": 2,
"trailingComma": "all",
"printWidth": 100
}

View File

@@ -2,13 +2,22 @@ FROM node:20-alpine AS base
# Install dependencies only when needed
FROM base AS deps
# Check https://github.com/nodejs/docker-node/tree/b4117f9333da4138b03a546ec926ef50a31506c3#nodealpine to understand why libc6-compat might be needed.
RUN apk add --no-cache libc6-compat
RUN apk add --no-cache libc6-compat curl
WORKDIR /app
# Install pnpm
RUN npm install -g pnpm@10
# Install dependencies based on the preferred package manager
COPY package.json package-lock.json* ./
RUN npm ci
COPY package.json pnpm-lock.yaml* ./
ARG REGISTRY_HOST
ARG NPM_TOKEN
RUN if [ -n "$NPM_TOKEN" ]; then \
REGISTRY="${REGISTRY_HOST:-npm.infra.mintel.me}" && \
echo "@mintel:registry=https://$REGISTRY" > .npmrc && \
echo "//$REGISTRY/:_authToken=$NPM_TOKEN" >> .npmrc; \
fi
RUN --mount=type=cache,target=/root/.local/share/pnpm/store pnpm install --frozen-lockfile
# Rebuild the source code only when needed
@@ -20,15 +29,31 @@ COPY . .
# Next.js collects completely anonymous telemetry data about general usage.
# Learn more here: https://nextjs.org/telemetry
# Uncomment the following line in case you want to disable telemetry during the build.
# ENV NEXT_TELEMETRY_DISABLED 1
ENV NEXT_TELEMETRY_DISABLED=1
RUN npm run build
# Build-time environment variables for Next.js
# These are baked into the client bundle during build
ARG NEXT_PUBLIC_BASE_URL
ARG NEXT_PUBLIC_TARGET
ARG DIRECTUS_URL
ENV NEXT_PUBLIC_BASE_URL=$NEXT_PUBLIC_BASE_URL
ENV NEXT_PUBLIC_TARGET=$NEXT_PUBLIC_TARGET
ENV DIRECTUS_URL=$DIRECTUS_URL
# Validate environment variables during build
RUN SKIP_RUNTIME_ENV_VALIDATION=true npx tsx scripts/validate-env.ts
RUN --mount=type=cache,target=/app/.next/cache pnpm run build
# Production image, copy all the files and run next
FROM base AS runner
WORKDIR /app
ENV NODE_ENV production
# Install curl for health checks
RUN apk add --no-cache curl
ENV NODE_ENV=production
# Uncomment the following line in case you want to disable telemetry during runtime.
# ENV NEXT_TELEMETRY_DISABLED 1
@@ -50,9 +75,9 @@ USER nextjs
EXPOSE 3000
ENV PORT 3000
ENV PORT=3000
# set hostname to localhost
ENV HOSTNAME "0.0.0.0"
ENV HOSTNAME="0.0.0.0"
# server.js is created by next build from the standalone output
# https://nextjs.org/docs/pages/api-reference/next-config-js/output

167
README.md
View File

@@ -5,11 +5,13 @@ A complete WordPress to Next.js static site migration for KLZ Cables, transformi
## 🚀 Quick Start
### Prerequisites
- Node.js 18+
- Node.js 18+
- npm or yarn
### Installation
```bash
````bash
# Install dependencies
npm install --legacy-peer-deps
@@ -29,21 +31,61 @@ npm run export
# Or run development server
npm run dev
### 🏗️ CMS (Strapi)
The CMS runs in Docker. Use the following npm scripts for local development:
```bash
# Start Strapi and its database
npm run cms:dev
# View logs
npm run cms:logs
# Stop the CMS
npm run cms:stop
````
Once running, you can access the Strapi admin panel at `http://localhost:1337/admin`.
### 🔄 Data & Migration
To sync data or migrate existing content:
```bash
# Export local data
npm run cms:export -- my-data.tar.gz
# Import data
npm run cms:import -- my-data.tar.gz
# Migrate existing MDX data to Strapi
npm run cms:migrate
```
### Environment Variables
```bash
# .env
SITE_URL=https://klz-cables.com
RESEND_API_KEY=your_resend_key
TURNSTILE_SITE_KEY=your_turnstile_key
TURNSTILE_SECRET_KEY=your_turnstile_secret
VERCEL_ANALYTICS_ID=your_analytics_id
# Umami
UMAMI_WEBSITE_ID=your_umami_website_id
UMAMI_API_ENDPOINT=https://analytics.infra.mintel.me
# GlitchTip (Sentry compatible)
SENTRY_DSN=https://PUBLIC_KEY@errors.infra.mintel.me/PROJECT_ID
NEXT_PUBLIC_SENTRY_DSN=https://PUBLIC_KEY@errors.infra.mintel.me/PROJECT_ID
```
## 📊 Project Overview
### Migration Statistics
- **Content Exported**: 141 items
- 18 pages (9 EN + 9 DE)
- 59 posts (29 EN + 30 DE)
@@ -54,6 +96,7 @@ VERCEL_ANALYTICS_ID=your_analytics_id
- **Translation Pairs**: 16
### Performance Benefits
- **Before**: Dynamic WordPress with database queries
- **After**: Static HTML with CDN delivery
- **Load Time**: <100ms (vs 500ms+)
@@ -62,15 +105,18 @@ VERCEL_ANALYTICS_ID=your_analytics_id
## 🏗️ Architecture
### Tech Stack
- **Framework**: Next.js 14 (App Router)
- **Language**: TypeScript
- **Styling**: SCSS
- **Data**: Static JSON (WordPress export)
- **CMS**: Strapi (Source of Truth)
- **Data**: Static JSON (WordPress export) & Strapi API
- **Email**: Resend
- **Analytics**: Vercel (consent-based)
- **CAPTCHA**: Cloudflare Turnstile
### Project Structure
```
app/
├── layout.tsx # Root layout
@@ -95,7 +141,7 @@ app/
├── api/
│ └── contact/route.ts # Contact API
├── sitemap.ts # Sitemap generator
── robots.ts # Robots.txt generator
── robots.ts # Robots.txt generator
lib/
├── data.ts # Data access
@@ -106,7 +152,7 @@ components/
├── LocaleSwitcher.tsx # Language switcher
├── ContactForm.tsx # Contact form
├── CookieConsent.tsx # GDPR banner
── SEO.tsx # SEO utilities
── SEO.tsx # SEO utilities
data/
├── raw/ # WordPress export
@@ -125,6 +171,7 @@ scripts/
## 🎯 Features
### ✅ Implemented
- **Multi-language**: EN/DE with `/de/` prefix routing
- **Contact Forms**: Resend integration with validation
- **GDPR Compliance**: Cookie consent banner
@@ -137,12 +184,14 @@ scripts/
- **Asset Management**: WordPress → local path mapping
### 🔄 In Progress
- Analytics integration (consent-based)
- Turnstile CAPTCHA
- Build testing
- Deployment configuration
### 📝 Remaining
- Performance optimization
- Final QA testing
- Documentation updates
@@ -150,6 +199,7 @@ scripts/
## 📝 Content Management
### Data Export
```bash
# Export from WordPress
npm run data:export
@@ -165,6 +215,7 @@ npm run data:improve-mapping
```
### Adding New Content
1. Export new content from WordPress
2. Process the data
3. Rebuild the site
@@ -172,17 +223,20 @@ npm run data:improve-mapping
## 🎨 Design System
### Colors
- Primary: `#0066cc` (KLZ Blue)
- Secondary: `#00a896` (Teal)
- Text: `#1a1a1a`
- Background: `#f8f9fa`
### Typography
- Font: Inter
- Base: 16px
- Scale: 1.25 (Major Third)
### Layout
- Max width: 1200px
- Responsive grid
- Mobile-first
@@ -190,6 +244,7 @@ npm run data:improve-mapping
## 🔧 API Endpoints
### Contact Form
```
POST /api/contact
{
@@ -201,65 +256,115 @@ POST /api/contact
```
### Sitemap
```
GET /sitemap.xml
```
### Robots
```
GET /robots.txt
```
## 🚀 Deployment
### Vercel (Recommended)
```bash
# Install Vercel CLI
npm i -g vercel
### Automatic Deployment (Current Setup)
# Deploy
vercel --prod
The project uses **Gitea Actions** for CI/CD. Every push to `main` or `staging` triggers:
1. **Build**: Docker image built for `linux/arm64` with branch-specific build args
2. **Push**: Image pushed to `registry.infra.mintel.me` with commit SHA tag
3. **Deploy**: SSH to production server, pull and restart containers using branch-specific `.env` files
**Workflow**: `.gitea/workflows/deploy.yml`
**Branch Deployments**:
- `main` branch: Deploys to production using `.env.prod`
- `staging` branch: Deploys to staging using `.env.staging`
**Environment Overrides**:
The CI/CD workflow supports `STAGING_`-prefixed secrets (e.g., `STAGING_NEXT_PUBLIC_BASE_URL`) to override default secrets when deploying the `staging` branch.
**Required Secrets** (configure in Gitea repository settings):
- `REGISTRY_USER` - Docker registry username
- `REGISTRY_PASS` - Docker registry password
- `ALPHA_SSH_KEY` - SSH private key for deployment
- `NEXT_PUBLIC_BASE_URL` - Application base URL (e.g., `https://klz-cables.com`)
- `UMAMI_WEBSITE_ID` - Analytics ID
- `UMAMI_API_ENDPOINT` - Analytics API endpoint (formerly NEXT_PUBLIC_UMAMI_SCRIPT_URL)
- `SENTRY_DSN` - Error tracking DSN
- `MAIL_HOST`, `MAIL_PORT`, `MAIL_USERNAME`, `MAIL_PASSWORD`, `MAIL_FROM`, `MAIL_RECIPIENTS` - Email configuration
### Manual Deployment
```bash
# SSH into production server
ssh deploy@alpha.mintel.me
# Navigate to project
cd /home/deploy/sites/klz-cables.com
# Pull latest image and restart
docker compose pull
docker compose up -d --force-recreate --remove-orphans
docker image prune -f
```
### Static Export
```bash
# Build and export
npm run build
npm run export
Or use the convenience script:
# Deploy to any static host
# Upload /out directory
```bash
bash scripts/deploy-webhook.sh
```
### Netlify
```bash
# Connect repository
# Set build command: npm run build
# Set publish directory: out
### Architecture
```
Client → Traefik (TLS) → Next.js App
```
**Domains**:
- `klz-cables.com` - Production
- `www.klz-cables.com` - Production (www)
- `staging.klz-cables.com` - Staging
**Services**:
- `app`: Next.js application (port 3000)
- `traefik`: Reverse proxy (external)
For detailed deployment documentation, see [`docs/DEPLOYMENT.md`](docs/DEPLOYMENT.md).
## 📈 Performance
### Build Time
- **Target**: < 2 minutes
- **Current**: ~1-2 minutes
### Page Load
- **Target**: < 100ms
- **Current**: Static HTML from CDN
### Bundle Size
- **Target**: < 100KB gzipped
- **Current**: Optimized with code splitting
## 🔒 Security
### Environment Variables
- Never commit `.env` file
- Rotate keys regularly
- Use secrets in deployment platform
### Form Security
- Email validation
- Rate limiting (recommended)
- Turnstile CAPTCHA (pending)
@@ -267,6 +372,7 @@ npm run export
## 🎓 WordPress Specifics
### WPBakery Shortcodes Removed
- `[vc_row]`, `[vc_column]`, `[vc_column_text]`
- `[nectar_*]` (Salient theme)
- `[image_with_animation]`
@@ -274,13 +380,16 @@ npm run export
- `[divider]`
### HTML Sanitization
- Removes inline event handlers
- Strips scripts
- Normalizes classes
- Preserves structure
### Asset Mapping
WordPress URLs → Local paths:
```
https://klz-cables.com/wp-content/uploads/... → /media/...
```
@@ -288,11 +397,13 @@ https://klz-cables.com/wp-content/uploads/... → /media/...
## 📚 Documentation
### Internal
- `PROJECT_STRUCTURE.md` - Detailed structure
- `IMPLEMENTATION_SUMMARY.md` - Progress tracking
- `FINAL_SUMMARY.md` - Complete overview
### External
- [Next.js Docs](https://nextjs.org/docs)
- [WordPress REST API](https://developer.wordpress.org/rest-api/)
- [Resend Docs](https://resend.com/docs)
@@ -303,17 +414,20 @@ https://klz-cables.com/wp-content/uploads/... → /media/...
### Common Issues
**TypeScript Errors**
- The TypeScript errors shown in the editor are expected
- They occur because modules reference each other
- The build process resolves these correctly
- Run `npm run build` to verify
**Build Failures**
- Check environment variables
- Verify data files exist
- Clear `.next` cache: `rm -rf .next`
**Missing Modules**
- Run `npm install --legacy-peer-deps`
- Check `package.json` dependencies
@@ -328,11 +442,12 @@ https://klz-cables.com/wp-content/uploads/... → /media/...
**i18n**: Multi-language support
**SEO**: Metadata and sitemaps
**Compatibility**: WPBakery content handled
**Media**: All images downloaded
**Media**: All images downloaded
## 📞 Support
For issues or questions:
1. Check the documentation
2. Review the troubleshooting section
3. Check environment variables
@@ -346,4 +461,4 @@ Proprietary - KLZ Cables
**Status**: ✅ **READY FOR DEPLOYMENT**
**Version**: 1.0.0
**Last Updated**: December 27, 2025
**Last Updated**: December 27, 2025

View File

@@ -0,0 +1,31 @@
import { ImageResponse } from 'next/og';
import { getPageBySlug } from '@/lib/pages';
import { OGImageTemplate } from '@/components/OGImageTemplate';
import { getOgFonts, OG_IMAGE_SIZE } from '@/lib/og-helper';
export const runtime = 'nodejs';
export default async function Image({ params: { locale, slug } }: { params: { locale: string, slug: string } }) {
const pageData = await getPageBySlug(slug, locale);
if (!pageData) {
return new Response('Page not found', { status: 404 });
}
const fonts = await getOgFonts();
return new ImageResponse(
(
<OGImageTemplate
title={pageData.frontmatter.title}
description={pageData.frontmatter.excerpt}
label="Information"
/>
),
{
...OG_IMAGE_SIZE,
fonts,
}
);
}

View File

@@ -1,17 +1,67 @@
import { notFound } from 'next/navigation';
import { MDXRemote } from 'next-mdx-remote/rsc';
import { Section, Container, Heading, Badge } from '@/components/ui';
import { Container, Badge, Heading } from '@/components/ui';
import { getTranslations } from 'next-intl/server';
import { Metadata } from 'next';
import { getPageBySlug, getAllPages } from '@/lib/pages';
import { mdxComponents } from '@/components/blog/MDXComponents';
import { getOGImageMetadata } from '@/lib/metadata';
import { SITE_URL } from '@/lib/schema';
interface PageProps {
params: {
params: Promise<{
locale: string;
slug: string;
}>;
}
export async function generateStaticParams() {
const locales = ['en', 'de'];
const params = [];
for (const locale of locales) {
const pages = await getAllPages(locale);
for (const page of pages) {
params.push({ locale, slug: page.slug });
}
}
return params;
}
export async function generateMetadata({ params }: PageProps): Promise<Metadata> {
const { locale, slug } = await params;
const pageData = await getPageBySlug(slug, locale);
if (!pageData) return {};
return {
title: pageData.frontmatter.title,
description: pageData.frontmatter.excerpt || '',
alternates: {
canonical: `/${locale}/${slug}`,
languages: {
de: `/de/${slug}`,
en: `/en/${slug}`,
'x-default': `/en/${slug}`,
},
},
openGraph: {
title: `${pageData.frontmatter.title} | KLZ Cables`,
description: pageData.frontmatter.excerpt || '',
url: `${SITE_URL}/${locale}/${slug}`,
images: getOGImageMetadata(slug, pageData.frontmatter.title, locale),
},
twitter: {
card: 'summary_large_image',
title: `${pageData.frontmatter.title} | KLZ Cables`,
description: pageData.frontmatter.excerpt || '',
},
};
}
export default async function StandardPage({ params: { locale, slug } }: PageProps) {
const { getPageBySlug } = await import('@/lib/pages');
export default async function StandardPage({ params }: PageProps) {
const { locale, slug } = await params;
const pageData = await getPageBySlug(slug, locale);
const t = await getTranslations('StandardPage');
@@ -20,7 +70,7 @@ export default async function StandardPage({ params: { locale, slug } }: PagePro
}
return (
<div className="flex flex-col min-h-screen bg-neutral-light">
<div className="flex flex-col min-h-screen bg-white">
{/* Hero Section */}
<section className="bg-primary-dark text-white py-20 md:py-32 relative overflow-hidden">
<div className="absolute inset-0 opacity-20">
@@ -28,68 +78,52 @@ export default async function StandardPage({ params: { locale, slug } }: PagePro
</div>
<Container className="relative z-10">
<div className="max-w-4xl animate-slide-up">
<Badge variant="accent" className="mb-4 md:mb-6">{t('badge')}</Badge>
<Heading level={1} className="text-3xl md:text-6xl lg:text-7xl xl:text-8xl text-white mb-0">
<span className="text-white">{pageData.frontmatter.title}</span>
<Badge variant="accent" className="mb-4 md:mb-6">
{t('badge')}
</Badge>
<Heading level={1} className="text-white mb-0">
{pageData.frontmatter.title}
</Heading>
</div>
</Container>
</section>
<Section className="bg-white -mt-8 md:-mt-12 relative z-20 rounded-t-[32px] md:rounded-t-[60px] shadow-2xl py-12 md:py-28">
<Container>
<div className="sticky-narrative-container">
{/* Sticky Narrative Sidebar - Mobile Optimized */}
<div className="sticky-narrative-sidebar mb-8 lg:mb-0">
<div className="lg:sticky lg:top-32 space-y-4 md:space-y-8">
{/* Mobile-only chip/stepper feel */}
<div className="flex lg:hidden overflow-x-auto pb-4 gap-3 no-scrollbar -mx-4 px-4">
<Badge variant="primary" className="whitespace-nowrap px-4 py-2 rounded-full shadow-sm">{t('overview')}</Badge>
<Badge variant="neutral" className="whitespace-nowrap px-4 py-2 rounded-full shadow-sm opacity-60">{t('details')}</Badge>
<Badge variant="neutral" className="whitespace-nowrap px-4 py-2 rounded-full shadow-sm opacity-60">{t('support')}</Badge>
</div>
<div className="p-6 md:p-8 bg-neutral-light rounded-2xl md:rounded-3xl border border-neutral-medium shadow-sm">
<h3 className="text-lg md:text-xl font-bold text-primary mb-3 md:mb-4 flex items-center gap-2">
<span className="w-1.5 h-6 bg-accent rounded-full" />
{t('quickNavigation')}
</h3>
<nav className="space-y-3 md:space-y-4">
<p className="text-sm md:text-base text-text-secondary leading-relaxed">
{t('exploreDetails', { title: pageData.frontmatter.title })}
</p>
</nav>
</div>
<div className="p-6 md:p-8 bg-primary-dark rounded-2xl md:rounded-3xl text-white shadow-xl relative overflow-hidden group">
<div className="absolute top-0 right-0 w-24 h-full bg-accent/5 -skew-x-12 translate-x-1/2 transition-transform group-hover:translate-x-1/3" />
<h3 className="text-lg md:text-xl font-bold mb-3 md:mb-4 relative z-10">{t('needHelp')}</h3>
<p className="text-sm md:text-base text-white/70 mb-4 md:mb-6 relative z-10">{t('supportTeamAvailable')}</p>
<a href={`/${locale}/contact`} className="inline-flex items-center text-accent font-bold hover:underline touch-target relative z-10 group/link">
{t('contactUs')}
<span className="ml-2 transition-transform group-hover/link:translate-x-1">&rarr;</span>
</a>
</div>
</div>
{/* Main Content Area */}
<div className="container mx-auto px-4 py-16 md:py-24">
<div className="max-w-4xl mx-auto">
{/* Excerpt/Lead paragraph if available */}
{pageData.frontmatter.excerpt && (
<div className="mb-16 animate-slight-fade-in-from-bottom">
<p className="text-xl md:text-2xl text-text-primary leading-relaxed font-medium border-l-4 border-primary pl-8 py-2 italic">
{pageData.frontmatter.excerpt}
</p>
</div>
)}
{/* Main Content */}
<div className="sticky-narrative-content">
<article className="prose prose-sm md:prose-lg lg:prose-xl prose-primary max-w-none
prose-headings:text-primary prose-headings:font-bold prose-headings:tracking-tight
prose-p:text-text-secondary prose-p:leading-relaxed
prose-strong:text-primary prose-strong:font-extrabold
prose-a:text-primary prose-a:font-bold prose-a:no-underline hover:prose-a:underline
prose-img:rounded-2xl md:prose-img:rounded-3xl prose-img:shadow-2xl
prose-ul:list-disc prose-ul:pl-5 md:prose-ul:pl-6
prose-li:text-text-secondary
">
<MDXRemote source={pageData.content} />
</article>
{/* Main content with shared blog components */}
<div className="prose prose-lg md:prose-xl max-w-none prose-headings:font-bold prose-headings:text-text-primary prose-p:text-text-secondary prose-p:leading-relaxed prose-a:text-primary prose-a:no-underline hover:prose-a:underline prose-img:rounded-2xl prose-img:shadow-2xl prose-blockquote:border-primary prose-blockquote:bg-primary/5 prose-blockquote:rounded-r-2xl prose-strong:text-primary animate-slight-fade-in-from-bottom">
<MDXRemote source={pageData.content} components={mdxComponents} />
</div>
{/* Support Section */}
<div className="mt-24 p-8 md:p-12 bg-primary-dark rounded-3xl text-white shadow-2xl relative overflow-hidden group animate-slight-fade-in-from-bottom">
<div className="absolute top-0 right-0 w-64 h-full bg-accent/5 -skew-x-12 translate-x-1/2 transition-transform group-hover:translate-x-1/3" />
<div className="relative z-10 max-w-2xl">
<h3 className="text-2xl md:text-3xl font-bold mb-4">{t('needHelp')}</h3>
<p className="text-lg text-white/70 mb-8">{t('supportTeamAvailable')}</p>
<a
href={`/${locale}/contact`}
className="inline-flex items-center px-8 py-4 bg-accent text-primary-dark font-bold rounded-full hover:bg-white transition-all duration-300 group/link"
>
{t('contactUs')}
<span className="ml-2 transition-transform group-hover/link:translate-x-1">
&rarr;
</span>
</a>
</div>
</div>
</Container>
</Section>
</div>
</div>
</div>
);
}

View File

@@ -0,0 +1,76 @@
import { ImageResponse } from 'next/og';
import { getProductBySlug } from '@/lib/mdx';
import { getTranslations } from 'next-intl/server';
import { OGImageTemplate } from '@/components/OGImageTemplate';
import { NextRequest } from 'next/server';
import { getOgFonts, OG_IMAGE_SIZE } from '@/lib/og-helper';
export const runtime = 'nodejs';
export async function GET(
request: NextRequest,
{ params }: { params: Promise<{ locale: string }> },
) {
const { searchParams, origin } = new URL(request.url);
const slug = searchParams.get('slug');
const { locale } = await params;
if (!slug) {
return new Response('Missing slug', { status: 400 });
}
const fonts = await getOgFonts();
const t = await getTranslations({ locale, namespace: 'Products' });
// Check if it's a category page
const categories = [
'low-voltage-cables',
'medium-voltage-cables',
'high-voltage-cables',
'solar-cables',
];
if (categories.includes(slug)) {
const categoryKey = slug
.replace(/-cables$/, '')
.replace(/-([a-z])/g, (g) => g[1].toUpperCase());
const categoryTitle = t.has(`categories.${categoryKey}.title`)
? t(`categories.${categoryKey}.title`)
: slug;
const categoryDesc = t.has(`categories.${categoryKey}.description`)
? t(`categories.${categoryKey}.description`)
: '';
return new ImageResponse(
<OGImageTemplate title={categoryTitle} description={categoryDesc} label="Product Category" />,
{
...OG_IMAGE_SIZE,
fonts,
},
);
}
const product = await getProductBySlug(slug, locale);
if (!product) {
return new Response('Product not found', { status: 404 });
}
const featuredImage = product.frontmatter.images?.[0]
? product.frontmatter.images[0].startsWith('http')
? product.frontmatter.images[0]
: `${origin}${product.frontmatter.images[0]}`
: undefined;
return new ImageResponse(
<OGImageTemplate
title={product.frontmatter.title}
description={product.frontmatter.description}
label={product.frontmatter.categories?.[0] || 'Product'}
image={featuredImage}
/>,
{
...OG_IMAGE_SIZE,
fonts,
},
);
}

View File

@@ -1,113 +1,44 @@
import { ImageResponse } from 'next/og';
import { getPostBySlug } from '@/lib/blog';
import { OGImageTemplate } from '@/components/OGImageTemplate';
import { getOgFonts, OG_IMAGE_SIZE } from '@/lib/og-helper';
import { SITE_URL } from '@/lib/schema';
export const runtime = 'nodejs';
export default async function Image({ params: { locale, slug } }: { params: { locale: string, slug: string } }) {
export default async function Image({
params: { locale, slug },
}: {
params: { locale: string; slug: string };
}) {
const post = await getPostBySlug(slug, locale);
if (!post) {
return new ImageResponse(
<div style={{ display: 'flex', width: '100%', height: '100%', backgroundColor: '#001a4d' }} />
);
return new Response('Post not found', { status: 404 });
}
return new ImageResponse(
(
<div
style={{
height: '100%',
width: '100%',
display: 'flex',
flexDirection: 'column',
alignItems: 'flex-start',
justifyContent: 'flex-end',
backgroundColor: '#001a4d',
padding: '80px',
position: 'relative',
}}
>
{/* Background Image Overlay if available */}
{post.frontmatter.featuredImage && (
<img
src={post.frontmatter.featuredImage.startsWith('http') ? post.frontmatter.featuredImage : `https://klz-cables.com${post.frontmatter.featuredImage}`}
alt=""
style={{
position: 'absolute',
inset: 0,
width: '100%',
height: '100%',
objectFit: 'cover',
opacity: 0.4,
}}
/>
)}
{/* Gradient Overlay */}
<div
style={{
position: 'absolute',
inset: 0,
background: 'linear-gradient(to top, rgba(0,26,77,1) 0%, rgba(0,26,77,0.4) 100%)',
}}
/>
const fonts = await getOgFonts();
<div style={{ display: 'flex', flexDirection: 'column', position: 'relative', zIndex: 10 }}>
{post.frontmatter.category && (
<div
style={{
fontSize: '20px',
fontWeight: 'bold',
color: '#00ff99',
textTransform: 'uppercase',
letterSpacing: '0.1em',
marginBottom: '16px',
backgroundColor: 'rgba(0,255,153,0.1)',
padding: '4px 12px',
borderRadius: '4px',
}}
>
{post.frontmatter.category}
</div>
)}
<div
style={{
fontSize: '64px',
fontWeight: '900',
color: 'white',
lineHeight: '1.1',
maxWidth: '900px',
marginBottom: '24px',
}}
>
{post.frontmatter.title}
</div>
<div style={{ display: 'flex', alignItems: 'center' }}>
<div
style={{
fontSize: '24px',
color: 'rgba(255,255,255,0.6)',
fontWeight: '500',
}}
>
KLZ Cables Blog
</div>
<div style={{ width: '8px', height: '8px', borderRadius: '50%', backgroundColor: '#00ff99', margin: '0 16px' }} />
<div
style={{
fontSize: '24px',
color: 'rgba(255,255,255,0.6)',
}}
>
{new Date(post.frontmatter.date).toLocaleDateString(locale, { year: 'numeric', month: 'long', day: 'numeric' })}
</div>
</div>
</div>
</div>
),
// We don't have request.url here, but we can assume the domain from SITE_URL or config
// For local images during dev, relative paths in <img> might not work in Satori
// but if we are in nodejs runtime, we could potentially read from disk.
// For now, let's just make sure it's absolute.
const featuredImage = post.frontmatter.featuredImage
? post.frontmatter.featuredImage.startsWith('http')
? post.frontmatter.featuredImage
: `${SITE_URL}${post.frontmatter.featuredImage}`
: undefined;
return new ImageResponse(
<OGImageTemplate
title={post.frontmatter.title}
description={post.frontmatter.excerpt}
label={post.frontmatter.category || 'Blog'}
image={featuredImage}
/>,
{
width: 1200,
height: 630,
}
...OG_IMAGE_SIZE,
fonts,
},
);
}

View File

@@ -1,180 +1,62 @@
import { notFound } from 'next/navigation';
import Script from 'next/script';
import JsonLd from '@/components/JsonLd';
import { getBreadcrumbSchema, SITE_URL, LOGO_URL } from '@/lib/schema';
import { MDXRemote } from 'next-mdx-remote/rsc';
import { getPostBySlug, getAdjacentPosts } from '@/lib/blog';
import { getPostBySlug, getAdjacentPosts, getReadingTime, getHeadings } from '@/lib/blog';
import { Metadata } from 'next';
import Link from 'next/link';
import PostNavigation from '@/components/blog/PostNavigation';
import PowerCTA from '@/components/blog/PowerCTA';
import TableOfContents from '@/components/blog/TableOfContents';
import { mdxComponents } from '@/components/blog/MDXComponents';
import { Heading } from '@/components/ui';
import { getOGImageMetadata } from '@/lib/metadata';
interface BlogPostProps {
params: {
params: Promise<{
locale: string;
slug: string;
};
}>;
}
export async function generateMetadata({ params: { locale, slug } }: BlogPostProps): Promise<Metadata> {
export async function generateMetadata({ params }: BlogPostProps): Promise<Metadata> {
const { locale, slug } = await params;
const post = await getPostBySlug(slug, locale);
if (!post) return {};
const description = post.frontmatter.excerpt || '';
return {
title: post.frontmatter.title,
description: description,
alternates: {
canonical: `/${locale}/blog/${slug}`,
languages: {
de: `/de/blog/${slug}`,
en: `/en/blog/${slug}`,
'x-default': `/en/blog/${slug}`,
},
},
openGraph: {
title: post.frontmatter.title,
title: `${post.frontmatter.title} | KLZ Cables`,
description: description,
type: 'article',
publishedTime: post.frontmatter.date,
authors: ['KLZ Cables'],
url: `https://klz-cables.com/${locale}/blog/${slug}`,
url: `${SITE_URL}/${locale}/blog/${slug}`,
images: getOGImageMetadata(`blog/${slug}`, post.frontmatter.title, locale),
},
twitter: {
card: 'summary_large_image',
title: post.frontmatter.title,
title: `${post.frontmatter.title} | KLZ Cables`,
description: description,
},
};
}
import Link from 'next/link';
import VisualLinkPreview from '@/components/blog/VisualLinkPreview';
import Callout from '@/components/blog/Callout';
import HighlightBox from '@/components/blog/HighlightBox';
import Stats from '@/components/blog/Stats';
import AnimatedImage from '@/components/blog/AnimatedImage';
import ChatBubble from '@/components/blog/ChatBubble';
import SplitHeading from '@/components/blog/SplitHeading';
import PostNavigation from '@/components/blog/PostNavigation';
import PowerCTA from '@/components/blog/PowerCTA';
import ShareButton from '@/components/blog/ShareButton';
const components = {
VisualLinkPreview,
Callout,
HighlightBox,
Stats,
AnimatedImage,
ChatBubble,
PowerCTA,
SplitHeading,
a: ({ href, children, ...props }: any) => {
if (href?.startsWith('/')) {
return (
<Link href={href} {...props} className="text-primary font-medium hover:underline decoration-2 underline-offset-2 transition-all">
{children}
</Link>
);
}
return (
<a
href={href}
{...props}
target="_blank"
rel="noopener noreferrer"
className="text-primary font-medium hover:underline decoration-2 underline-offset-2 transition-all inline-flex items-center gap-1"
>
{children}
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M10 6H6a2 2 0 00-2 2v10a2 2 0 002 2h10a2 2 0 002-2v-4M14 4h6m0 0v6m0-6L10 14" />
</svg>
</a>
);
},
img: (props: any) => (
<AnimatedImage src={props.src} alt={props.alt} />
),
h2: ({ children, ...props }: any) => (
<SplitHeading {...props} className="mt-16 mb-6 pb-3 border-b-2 border-primary/20">
{children}
</SplitHeading>
),
h3: ({ children, ...props }: any) => (
<h3 {...props} className="text-2xl font-bold text-text-primary mt-12 mb-4">
{children}
</h3>
),
p: ({ children, ...props }: any) => (
<p {...props} className="text-lg text-text-secondary leading-relaxed mb-6">
{children}
</p>
),
ul: ({ children, ...props }: any) => (
<ul {...props} className="my-8 space-y-3">
{children}
</ul>
),
ol: ({ children, ...props }: any) => (
<ol {...props} className="my-8 space-y-3 list-decimal list-inside">
{children}
</ol>
),
li: ({ children, ...props }: any) => (
<li {...props} className="text-lg text-text-secondary flex items-start gap-3">
<span className="text-primary mt-1.5 flex-shrink-0">
<svg className="w-5 h-5" fill="currentColor" viewBox="0 0 20 20">
<path fillRule="evenodd" d="M10 18a8 8 0 100-16 8 8 0 000 16zm3.707-9.293a1 1 0 00-1.414-1.414L9 10.586 7.707 9.293a1 1 0 00-1.414 1.414l2 2a1 1 0 001.414 0l4-4z" clipRule="evenodd" />
</svg>
</span>
<span className="flex-1">{children}</span>
</li>
),
blockquote: ({ children, ...props }: any) => (
<blockquote {...props} className="my-8 pl-6 border-l-4 border-primary bg-neutral-light/30 py-4 pr-6 rounded-r-lg">
<div className="text-lg text-text-primary italic">
{children}
</div>
</blockquote>
),
strong: ({ children, ...props }: any) => (
<strong {...props} className="font-bold text-primary">
{children}
</strong>
),
code: ({ children, ...props }: any) => (
<code {...props} className="px-2 py-1 bg-neutral-light text-primary rounded font-mono text-sm">
{children}
</code>
),
pre: ({ children, ...props }: any) => (
<pre {...props} className="my-8 p-6 bg-neutral-dark/5 rounded-xl overflow-x-auto">
{children}
</pre>
),
table: ({ children, ...props }: any) => (
<div className="my-8 overflow-x-auto rounded-lg border border-neutral-200 shadow-sm">
<table {...props} className="w-full text-left text-sm text-text-secondary">
{children}
</table>
</div>
),
thead: ({ children, ...props }: any) => (
<thead {...props} className="bg-neutral-50 text-text-primary font-semibold border-b border-neutral-200">
{children}
</thead>
),
tbody: ({ children, ...props }: any) => (
<tbody {...props} className="divide-y divide-neutral-200 bg-white">
{children}
</tbody>
),
tr: ({ children, ...props }: any) => (
<tr {...props} className="hover:bg-neutral-50/50 transition-colors">
{children}
</tr>
),
th: ({ children, ...props }: any) => (
<th {...props} className="px-6 py-4 whitespace-nowrap">
{children}
</th>
),
td: ({ children, ...props }: any) => (
<td {...props} className="px-6 py-4">
{children}
</td>
),
};
export default async function BlogPost({ params: { locale, slug } }: BlogPostProps) {
export default async function BlogPost({ params }: BlogPostProps) {
const { locale, slug } = await params;
const post = await getPostBySlug(slug, locale);
const { prev, next } = await getAdjacentPosts(slug, locale);
@@ -182,153 +64,202 @@ export default async function BlogPost({ params: { locale, slug } }: BlogPostPro
notFound();
}
const headings = getHeadings(post.content);
return (
<article className="bg-white min-h-screen font-sans">
<article className="bg-white min-h-screen font-sans selection:bg-primary/10 selection:text-primary">
{/* Featured Image Header */}
{post.frontmatter.featuredImage && (
<div className="relative w-full h-[60vh] min-h-[400px] overflow-hidden group">
<div
className="absolute inset-0 bg-cover bg-center transition-transform duration-[2s] ease-out scale-105 group-hover:scale-100"
{post.frontmatter.featuredImage ? (
<div className="relative w-full h-[70vh] min-h-[500px] overflow-hidden group">
<div
className="absolute inset-0 bg-cover bg-center transition-transform duration-[3s] ease-out scale-110 group-hover:scale-100"
style={{ backgroundImage: `url(${post.frontmatter.featuredImage})` }}
/>
<div className="absolute inset-0 bg-gradient-to-t from-black/80 via-black/40 to-transparent" />
<div className="absolute inset-0 bg-gradient-to-t from-neutral-dark via-neutral-dark/40 to-transparent" />
{/* Title overlay on image */}
<div className="absolute inset-0 flex flex-col justify-end p-8 md:p-16 lg:p-24">
<div className="container mx-auto max-w-3xl">
{post.frontmatter.category && (
<span className="inline-block px-4 py-1.5 bg-primary/90 backdrop-blur-sm text-white text-sm font-bold uppercase tracking-wider rounded-full mb-6 shadow-lg transform transition-transform hover:scale-105">
{post.frontmatter.category}
</span>
)}
<h1 className="text-4xl md:text-5xl lg:text-6xl font-extrabold text-white mb-6 leading-tight drop-shadow-xl">
{post.frontmatter.title}
</h1>
<div className="flex items-center gap-4 text-white/90 text-sm md:text-base font-medium">
<time dateTime={post.frontmatter.date}>
{new Date(post.frontmatter.date).toLocaleDateString(locale, {
year: 'numeric',
month: 'long',
day: 'numeric'
})}
</time>
<span className="w-1.5 h-1.5 bg-primary rounded-full" />
<span>KLZ Cables</span>
<div className="ml-auto hidden md:block">
<ShareButton
title={post.frontmatter.title}
text={post.frontmatter.excerpt || ''}
url={`https://klz-cables.com/${locale}/blog/${slug}`}
locale={locale}
/>
<div className="absolute inset-0 flex flex-col justify-end pb-16 md:pb-24">
<div className="container mx-auto px-4">
<div className="max-w-4xl">
{post.frontmatter.category && (
<div className="overflow-hidden mb-6">
<span className="inline-block px-4 py-1.5 bg-accent text-neutral-dark text-xs font-bold uppercase tracking-[0.2em] rounded-sm animate-slight-fade-in-from-bottom">
{post.frontmatter.category}
</span>
</div>
)}
<Heading
level={1}
className="text-white mb-8 drop-shadow-2xl animate-slight-fade-in-from-bottom [animation-delay:200ms]"
>
{post.frontmatter.title}
</Heading>
<div className="flex flex-wrap items-center gap-6 text-white/80 text-sm md:text-base font-medium animate-slight-fade-in-from-bottom [animation-delay:400ms]">
<time dateTime={post.frontmatter.date}>
{new Date(post.frontmatter.date).toLocaleDateString(locale, {
year: 'numeric',
month: 'long',
day: 'numeric',
})}
</time>
<span className="w-1 h-1 bg-white/30 rounded-full" />
<span>{getReadingTime(post.content)} min read</span>
</div>
</div>
</div>
</div>
</div>
)}
{/* Content */}
<div className="container mx-auto px-4 py-16 md:py-24 max-w-3xl">
{/* Mobile Share Button */}
<div className="md:hidden mb-8 flex justify-end">
<ShareButton
title={post.frontmatter.title}
text={post.frontmatter.excerpt || ''}
url={`https://klz-cables.com/${locale}/blog/${slug}`}
locale={locale}
/>
</div>
{/* If no featured image, show header here */}
{!post.frontmatter.featuredImage && (
<header className="mb-16 text-center">
) : (
<header className="pt-32 pb-16 bg-neutral-50 border-b border-neutral-100">
<div className="container mx-auto px-4 max-w-4xl">
{post.frontmatter.category && (
<div className="mb-6">
<span className="inline-block px-4 py-1.5 bg-primary/10 text-primary text-sm font-bold uppercase tracking-wider rounded-full">
<span className="inline-block px-4 py-1.5 bg-primary/10 text-primary text-xs font-bold uppercase tracking-[0.2em] rounded-sm">
{post.frontmatter.category}
</span>
</div>
)}
<h1 className="text-4xl md:text-5xl lg:text-6xl font-extrabold text-text-primary mb-8 leading-tight">
<Heading level={1} className="mb-8">
{post.frontmatter.title}
</h1>
<div className="flex items-center justify-center gap-4 text-text-secondary font-medium">
</Heading>
<div className="flex items-center gap-6 text-text-secondary font-medium">
<time dateTime={post.frontmatter.date}>
{new Date(post.frontmatter.date).toLocaleDateString(locale, {
year: 'numeric',
month: 'long',
day: 'numeric'
day: 'numeric',
})}
</time>
<span className="w-1.5 h-1.5 bg-primary rounded-full" />
<span>KLZ Cables</span>
<span className="w-1 h-1 bg-neutral-300 rounded-full" />
<span>{getReadingTime(post.content)} min read</span>
</div>
</header>
)}
{/* Excerpt/Lead paragraph if available */}
{post.frontmatter.excerpt && (
<div className="mb-16">
<p className="text-xl md:text-2xl text-text-primary leading-relaxed font-medium border-l-4 border-primary pl-6 py-2">
{post.frontmatter.excerpt}
</p>
</div>
)}
</header>
)}
{/* Main content with enhanced styling */}
<div className="prose prose-lg md:prose-xl max-w-none prose-headings:font-bold prose-headings:text-text-primary prose-p:text-text-secondary prose-p:leading-relaxed prose-a:text-primary prose-a:no-underline hover:prose-a:underline prose-img:rounded-xl prose-img:shadow-lg">
<MDXRemote source={post.content} components={components} />
</div>
{/* Main Content Area with Sticky Narrative Layout */}
<div className="container mx-auto px-4 py-16 md:py-24">
<div className="sticky-narrative-container">
{/* Left Column: Content */}
<div className="sticky-narrative-content">
{/* Excerpt/Lead paragraph if available */}
{post.frontmatter.excerpt && (
<div className="mb-16 animate-slight-fade-in-from-bottom [animation-delay:600ms]">
<p className="text-xl md:text-2xl text-text-primary leading-relaxed font-medium border-l-4 border-primary pl-8 py-2 italic">
{post.frontmatter.excerpt}
</p>
</div>
)}
{/* Structured Data */}
<script
type="application/ld+json"
dangerouslySetInnerHTML={{
__html: JSON.stringify({
'@context': 'https://schema.org',
'@type': 'BlogPosting',
headline: post.frontmatter.title,
datePublished: post.frontmatter.date,
image: post.frontmatter.featuredImage ? `https://klz-cables.com${post.frontmatter.featuredImage}` : undefined,
author: {
'@type': 'Organization',
name: 'KLZ Cables',
url: 'https://klz-cables.com',
},
publisher: {
'@type': 'Organization',
name: 'KLZ Cables',
logo: {
'@type': 'ImageObject',
url: 'https://klz-cables.com/logo.png', // Assuming logo exists
},
},
description: post.frontmatter.excerpt,
}),
}}
/>
{/* Main content with enhanced styling */}
<div className="prose prose-lg md:prose-xl max-w-none prose-headings:font-bold prose-headings:text-text-primary prose-p:text-text-secondary prose-p:leading-relaxed prose-a:text-primary prose-a:no-underline hover:prose-a:underline prose-img:rounded-2xl prose-img:shadow-2xl prose-blockquote:border-primary prose-blockquote:bg-primary/5 prose-blockquote:rounded-r-2xl prose-strong:text-primary animate-slight-fade-in-from-bottom [animation-delay:800ms]">
<MDXRemote source={post.content} components={mdxComponents} />
</div>
{/* Power CTA */}
<div className="mt-20">
<PowerCTA locale={locale} />
</div>
{/* Power CTA */}
<div className="mt-24 animate-slight-fade-in-from-bottom">
<PowerCTA locale={locale} />
</div>
{/* Post Navigation */}
<PostNavigation prev={prev} next={next} locale={locale} />
{/* Post Navigation */}
<div className="mt-16">
<PostNavigation prev={prev} next={next} locale={locale} />
</div>
{/* Back to blog link */}
<div className="mt-16 pt-10 border-t border-neutral-200 text-center">
<Link
href={`/${locale}/blog`}
className="inline-flex items-center gap-2 text-text-secondary hover:text-primary font-medium text-lg transition-colors group"
>
<svg className="w-5 h-5 transition-transform group-hover:-translate-x-1" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M15 19l-7-7 7-7" />
</svg>
{locale === 'de' ? 'Zurück zur Übersicht' : 'Back to Overview'}
</Link>
{/* Back to blog link */}
<div className="mt-16 pt-10 border-t border-neutral-100">
<Link
href={`/${locale}/blog`}
className="inline-flex items-center gap-3 text-text-secondary hover:text-primary font-bold text-sm uppercase tracking-widest transition-all group"
>
<svg
className="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="M15 19l-7-7 7-7"
/>
</svg>
{locale === 'de' ? 'Zurück zur Übersicht' : 'Back to Overview'}
</Link>
</div>
</div>
{/* Right Column: Sticky Sidebar */}
<aside className="sticky-narrative-sidebar hidden lg:block">
<div className="space-y-12">
<TableOfContents headings={headings} locale={locale} />
</div>
</aside>
</div>
</div>
{/* Structured Data */}
<JsonLd
id={`jsonld-${slug}`}
data={
{
'@context': 'https://schema.org',
'@type': 'BlogPosting',
headline: post.frontmatter.title,
datePublished: post.frontmatter.date,
dateModified: post.frontmatter.date,
image: post.frontmatter.featuredImage
? `${SITE_URL}${post.frontmatter.featuredImage}`
: undefined,
author: {
'@type': 'Organization',
name: 'KLZ Cables',
url: SITE_URL,
logo: `${SITE_URL}/logo-blue.svg`,
},
publisher: {
'@type': 'Organization',
name: 'KLZ Cables',
logo: {
'@type': 'ImageObject',
url: `${SITE_URL}/logo-blue.svg`,
},
},
description: post.frontmatter.excerpt,
mainEntityOfPage: {
'@type': 'WebPage',
'@id': `${SITE_URL}/${locale}/blog/${slug}`,
},
articleSection: post.frontmatter.category,
wordCount: post.content.split(/\s+/).length,
timeRequired: `PT${getReadingTime(post.content)}M`,
} as any
}
/>
<JsonLd
id={`breadcrumb-${slug}`}
data={
{
'@context': 'https://schema.org',
'@type': 'BreadcrumbList',
itemListElement: [
{
'@type': 'ListItem',
position: 1,
name: 'Blog',
item: `${SITE_URL}/${locale}/blog`,
},
{
'@type': 'ListItem',
position: 2,
name: post.frontmatter.title,
item: `${SITE_URL}/${locale}/blog/${slug}`,
},
],
} as any
}
/>
</article>
);
}

View File

@@ -0,0 +1,25 @@
import { ImageResponse } from 'next/og';
import { getTranslations } from 'next-intl/server';
import { OGImageTemplate } from '@/components/OGImageTemplate';
import { getOgFonts, OG_IMAGE_SIZE } from '@/lib/og-helper';
export const runtime = 'nodejs';
export default async function Image({ params: { locale } }: { params: { locale: string } }) {
const t = await getTranslations({ locale, namespace: 'Blog.meta' });
const fonts = await getOgFonts();
return new ImageResponse(
(
<OGImageTemplate
title={t('title')}
description={t('description')}
label="Blog"
/>
),
{
...OG_IMAGE_SIZE,
fonts,
}
);
}

View File

@@ -3,28 +3,51 @@ import { getAllPosts } from '@/lib/blog';
import { Section, Container, Heading, Card, Badge, Button } from '@/components/ui';
import Reveal from '@/components/Reveal';
import { getTranslations } from 'next-intl/server';
import { getOGImageMetadata } from '@/lib/metadata';
import { SITE_URL } from '@/lib/schema';
interface BlogIndexProps {
params: {
params: Promise<{
locale: string;
};
}>;
}
export async function generateMetadata({ params: { locale } }: BlogIndexProps) {
export async function generateMetadata({ params }: BlogIndexProps) {
const { locale } = await params;
const t = await getTranslations({ locale, namespace: 'Blog.meta' });
return {
title: t('title'),
description: t('description'),
alternates: {
canonical: `/${locale}/blog`,
languages: {
de: '/de/blog',
en: '/en/blog',
'x-default': '/en/blog',
},
},
openGraph: {
title: `${t('title')} | KLZ Cables`,
description: t('description'),
url: `${SITE_URL}/${locale}/blog`,
images: getOGImageMetadata('blog', t('title'), locale),
},
twitter: {
card: 'summary_large_image',
title: `${t('title')} | KLZ Cables`,
description: t('description'),
},
};
}
export default async function BlogIndex({ params: { locale } }: BlogIndexProps) {
export default async function BlogIndex({ params }: BlogIndexProps) {
const { locale } = await params;
const t = await getTranslations('Blog');
const posts = await getAllPosts(locale);
// Sort posts by date descending
const sortedPosts = [...posts].sort((a, b) =>
new Date(b.frontmatter.date).getTime() - new Date(a.frontmatter.date).getTime()
const sortedPosts = [...posts].sort(
(a, b) => new Date(b.frontmatter.date).getTime() - new Date(a.frontmatter.date).getTime(),
);
const featuredPost = sortedPosts[0];
@@ -33,38 +56,49 @@ export default async function BlogIndex({ params: { locale } }: BlogIndexProps)
return (
<div className="bg-neutral-light min-h-screen">
{/* Hero Section - Immersive Magazine Feel */}
<section 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 && (
<>
<img
src={featuredPost.frontmatter.featuredImage}
alt={featuredPost.frontmatter.title}
className="absolute inset-0 w-full h-full object-cover scale-105 animate-slow-zoom opacity-40 md:opacity-60"
/>
<div className="absolute inset-0 image-overlay-gradient" />
</>
)}
<Container className="relative z-10">
<div className="max-w-4xl animate-slide-up">
<Badge variant="accent" className="mb-4 md:mb-6">{t('featuredPost')}</Badge>
{featuredPost && (
<>
<h1 className="text-3xl md:text-7xl font-extrabold text-white mb-4 md:mb-8 leading-[1.1] line-clamp-3 md:line-clamp-none">
{featuredPost.frontmatter.title}
</h1>
<p className="text-base md:text-2xl text-white/80 mb-6 md:mb-10 line-clamp-2 md:line-clamp-2 max-w-2xl">
{featuredPost.frontmatter.excerpt}
</p>
<Button href={`/${locale}/blog/${featuredPost.slug}`} variant="accent" size="lg" className="group w-full md:w-auto md:h-16 md:px-10 md:text-xl">
{t('readFullArticle')}
<span className="ml-3 transition-transform group-hover:translate-x-2">&rarr;</span>
</Button>
</>
)}
</div>
</Container>
</section>
<Reveal>
<section 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 && (
<>
<img
src={featuredPost.frontmatter.featuredImage}
alt={featuredPost.frontmatter.title}
className="absolute inset-0 w-full h-full object-cover scale-105 animate-slow-zoom opacity-40 md:opacity-60"
/>
<div className="absolute inset-0 image-overlay-gradient" />
</>
)}
<Container className="relative z-10">
<div className="max-w-4xl animate-slide-up">
<Badge variant="saturated" className="mb-4 md:mb-6">
{t('featuredPost')}
</Badge>
{featuredPost && (
<>
<Heading level={1} className="text-white mb-4 md:mb-8">
{featuredPost.frontmatter.title}
</Heading>
<p className="text-base md:text-xl text-white/80 mb-6 md:mb-10 line-clamp-2 md:line-clamp-2 max-w-2xl">
{featuredPost.frontmatter.excerpt}
</p>
<Button
href={`/${locale}/blog/${featuredPost.slug}`}
variant="accent"
size="lg"
className="group w-full md:w-auto md:h-16 md:px-10 md:text-xl"
>
{t('readFullArticle')}
<span className="ml-3 transition-transform group-hover:translate-x-2">
&rarr;
</span>
</Button>
</>
)}
</div>
</Container>
</section>
</Reveal>
<Section className="bg-neutral-light py-12 md:py-28">
<Container>
@@ -75,10 +109,30 @@ export default async function BlogIndex({ params: { locale } }: BlogIndexProps)
</Heading>
<div className="flex flex-wrap gap-2 md:gap-4">
{/* Category filters could go here */}
<Badge variant="primary" className="cursor-pointer hover:bg-primary hover:text-white transition-colors touch-target px-3 md:px-4">{t('categories.all')}</Badge>
<Badge variant="neutral" className="cursor-pointer hover:bg-primary hover:text-white transition-colors touch-target px-3 md:px-4">{t('categories.industry')}</Badge>
<Badge variant="neutral" className="cursor-pointer hover:bg-primary hover:text-white transition-colors touch-target px-3 md:px-4">{t('categories.technical')}</Badge>
<Badge variant="neutral" className="cursor-pointer hover:bg-primary hover:text-white transition-colors touch-target px-3 md:px-4">{t('categories.sustainability')}</Badge>
<Badge
variant="primary"
className="cursor-pointer hover:bg-primary hover:text-white transition-colors touch-target px-3 md:px-4"
>
{t('categories.all')}
</Badge>
<Badge
variant="neutral"
className="cursor-pointer hover:bg-primary hover:text-white transition-colors touch-target px-3 md:px-4"
>
{t('categories.industry')}
</Badge>
<Badge
variant="neutral"
className="cursor-pointer hover:bg-primary hover:text-white transition-colors touch-target px-3 md:px-4"
>
{t('categories.technical')}
</Badge>
<Badge
variant="neutral"
className="cursor-pointer hover:bg-primary hover:text-white transition-colors touch-target px-3 md:px-4"
>
{t('categories.sustainability')}
</Badge>
</div>
</div>
</Reveal>
@@ -98,7 +152,10 @@ export default async function BlogIndex({ params: { locale } }: BlogIndexProps)
/>
<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-3 left-3 md:top-6 md:left-6 shadow-lg">
<Badge
variant="accent"
className="absolute top-3 left-3 md:top-6 md:left-6 shadow-lg"
>
{post.frontmatter.category}
</Badge>
)}
@@ -109,7 +166,7 @@ export default async function BlogIndex({ params: { locale } }: BlogIndexProps)
{new Date(post.frontmatter.date).toLocaleDateString(locale, {
year: 'numeric',
month: 'long',
day: 'numeric'
day: 'numeric',
})}
</div>
<h3 className="text-lg md:text-2xl font-bold text-primary mb-3 md:mb-6 group-hover:text-accent-dark transition-colors line-clamp-2 leading-tight">
@@ -119,12 +176,22 @@ export default async function BlogIndex({ params: { locale } }: BlogIndexProps)
{post.frontmatter.excerpt}
</p>
<div className="mt-auto pt-4 md:pt-8 border-t border-neutral-medium flex items-center justify-between">
<span className="text-primary text-sm md:text-base font-extrabold group-hover:text-accent-dark transition-colors">
<span className="text-saturated text-sm md:text-base font-extrabold group-hover:text-accent-dark transition-colors">
{t('readMore')}
</span>
<div className="w-8 h-8 md:w-10 md:h-10 rounded-full bg-primary-light flex items-center justify-center text-primary group-hover:bg-accent group-hover:text-primary-dark transition-all duration-300">
<svg className="w-4 h-4 md:w-5 md:h-5 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" />
<div className="w-8 h-8 md:w-10 md:h-10 rounded-full bg-primary-light flex items-center justify-center text-saturated group-hover:bg-accent group-hover:text-primary-dark transition-all duration-300">
<svg
className="w-4 h-4 md:w-5 md:h-5 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>
@@ -134,13 +201,21 @@ export default async function BlogIndex({ params: { locale } }: BlogIndexProps)
</Reveal>
))}
</div>
{/* Pagination Placeholder */}
<div className="mt-12 md:mt-24 flex justify-center gap-2 md:gap-4">
<Button variant="outline" size="sm" className="md:h-11 md:px-6 md:text-base" disabled>{t('prev')}</Button>
<Button variant="primary" size="sm" className="md:h-11 md:px-6 md:text-base">1</Button>
<Button variant="outline" size="sm" className="md:h-11 md:px-6 md:text-base">2</Button>
<Button variant="outline" size="sm" className="md:h-11 md:px-6 md:text-base">{t('next')}</Button>
<Button variant="outline" size="sm" className="md:h-11 md:px-6 md:text-base" disabled>
{t('prev')}
</Button>
<Button variant="primary" size="sm" className="md:h-11 md:px-6 md:text-base">
1
</Button>
<Button variant="outline" size="sm" className="md:h-11 md:px-6 md:text-base">
2
</Button>
<Button variant="outline" size="sm" className="md:h-11 md:px-6 md:text-base">
{t('next')}
</Button>
</div>
</Container>
</Section>

View File

@@ -0,0 +1,28 @@
import { ImageResponse } from 'next/og';
import { getTranslations } from 'next-intl/server';
import { OGImageTemplate } from '@/components/OGImageTemplate';
import { getOgFonts, OG_IMAGE_SIZE } from '@/lib/og-helper';
export const runtime = 'nodejs';
export default async function Image({ params: { locale } }: { params: { locale: string } }) {
const t = await getTranslations({ locale, namespace: 'Contact' });
const fonts = await getOgFonts();
const title = t('meta.title') || t('title');
const description = t('meta.description') || t('subtitle');
return new ImageResponse(
(
<OGImageTemplate
title={title}
description={description}
label="Contact"
/>
),
{
...OG_IMAGE_SIZE,
fonts,
}
);
}

View File

@@ -1,27 +1,131 @@
import { useTranslations } from 'next-intl';
import { Section, Container, Button, Heading, Card } from '@/components/ui';
import ContactForm from '@/components/ContactForm';
import JsonLd from '@/components/JsonLd';
import Reveal from '@/components/Reveal';
import { Container, Heading, Section } from '@/components/ui';
import { Metadata } from 'next';
import { getTranslations } from 'next-intl/server';
import { SITE_URL } from '@/lib/schema';
import { getOGImageMetadata } from '@/lib/metadata';
import { Suspense } from 'react';
import ContactMap from '@/components/ContactMap';
export default function ContactPage() {
const t = useTranslations('Contact');
interface ContactPageProps {
params: Promise<{
locale: string;
}>;
}
export async function generateMetadata({ params }: ContactPageProps): Promise<Metadata> {
const { locale } = await params;
const t = await getTranslations({ locale, namespace: 'Contact' });
const title = t('meta.title') || t('title');
const description = t('meta.description') || t('subtitle');
return {
title,
description,
alternates: {
canonical: `${SITE_URL}/${locale}/contact`,
languages: {
'de-DE': '/de/contact',
'en-US': '/en/contact',
},
},
openGraph: {
title: `${title} | KLZ Cables`,
description,
url: `${SITE_URL}/${locale}/contact`,
siteName: 'KLZ Cables',
images: getOGImageMetadata('contact', title, locale),
locale: `${locale.toUpperCase()}_DE`,
type: 'website',
},
twitter: {
card: 'summary_large_image',
title: `${title} | KLZ Cables`,
description,
images: [`${SITE_URL}/${locale}/contact/opengraph-image`],
},
robots: {
index: true,
follow: true,
},
};
}
export async function generateStaticParams() {
return [{ locale: 'de' }, { locale: 'en' }];
}
export default async function ContactPage({ params }: ContactPageProps) {
const { locale } = await params;
const t = await getTranslations({ locale, namespace: 'Contact' });
return (
<div className="flex flex-col min-h-screen bg-neutral-light">
<JsonLd
id="breadcrumb-contact"
data={{
'@context': 'https://schema.org',
'@type': 'BreadcrumbList',
itemListElement: [
{
'@type': 'ListItem',
position: 1,
name: t('title'),
item: `${SITE_URL}/${locale}/contact`,
},
],
}}
/>
<JsonLd
id="local-business-contact"
data={{
'@context': 'https://schema.org',
'@type': 'LocalBusiness',
name: 'KLZ Cables',
image: `${SITE_URL}/logo.png`,
'@id': SITE_URL,
url: SITE_URL,
address: {
'@type': 'PostalAddress',
streetAddress: 'Raiffeisenstraße 22',
addressLocality: 'Remshalden',
postalCode: '73630',
addressCountry: 'DE',
},
geo: {
'@type': 'GeoCoordinates',
latitude: 48.8144,
longitude: 9.4144,
},
openingHoursSpecification: [
{
'@type': 'OpeningHoursSpecification',
dayOfWeek: ['Monday', 'Tuesday', 'Wednesday', 'Thursday', 'Friday'],
opens: '08:00',
closes: '17:00',
},
],
sameAs: ['https://www.linkedin.com/company/klz-cables'],
}}
/>
{/* Hero Section */}
<section className="bg-primary-dark text-white py-20 md:py-32 relative overflow-hidden">
<div className="absolute inset-0 opacity-20">
<div className="absolute top-0 left-0 w-full h-full bg-[radial-gradient(circle_at_center,_var(--tw-gradient-stops))] from-accent via-transparent to-transparent" />
</div>
<Container className="relative z-10">
<div className="max-w-4xl animate-slide-up">
<Heading level={1} subtitle={t('heroSubtitle')} className="text-white mb-4 md:mb-6">
<span className="text-white">{t('title')}</span>
</Heading>
<p className="text-lg md:text-2xl text-white/70 leading-relaxed max-w-2xl">
{t('subtitle')}
</p>
<Reveal>
<section className="bg-primary-dark text-white py-20 md:py-32 relative overflow-hidden">
<div className="absolute inset-0 opacity-20">
<div className="absolute top-0 left-0 w-full h-full bg-[radial-gradient(circle_at_center,_var(--tw-gradient-stops))] from-accent via-transparent to-transparent" />
</div>
</Container>
</section>
<Container className="relative z-10">
<div className="max-w-4xl">
<Heading level={1} subtitle={t('heroSubtitle')} className="text-white mb-4 md:mb-6">
<span className="text-white">{t('title')}</span>
</Heading>
<p className="text-lg md:text-xl text-white/70 leading-relaxed max-w-2xl">
{t('subtitle')}
</p>
</div>
</Container>
</section>
</Reveal>
<Section className="bg-neutral-light -mt-8 md:-mt-20 relative z-20 py-12 md:py-28">
<Container>
@@ -34,14 +138,31 @@ export default function ContactPage() {
</Heading>
<div className="space-y-4 md:space-y-8">
<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-primary-light flex items-center justify-center text-primary group-hover:bg-accent group-hover:text-primary-dark transition-all duration-300 shadow-sm flex-shrink-0">
<svg className="w-5 h-5 md:w-7 md:h-7" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M17.657 16.657L13.414 20.9a1.998 1.998 0 01-2.827 0l-4.244-4.243a8 8 0 1111.314 0z" />
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M15 11a3 3 0 11-6 0 3 3 0 016 0z" />
<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
className="w-5 h-5 md:w-7 md:h-7"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M17.657 16.657L13.414 20.9a1.998 1.998 0 01-2.827 0l-4.244-4.243a8 8 0 1111.314 0z"
/>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M15 11a3 3 0 11-6 0 3 3 0 016 0z"
/>
</svg>
</div>
<div>
<h4 className="text-base md:text-xl font-bold text-primary mb-1 md:mb-2">{t('info.office')}</h4>
<h4 className="text-base md:text-xl font-bold text-primary mb-1 md:mb-2">
{t('info.office')}
</h4>
<p className="text-sm md:text-lg text-text-secondary leading-relaxed whitespace-pre-line">
{t('info.address')}
</p>
@@ -49,34 +170,41 @@ export default function ContactPage() {
</div>
<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-primary-light flex items-center justify-center text-primary group-hover:bg-accent group-hover:text-primary-dark transition-all duration-300 shadow-sm flex-shrink-0">
<svg className="w-5 h-5 md:w-7 md:h-7" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M3 5a2 2 0 012-2h3.28a1 1 0 01.948.684l1.498 4.493a1 1 0 01-.502 1.21l-2.257 1.13a11.042 11.042 0 005.516 5.516l1.13-2.257a1 1 0 011.21-.502l4.493 1.498a1 1 0 01.684.949V19a2 2 0 01-2 2h-1C9.716 21 3 14.284 3 6V5z" />
<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
className="w-5 h-5 md:w-7 md:h-7"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M3 8l7.89 5.26a2 2 0 002.22 0L21 8M5 19h14a2 2 0 002-2V7a2 2 0 00-2-2H5a2 2 0 00-2 2v10a2 2 0 002 2z"
/>
</svg>
</div>
<div>
<h4 className="text-base md:text-xl font-bold text-primary mb-1 md:mb-2">{t('info.phone')}</h4>
<a href="tel:+4988192537298" className="text-sm md:text-lg text-text-secondary hover:text-primary transition-colors font-medium touch-target">+49 881 92537298</a>
</div>
</div>
<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-primary-light flex items-center justify-center text-primary group-hover:bg-accent group-hover:text-primary-dark transition-all duration-300 shadow-sm flex-shrink-0">
<svg className="w-5 h-5 md:w-7 md:h-7" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M3 8l7.89 5.26a2 2 0 002.22 0L21 8M5 19h14a2 2 0 002-2V7a2 2 0 00-2-2H5a2 2 0 00-2 2v10a2 2 0 002 2z" />
</svg>
</div>
<div>
<h4 className="text-base md:text-xl font-bold text-primary mb-1 md:mb-2">{t('info.email')}</h4>
<a href="mailto:info@klz-vertriebs-gmbh.com" className="text-sm md:text-lg text-text-secondary hover:text-primary transition-colors font-medium touch-target">info@klz-vertriebs-gmbh.com</a>
<h4 className="text-base md:text-xl font-bold text-primary mb-1 md:mb-2">
{t('info.email')}
</h4>
<a
href="mailto:info@klz-cables.com"
className="text-sm md:text-lg text-text-secondary hover:text-primary transition-colors font-medium touch-target"
>
info@klz-cables.com
</a>
</div>
</div>
</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">
<Heading level={4} className="mb-4 md:mb-6">{t('hours.title')}</Heading>
<ul className="space-y-2 md:space-y-4">
<Heading level={4} className="mb-4 md:mb-6">
{t('hours.title')}
</Heading>
<ul className="space-y-2 md:space-y-4 list-none m-0 p-0">
<li className="flex justify-between items-center pb-2 md:pb-4 border-b border-neutral-medium text-sm md:text-base">
<span className="font-bold text-primary">{t('hours.weekdays')}</span>
<span className="text-text-secondary">{t('hours.weekdaysTime')}</span>
@@ -91,74 +219,29 @@ export default function ContactPage() {
{/* Contact Form */}
<div className="lg:col-span-7">
<Card className="p-6 md:p-12 rounded-2xl md:rounded-[40px] border-none shadow-2xl animate-slide-up">
<Heading level={3} subtitle={t('form.subtitle')} className="mb-6 md:mb-10">
{t('form.title')}
</Heading>
<form className="grid grid-cols-1 md:grid-cols-2 gap-4 md:gap-8">
<div className="space-y-1 md:space-y-2">
<label htmlFor="name" className="text-[10px] md:text-xs font-extrabold text-primary uppercase tracking-widest">{t('form.name')}</label>
<input
type="text"
id="name"
name="name"
autoComplete="name"
enterKeyHint="next"
className="w-full px-4 md:px-6 py-2.5 md:py-4 bg-neutral rounded-xl md:rounded-2xl border-2 border-transparent focus:border-primary focus:bg-white transition-all outline-none text-sm md:text-lg"
placeholder={t('form.namePlaceholder')}
required
/>
</div>
<div className="space-y-1 md:space-y-2">
<label htmlFor="email" className="text-[10px] md:text-xs font-extrabold text-primary uppercase tracking-widest">{t('form.email')}</label>
<input
type="email"
id="email"
name="email"
autoComplete="email"
inputMode="email"
enterKeyHint="next"
className="w-full px-4 md:px-6 py-2.5 md:py-4 bg-neutral rounded-xl md:rounded-2xl border-2 border-transparent focus:border-primary focus:bg-white transition-all outline-none text-sm md:text-lg"
placeholder={t('form.emailPlaceholder')}
required
/>
</div>
<div className="md:col-span-2 space-y-1 md:space-y-2">
<label htmlFor="message" className="text-[10px] md:text-xs font-extrabold text-primary uppercase tracking-widest">{t('form.message')}</label>
<textarea
id="message"
name="message"
rows={4}
enterKeyHint="send"
className="w-full px-4 md:px-6 py-2.5 md:py-4 bg-neutral rounded-xl md:rounded-2xl border-2 border-transparent focus:border-primary focus:bg-white transition-all outline-none text-sm md:text-lg resize-none"
placeholder={t('form.messagePlaceholder')}
required
></textarea>
</div>
<div className="md:col-span-2 pt-2 md:pt-4">
<Button type="submit" size="lg" className="w-full shadow-xl shadow-primary/20 md:h-16 md:px-10 md:text-xl active:scale-[0.98] transition-transform">
{t('form.submit')}
</Button>
</div>
</form>
</Card>
<Suspense
fallback={
<div className="animate-pulse bg-neutral-medium h-96 rounded-2xl md:rounded-3xl"></div>
}
>
<ContactForm />
</Suspense>
</div>
</div>
</Container>
</Section>
{/* Map Placeholder */}
{/* Map Section */}
<section className="h-[300px] md:h-[500px] bg-neutral-medium relative overflow-hidden grayscale hover:grayscale-0 transition-all duration-1000">
<div className="absolute inset-0 flex items-center justify-center">
<div className="text-center">
<div className="w-20 h-20 bg-primary rounded-full flex items-center justify-center text-white mb-4 mx-auto shadow-2xl animate-bounce">
<svg className="w-10 h-10" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M17.657 16.657L13.414 20.9a1.998 1.998 0 01-2.827 0l-4.244-4.243a8 8 0 1111.314 0z" />
</svg>
<Suspense
fallback={
<div className="h-full w-full bg-neutral-medium animate-pulse flex items-center justify-center">
<div className="text-primary font-medium">Loading Map...</div>
</div>
<p className="font-bold text-primary text-xl">{t('map.comingSoon')}</p>
</div>
</div>
}
>
<ContactMap address={t('info.address')} lat={48.8144} lng={9.4144} />
</Suspense>
</section>
</div>
);

66
app/[locale]/error.tsx Normal file
View File

@@ -0,0 +1,66 @@
'use client';
import { useEffect } from 'react';
import { useTranslations } from 'next-intl';
import { getAppServices } from '@/lib/services/create-services';
import { Container, Button, Heading } from '@/components/ui';
import Scribble from '@/components/Scribble';
export default function Error({
error,
reset,
}: {
error: Error & { digest?: string };
reset: () => void;
}) {
const t = useTranslations('Error');
useEffect(() => {
const services = getAppServices();
services.errors.captureException(error);
services.logger.error('Application error caught by boundary', {
message: error.message,
stack: error.stack,
digest: error.digest
});
}, [error]);
return (
<Container className="relative py-24 flex flex-col items-center justify-center text-center min-h-[70vh] overflow-hidden">
{/* Industrial Background Element */}
<div className="absolute inset-0 -z-10 opacity-[0.03] pointer-events-none flex items-center justify-center">
<span className="text-[20rem] font-bold select-none">500</span>
</div>
<div className="relative mb-8">
<Heading level={1} className="text-6xl md:text-8xl font-bold mb-2 text-saturated">
500
</Heading>
<Scribble
variant="underline"
className="w-full h-6 -bottom-2 left-0 text-saturated/40"
/>
</div>
<Heading level={2} className="text-2xl md:text-3xl font-bold mb-4">
{t('title')}
</Heading>
<p className="text-white/60 mb-10 max-w-md text-lg">
{t('description')}
</p>
<div className="flex flex-col sm:flex-row gap-4">
<Button onClick={() => reset()} variant="saturated" size="lg">
{t('tryAgain')}
</Button>
<Button href="/" variant="outline" size="lg">
{t('goHome')}
</Button>
</div>
{/* Decorative Industrial Line */}
<div className="absolute top-0 left-1/2 -translate-x-1/2 w-px h-24 bg-gradient-to-b from-saturated/50 to-transparent" />
</Container>
);
}

View File

@@ -1,53 +1,26 @@
import {NextIntlClientProvider} from 'next-intl';
import {getMessages, getTranslations} from 'next-intl/server';
import '../../styles/globals.css';
import Header from '@/components/Header';
import Footer from '@/components/Footer';
import Header from '@/components/Header';
import JsonLd from '@/components/JsonLd';
import AnalyticsProvider from '@/components/analytics/AnalyticsProvider';
import CMSConnectivityNotice from '@/components/CMSConnectivityNotice';
import { FeedbackOverlay } from '@mintel/next-feedback';
import { Metadata, Viewport } from 'next';
import { NextIntlClientProvider } from 'next-intl';
import { getMessages } from 'next-intl/server';
import '../../styles/globals.css';
import { SITE_URL } from '@/lib/schema';
import { config } from '@/lib/config';
export async function generateMetadata({params: {locale}}: {params: {locale: string}}): Promise<Metadata> {
const t = await getTranslations({locale, namespace: 'Index.meta'});
return {
title: {
default: t('title'),
template: `%s | ${t('title')}`
},
description: t('description'),
metadataBase: new URL('https://klz-cables.com'),
alternates: {
canonical: `/${locale}`,
languages: {
'de-DE': '/de',
'en-US': '/en',
},
},
openGraph: {
type: 'website',
locale: locale === 'de' ? 'de_DE' : 'en_US',
url: `https://klz-cables.com/${locale}`,
siteName: 'KLZ Cables',
title: t('title'),
description: t('description'),
},
twitter: {
card: 'summary_large_image',
title: t('title'),
description: t('description'),
},
robots: {
index: true,
follow: true,
googleBot: {
index: true,
follow: true,
'max-video-preview': -1,
'max-image-preview': 'large',
'max-snippet': -1,
},
},
};
}
export const metadata: Metadata = {
metadataBase: new URL(SITE_URL),
icons: {
icon: [
{ url: '/favicon.ico', sizes: 'any' },
{ url: '/logo-blue.svg', type: 'image/svg+xml' },
],
apple: [{ url: '/apple-touch-icon.png', sizes: '180x180', type: 'image/png' }],
},
};
export const viewport: Viewport = {
width: 'device-width',
@@ -57,27 +30,60 @@ export const viewport: Viewport = {
viewportFit: 'cover',
themeColor: '#001a4d',
};
export default async function LocaleLayout({
children,
params: {locale}
params,
}: {
children: React.ReactNode;
params: {locale: string};
params: Promise<{ locale: string }>;
}) {
// Providing all messages to the client
// side is the easiest way to get started
const messages = await getMessages();
const { locale } = await params;
// Ensure locale is a valid string, fallback to 'en'
const supportedLocales = ['en', 'de'];
const localeStr = (typeof locale === 'string' ? locale : '').trim();
const safeLocale = supportedLocales.includes(localeStr) ? localeStr : 'en';
let messages = {};
try {
messages = await getMessages();
} catch (error) {
console.error(`Failed to load messages for locale '${safeLocale}':`, error);
messages = {};
}
// Track pageview on the server with high-fidelity header context
const { getServerAppServices } = await import('@/lib/services/create-services.server');
const serverServices = getServerAppServices();
const { headers } = await import('next/headers');
const requestHeaders = await headers();
if ('setServerContext' in serverServices.analytics) {
(serverServices.analytics as any).setServerContext({
userAgent: requestHeaders.get('user-agent') || undefined,
language: requestHeaders.get('accept-language')?.split(',')[0] || undefined,
referrer: requestHeaders.get('referer') || undefined,
ip: requestHeaders.get('x-forwarded-for')?.split(',')[0] || undefined,
});
}
// Track initial server-side pageview
serverServices.analytics.trackPageview();
return (
<html lang={locale} className="scroll-smooth overflow-x-hidden">
<html lang={safeLocale} className="scroll-smooth 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={locale}>
<NextIntlClientProvider messages={messages} locale={safeLocale}>
<JsonLd />
<Header />
<main className="flex-grow animate-fade-in overflow-visible">
{children}
</main>
<main className="flex-grow animate-fade-in overflow-visible">{children}</main>
<Footer />
<CMSConnectivityNotice />
<AnalyticsProvider />
{config.feedbackEnabled && <FeedbackOverlay />}
</NextIntlClientProvider>
</body>
</html>

View File

@@ -0,0 +1,46 @@
import { useTranslations } from 'next-intl';
import { Container, Button, Heading } from '@/components/ui';
import Scribble from '@/components/Scribble';
export default function NotFound() {
const t = useTranslations('Error.notFound');
return (
<Container className="relative py-24 flex flex-col items-center justify-center text-center min-h-[70vh] overflow-hidden">
{/* Industrial Background Element */}
<div className="absolute inset-0 -z-10 opacity-[0.03] pointer-events-none flex items-center justify-center">
<span className="text-[20rem] font-bold select-none">404</span>
</div>
<div className="relative mb-8">
<Heading level={1} className="text-6xl md:text-8xl font-bold mb-2">
404
</Heading>
<Scribble
variant="circle"
className="w-[150%] h-[150%] -top-[25%] -left-[25%] text-accent/40"
/>
</div>
<Heading level={2} className="text-2xl md:text-3xl font-bold mb-4 text-primary">
{t('title')}
</Heading>
<p className="text-white/60 mb-10 max-w-md text-lg">
{t('description')}
</p>
<div className="flex flex-col sm:flex-row gap-4">
<Button href="/" variant="accent" size="lg">
{t('cta')}
</Button>
<Button href="/contact" variant="outline" size="lg">
Contact Support
</Button>
</div>
{/* Decorative Industrial Line */}
<div className="absolute bottom-0 left-1/2 -translate-x-1/2 w-px h-24 bg-gradient-to-t from-accent/50 to-transparent" />
</Container>
);
}

View File

@@ -1,92 +1,26 @@
import { ImageResponse } from 'next/og';
import { getTranslations } from 'next-intl/server';
import { OGImageTemplate } from '@/components/OGImageTemplate';
import { getOgFonts, OG_IMAGE_SIZE } from '@/lib/og-helper';
export const runtime = 'edge';
export const runtime = 'nodejs';
export default async function Image({ params: { locale } }: { params: { locale: string } }) {
const t = await getTranslations({ locale, namespace: 'Index.meta' });
const fonts = await getOgFonts();
return new ImageResponse(
(
<div
style={{
height: '100%',
width: '100%',
display: 'flex',
flexDirection: 'column',
alignItems: 'flex-start',
justifyContent: 'center',
backgroundColor: '#001a4d', // Primary Blue from Styleguide
padding: '80px',
position: 'relative',
}}
>
{/* Background Pattern / Scribble placeholder */}
<div
style={{
position: 'absolute',
top: '-100px',
right: '-100px',
width: '600px',
height: '600px',
borderRadius: '50%',
background: 'radial-gradient(circle, rgba(0,255,153,0.1) 0%, transparent 70%)',
}}
/>
<div style={{ display: 'flex', flexDirection: 'column' }}>
<div
style={{
fontSize: '24px',
fontWeight: 'bold',
color: '#00ff99', // Accent Green
textTransform: 'uppercase',
letterSpacing: '0.2em',
marginBottom: '20px',
}}
>
KLZ Cables
</div>
<div
style={{
fontSize: '72px',
fontWeight: '900',
color: 'white',
lineHeight: '1.1',
maxWidth: '800px',
marginBottom: '30px',
}}
>
{t('title')}
</div>
<div
style={{
fontSize: '32px',
color: 'rgba(255,255,255,0.7)',
maxWidth: '700px',
}}
>
{t('description')}
</div>
</div>
{/* Bottom Accent Line */}
<div
style={{
position: 'absolute',
bottom: '80px',
left: '80px',
width: '120px',
height: '8px',
backgroundColor: '#00ff99',
borderRadius: '4px',
}}
/>
</div>
<OGImageTemplate
title={t('title')}
description={t('description')}
label="Reliable Energy Infrastructure"
/>
),
{
width: 1200,
height: 630,
...OG_IMAGE_SIZE,
fonts,
}
);
}

View File

@@ -1,5 +1,6 @@
import { useTranslations } from 'next-intl';
import Hero from '@/components/home/Hero';
import JsonLd from '@/components/JsonLd';
import { getBreadcrumbSchema, SITE_URL } from '@/lib/schema';
import ProductCategories from '@/components/home/ProductCategories';
import WhatWeDo from '@/components/home/WhatWeDo';
import RecentPosts from '@/components/home/RecentPosts';
@@ -10,20 +11,98 @@ import GallerySection from '@/components/home/GallerySection';
import VideoSection from '@/components/home/VideoSection';
import CTA from '@/components/home/CTA';
import Reveal from '@/components/Reveal';
import { getTranslations } from 'next-intl/server';
import { Metadata } from 'next';
import { getOGImageMetadata } from '@/lib/metadata';
export default function HomePage({ params: { locale } }: { params: { locale: string } }) {
export default async function HomePage({
params,
}: {
params: Promise<{ locale: string }>;
}) {
const { locale } = await params;
return (
<div className="flex flex-col min-h-screen">
<JsonLd
id="breadcrumb-home"
data={getBreadcrumbSchema([{ name: 'Home', item: `/${locale}` }])}
/>
<Hero />
<Reveal><ProductCategories /></Reveal>
<Reveal><WhatWeDo /></Reveal>
<Reveal><RecentPosts locale={locale} /></Reveal>
<Reveal><Experience /></Reveal>
<Reveal><WhyChooseUs /></Reveal>
<Reveal><MeetTheTeam /></Reveal>
<Reveal><GallerySection /></Reveal>
<Reveal><VideoSection /></Reveal>
<Reveal><CTA /></Reveal>
<Reveal>
<ProductCategories />
</Reveal>
<Reveal>
<WhatWeDo />
</Reveal>
<Reveal>
<RecentPosts locale={locale} />
</Reveal>
<Reveal>
<Experience />
</Reveal>
<Reveal>
<WhyChooseUs />
</Reveal>
<Reveal>
<MeetTheTeam />
</Reveal>
<Reveal>
<GallerySection />
</Reveal>
<Reveal>
<VideoSection />
</Reveal>
<Reveal>
<CTA />
</Reveal>
</div>
);
}
export async function generateMetadata({
params,
}: {
params: Promise<{ locale: string }>;
}): Promise<Metadata> {
const { locale } = await params;
// Use translations for meta where available (namespace: Index.meta)
// Fallback to a sensible default if translation keys are missing.
let t;
try {
t = await getTranslations({ locale, namespace: 'Index.meta' });
} catch (err) {
// If translations for Index.meta are not present, try generic Index namespace
try {
t = await getTranslations({ locale, namespace: 'Index' });
} catch (e) {
t = (key: string) => '';
}
}
const title = t('title') || 'KLZ Cables';
const description = t('description') || '';
return {
title,
description,
alternates: {
canonical: `/${locale}`,
languages: {
de: '/de',
en: '/en',
'x-default': '/en',
},
},
openGraph: {
title: `${title} | KLZ Cables`,
description,
url: `${SITE_URL}/${locale}`,
images: getOGImageMetadata('', title, locale),
},
twitter: {
card: 'summary_large_image',
title: `${title} | KLZ Cables`,
description,
},
};
}

View File

@@ -0,0 +1,522 @@
import Script from 'next/script';
import JsonLd from '@/components/JsonLd';
import { getBreadcrumbSchema, SITE_URL } from '@/lib/schema';
import ProductSidebar from '@/components/ProductSidebar';
import ProductTabs from '@/components/ProductTabs';
import ProductTechnicalData from '@/components/ProductTechnicalData';
import RelatedProducts from '@/components/RelatedProducts';
import DatasheetDownload from '@/components/DatasheetDownload';
import { Badge, Container, Heading, Section } from '@/components/ui';
import { getDatasheetPath } from '@/lib/datasheets';
import { getAllProducts, getProductBySlug } from '@/lib/mdx';
import { mapFileSlugToTranslated, mapSlugToFileSlug } from '@/lib/slugs';
import { Metadata } from 'next';
import { getTranslations } from 'next-intl/server';
import { getProductOGImageMetadata } from '@/lib/metadata';
import { MDXRemote } from 'next-mdx-remote/rsc';
import Image from 'next/image';
import Link from 'next/link';
import { notFound } from 'next/navigation';
interface ProductPageProps {
params: Promise<{
locale: string;
slug: string[];
}>;
}
export async function generateMetadata({ params }: ProductPageProps): Promise<Metadata> {
const { locale, slug } = await params;
const productSlug = slug[slug.length - 1];
const t = await getTranslations('Products');
// Check if it's a category page
const categories = [
'low-voltage-cables',
'medium-voltage-cables',
'high-voltage-cables',
'solar-cables',
];
const fileSlug = await mapSlugToFileSlug(productSlug, locale);
if (categories.includes(fileSlug)) {
const categoryKey = fileSlug
.replace(/-cables$/, '')
.replace(/-([a-z])/g, (g) => g[1].toUpperCase());
const categoryTitle = t.has(`categories.${categoryKey}.title`)
? t(`categories.${categoryKey}.title`)
: fileSlug;
const categoryDesc = t.has(`categories.${categoryKey}.description`)
? t(`categories.${categoryKey}.description`)
: '';
return {
title: categoryTitle,
description: categoryDesc,
alternates: {
canonical: `/${locale}/products/${productSlug}`,
languages: {
de: `/de/products/${await mapFileSlugToTranslated(productSlug, 'de')}`,
en: `/en/products/${await mapFileSlugToTranslated(productSlug, 'en')}`,
'x-default': `/en/products/${await mapFileSlugToTranslated(productSlug, 'en')}`,
},
},
openGraph: {
title: `${categoryTitle} | KLZ Cables`,
description: categoryDesc,
url: `${SITE_URL}/${locale}/products/${productSlug}`,
images: getProductOGImageMetadata(fileSlug, categoryTitle, locale),
},
twitter: {
card: 'summary_large_image',
title: `${categoryTitle} | KLZ Cables`,
description: categoryDesc,
},
};
}
const product = await getProductBySlug(productSlug, locale);
if (!product) return {};
return {
title: product.frontmatter.title,
description: product.frontmatter.description,
alternates: {
canonical: `/${locale}/products/${slug.join('/')}`,
languages: {
de: `/de/products/${await mapFileSlugToTranslated(slug[0], 'de')}/${await mapFileSlugToTranslated(productSlug, 'de')}`,
en: `/en/products/${await mapFileSlugToTranslated(slug[0], 'en')}/${await mapFileSlugToTranslated(productSlug, 'en')}`,
'x-default': `/en/products/${await mapFileSlugToTranslated(slug[0], 'en')}/${await mapFileSlugToTranslated(productSlug, 'en')}`,
},
},
openGraph: {
title: `${product.frontmatter.title} | KLZ Cables`,
description: product.frontmatter.description,
type: 'website',
url: `${SITE_URL}/${locale}/products/${slug.join('/')}`,
images: getProductOGImageMetadata(productSlug, product.frontmatter.title, locale),
},
twitter: {
card: 'summary_large_image',
title: `${product.frontmatter.title} | KLZ Cables`,
description: product.frontmatter.description,
},
};
}
const components = {
ProductTechnicalData,
ProductTabs,
p: (props: any) => (
<p
{...props}
className="text-lg md:text-xl text-text-secondary leading-relaxed mb-8 font-medium"
/>
),
h2: (props: any) => (
<div className="relative mb-16">
<h2
{...props}
className="text-3xl md:text-4xl font-black text-primary tracking-tighter uppercase mb-6"
/>
<div className="w-20 h-1.5 bg-accent rounded-full" />
</div>
),
h3: (props: any) => (
<h3
{...props}
className="text-2xl md:text-3xl font-black text-primary mb-10 tracking-tight uppercase"
/>
),
ul: (props: any) => <ul {...props} className="list-none pl-0 mb-10" />,
section: (props: any) => <div {...props} className="block" />,
li: (props: any) => (
<li className="flex items-start gap-4 group mb-4 last:mb-0">
<div className="mt-2.5 w-2 h-2 rounded-full bg-accent flex-shrink-0 group-hover:scale-125 transition-transform" />
<span
{...props}
className="text-lg md:text-xl text-text-secondary leading-relaxed font-medium"
/>
</li>
),
strong: (props: any) => <strong {...props} className="font-black text-primary" />,
table: (props: any) => (
<div className="overflow-x-auto my-20 rounded-[32px] border border-neutral-dark/10 shadow-xl bg-white p-1">
<table {...props} className="min-w-full divide-y divide-neutral-dark/10" />
</div>
),
th: (props: any) => (
<th
{...props}
className="px-8 py-6 bg-neutral-light/50 text-left text-[10px] font-black uppercase tracking-[0.25em] text-primary/60"
/>
),
td: (props: any) => (
<td
{...props}
className="px-8 py-6 text-text-secondary border-t border-neutral-dark/5 text-lg md:text-xl font-medium"
/>
),
hr: () => <hr className="my-24 border-t-2 border-neutral-dark/5" />,
blockquote: (props: any) => (
<div className="my-20 p-10 md:p-16 bg-primary-dark rounded-[40px] relative overflow-hidden group">
<div className="absolute top-0 right-0 w-64 h-64 bg-accent/10 rounded-full -translate-y-1/2 translate-x-1/2 blur-3xl group-hover:bg-accent/20 transition-colors duration-700" />
<div
className="relative z-10 italic text-2xl md:text-3xl text-white/90 leading-relaxed font-black tracking-tight"
{...props}
/>
</div>
),
};
export default async function ProductPage({ params }: ProductPageProps) {
const { locale, slug } = await params;
const productSlug = slug[slug.length - 1];
const t = await getTranslations('Products');
// Check if it's a category page
const categories = [
'low-voltage-cables',
'medium-voltage-cables',
'high-voltage-cables',
'solar-cables',
];
const fileSlug = await mapSlugToFileSlug(productSlug, locale);
if (categories.includes(fileSlug)) {
const allProducts = await getAllProducts(locale);
const categoryKey = fileSlug
.replace(/-cables$/, '')
.replace(/-([a-z])/g, (g) => g[1].toUpperCase());
const categoryTitle = t.has(`categories.${categoryKey}.title`)
? t(`categories.${categoryKey}.title`)
: fileSlug;
// Filter products for this category
const filteredProducts = allProducts.filter((p) =>
p.frontmatter.categories.some(
(cat) => cat.toLowerCase().replace(/\s+/g, '-') === fileSlug || cat === categoryTitle,
),
);
// Get translated product slugs
const productsWithTranslatedSlugs = await Promise.all(
filteredProducts.map(async (p) => ({
...p,
translatedSlug: await mapFileSlugToTranslated(p.slug, locale),
})),
);
return (
<div className="flex flex-col min-h-screen bg-white">
<section className="relative min-h-[50vh] flex items-center pt-32 pb-20 overflow-hidden bg-primary-dark">
<Container className="relative z-10">
<div className="max-w-4xl animate-slide-up">
<nav className="flex items-center mb-8 text-white/40 text-sm font-bold uppercase tracking-widest">
<Link href={`/${locale}/products`} className="hover:text-accent transition-colors">
{t('title')}
</Link>
<span className="mx-3 opacity-30">/</span>
<span className="text-white/90">{categoryTitle}</span>
</nav>
<Heading level={1} className="text-white mb-8">
{categoryTitle}
</Heading>
<div className="h-1.5 w-24 bg-accent rounded-full" />
</div>
</Container>
</section>
<Section className="bg-neutral-light relative">
<Container>
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-8">
{productsWithTranslatedSlugs.map((product) => (
<Link
key={product.slug}
href={`/${locale}/products/${productSlug}/${product.translatedSlug}`}
className="group block bg-white rounded-[32px] overflow-hidden shadow-sm hover:shadow-2xl transition-all duration-500 border border-neutral-dark/5"
>
<div className="aspect-[4/3] relative bg-neutral-light/30 p-12 overflow-hidden">
{product.frontmatter.images?.[0] && (
<>
<Image
src={product.frontmatter.images[0]}
alt={product.frontmatter.title}
fill
className="object-contain p-8 transition-transform duration-700 group-hover:scale-110 z-10"
/>
{/* Subtle reflection/shadow effect */}
<div className="absolute bottom-4 left-1/2 -translate-x-1/2 w-2/3 h-4 bg-black/5 blur-xl rounded-full" />
</>
)}
</div>
<div className="p-8 md:p-10">
<div className="flex flex-wrap gap-2 mb-4">
{product.frontmatter.categories.map((cat, i) => (
<span
key={i}
className="text-[10px] font-bold uppercase tracking-widest text-primary/40"
>
{cat}
</span>
))}
</div>
<h2 className="text-2xl md:text-3xl font-bold text-text-primary group-hover:text-primary transition-colors mb-4 leading-tight">
{product.frontmatter.title}
</h2>
<p className="text-text-secondary line-clamp-2 text-base leading-relaxed mb-8">
{product.frontmatter.description}
</p>
<div className="flex items-center text-primary font-bold group-hover:text-accent-dark transition-colors">
<span className="border-b-2 border-primary/10 group-hover:border-accent-dark transition-colors pb-1">
{t('details')}
</span>
<svg
className="w-5 h-5 ml-3 transition-transform group-hover:translate-x-1"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M17 8l4 4m0 0l-4 4m4-4H3"
/>
</svg>
</div>
</div>
</Link>
))}
</div>
</Container>
</Section>
</div>
);
}
const product = await getProductBySlug(productSlug, locale);
if (!product) {
notFound();
}
// Extract technical data for schema
const technicalDataMatch = product.content.match(
/technicalData=\{<ProductTechnicalData data=\{(.*?)\}\s*\/>\}/s,
);
let technicalItems = [];
if (technicalDataMatch) {
try {
const data = JSON.parse(technicalDataMatch[1]);
technicalItems = data.technicalItems || [];
} catch (e) {
console.error('Failed to parse technical data for schema', e);
}
}
const datasheetPath = getDatasheetPath(productSlug, locale);
const isFallback = (product.frontmatter as any).isFallback;
const categorySlug = slug[0];
const categoryFileSlug = await mapSlugToFileSlug(categorySlug, locale);
const categoryKey = categoryFileSlug
.replace(/-cables$/, '')
.replace(/-([a-z])/g, (g) => g[1].toUpperCase());
const categoryTitle = t.has(`categories.${categoryKey}.title`)
? t(`categories.${categoryKey}.title`)
: categoryFileSlug;
const sidebar = (
<ProductSidebar
productName={product.frontmatter.title}
productImage={product.frontmatter.images?.[0]}
datasheetPath={datasheetPath}
/>
);
const productComponents = {
...components,
ProductTabs: (props: any) => <ProductTabs {...props} sidebar={sidebar} />,
};
// Pre-process content to convert raw HTML tags to Markdown so they use our custom components
const processedContent = product.content
.replace(/<h2[^>]*>(.*?)<\/h2>/g, '\n## $1\n')
.replace(/<h3[^>]*>(.*?)<\/h3>/g, '\n### $1\n')
.replace(/<p[^>]*>(.*?)<\/p>/g, '\n$1\n')
.replace(/<ul[^>]*>(.*?)<\/ul>/gs, '\n$1\n')
.replace(/<li[^>]*>(.*?)<\/li>/g, '\n- $1\n')
.replace(/<strong[^>]*>(.*?)<\/strong>/g, '**$1**')
.replace(/<section[^>]*>/g, '')
.replace(/<\/section>/g, '');
return (
<div className="flex flex-col min-h-screen bg-white relative">
{/* Product Hero */}
<section className="relative pt-40 pb-24 overflow-hidden bg-primary-dark">
{/* Background Decorative Elements */}
<div className="absolute top-0 right-0 w-1/2 h-full bg-gradient-to-l from-accent/5 to-transparent pointer-events-none" />
<div className="absolute -top-24 -right-24 w-96 h-96 bg-accent/10 rounded-full blur-3xl pointer-events-none" />
<Container className="relative z-10">
<div className="max-w-4xl animate-slide-up">
<nav className="flex items-center mb-12 text-white/40 text-[10px] font-black uppercase tracking-[0.2em]">
<Link href={`/${locale}/products`} className="hover:text-accent transition-colors">
{t('title')}
</Link>
<span className="mx-4 opacity-20">/</span>
<Link
href={`/${locale}/products/${categorySlug}`}
className="hover:text-accent transition-colors"
>
{categoryTitle}
</Link>
<span className="mx-4 opacity-20">/</span>
<span className="text-white/90">{product.frontmatter.title}</span>
</nav>
<div className="flex flex-col lg:flex-row lg:items-center justify-between gap-12">
<div className="flex-1">
{isFallback && (
<div className="mb-8 inline-flex items-center px-4 py-2 rounded-full bg-accent/10 border border-accent/20 text-accent text-[10px] font-black uppercase tracking-[0.2em] backdrop-blur-md">
<span className="w-2 h-2 rounded-full bg-accent mr-3 animate-pulse" />
{t('englishVersion')}
</div>
)}
<div className="flex flex-wrap gap-3 mb-8">
{product.frontmatter.categories.map((cat, idx) => (
<Badge
key={idx}
variant="accent"
className="bg-white/5 text-white/80 border-white/10 backdrop-blur-md px-5 py-2 text-[10px] font-black tracking-[0.15em]"
>
{cat}
</Badge>
))}
</div>
<Heading level={1} className="text-white mb-8 uppercase">
{product.frontmatter.title}
</Heading>
<p className="text-xl md:text-2xl text-white/60 max-w-2xl leading-relaxed font-medium">
{product.frontmatter.description}
</p>
</div>
</div>
</div>
</Container>
</section>
<Section className="bg-white relative">
<Container className="relative">
{/* Large Product Image Section */}
{product.frontmatter.images && product.frontmatter.images.length > 0 && (
<div
className="relative -mt-32 mb-32 animate-slide-up"
style={{ animationDelay: '200ms' }}
>
<div className="bg-white shadow-[0_32px_64px_-12px_rgba(0,0,0,0.1)] rounded-[48px] border border-neutral-dark/5 overflow-hidden p-12 md:p-20 lg:p-24">
<div className="relative w-full aspect-[21/9]">
<Image
src={product.frontmatter.images[0]}
alt={product.frontmatter.title}
fill
className="object-contain transition-transform duration-1000 hover:scale-105"
priority
/>
{/* Subtle reflection/shadow effect */}
<div className="absolute bottom-0 left-1/2 -translate-x-1/2 w-3/4 h-12 bg-black/5 blur-3xl rounded-[100%]" />
</div>
{product.frontmatter.images.length > 1 && (
<div className="flex justify-center gap-8 mt-20">
{product.frontmatter.images.slice(0, 5).map((img, idx) => (
<div
key={idx}
className="relative w-24 h-24 md:w-32 md:h-32 border-2 border-neutral-dark/5 rounded-3xl overflow-hidden bg-neutral-light/30 hover:border-accent transition-all duration-500 cursor-pointer group p-4"
>
<Image
src={img}
alt=""
fill
className="object-contain p-4 transition-transform duration-700 group-hover:scale-110"
/>
</div>
))}
</div>
)}
</div>
</div>
)}
<div className="relative">
<div className="w-full">
{/* Main Content Area */}
<div className="max-w-none">
<MDXRemote source={processedContent} components={productComponents} />
</div>
{/* Datasheet Download Section - Only for Medium Voltage for now */}
{categoryFileSlug === 'medium-voltage-cables' && datasheetPath && (
<div className="mt-24 pt-24 border-t-2 border-neutral-dark/5">
<div className="mb-12">
<h2 className="text-3xl md:text-4xl font-black text-primary tracking-tighter uppercase mb-4">
{t('downloadDatasheet')}
</h2>
<div className="h-1.5 w-24 bg-accent rounded-full" />
</div>
<DatasheetDownload datasheetPath={datasheetPath} />
</div>
)}
{/* Structured Data */}
<JsonLd
id={`jsonld-${product.slug}`}
data={
{
'@context': 'https://schema.org',
'@type': 'Product',
name: product.frontmatter.title,
description: product.frontmatter.description,
sku: product.frontmatter.sku || product.slug.toUpperCase(),
image: product.frontmatter.images?.[0]
? `${SITE_URL}${product.frontmatter.images[0]}`
: undefined,
brand: {
'@type': 'Brand',
name: 'KLZ Cables',
},
offers: {
'@type': 'Offer',
availability: 'https://schema.org/InStock',
priceCurrency: 'EUR',
url: `${SITE_URL}/${locale}/products/${slug.join('/')}`,
itemCondition: 'https://schema.org/NewCondition',
},
additionalProperty: technicalItems.map((item: any) => ({
'@type': 'PropertyValue',
name: item.label,
value: item.value,
})),
category: product.frontmatter.categories.join(', '),
mainEntityOfPage: {
'@type': 'WebPage',
'@id': `${SITE_URL}/${locale}/products/${slug.join('/')}`,
},
} as any
}
/>
</div>
</div>
{/* Related Products Section */}
<div className="mt-16 pt-16 border-t border-neutral-dark/5">
<RelatedProducts
currentSlug={productSlug}
categories={product.frontmatter.categories}
locale={locale}
/>
</div>
</Container>
</Section>
</div>
);
}

View File

@@ -1,135 +0,0 @@
import { ImageResponse } from 'next/og';
import { getProductBySlug } from '@/lib/mdx';
export const runtime = 'nodejs';
export default async function Image({ params: { locale, slug } }: { params: { locale: string, slug: string } }) {
const productSlug = slug;
const product = await getProductBySlug(productSlug, locale);
if (!product) {
return new ImageResponse(
<div style={{ display: 'flex', width: '100%', height: '100%', backgroundColor: '#001a4d' }} />
);
}
return new ImageResponse(
(
<div
style={{
height: '100%',
width: '100%',
display: 'flex',
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'space-between',
backgroundColor: 'white',
padding: '60px',
position: 'relative',
}}
>
{/* Left Side: Content */}
<div style={{ display: 'flex', flexDirection: 'column', width: '55%' }}>
<div
style={{
fontSize: '20px',
fontWeight: 'bold',
color: '#001a4d',
textTransform: 'uppercase',
letterSpacing: '0.2em',
marginBottom: '20px',
opacity: 0.6,
}}
>
KLZ Cables | {product.frontmatter.categories[0]}
</div>
<div
style={{
fontSize: '72px',
fontWeight: '900',
color: '#001a4d',
lineHeight: '1.1',
marginBottom: '30px',
}}
>
{product.frontmatter.title}
</div>
<div
style={{
fontSize: '28px',
color: '#4a5568',
lineHeight: '1.4',
}}
>
{product.frontmatter.description}
</div>
{/* Accent Line */}
<div
style={{
marginTop: '40px',
width: '100px',
height: '8px',
backgroundColor: '#00ff99',
borderRadius: '4px',
}}
/>
</div>
{/* Right Side: Product Image */}
<div
style={{
display: 'flex',
width: '40%',
height: '100%',
alignItems: 'center',
justifyContent: 'center',
position: 'relative',
}}
>
{product.frontmatter.images?.[0] && (
<img
src={product.frontmatter.images[0].startsWith('http') ? product.frontmatter.images[0] : `https://klz-cables.com${product.frontmatter.images[0]}`}
alt=""
style={{
maxWidth: '100%',
maxHeight: '80%',
objectFit: 'contain',
}}
/>
)}
{/* Subtle shadow under product */}
<div
style={{
position: 'absolute',
bottom: '10%',
width: '60%',
height: '20px',
backgroundColor: 'rgba(0,0,0,0.05)',
borderRadius: '50%',
filter: 'blur(10px)',
}}
/>
</div>
{/* Branding Corner */}
<div
style={{
position: 'absolute',
bottom: '40px',
right: '60px',
display: 'flex',
alignItems: 'center',
}}
>
<div style={{ fontSize: '24px', fontWeight: 'bold', color: '#001a4d' }}>KLZ</div>
<div style={{ fontSize: '24px', fontWeight: 'normal', color: '#00ff99', marginLeft: '4px' }}>Cables</div>
</div>
</div>
),
{
width: 1200,
height: 630,
}
);
}

View File

@@ -1,337 +0,0 @@
import { notFound } from 'next/navigation';
import { MDXRemote } from 'next-mdx-remote/rsc';
import { getProductBySlug, getAllProducts } from '@/lib/mdx';
import ProductTechnicalData from '@/components/ProductTechnicalData';
import ProductTabs from '@/components/ProductTabs';
import RequestQuoteForm from '@/components/RequestQuoteForm';
import RelatedProducts from '@/components/RelatedProducts';
import Link from 'next/link';
import Image from 'next/image';
import { getTranslations } from 'next-intl/server';
import { Section, Container, Heading, Badge, Button } from '@/components/ui';
import Scribble from '@/components/Scribble';
import { Metadata } from 'next';
interface ProductPageProps {
params: {
locale: string;
slug: string[];
};
}
export async function generateMetadata({ params }: ProductPageProps): Promise<Metadata> {
const { locale, slug } = params;
const productSlug = slug[slug.length - 1];
const t = await getTranslations('Products');
// Check if it's a category page
const categories = ['low-voltage-cables', 'medium-voltage-cables', 'high-voltage-cables', 'solar-cables'];
if (categories.includes(productSlug)) {
const categoryKey = productSlug.replace(/-cables$/, '').replace(/-([a-z])/g, (g) => g[1].toUpperCase());
const categoryTitle = t(`categories.${categoryKey}.title`);
const categoryDesc = t(`categories.${categoryKey}.description`);
return {
title: categoryTitle,
description: categoryDesc,
openGraph: {
title: categoryTitle,
description: categoryDesc,
url: `https://klz-cables.com/${locale}/products/${productSlug}`,
}
};
}
const product = await getProductBySlug(productSlug, locale);
if (!product) return {};
return {
title: product.frontmatter.title,
description: product.frontmatter.description,
openGraph: {
title: product.frontmatter.title,
description: product.frontmatter.description,
type: 'website',
url: `https://klz-cables.com/${locale}/products/${slug.join('/')}`,
},
twitter: {
card: 'summary_large_image',
title: product.frontmatter.title,
description: product.frontmatter.description,
},
};
}
const components = {
ProductTechnicalData,
ProductTabs,
p: (props: any) => <p {...props} className="text-lg md:text-xl text-text-secondary leading-relaxed mb-8" />,
h2: (props: any) => (
<div className="relative mt-20 mb-10">
<h2 {...props} className="text-3xl md:text-4xl lg:text-5xl font-extrabold text-primary tracking-tight" />
<div className="absolute -bottom-4 left-0 w-24 h-1.5 bg-accent rounded-full" />
</div>
),
h3: (props: any) => <h3 {...props} className="text-2xl md:text-3xl lg:text-4xl font-bold text-primary mt-16 mb-6 tracking-tight" />,
ul: (props: any) => <ul {...props} className="list-disc pl-8 mb-10 space-y-4 text-text-secondary text-lg md:text-xl" />,
li: (props: any) => <li {...props} className="pl-2 marker:text-accent marker:font-bold" />,
table: (props: any) => (
<div className="overflow-x-auto my-16 rounded-3xl border border-neutral-dark/10 shadow-xl bg-white p-1">
<table {...props} className="min-w-full divide-y divide-neutral-dark/10" />
</div>
),
th: (props: any) => <th {...props} className="px-8 py-6 bg-neutral-light/50 text-left text-xs font-black uppercase tracking-[0.25em] text-primary/60" />,
td: (props: any) => <td {...props} className="px-8 py-6 text-text-secondary border-t border-neutral-dark/5 text-lg md:text-xl" />,
hr: () => <hr className="my-20 border-t-2 border-neutral-dark/5" />,
blockquote: (props: any) => (
<div className="my-12 p-8 md:p-12 bg-primary-dark rounded-3xl relative overflow-hidden group">
<div className="absolute top-0 right-0 w-48 h-48 bg-accent/10 rounded-full -translate-y-1/2 translate-x-1/2 blur-3xl group-hover:bg-accent/20 transition-colors duration-700" />
<div className="relative z-10 italic text-2xl md:text-3xl text-white/90 leading-relaxed font-medium" {...props} />
</div>
),
};
export default async function ProductPage({ params }: ProductPageProps) {
const { locale, slug } = params;
const productSlug = slug[slug.length - 1];
const t = await getTranslations('Products');
// Check if it's a category page
const categories = ['low-voltage-cables', 'medium-voltage-cables', 'high-voltage-cables', 'solar-cables'];
if (categories.includes(productSlug)) {
const allProducts = await getAllProducts(locale);
const categoryKey = productSlug.replace(/-cables$/, '').replace(/-([a-z])/g, (g) => g[1].toUpperCase());
const categoryTitle = t(`categories.${categoryKey}.title`);
// Filter products for this category
const filteredProducts = allProducts.filter(p =>
p.frontmatter.categories.some(cat => cat.toLowerCase().replace(/\s+/g, '-') === productSlug)
);
return (
<div className="flex flex-col min-h-screen bg-white">
<section className="relative min-h-[50vh] flex items-center pt-32 pb-20 overflow-hidden bg-primary-dark">
<Container className="relative z-10">
<div className="max-w-4xl animate-slide-up">
<nav className="flex items-center mb-8 text-white/40 text-sm font-bold uppercase tracking-widest">
<Link href={`/${locale}/products`} className="hover:text-accent transition-colors">{t('title')}</Link>
<span className="mx-3 opacity-30">/</span>
<span className="text-white/90">{categoryTitle}</span>
</nav>
<h1 className="text-5xl md:text-7xl lg:text-8xl font-extrabold text-white mb-8 tracking-tight leading-[1.05]">
{categoryTitle}
</h1>
<div className="h-1.5 w-24 bg-accent rounded-full" />
</div>
</Container>
</section>
<Section className="bg-neutral-light relative z-20 -mt-12 rounded-t-[48px] md:rounded-t-[64px]">
<Container>
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-8">
{filteredProducts.map((product) => (
<Link
key={product.slug}
href={`/${locale}/products/${productSlug}/${product.slug}`}
className="group block bg-white rounded-[32px] overflow-hidden shadow-sm hover:shadow-2xl transition-all duration-500 border border-neutral-dark/5"
>
<div className="aspect-[4/3] relative bg-neutral-light/30 p-12 overflow-hidden">
{product.frontmatter.images?.[0] && (
<>
<Image
src={product.frontmatter.images[0]}
alt={product.frontmatter.title}
fill
className="object-contain p-8 transition-transform duration-700 group-hover:scale-110 z-10"
/>
{/* Subtle reflection/shadow effect */}
<div className="absolute bottom-4 left-1/2 -translate-x-1/2 w-2/3 h-4 bg-black/5 blur-xl rounded-full" />
</>
)}
</div>
<div className="p-8 md:p-10">
<div className="flex flex-wrap gap-2 mb-4">
{product.frontmatter.categories.map((cat, i) => (
<span key={i} className="text-[10px] font-bold uppercase tracking-widest text-primary/40">
{cat}
</span>
))}
</div>
<h2 className="text-2xl md:text-3xl font-bold text-text-primary group-hover:text-primary transition-colors mb-4 leading-tight">
{product.frontmatter.title}
</h2>
<p className="text-text-secondary line-clamp-2 text-base leading-relaxed mb-8">
{product.frontmatter.description}
</p>
<div className="flex items-center text-primary font-bold group-hover:text-accent-dark transition-colors">
<span className="border-b-2 border-primary/10 group-hover:border-accent-dark transition-colors pb-1">
{t('details')}
</span>
<svg className="w-5 h-5 ml-3 transition-transform group-hover:translate-x-1" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M17 8l4 4m0 0l-4 4m4-4H3" />
</svg>
</div>
</div>
</Link>
))}
</div>
</Container>
</Section>
</div>
);
}
const product = await getProductBySlug(productSlug, locale);
if (!product) {
notFound();
}
const isFallback = (product.frontmatter as any).isFallback;
const categorySlug = slug[0];
const categoryKey = categorySlug.replace(/-cables$/, '').replace(/-([a-z])/g, (g) => g[1].toUpperCase());
const categoryTitle = t(`categories.${categoryKey}.title`);
return (
<div className="flex flex-col min-h-screen bg-neutral-light">
{/* Product Hero */}
<section className="relative pt-40 pb-32 overflow-hidden bg-primary-dark">
<Container className="relative z-10">
<div className="max-w-5xl animate-slide-up">
<nav className="flex items-center mb-8 text-white/40 text-sm font-bold uppercase tracking-widest">
<Link href={`/${locale}/products`} className="hover:text-accent transition-colors">{t('title')}</Link>
<span className="mx-3 opacity-30">/</span>
<Link href={`/${locale}/products/${categorySlug}`} className="hover:text-accent transition-colors">{categoryTitle}</Link>
<span className="mx-3 opacity-30">/</span>
<span className="text-white/90">{product.frontmatter.title}</span>
</nav>
<div className="flex flex-col lg:flex-row lg:items-end justify-between gap-12">
<div className="flex-1">
{isFallback && (
<div className="mb-6 inline-flex items-center px-3 py-1 rounded-md bg-accent/10 border border-accent/20 text-accent text-[10px] font-bold uppercase tracking-widest">
<span className="w-1.5 h-1.5 rounded-full bg-accent mr-2 animate-pulse" />
{t('englishVersion')}
</div>
)}
<div className="flex flex-wrap gap-3 mb-8">
{product.frontmatter.categories.map((cat, idx) => (
<Badge key={idx} variant="accent" className="bg-white/10 text-white/90 border-white/10 backdrop-blur-md px-4 py-1.5">
{cat}
</Badge>
))}
</div>
<h1 className="text-5xl md:text-7xl lg:text-8xl font-extrabold text-white mb-0 tracking-tight leading-[1.05]">
{product.frontmatter.title}
</h1>
</div>
</div>
</div>
</Container>
</section>
<Section className="bg-neutral-light relative z-20 -mt-16 pt-0">
<Container>
{/* Large Product Image Section */}
{product.frontmatter.images && product.frontmatter.images.length > 0 && (
<div className="relative -mt-24 mb-24 animate-slide-up" style={{ animationDelay: '200ms' }}>
<div className="bg-white shadow-2xl border border-neutral-dark/5 overflow-hidden p-10 md:p-16 lg:p-20">
<div className="relative w-full aspect-[21/9]">
<Image
src={product.frontmatter.images[0]}
alt={product.frontmatter.title}
fill
className="object-contain transition-transform duration-1000 hover:scale-105"
priority
/>
{/* Subtle reflection/shadow effect */}
<div className="absolute bottom-0 left-1/2 -translate-x-1/2 w-3/4 h-12 bg-black/5 blur-3xl rounded-[100%]" />
</div>
{product.frontmatter.images.length > 1 && (
<div className="flex justify-center gap-6 mt-16">
{product.frontmatter.images.slice(0, 5).map((img, idx) => (
<div key={idx} className="relative w-24 h-24 md:w-32 md:h-32 border-2 border-neutral-dark/10 rounded-xl overflow-hidden bg-neutral-light/20 hover:border-accent transition-all duration-300 cursor-pointer group">
<Image src={img} alt="" fill className="object-cover transition-transform duration-500 group-hover:scale-110" />
</div>
))}
</div>
)}
</div>
</div>
)}
<div className="flex flex-col lg:flex-row gap-16 lg:gap-24 relative items-start overflow-visible">
<div className="flex-1 w-full">
{/* Main Content Area */}
<div className="prose prose-lg md:prose-xl prose-slate max-w-none prose-headings:text-primary prose-headings:font-extrabold prose-a:text-primary prose-strong:text-primary prose-img:rounded-3xl prose-img:shadow-2xl">
<MDXRemote source={product.content} components={components} />
</div>
{/* Structured Data */}
<script
type="application/ld+json"
dangerouslySetInnerHTML={{
__html: JSON.stringify({
'@context': 'https://schema.org',
'@type': 'Product',
name: product.frontmatter.title,
description: product.frontmatter.description,
sku: product.frontmatter.sku,
image: product.frontmatter.images?.[0] ? `https://klz-cables.com${product.frontmatter.images[0]}` : undefined,
brand: {
'@type': 'Brand',
name: 'KLZ Cables',
},
offers: {
'@type': 'Offer',
availability: 'https://schema.org/InStock',
priceCurrency: 'EUR',
url: `https://klz-cables.com/${locale}/products/${slug.join('/')}`,
},
}),
}}
/>
{/* Related Products */}
<RelatedProducts
currentSlug={productSlug}
categories={product.frontmatter.categories}
locale={locale}
/>
</div>
<div className="w-full lg:w-[450px] xl:w-[500px] lg:sticky lg:top-32">
<div className="lg:translate-x-12 xl:translate-x-20">
{/* Request Quote Form */}
<div className="bg-white rounded-3xl shadow-2xl border border-neutral-dark/5 overflow-hidden">
<div className="bg-primary-dark p-6 md:p-8 text-white relative overflow-hidden">
<div className="absolute top-0 right-0 w-40 h-40 bg-accent/10 rounded-full -translate-y-1/2 translate-x-1/2 blur-3xl" />
{/* Product Thumbnail for Sticky State */}
{product.frontmatter.images?.[0] && (
<div className="relative w-full aspect-[16/9] mb-6 rounded-xl overflow-hidden bg-white/10 backdrop-blur-md p-3 border border-white/10 z-10">
<Image
src={product.frontmatter.images[0]}
alt=""
fill
className="object-contain p-1"
/>
</div>
)}
<h3 className="text-xl md:text-2xl font-extrabold mb-2 relative z-10 tracking-tight">{t('requestQuote')}</h3>
<p className="text-white/60 text-sm relative z-10 leading-relaxed">{t('requestQuoteDesc')}</p>
</div>
<div className="p-6 md:p-8">
<RequestQuoteForm productName={product.frontmatter.title} />
</div>
</div>
</div>
</div>
</div>
</Container>
</Section>
</div>
);
}

View File

@@ -0,0 +1,29 @@
import { ImageResponse } from 'next/og';
import { getTranslations } from 'next-intl/server';
import { OGImageTemplate } from '@/components/OGImageTemplate';
import { getOgFonts, OG_IMAGE_SIZE } from '@/lib/og-helper';
export const runtime = 'nodejs';
export default async function Image({ params: { locale } }: { params: { locale: string } }) {
const t = await getTranslations({ locale, namespace: 'Products' });
const fonts = await getOgFonts();
const title = t('meta.title') || t('title');
const description = t('meta.description') || t('subtitle');
return new ImageResponse(
(
<OGImageTemplate
title={title}
description={description}
label="Products"
/>
),
{
...OG_IMAGE_SIZE,
fonts,
}
);
}

View File

@@ -1,48 +1,89 @@
import Link from 'next/link';
import Image from 'next/image';
import { useTranslations } from 'next-intl';
import { Section, Container, Card, Badge, Button } from '@/components/ui';
import Scribble from '@/components/Scribble';
import Reveal from '@/components/Reveal';
import Scribble from '@/components/Scribble';
import { Badge, Button, Card, Container, Heading, Section } from '@/components/ui';
import { getTranslations } from 'next-intl/server';
import { Metadata } from 'next';
import Image from 'next/image';
import Link from 'next/link';
import { mapFileSlugToTranslated } from '@/lib/slugs';
import { getOGImageMetadata } from '@/lib/metadata';
import { SITE_URL } from '@/lib/schema';
interface ProductsPageProps {
params: {
params: Promise<{
locale: string;
}>;
}
export async function generateMetadata({ params }: ProductsPageProps): Promise<Metadata> {
const { locale } = await params;
const t = await getTranslations({ locale, namespace: 'Products' });
const title = t('meta.title') || t('title');
const description = t('meta.description') || t('subtitle');
return {
title,
description,
alternates: {
canonical: `/${locale}/products`,
languages: {
de: '/de/products',
en: '/en/products',
'x-default': '/en/products',
},
},
openGraph: {
title: `${title} | KLZ Cables`,
description,
url: `${SITE_URL}/${locale}/products`,
images: getOGImageMetadata('products', title, locale),
},
twitter: {
card: 'summary_large_image',
title: `${title} | KLZ Cables`,
description,
},
};
}
export default function ProductsPage({ params }: ProductsPageProps) {
const t = useTranslations('Products');
export default async function ProductsPage({ params }: ProductsPageProps) {
const { locale } = await params;
const t = await getTranslations('Products');
// Get translated category slugs
const lowVoltageSlug = await mapFileSlugToTranslated('low-voltage-cables', locale);
const mediumVoltageSlug = await mapFileSlugToTranslated('medium-voltage-cables', locale);
const highVoltageSlug = await mapFileSlugToTranslated('high-voltage-cables', locale);
const solarSlug = await mapFileSlugToTranslated('solar-cables', locale);
const categories = [
{
title: t('categories.lowVoltage.title'),
{
title: t('categories.lowVoltage.title'),
desc: t('categories.lowVoltage.description'),
img: '/uploads/2024/11/low-voltage-category.webp',
icon: '/uploads/2024/11/Low-Voltage.svg',
href: `/${params.locale}/products/low-voltage-cables`
href: `/${locale}/products/${lowVoltageSlug}`,
},
{
title: t('categories.mediumVoltage.title'),
{
title: t('categories.mediumVoltage.title'),
desc: t('categories.mediumVoltage.description'),
img: '/uploads/2024/11/medium-voltage-category.webp',
icon: '/uploads/2024/11/Medium-Voltage.svg',
href: `/${params.locale}/products/medium-voltage-cables`
href: `/${locale}/products/${mediumVoltageSlug}`,
},
{
title: t('categories.highVoltage.title'),
{
title: t('categories.highVoltage.title'),
desc: t('categories.highVoltage.description'),
img: '/uploads/2024/11/high-voltage-category.webp',
icon: '/uploads/2024/11/High-Voltage.svg',
href: `/${params.locale}/products/high-voltage-cables`
href: `/${locale}/products/${highVoltageSlug}`,
},
{
title: t('categories.solar.title'),
{
title: t('categories.solar.title'),
desc: t('categories.solar.description'),
img: '/uploads/2024/11/solar-category.webp',
icon: '/uploads/2024/11/Solar.svg',
href: `/${params.locale}/products/solar-cables`
}
href: `/${locale}/products/${solarSlug}`,
},
];
return (
@@ -51,24 +92,35 @@ export default function ProductsPage({ params }: ProductsPageProps) {
<section className="relative min-h-[50vh] md:min-h-[70vh] flex items-center pt-32 pb-20 md:pt-40 md:pb-32 overflow-hidden bg-primary-dark">
<Container className="relative z-10">
<div className="max-w-4xl animate-slide-up">
<Badge variant="accent" className="mb-4 md:mb-8 bg-white/10 text-white border border-white/20 backdrop-blur-md px-3 py-1 md:px-4 md:py-1.5">
<Badge
variant="saturated"
className="mb-4 md:mb-8 shadow-lg px-3 py-1 md:px-4 md:py-1.5"
>
{t('heroSubtitle')}
</Badge>
<h1 className="text-4xl md:text-7xl lg:text-8xl font-extrabold text-white mb-4 md:mb-8 tracking-tight leading-[1.05]">
<Heading level={1} className="text-white mb-4 md:mb-8">
{t.rich('title', {
green: (chunks) => (
<span className="relative inline-block">
<span className="relative z-10 text-accent italic">{chunks}</span>
<Scribble variant="circle" className="w-[140%] h-[140%] -top-[20%] -left-[20%] text-accent/30 hidden md:block" />
<Scribble
variant="circle"
className="w-[140%] h-[140%] -top-[20%] -left-[20%] text-accent/30 hidden md:block"
/>
</span>
)
),
})}
</h1>
<p className="text-lg md:text-2xl text-white/70 leading-relaxed max-w-2xl mb-8 md:mb-12 line-clamp-2 md:line-clamp-none">
</Heading>
<p className="text-lg md:text-xl text-white/70 leading-relaxed max-w-2xl mb-8 md:mb-12 line-clamp-2 md:line-clamp-none">
{t('subtitle')}
</p>
<div className="flex flex-wrap gap-4 md:gap-6">
<Button href="#categories" variant="accent" size="lg" className="group w-full md:w-auto">
<Button
href="#categories"
variant="accent"
size="lg"
className="group w-full md:w-auto"
>
{t('viewProducts')}
<span className="ml-3 transition-transform group-hover:translate-y-1"></span>
</Button>
@@ -77,7 +129,7 @@ export default function ProductsPage({ params }: ProductsPageProps) {
</Container>
</section>
<Section id="categories" className="bg-neutral-light relative z-20 -mt-8 md:-mt-16 rounded-t-[32px] md:rounded-t-[64px] pt-12 md:pt-24">
<Section id="categories" className="bg-neutral-light relative">
<Container>
<div className="grid grid-cols-1 md:grid-cols-2 gap-6 md:gap-8 lg:gap-12">
{categories.map((category, idx) => (
@@ -85,8 +137,8 @@ export default function ProductsPage({ params }: ProductsPageProps) {
<Link key={idx} href={category.href} className="group block">
<Card className="h-full border-none shadow-sm hover:shadow-2xl transition-all duration-500 rounded-[24px] md:rounded-[48px] overflow-hidden bg-white active:scale-[0.98]">
<div className="relative h-[200px] md:h-[400px] overflow-hidden">
<Image
src={category.img}
<Image
src={category.img}
alt={category.title}
fill
className="object-cover transition-transform duration-1000 group-hover:scale-105"
@@ -94,13 +146,22 @@ export default function ProductsPage({ params }: ProductsPageProps) {
unoptimized
/>
<div className="absolute inset-0 image-overlay-gradient opacity-80 group-hover:opacity-90 transition-opacity duration-500" />
<div className="absolute top-3 right-3 md:top-8 md:right-8 w-10 h-10 md:w-20 md:h-20 bg-white/10 backdrop-blur-md rounded-xl md:rounded-[24px] flex items-center justify-center border border-white/20 shadow-2xl transition-all duration-500 group-hover:scale-110 group-hover:bg-white/20">
<img src={category.icon} alt="" className="w-6 h-6 md:w-12 md:h-12 brightness-0 invert opacity-80" />
<Image
src={category.icon}
alt=""
width={24}
height={24}
className="w-6 h-6 md:w-12 md:h-12 brightness-0 invert opacity-80"
/>
</div>
<div className="absolute bottom-4 left-4 md:bottom-10 md:left-10 right-4 md:right-10">
<Badge variant="accent" className="mb-2 md:mb-4 shadow-lg bg-accent text-primary-dark border-none text-[10px] md:text-xs">
<Badge
variant="accent"
className="mb-2 md:mb-4 shadow-lg bg-accent text-primary-dark border-none text-[10px] md:text-xs"
>
{t('categoryLabel')}
</Badge>
<h2 className="text-xl md:text-4xl font-bold text-white leading-tight transition-transform duration-500 group-hover:translate-x-1">
@@ -112,13 +173,23 @@ export default function ProductsPage({ params }: ProductsPageProps) {
<p className="text-text-secondary text-sm md:text-lg leading-relaxed mb-4 md:mb-8 line-clamp-2 md:line-clamp-none">
{category.desc}
</p>
<div className="flex items-center text-primary font-bold text-base md:text-lg group-hover:text-accent-dark transition-colors">
<span className="border-b-2 border-primary/10 group-hover:border-accent-dark transition-colors pb-1">
<div className="flex items-center text-saturated font-bold text-base md:text-lg group-hover:text-accent-dark transition-colors">
<span className="border-b-2 border-saturated/10 group-hover:border-accent-dark transition-colors pb-1">
{t('viewProducts')}
</span>
<div className="ml-3 md:ml-4 w-8 h-8 md:w-10 md:h-10 rounded-full bg-primary-light flex items-center justify-center text-primary group-hover:bg-accent group-hover:text-primary-dark transition-all duration-300 shadow-sm">
<svg className="w-4 h-4 md:w-5 md:h-5 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" />
<div className="ml-3 md:ml-4 w-8 h-8 md:w-10 md:h-10 rounded-full bg-primary-light flex items-center justify-center text-saturated group-hover:bg-accent group-hover:text-primary-dark transition-all duration-300 shadow-sm">
<svg
className="w-4 h-4 md:w-5 md:h-5 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>
@@ -130,27 +201,38 @@ export default function ProductsPage({ params }: ProductsPageProps) {
</div>
</Container>
</Section>
{/* Technical Support CTA */}
<Section className="bg-white py-12 md:py-28">
<Container>
<div className="bg-primary-dark rounded-[32px] md:rounded-[64px] p-6 md:p-20 lg:p-24 relative overflow-hidden">
<div className="absolute top-0 right-0 w-1/2 h-full bg-accent/5 -skew-x-12 translate-x-1/4" />
<div className="relative z-10 flex flex-col lg:flex-row items-center justify-between gap-6 md:gap-12">
<div className="max-w-2xl text-center lg:text-left">
<h2 className="text-2xl md:text-5xl lg:text-6xl font-bold text-white mb-4 md:mb-8 tracking-tight">{t('cta.title')}</h2>
<p className="text-base md:text-xl text-white/70 leading-relaxed">
{t('cta.description')}
</p>
<Reveal>
<Section className="bg-white py-12 md:py-28">
<Container>
<div className="bg-primary-dark rounded-[32px] md:rounded-[64px] p-6 md:p-20 lg:p-24 relative overflow-hidden">
<div className="absolute top-0 right-0 w-1/2 h-full bg-accent/5 -skew-x-12 translate-x-1/4" />
<div className="relative z-10 flex flex-col lg:flex-row items-center justify-between gap-6 md:gap-12">
<div className="max-w-2xl text-center lg:text-left">
<h2 className="text-2xl md:text-5xl lg:text-6xl font-bold text-white mb-4 md:mb-8 tracking-tight">
{t('cta.title')}
</h2>
<p className="text-base md:text-xl text-white/70 leading-relaxed">
{t('cta.description')}
</p>
</div>
<Button
href={`/${locale}/contact`}
variant="accent"
size="lg"
className="group whitespace-nowrap w-full md:w-auto md:h-16 md:px-10 md:text-xl"
>
{t('cta.button')}
<span className="ml-4 transition-transform group-hover:translate-x-2">
&rarr;
</span>
</Button>
</div>
<Button href={`/${params.locale}/contact`} variant="accent" size="lg" className="group whitespace-nowrap w-full md:w-auto md:h-16 md:px-10 md:text-xl">
{t('cta.button')}
<span className="ml-4 transition-transform group-hover:translate-x-2">&rarr;</span>
</Button>
</div>
</div>
</Container>
</Section>
</Container>
</Section>
</Reveal>
</div>
);
}

View File

@@ -0,0 +1,29 @@
import { ImageResponse } from 'next/og';
import { getTranslations } from 'next-intl/server';
import { OGImageTemplate } from '@/components/OGImageTemplate';
import { getOgFonts, OG_IMAGE_SIZE } from '@/lib/og-helper';
export const runtime = 'nodejs';
export default async function Image({ params: { locale } }: { params: { locale: string } }) {
const t = await getTranslations({ locale, namespace: 'Team' });
const fonts = await getOgFonts();
const title = t('meta.title') || t('hero.subtitle');
const description = t('meta.description') || t('hero.title');
return new ImageResponse(
(
<OGImageTemplate
title={title}
description={description}
label="Our Team"
/>
),
{
...OG_IMAGE_SIZE,
fonts,
}
);
}

View File

@@ -1,36 +1,116 @@
import { useTranslations } from 'next-intl';
import { getTranslations } from 'next-intl/server';
import { Metadata } from 'next';
import JsonLd from '@/components/JsonLd';
import { getBreadcrumbSchema, SITE_URL } from '@/lib/schema';
import { Section, Container, Heading, Badge, Button } from '@/components/ui';
import { getOGImageMetadata } from '@/lib/metadata';
import Image from 'next/image';
import Reveal from '@/components/Reveal';
import Gallery from '@/components/team/Gallery';
export default function TeamPage() {
const t = useTranslations('Team');
interface TeamPageProps {
params: Promise<{
locale: string;
}>;
}
export async function generateMetadata({ params }: TeamPageProps): Promise<Metadata> {
const { locale } = await params;
const t = await getTranslations({ locale, namespace: 'Team' });
const title = t('meta.title') || t('hero.subtitle');
const description = t('meta.description') || t('hero.title');
return {
title,
description,
alternates: {
canonical: `/${locale}/team`,
languages: {
de: '/de/team',
en: '/en/team',
'x-default': '/en/team',
},
},
openGraph: {
title: `${title} | KLZ Cables`,
description,
url: `${SITE_URL}/${locale}/team`,
images: getOGImageMetadata('team', title, locale),
},
twitter: {
card: 'summary_large_image',
title: `${title} | KLZ Cables`,
description,
},
};
}
export default async function TeamPage({ params }: TeamPageProps) {
const { locale } = await params;
const t = await getTranslations({ locale, namespace: 'Team' });
return (
<div className="flex flex-col min-h-screen bg-neutral-light">
<JsonLd
id="breadcrumb-team"
data={getBreadcrumbSchema([{ name: t('hero.subtitle'), item: `/team` }])}
/>
<JsonLd
id="person-michael"
data={{
'@context': 'https://schema.org',
'@type': 'Person',
name: t('michael.name'),
jobTitle: t('michael.role'),
worksFor: {
'@type': 'Organization',
name: 'KLZ Cables',
},
sameAs: ['https://www.linkedin.com/in/michael-bodemer-33b493122/'],
image: `${SITE_URL}/uploads/2024/12/DSC07768-Large.webp`,
}}
/>
<JsonLd
id="person-klaus"
data={{
'@context': 'https://schema.org',
'@type': 'Person',
name: t('klaus.name'),
jobTitle: t('klaus.role'),
worksFor: {
'@type': 'Organization',
name: 'KLZ Cables',
},
sameAs: ['https://www.linkedin.com/in/klaus-mintel-b80a8b193/'],
image: `${SITE_URL}/uploads/2024/12/DSC07963-Large.webp`,
}}
/>
{/* Hero Section */}
<section className="relative flex items-center justify-center overflow-hidden bg-primary-dark pt-32 pb-24 md:pt-[14%] md:pb-[12%]">
<div className="absolute inset-0 z-0">
<Image
src="/uploads/2024/12/DSC07655-Large.webp"
alt="KLZ Team"
fill
className="object-cover scale-105 animate-slow-zoom opacity-30 md:opacity-40"
priority
/>
<div className="absolute inset-0 bg-gradient-to-b from-primary-dark/80 via-primary-dark/40 to-primary-dark/80" />
</div>
<Container className="relative z-10 text-center text-white max-w-5xl animate-slide-up">
<Badge variant="accent" className="mb-4 md:mb-8 shadow-lg">{t('hero.badge')}</Badge>
<h1 className="text-3xl md:text-7xl lg:text-8xl font-extrabold tracking-tight leading-[1.1] mb-4 md:mb-8">
{t('hero.subtitle')}
</h1>
<p className="text-lg md:text-3xl text-white/70 font-medium italic">
{t('hero.title')}
</p>
</Container>
</section>
<Reveal>
<section className="relative flex items-center justify-center overflow-hidden bg-primary-dark pt-32 pb-24 md:pt-[14%] md:pb-[12%]">
<div className="absolute inset-0 z-0">
<Image
src="/uploads/2024/12/DSC07655-Large.webp"
alt="KLZ Team"
fill
className="object-cover scale-105 animate-slow-zoom opacity-30 md:opacity-40"
priority
/>
<div className="absolute inset-0 bg-gradient-to-b from-primary-dark/80 via-primary-dark/40 to-primary-dark/80" />
</div>
<Container className="relative z-10 text-center text-white max-w-5xl">
<Badge variant="saturated" className="mb-4 md:mb-8 shadow-lg">
{t('hero.badge')}
</Badge>
<Heading level={1} className="text-white mb-4 md:mb-8">
{t('hero.subtitle')}
</Heading>
<p className="text-lg md:text-2xl text-white/70 font-medium italic">
{t('hero.title')}
</p>
</Container>
</section>
</Reveal>
{/* Michael Bodemer Section - Sticky Narrative Split Layout */}
<section className="relative bg-white overflow-hidden">
@@ -38,22 +118,24 @@ export default function TeamPage() {
<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="relative z-10">
<Badge variant="accent" className="mb-4 md:mb-8">{t('michael.role')}</Badge>
<Heading level={2} className="text-white mb-6 md:mb-10 text-3xl md:text-6xl">
<Badge variant="accent" className="mb-4 md:mb-8">
{t('michael.role')}
</Badge>
<Heading level={2} className="text-white mb-6 md:mb-10 text-3xl md:text-5xl">
<span className="text-white">{t('michael.name')}</span>
</Heading>
<div className="relative mb-6 md:mb-12">
<div className="absolute -left-4 md:-left-8 top-0 bottom-0 w-1 md:w-1.5 bg-accent rounded-full" />
<p className="text-lg md:text-3xl font-bold italic leading-relaxed pl-5 md:pl-8 text-white/90">
"{t('michael.quote')}"
<p className="text-lg md:text-2xl font-bold italic leading-relaxed pl-5 md:pl-8 text-white/90">
{t('michael.quote')}
</p>
</div>
<p className="text-base md:text-xl leading-relaxed text-white/70 mb-6 md:mb-12 max-w-xl">
{t('michael.description')}
</p>
<Button
href="https://www.linkedin.com/in/michael-bodemer-33b493122/"
variant="accent"
<Button
href="https://www.linkedin.com/in/michael-bodemer-33b493122/"
variant="accent"
size="lg"
className="group w-full md:w-auto md:h-16 md:px-10 md:text-xl active:scale-95 transition-transform"
>
@@ -62,92 +144,106 @@ export default function TeamPage() {
</Button>
</div>
</Reveal>
<div className="w-full lg:w-1/2 relative min-h-[400px] md:min-h-[600px] lg:min-h-screen overflow-hidden order-1 lg:order-2">
<Image
src="/uploads/2024/12/DSC07768-Large.webp"
alt={t('michael.name')}
fill
<Reveal className="w-full lg:w-1/2 relative min-h-[400px] md:min-h-[600px] lg:min-h-screen overflow-hidden order-1 lg:order-2">
<Image
src="/uploads/2024/12/DSC07768-Large.webp"
alt={t('michael.name')}
fill
className="object-cover scale-105 hover:scale-100 transition-transform duration-1000"
sizes="(max-width: 1024px) 100vw, 50vw"
/>
<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>
</Reveal>
</div>
</section>
{/* Legacy Section - Immersive Background */}
<section className="relative py-16 md:py-48 bg-primary-dark text-white overflow-hidden">
<div className="absolute inset-0 z-0">
<Image
src="/uploads/2024/12/1694273920124-copy.webp"
alt={t('legacy.subtitle')}
fill
className="object-cover opacity-20 md:opacity-30 scale-110 animate-slow-zoom"
sizes="100vw"
/>
<div className="absolute inset-0 bg-primary-dark/60 mix-blend-multiply" />
</div>
<Container className="relative z-10">
<div className="grid grid-cols-1 lg:grid-cols-12 gap-8 md:gap-16 items-center">
<div className="lg:col-span-6">
<Heading level={2} subtitle={t('legacy.subtitle')} className="text-white mb-6 md:mb-10">
<span className="text-white">{t('legacy.title')}</span>
</Heading>
<div className="space-y-4 md:space-y-8 text-base md:text-2xl text-white/80 leading-relaxed font-medium">
<p className="border-l-4 border-accent pl-5 md:pl-8 py-2 bg-white/5 backdrop-blur-sm rounded-r-xl md:rounded-r-2xl">
{t('legacy.p1')}
</p>
<p className="pl-6 md:pl-9 line-clamp-3 md:line-clamp-none">
{t('legacy.p2')}
</p>
</div>
</div>
<div className="lg:col-span-6 grid grid-cols-2 md:grid-cols-2 gap-3 md:gap-6">
<div className="p-4 md:p-8 bg-white/5 backdrop-blur-md border border-white/10 rounded-2xl md:rounded-[32px] hover:bg-white/10 transition-colors">
<div className="text-xl md:text-4xl font-extrabold text-accent mb-1 md:mb-2">{t('legacy.expertise')}</div>
<div className="text-[10px] md:text-sm font-bold uppercase tracking-widest text-white/50">{t('legacy.expertiseDesc')}</div>
</div>
<div className="p-4 md:p-8 bg-white/5 backdrop-blur-md border border-white/10 rounded-2xl md:rounded-[32px] hover:bg-white/10 transition-colors">
<div className="text-xl md:text-4xl font-extrabold text-accent mb-1 md:mb-2">{t('legacy.network')}</div>
<div className="text-[10px] md:text-sm font-bold uppercase tracking-widest text-white/50">{t('legacy.networkDesc')}</div>
</div>
</div>
<Reveal>
<section className="relative py-16 md:py-48 bg-primary-dark text-white overflow-hidden">
<div className="absolute inset-0 z-0">
<Image
src="/uploads/2024/12/1694273920124-copy.webp"
alt={t('legacy.subtitle')}
fill
className="object-cover opacity-20 md:opacity-30 scale-110 animate-slow-zoom"
sizes="100vw"
/>
<div className="absolute inset-0 bg-primary-dark/60 mix-blend-multiply" />
</div>
</Container>
</section>
<Container className="relative z-10">
<div className="grid grid-cols-1 lg:grid-cols-12 gap-8 md:gap-16 items-center">
<div className="lg:col-span-6">
<Heading
level={2}
subtitle={t('legacy.subtitle')}
className="text-white mb-6 md:mb-10"
>
<span className="text-white">{t('legacy.title')}</span>
</Heading>
<div className="space-y-4 md:space-y-8 text-base md:text-2xl text-white/80 leading-relaxed font-medium">
<p className="border-l-4 border-accent pl-5 md:pl-8 py-2 bg-white/5 backdrop-blur-sm rounded-r-xl md:rounded-r-2xl">
{t('legacy.p1')}
</p>
<p className="pl-6 md:pl-9 line-clamp-3 md:line-clamp-none">{t('legacy.p2')}</p>
</div>
</div>
<div className="lg:col-span-6 grid grid-cols-2 md:grid-cols-2 gap-3 md:gap-6">
<div className="p-4 md:p-8 bg-white/5 backdrop-blur-md border border-white/10 rounded-2xl md:rounded-[32px] hover:bg-white/10 transition-colors">
<div className="text-xl md:text-4xl font-extrabold text-accent mb-1 md:mb-2">
{t('legacy.expertise')}
</div>
<div className="text-[10px] md:text-sm font-bold uppercase tracking-widest text-white/50">
{t('legacy.expertiseDesc')}
</div>
</div>
<div className="p-4 md:p-8 bg-white/5 backdrop-blur-md border border-white/10 rounded-2xl md:rounded-[32px] hover:bg-white/10 transition-colors">
<div className="text-xl md:text-4xl font-extrabold text-accent mb-1 md:mb-2">
{t('legacy.network')}
</div>
<div className="text-[10px] md:text-sm font-bold uppercase tracking-widest text-white/50">
{t('legacy.networkDesc')}
</div>
</div>
</div>
</div>
</Container>
</section>
</Reveal>
{/* Klaus Mintel Section - Reversed Split Layout */}
<section className="relative bg-white overflow-hidden">
<div className="flex flex-col lg:flex-row">
<div className="w-full lg:w-1/2 relative min-h-[400px] md:min-h-[600px] lg:min-h-screen overflow-hidden order-1">
<Image
src="/uploads/2024/12/DSC07963-Large.webp"
alt={t('klaus.name')}
fill
<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
src="/uploads/2024/12/DSC07963-Large.webp"
alt={t('klaus.name')}
fill
className="object-cover scale-105 hover:scale-100 transition-transform duration-1000"
sizes="(max-width: 1024px) 100vw, 50vw"
/>
<div className="absolute inset-0 bg-gradient-to-t from-white/60 lg:bg-gradient-to-l lg:from-primary-dark/20 to-transparent" />
</div>
<Reveal className="w-full lg:w-1/2 p-6 md:p-24 lg:p-32 flex flex-col justify-center bg-neutral-light text-primary relative order-2">
<div className="absolute top-0 left-0 w-32 h-full bg-primary/5 skew-x-12 -translate-x-1/2" />
</Reveal>
<Reveal className="w-full lg:w-1/2 p-6 md:p-24 lg:p-32 flex flex-col justify-center bg-neutral-light text-saturated relative order-2">
<div className="absolute top-0 left-0 w-32 h-full bg-saturated/5 skew-x-12 -translate-x-1/2" />
<div className="relative z-10">
<Badge variant="primary" className="mb-4 md:mb-8">{t('klaus.role')}</Badge>
<Heading level={2} className="text-primary mb-6 md:mb-10 text-3xl md:text-6xl">
<Badge variant="saturated" className="mb-4 md:mb-8">
{t('klaus.role')}
</Badge>
<Heading level={2} className="text-saturated mb-6 md:mb-10 text-3xl md:text-6xl">
{t('klaus.name')}
</Heading>
<div className="relative mb-6 md:mb-12">
<div className="absolute -left-4 md:-left-8 top-0 bottom-0 w-1 md:w-1.5 bg-primary rounded-full" />
<div className="absolute -left-4 md:-left-8 top-0 bottom-0 w-1 md:w-1.5 bg-saturated rounded-full" />
<p className="text-lg md:text-3xl font-bold italic leading-relaxed pl-5 md:pl-8 text-text-secondary">
"{t('klaus.quote')}"
{t('klaus.quote')}
</p>
</div>
<p className="text-base md:text-xl leading-relaxed text-text-secondary mb-6 md:mb-12 max-w-xl">
{t('klaus.description')}
</p>
<Button
href="https://www.linkedin.com/in/klaus-mintel-b80a8b193/"
variant="primary"
<Button
href="https://www.linkedin.com/in/klaus-mintel-b80a8b193/"
variant="saturated"
size="lg"
className="group w-full md:w-auto md:h-16 md:px-10 md:text-xl active:scale-95 transition-transform"
>
@@ -171,11 +267,14 @@ export default function TeamPage() {
<p className="text-base md:text-xl text-text-secondary leading-relaxed">
{t('manifesto.tagline')}
</p>
{/* Mobile-only progress indicator */}
<div className="flex lg:hidden mt-8 gap-2">
{[0, 1, 2, 3, 4, 5].map((i) => (
<div key={i} className="h-1.5 flex-1 bg-neutral-medium rounded-full overflow-hidden">
<div
key={i}
className="h-1.5 flex-1 bg-neutral-medium rounded-full overflow-hidden"
>
<div className="h-full bg-accent w-0 group-active:w-full transition-all duration-500" />
</div>
))}
@@ -184,12 +283,21 @@ export default function TeamPage() {
</div>
<div className="sticky-narrative-content grid grid-cols-1 md:grid-cols-2 gap-4 md:gap-10">
{[0, 1, 2, 3, 4, 5].map((idx) => (
<div 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">
<div
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"
>
<div className="w-10 h-10 md:w-16 md:h-16 bg-white rounded-xl md:rounded-2xl flex items-center justify-center mb-4 md:mb-8 shadow-sm group-hover:bg-accent transition-colors duration-500">
<span className="text-primary font-extrabold text-lg md:text-2xl group-hover:text-primary-dark">0{idx + 1}</span>
<span className="text-primary font-extrabold text-lg md:text-2xl group-hover:text-primary-dark">
0{idx + 1}
</span>
</div>
<h3 className="text-lg md:text-2xl font-bold mb-2 md:mb-4 text-primary">{t(`manifesto.items.${idx}.title`)}</h3>
<p className="text-sm md:text-lg text-text-secondary leading-relaxed">{t(`manifesto.items.${idx}.description`)}</p>
<h3 className="text-lg md:text-2xl font-bold mb-2 md:mb-4 text-primary">
{t(`manifesto.items.${idx}.title`)}
</h3>
<p className="text-sm md:text-lg text-text-secondary leading-relaxed">
{t(`manifesto.items.${idx}.description`)}
</p>
</div>
))}
</div>
@@ -197,29 +305,9 @@ export default function TeamPage() {
</Container>
</Section>
{/* Gallery Section - Premium Treatment */}
<Section className="bg-primary-dark py-16 md:py-32">
<Container>
<Heading level={2} subtitle={t('gallery.subtitle')} align="center" className="text-white mb-12 md:mb-20">
<span className="text-white">{t('gallery.title')}</span>
</Heading>
<div className="grid grid-cols-2 md:grid-cols-5 gap-3 md:gap-6">
{[
'/uploads/2024/12/DSC07539-Large-600x400.webp',
'/uploads/2024/12/DSC07460-Large-600x400.webp',
'/uploads/2024/12/DSC07469-Large-600x400.webp',
'/uploads/2024/12/DSC07433-Large-600x400.webp',
'/uploads/2024/12/DSC07387-Large-600x400.webp'
].map((src, idx) => (
<div key={idx} className="relative aspect-[3/4] rounded-2xl md:rounded-[32px] overflow-hidden group shadow-2xl">
<Image src={src} alt={t('gallery.title')} fill className="object-cover transition-transform duration-1000 group-hover:scale-110" />
<div className="absolute inset-0 bg-primary-dark/40 group-hover:bg-transparent transition-all duration-500" />
<div className="absolute inset-0 border-0 group-hover:border-[8px] md:group-hover:border-[12px] border-white/10 transition-all duration-500 pointer-events-none" />
</div>
))}
</div>
</Container>
</Section>
<Reveal>
<Gallery />
</Reveal>
</div>
);
}

153
app/actions/contact.ts Normal file
View File

@@ -0,0 +1,153 @@
'use server';
import client, { ensureAuthenticated } from '@/lib/directus';
import { createItem } from '@directus/sdk';
import { sendEmail } from '@/lib/mail/mailer';
import { render, ContactFormNotification, ConfirmationMessage } from '@mintel/mail';
import React from 'react';
import { getServerAppServices } from '@/lib/services/create-services.server';
export async function sendContactFormAction(formData: FormData) {
const services = getServerAppServices();
const logger = services.logger.child({ action: 'sendContactFormAction' });
// Set analytics context from request headers for high-fidelity server-side tracking
const { headers } = await import('next/headers');
const requestHeaders = await headers();
if ('setServerContext' in services.analytics) {
(services.analytics as any).setServerContext({
userAgent: requestHeaders.get('user-agent') || undefined,
language: requestHeaders.get('accept-language')?.split(',')[0] || undefined,
referrer: requestHeaders.get('referer') || undefined,
ip: requestHeaders.get('x-forwarded-for')?.split(',')[0] || undefined,
});
}
// Track attempt
services.analytics.track('contact-form-attempt');
const name = formData.get('name') as string;
const email = formData.get('email') as string;
const message = formData.get('message') as string;
const productName = formData.get('productName') as string | null;
if (!name || !email || !message) {
logger.warn('Missing required fields in contact form', {
name: !!name,
email: !!email,
message: !!message,
});
return { success: false, error: 'Missing required fields' };
}
// 1. Save to Directus
try {
await ensureAuthenticated();
if (productName) {
await client.request(
createItem('product_requests', {
product_name: productName,
email,
message,
}),
);
logger.info('Product request stored in Directus');
} else {
await client.request(
createItem('contact_submissions', {
name,
email,
message,
}),
);
logger.info('Contact submission stored in Directus');
}
} catch (error) {
logger.error('Failed to store submission in Directus', { error });
services.errors.captureException(error, { action: 'directus_store_submission' });
}
// 2. Send Emails
logger.info('Sending branded emails', { email, productName });
const notificationSubject = productName
? `Product Inquiry: ${productName}`
: 'New Contact Form Submission';
const confirmationSubject = 'Thank you for your inquiry';
try {
// 2a. Send notification to Mintel/Client
const notificationHtml = await render(
React.createElement(ContactFormNotification, {
name,
email,
message,
productName: productName || undefined,
}),
);
const notificationResult = await sendEmail({
replyTo: email,
subject: notificationSubject,
html: notificationHtml,
});
if (notificationResult.success) {
logger.info('Notification email sent successfully', {
messageId: notificationResult.messageId,
});
}
// 2b. Send confirmation to Customer (branded as KLZ Cables)
const confirmationHtml = await render(
React.createElement(ConfirmationMessage, {
name,
clientName: 'KLZ Cables',
// brandColor: '#82ed20', // Optional: could be KLZ specific
}),
);
const confirmationResult = await sendEmail({
to: email,
subject: confirmationSubject,
html: confirmationHtml,
});
if (confirmationResult.success) {
logger.info('Confirmation email sent successfully', {
messageId: confirmationResult.messageId,
});
}
// Notify via Gotify (Internal)
await services.notifications.notify({
title: `📩 ${notificationSubject}`,
message: `New message from ${name} (${email}):\n\n${message}`,
priority: 5,
});
// Track success
services.analytics.track('contact-form-success', {
is_product_request: !!productName,
});
return { success: true };
} catch (error) {
const errorMsg = error instanceof Error ? error.message : String(error);
logger.error('Failed to send branded emails', {
error: errorMsg,
stack: error instanceof Error ? error.stack : undefined,
});
services.errors.captureException(error, { action: 'sendContactFormAction', email });
await services.notifications.notify({
title: '🚨 Contact Form Error',
message: `Failed to send emails for ${name} (${email}). Error: ${errorMsg}`,
priority: 8,
});
return { success: false, error: errorMsg };
}
}

17
app/api/feedback/route.ts Normal file
View File

@@ -0,0 +1,17 @@
import { NextRequest } from 'next/server';
import { handleFeedbackRequest } from '@mintel/next-feedback';
import { config } from '@/lib/config';
export async function GET(req: NextRequest) {
return handleFeedbackRequest(req as any, {
url: config.infraCMS.url,
token: config.infraCMS.token,
});
}
export async function POST(req: NextRequest) {
return handleFeedbackRequest(req as any, {
url: config.infraCMS.url,
token: config.infraCMS.token,
});
}

View File

@@ -0,0 +1,9 @@
import { checkHealth } from '@/lib/directus';
import { NextResponse } from 'next/server';
export const dynamic = 'force-dynamic';
export async function GET() {
const health = await checkHealth();
return NextResponse.json(health, { status: health.status === 'ok' ? 200 : 503 });
}

7
app/api/whoami/route.ts Normal file
View File

@@ -0,0 +1,7 @@
import { NextRequest } from 'next/server';
import { handleWhoAmIRequest } from '@mintel/next-feedback';
import { config } from '@/lib/config';
export async function GET(req: NextRequest) {
return handleWhoAmIRequest(req, config.gatekeeperUrl);
}

View File

@@ -0,0 +1,69 @@
import { NextRequest, NextResponse } from 'next/server';
import { getServerAppServices } from '@/lib/services/create-services.server';
import { config } from '@/lib/config';
/**
* Smart Proxy / Relay for Sentry/GlitchTip events.
*
* This Route Handler receives Sentry envelopes from the client,
* injects the correct DSN if needed, and forwards them to the
* internal GlitchTip/Sentry instance.
*
* This hides the real DSN from the client and bypasses ad-blockers
* that target Sentry's default ingest endpoints.
*/
export async function POST(request: NextRequest) {
const services = getServerAppServices();
const logger = services.logger.child({ component: 'sentry-relay' });
try {
const envelope = await request.text();
// Sentry envelopes can contain multiple parts separated by newlines
const lines = envelope.split('\n');
if (lines.length < 1) {
return NextResponse.json({ error: 'Empty envelope' }, { status: 400 });
}
const header = JSON.parse(lines[0]);
const realDsn = config.errors.glitchtip.dsn;
if (!realDsn) {
logger.warn('Sentry relay received but no SENTRY_DSN configured on server');
return NextResponse.json({ status: 'ignored' }, { status: 200 });
}
const dsnUrl = new URL(realDsn);
const projectId = dsnUrl.pathname.replace('/', '');
const relayUrl = `${dsnUrl.protocol}//${dsnUrl.host}/api/${projectId}/envelope/`;
logger.debug('Relaying Sentry envelope', {
projectId,
host: dsnUrl.host,
});
const response = await fetch(relayUrl, {
method: 'POST',
body: envelope,
headers: {
'Content-Type': 'application/x-sentry-envelope',
},
});
if (!response.ok) {
const errorText = await response.text();
logger.error('Sentry/GlitchTip API responded with error', {
status: response.status,
error: errorText.slice(0, 100),
});
return new NextResponse(errorText, { status: response.status });
}
return NextResponse.json({ status: 'ok' });
} catch (error) {
logger.error('Failed to relay Sentry request', {
error: (error as Error).message,
});
return NextResponse.json({ error: 'Internal Server Error' }, { status: 500 });
}
}

24
app/global-error.tsx Normal file
View File

@@ -0,0 +1,24 @@
'use client';
import * as Sentry from '@sentry/nextjs';
import Error from 'next/error';
import { useEffect } from 'react';
export default function GlobalError({
error,
}: {
error: Error & { digest?: string };
}) {
useEffect(() => {
Sentry.captureException(error);
}, [error]);
return (
<html>
<body>
{/* This is the default Next.js error component but it doesn't allow omitting the statusCode */}
<Error statusCode={0} />
</body>
</html>
);
}

View File

@@ -1,5 +1,9 @@
import { getServerAppServices } from '@/lib/services/create-services.server';
export const dynamic = 'force-dynamic';
export async function GET() {
const services = getServerAppServices();
services.logger.debug('Health check requested');
return new Response('OK', { status: 200 });
}

30
app/manifest.ts Normal file
View File

@@ -0,0 +1,30 @@
import { MetadataRoute } from 'next';
export default function manifest(): MetadataRoute.Manifest {
return {
name: 'KLZ Cables',
short_name: 'KLZ',
description: 'Premium Cable Solutions',
start_url: '/',
display: 'standalone',
background_color: '#001a4d',
theme_color: '#001a4d',
icons: [
{
src: '/favicon.ico',
sizes: 'any',
type: 'image/x-icon',
},
{
src: '/logo.png',
sizes: '512x512',
type: 'image/png',
},
{
src: '/apple-touch-icon.png',
sizes: '180x180',
type: 'image/png',
},
],
};
}

20
app/robots.ts Normal file
View File

@@ -0,0 +1,20 @@
import { config } from '@/lib/config';
import { MetadataRoute } from 'next';
export default function robots(): MetadataRoute.Robots {
const baseUrl = config.baseUrl || 'https://klz-cables.com';
return {
rules: [
{
userAgent: '*',
allow: '/',
disallow: ['/api/', '/health/'],
},
{
userAgent: ['GPTBot', 'ClaudeBot', 'PerplexityBot', 'Google-Extended', 'OAI-SearchBot'],
allow: '/',
},
],
sitemap: `${baseUrl}/sitemap.xml`,
};
}

81
app/sitemap.ts Normal file
View File

@@ -0,0 +1,81 @@
import { config } from '@/lib/config';
import { MetadataRoute } from 'next';
import { getAllProductsMetadata } from '@/lib/mdx';
import { getAllPostsMetadata } from '@/lib/blog';
import { getAllPagesMetadata } from '@/lib/pages';
export const revalidate = 3600; // Revalidate every hour
export default async function sitemap(): Promise<MetadataRoute.Sitemap> {
const baseUrl = config.baseUrl || 'https://klz-cables.com';
const locales = ['de', 'en'];
const routes = [
'',
'/blog',
'/contact',
'/team',
'/products',
'/products/low-voltage-cables',
'/products/medium-voltage-cables',
'/products/high-voltage-cables',
'/products/solar-cables',
];
const sitemapEntries: MetadataRoute.Sitemap = [];
for (const locale of locales) {
// Static routes
for (const route of routes) {
sitemapEntries.push({
url: `${baseUrl}/${locale}${route}`,
lastModified: new Date(),
changeFrequency: route === '' ? 'daily' : 'weekly',
priority: route === '' ? 1 : 0.8,
});
}
// Products
const productsMetadata = await getAllProductsMetadata(locale);
for (const product of productsMetadata) {
if (!product.frontmatter || !product.slug) continue;
const category =
product.frontmatter.categories[0]?.toLowerCase().replace(/\s+/g, '-') || 'other';
sitemapEntries.push({
url: `${baseUrl}/${locale}/products/${category}/${product.slug}`,
lastModified: new Date(),
changeFrequency: 'monthly',
priority: 0.7,
});
}
// Blog posts
const postsMetadata = await getAllPostsMetadata(locale);
for (const post of postsMetadata) {
if (!post.frontmatter || !post.slug) continue;
sitemapEntries.push({
url: `${baseUrl}/${locale}/blog/${post.slug}`,
lastModified: new Date(post.frontmatter.date),
changeFrequency: 'monthly',
priority: 0.6,
});
}
// Static pages
const pagesMetadata = await getAllPagesMetadata(locale);
for (const page of pagesMetadata) {
if (!page.slug) continue;
sitemapEntries.push({
url: `${baseUrl}/${locale}/${page.slug}`,
lastModified: new Date(),
changeFrequency: 'monthly',
priority: 0.5,
});
}
}
return sitemapEntries;
}

View File

@@ -0,0 +1,73 @@
import { NextRequest, NextResponse } from 'next/server';
import { getServerAppServices } from '@/lib/services/create-services.server';
import { config } from '@/lib/config';
/**
* Smart Proxy for Umami Analytics.
*
* This Route Handler receives tracking events from the browser,
* injects the secret UMAMI_WEBSITE_ID, and forwards them to the
* internal Umami API endpoint.
*
* This ensures:
* 1. The Website ID is NOT leaked to the client bundle.
* 2. The Umami API endpoint is hidden behind our domain.
* 3. We have full control over the tracking data.
*/
export async function POST(request: NextRequest) {
const services = getServerAppServices();
const logger = services.logger.child({ component: 'umami-smart-proxy' });
try {
const body = await request.json();
const { type, payload } = body;
// Inject the secret websiteId from server config
const websiteId = config.analytics.umami.websiteId;
if (!websiteId) {
logger.warn('Umami tracking received but no Website ID configured on server');
return NextResponse.json({ status: 'ignored' }, { status: 200 });
}
// Prepare the enhanced payload with the secret ID
const enhancedPayload = {
...payload,
website: websiteId,
};
const umamiEndpoint = config.analytics.umami.apiEndpoint;
// Log the event (internal only)
logger.debug('Forwarding analytics event', {
type,
url: payload.url,
website: websiteId.slice(0, 8) + '...',
});
const response = await fetch(`${umamiEndpoint}/api/send`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'User-Agent': request.headers.get('user-agent') || 'KLZ-Smart-Proxy',
'X-Forwarded-For': request.headers.get('x-forwarded-for') || '',
},
body: JSON.stringify({ type, payload: enhancedPayload }),
});
if (!response.ok) {
const errorText = await response.text();
logger.error('Umami API responded with error', {
status: response.status,
error: errorText.slice(0, 100),
});
return new NextResponse(errorText, { status: response.status });
}
return NextResponse.json({ status: 'ok' });
} catch (error) {
logger.error('Failed to proxy analytics request', {
error: (error as Error).message,
});
return NextResponse.json({ error: 'Internal Server Error' }, { status: 500 });
}
}

8
commitlint.config.js Normal file
View File

@@ -0,0 +1,8 @@
module.exports = {
extends: ['@commitlint/config-conventional'],
rules: {
'header-max-length': [2, 'always', 500],
'subject-case': [0],
'subject-full-stop': [0],
},
};

View File

@@ -0,0 +1,84 @@
'use client';
import React, { useEffect, useState } from 'react';
import { AlertCircle, RefreshCw, Database } from 'lucide-react';
import { config } from '../lib/config';
export default function CMSConnectivityNotice() {
const [status, setStatus] = useState<'checking' | 'ok' | 'error'>('checking');
const [errorMsg, setErrorMsg] = useState('');
const [isVisible, setIsVisible] = useState(false);
useEffect(() => {
// Only show if we've detected an issue AND we are in a context where we want to see it
const checkCMS = async () => {
const isDebug = new URLSearchParams(window.location.search).has('cms_debug');
const isLocal = config.isDevelopment;
const isTesting = config.isTesting;
// Only proceed with check if it's developer context (Local or Testing)
// Staging and Production should NEVER see this unless forced with ?cms_debug
if (!isLocal && !isTesting && !isDebug) return;
try {
const response = await fetch('/api/health/cms');
const data = await response.json();
if (data.status !== 'ok') {
setStatus('error');
setErrorMsg(data.message);
setIsVisible(true);
} else {
setStatus('ok');
setIsVisible(false);
}
} catch (err) {
// If it's a connection error, only show if we are really debugging
if (isDebug || isLocal) {
setStatus('error');
setErrorMsg('Could not connect to CMS health endpoint');
setIsVisible(true);
}
}
};
checkCMS();
}, []);
if (!isVisible) return null;
return (
<div className="fixed bottom-4 right-4 z-[9999] animate-slide-up">
<div className="bg-red-500/90 backdrop-blur-md border border-red-400 text-white p-4 rounded-2xl shadow-2xl max-w-sm">
<div className="flex items-start gap-3">
<div className="bg-white/20 p-2 rounded-lg">
<AlertCircle className="w-5 h-5" />
</div>
<div className="flex-1">
<h4 className="font-bold text-sm mb-1">CMS Issue Detected</h4>
<p className="text-xs opacity-90 leading-relaxed mb-3">
{errorMsg === 'relation "products" does not exist'
? 'The database schema is missing. Please sync your local data to this environment.'
: errorMsg || 'The application cannot connect to the Directus CMS.'}
</p>
<div className="flex gap-2">
<button
onClick={() => window.location.reload()}
className="bg-white text-red-600 text-[10px] font-bold uppercase tracking-wider px-3 py-1.5 rounded-lg flex items-center gap-2 hover:bg-neutral-100 transition-colors"
>
<RefreshCw className="w-3 h-3" />
Retry
</button>
<button
onClick={() => setIsVisible(false)}
className="bg-black/20 text-white text-[10px] font-bold uppercase tracking-wider px-3 py-1.5 rounded-lg hover:bg-black/30 transition-colors"
>
Dismiss
</button>
</div>
</div>
</div>
</div>
</div>
);
}

183
components/ContactForm.tsx Normal file
View File

@@ -0,0 +1,183 @@
'use client';
import React, { useState } from 'react';
import { useTranslations } from 'next-intl';
import { Button, Heading, Card, Input, Textarea, Label } from '@/components/ui';
import { sendContactFormAction } from '@/app/actions/contact';
import { useAnalytics } from '@/components/analytics/useAnalytics';
export default function ContactForm() {
const t = useTranslations('Contact');
const { trackEvent } = useAnalytics();
const [status, setStatus] = useState<'idle' | 'submitting' | 'success' | 'error'>('idle');
async function handleSubmit(e: React.FormEvent<HTMLFormElement>) {
e.preventDefault();
setStatus('submitting');
const formData = new FormData(e.currentTarget);
const email = formData.get('email') as string;
try {
const result = await sendContactFormAction(formData);
if (result?.success) {
trackEvent('contact_form_submission', {
form_type: 'general',
email,
});
setStatus('success');
(e.target as HTMLFormElement).reset();
} else {
console.error('Contact form submission failed:', { email, error: result.error });
setStatus('error');
}
} catch (error) {
console.error('Contact form submission error:', { email, error });
setStatus('error');
}
}
if (status === 'success') {
return (
<Card className="p-6 md:p-12 rounded-2xl md:rounded-[40px] border-none shadow-2xl text-center">
<div className="w-20 h-20 bg-accent rounded-full flex items-center justify-center mx-auto mb-6 shadow-lg shadow-accent/20">
<svg
className="w-10 h-10 text-primary-dark"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={3} d="M5 13l4 4L19 7" />
</svg>
</div>
<Heading level={3} className="mb-4">
{t('form.successTitle') || 'Message Sent!'}
</Heading>
<p className="text-text-secondary text-lg mb-8">
{t('form.successDesc') ||
'Thank you for your message. We will get back to you as soon as possible.'}
</p>
<Button onClick={() => setStatus('idle')} variant="saturated">
{t('form.sendAnother') || 'Send another message'}
</Button>
</Card>
);
}
if (status === 'error') {
return (
<Card className="p-6 md:p-12 rounded-2xl md:rounded-[40px] border-destructive/20 shadow-2xl text-center bg-destructive/5 animate-slide-up">
<div className="w-20 h-20 bg-destructive rounded-full flex items-center justify-center mx-auto mb-6 shadow-lg shadow-destructive/20">
<svg
className="w-10 h-10 text-destructive-foreground"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
strokeWidth="2"
>
<circle cx="12" cy="12" r="10" />
<line x1="15" y1="9" x2="9" y2="15" strokeLinecap="round" strokeLinejoin="round" />
<line x1="9" y1="9" x2="15" y2="15" strokeLinecap="round" strokeLinejoin="round" />
</svg>
</div>
<Heading level={3} className="mb-4 text-destructive font-black">
{t('form.errorTitle') || 'Submission Failed!'}
</Heading>
<p className="text-destructive/80 text-lg mb-8 leading-relaxed font-medium">
{t('form.error') || 'Something went wrong. Please check your input and try again.'}
</p>
<Button
onClick={() => setStatus('idle')}
variant="destructive"
size="lg"
className="w-full"
>
{t('form.tryAgain') || 'Try Again'}
</Button>
</Card>
);
}
return (
<Card className="p-6 md:p-12 rounded-2xl md:rounded-[40px] border-none shadow-2xl animate-slide-up">
<Heading level={3} subtitle={t('form.subtitle')} className="mb-6 md:mb-10">
{t('form.title')}
</Heading>
<form onSubmit={handleSubmit} className="grid grid-cols-1 md:grid-cols-2 gap-4 md:gap-8">
<div className="space-y-1 md:space-y-2">
<Label htmlFor="name">{t('form.name')}</Label>
<Input
type="text"
id="name"
name="name"
autoComplete="name"
enterKeyHint="next"
placeholder={t('form.namePlaceholder')}
required
/>
</div>
<div className="space-y-1 md:space-y-2">
<Label htmlFor="email">{t('form.email')}</Label>
<Input
type="email"
id="email"
name="email"
autoComplete="email"
inputMode="email"
enterKeyHint="next"
placeholder={t('form.emailPlaceholder')}
required
/>
</div>
<div className="md:col-span-2 space-y-1 md:space-y-2">
<Label htmlFor="message">{t('form.message')}</Label>
<Textarea
id="message"
name="message"
rows={4}
enterKeyHint="send"
placeholder={t('form.messagePlaceholder')}
required
/>
</div>
<div className="md:col-span-2 pt-2 md:pt-4">
<Button
type="submit"
variant="saturated"
size="lg"
disabled={status === 'submitting'}
className="w-full shadow-xl shadow-saturated/20 md:h-16 md:px-10 md:text-xl active:scale-[0.98] transition-transform"
>
{status === 'submitting' ? (
<span className="flex items-center gap-2">
<svg
className="animate-spin h-5 w-5 text-white"
xmlns="http://www.w3.org/2000/svg"
fill="none"
viewBox="0 0 24 24"
>
<circle
className="opacity-25"
cx="12"
cy="12"
r="10"
stroke="currentColor"
strokeWidth="4"
></circle>
<path
className="opacity-75"
fill="currentColor"
d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"
></path>
</svg>
{t('form.submitting') || 'Sending...'}
</span>
) : (
t('form.submit')
)}
</Button>
</div>
</form>
</Card>
);
}

23
components/ContactMap.tsx Normal file
View File

@@ -0,0 +1,23 @@
'use client';
import React from 'react';
import dynamic from 'next/dynamic';
const LeafletMap = dynamic(() => import('@/components/LeafletMap'), {
ssr: false,
loading: () => (
<div className="h-full w-full bg-neutral-medium flex items-center justify-center">
<div className="animate-pulse text-primary font-medium">Loading Map...</div>
</div>
),
});
interface ContactMapProps {
address: string;
lat: number;
lng: number;
}
export default function ContactMap({ address, lat, lng }: ContactMapProps) {
return <LeafletMap address={address} lat={lat} lng={lng} />;
}

View File

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

View File

@@ -1,7 +1,7 @@
import Link from 'next/link';
import Image from 'next/image';
import { useTranslations, useLocale } from 'next-intl';
import { Container, Heading } from './ui';
import { Container } from './ui';
export default function Footer() {
const t = useTranslations('Footer');
@@ -10,7 +10,7 @@ export default function Footer() {
const currentYear = new Date().getFullYear();
return (
<footer className="bg-primary-dark text-white py-24 relative overflow-hidden">
<footer className="bg-primary text-white py-24 relative overflow-hidden">
<div className="absolute top-0 left-0 w-full h-px bg-gradient-to-r from-transparent via-white/20 to-transparent" />
<Container>
@@ -27,11 +27,11 @@ export default function Footer() {
unoptimized
/>
</Link>
<p className="text-white/60 text-lg leading-relaxed max-w-sm">
<p className="text-white/60 text-base md:text-lg leading-relaxed max-w-sm">
{t('tagline')}
</p>
<div className="flex gap-4">
<a href="https://www.linkedin.com/company/klz-vertriebs-gmbh/" target="_blank" rel="noopener noreferrer" className="w-12 h-12 rounded-full bg-white/5 flex items-center justify-center hover:bg-accent hover:text-primary-dark transition-all duration-300 border border-white/10">
<a href="https://www.linkedin.com/company/klz-vertriebs-gmbh/" target="_blank" rel="noopener noreferrer" className="w-12 h-12 rounded-full bg-white/5 flex items-center justify-center text-white hover:bg-accent hover:text-primary-dark transition-all duration-300 border border-white/10">
<span className="sr-only">LinkedIn</span>
<svg className="w-5 h-5 fill-current" viewBox="0 0 24 24">
<path d="M19 0h-14c-2.761 0-5 2.239-5 5v14c0 2.761 2.239 5 5 5h14c2.762 0 5-2.239 5-5v-14c0-2.761-2.238-5-5-5zm-11 19h-3v-11h3v11zm-1.5-12.268c-.966 0-1.75-.79-1.75-1.764s.784-1.764 1.75-1.764 1.75.79 1.75 1.764-.783 1.764-1.75 1.764zm13.5 12.268h-3v-5.604c0-3.368-4-3.113-4 0v5.604h-3v-11h3v1.765c1.396-2.586 7-2.777 7 2.476v6.759z"/>
@@ -42,36 +42,50 @@ export default function Footer() {
{/* Links Columns */}
<div className="lg:col-span-2">
<h4 className="text-accent font-bold uppercase tracking-widest text-sm mb-8">{t('legal')}</h4>
<ul className="space-y-4 text-white/70">
<li><Link href={`/${locale}/${t('legalNoticeSlug')}`} className="hover:text-white transition-colors">{t('legalNotice')}</Link></li>
<li><Link href={`/${locale}/${t('privacyPolicySlug')}`} className="hover:text-white transition-colors">{t('privacyPolicy')}</Link></li>
<li><Link href={`/${locale}/${t('termsSlug')}`} className="hover:text-white transition-colors">{t('terms')}</Link></li>
<h4 className="text-accent font-bold uppercase tracking-widest text-xs md:text-sm mb-8">{t('legal')}</h4>
<ul className="space-y-4 text-white/70 list-none m-0 p-0">
<li><Link href={`/${locale}/${t('legalNoticeSlug')}`} className="text-white/70 hover:text-accent transition-all duration-300 hover:translate-x-1 inline-block">{t('legalNotice')}</Link></li>
<li><Link href={`/${locale}/${t('privacyPolicySlug')}`} className="text-white/70 hover:text-accent transition-all duration-300 hover:translate-x-1 inline-block">{t('privacyPolicy')}</Link></li>
<li><Link href={`/${locale}/${t('termsSlug')}`} className="text-white/70 hover:text-accent transition-all duration-300 hover:translate-x-1 inline-block">{t('terms')}</Link></li>
</ul>
</div>
<div className="lg:col-span-2">
<h4 className="text-accent font-bold uppercase tracking-widest text-sm mb-8">{t('company')}</h4>
<ul className="space-y-4 text-white/70">
<li><Link href={`/${locale}/team`} className="hover:text-white transition-colors">{navT('team')}</Link></li>
<li><Link href={`/${locale}/products`} className="hover:text-white transition-colors">{navT('products')}</Link></li>
<li><Link href={`/${locale}/blog`} className="hover:text-white transition-colors">{navT('blog')}</Link></li>
<li><Link href={`/${locale}/contact`} className="hover:text-white transition-colors">{navT('contact')}</Link></li>
<h4 className="text-accent font-bold uppercase tracking-widest text-xs md:text-sm mb-8">{t('company')}</h4>
<ul className="space-y-4 text-white/70 list-none m-0 p-0">
<li><Link href={`/${locale}/team`} className="text-white/70 hover:text-accent transition-all duration-300 hover:translate-x-1 inline-block">{navT('team')}</Link></li>
<li><Link href={`/${locale}/products`} className="text-white/70 hover:text-accent transition-all duration-300 hover:translate-x-1 inline-block">{navT('products')}</Link></li>
<li><Link href={`/${locale}/blog`} className="text-white/70 hover:text-accent transition-all duration-300 hover:translate-x-1 inline-block">{navT('blog')}</Link></li>
<li><Link href={`/${locale}/contact`} className="text-white/70 hover:text-accent transition-all duration-300 hover:translate-x-1 inline-block">{navT('contact')}</Link></li>
</ul>
</div>
{/* Recent Posts Column */}
<div className="lg:col-span-4">
<h4 className="text-accent font-bold uppercase tracking-widest text-sm mb-8">{t('recentPosts')}</h4>
<ul className="space-y-6">
<h4 className="text-accent font-bold uppercase tracking-widest text-xs md:text-sm mb-8">{t('recentPosts')}</h4>
<ul className="space-y-6 list-none m-0 p-0">
{[
"Focus on wind farm construction: three typical cable challenges",
"Why the N2XS(F)2Y is the ideal cable for your energy project"
{
title: locale === 'de'
? "Windparkbau im Fokus: drei typische Kabelherausforderungen"
: "Focus on wind farm construction: three typical cable challenges",
slug: locale === 'de'
? "windparkbau-im-fokus-drei-typische-kabelherausforderungen"
: "focus-on-wind-farm-construction-three-typical-cable-challenges"
},
{
title: locale === 'de'
? "Warum das N2XS(F)2Y das ideale Kabel für Ihr Energieprojekt ist"
: "Why the N2XS(F)2Y is the ideal cable for your energy project",
slug: locale === 'de'
? "n2xsf2y-mittelspannungskabel-energieprojekt"
: "why-the-n2xsf2y-is-the-ideal-cable-for-your-energy-project"
}
].map((post, i) => (
<li key={i}>
<Link href="#" className="group block">
<p className="text-white/80 font-bold group-hover:text-accent transition-colors leading-snug mb-2">
{post}
<Link href={`/${locale}/blog/${post.slug}`} className="group block text-white/80">
<p className="text-white/80 font-bold group-hover:text-accent transition-colors leading-snug mb-2 text-base md:text-base">
{post.title}
</p>
<span className="text-xs text-white/40 uppercase tracking-widest">{t('readArticle')} &rarr;</span>
</Link>
@@ -81,11 +95,11 @@ export default function Footer() {
</div>
</div>
<div className="pt-12 border-t border-white/10 flex flex-col md:flex-row justify-between items-center gap-8 text-white/40 text-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/40 text-xs md:text-sm font-medium">
<p>{t('copyright', { year: currentYear })}</p>
<div className="flex gap-8">
<Link href="/en" className="hover:text-white transition-colors">English</Link>
<Link href="/de" className="hover:text-white transition-colors">Deutsch</Link>
<Link href="/en" locale="en" className="hover:text-white transition-colors">English</Link>
<Link href="/de" locale="de" className="hover:text-white transition-colors">Deutsch</Link>
</div>
</div>
</Container>

View File

@@ -2,6 +2,7 @@
import Link from 'next/link';
import Image from 'next/image';
import { motion } from 'framer-motion';
import { useTranslations } from 'next-intl';
import { usePathname } from 'next/navigation';
import { Button } from './ui';
@@ -13,10 +14,10 @@ export default function Header() {
const pathname = usePathname();
const [isScrolled, setIsScrolled] = useState(false);
const [isMobileMenuOpen, setIsMobileMenuOpen] = useState(false);
// Extract locale from pathname
const currentLocale = pathname.split('/')[1] || 'en';
// Check if homepage
const isHomePage = pathname === `/${currentLocale}` || pathname === '/';
@@ -31,8 +32,10 @@ export default function Header() {
// Close mobile menu on route change
useEffect(() => {
setIsMobileMenuOpen(false);
}, [pathname]);
if (isMobileMenuOpen) {
setIsMobileMenuOpen(false);
}
}, [pathname, isMobileMenuOpen]);
// Prevent scroll when mobile menu is open
useEffect(() => {
@@ -42,7 +45,7 @@ export default function Header() {
document.body.style.overflow = 'unset';
}
}, [isMobileMenuOpen]);
// Function to get path for a different locale
const getPathForLocale = (newLocale: string) => {
const segments = pathname.split('/');
@@ -58,150 +61,344 @@ export default function Header() {
];
const headerClass = cn(
"fixed top-0 left-0 right-0 z-50 transition-all duration-500 safe-area-p",
'fixed top-0 left-0 right-0 z-50 transition-all duration-500 safe-area-p transform-gpu',
{
"bg-transparent py-4 md:py-8": isHomePage && !isScrolled && !isMobileMenuOpen,
"bg-primary py-3 md:py-4 shadow-2xl": !isHomePage || isScrolled || isMobileMenuOpen,
}
'bg-transparent py-4 md:py-8': isHomePage && !isScrolled && !isMobileMenuOpen,
'bg-primary py-3 md:py-4 shadow-2xl': !isHomePage || isScrolled || isMobileMenuOpen,
},
);
const textColorClass = "text-white";
const logoSrc = "/logo-white.svg";
const textColorClass = 'text-white';
const logoSrc = '/logo-white.svg';
return (
<>
<header className={headerClass}>
<motion.header
className={headerClass}
initial={{ y: -100, opacity: 0 }}
animate={{ y: 0, opacity: 1 }}
transition={{ duration: 0.8, ease: 'easeOut' }}
>
<div className="container mx-auto px-4 md:px-12 lg:px-16 max-w-7xl flex items-center justify-between">
<Link href={`/${currentLocale}`} className="flex-shrink-0 group touch-target">
<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
unoptimized
/>
</Link>
<motion.div
className="flex-shrink-0 group touch-target"
initial={{ scale: 0.8, opacity: 0 }}
animate={{ scale: 1, opacity: 1 }}
transition={{ duration: 0.6, ease: 'easeOut', delay: 0.1 }}
>
<Link href={`/${currentLocale}`}>
<Image
src={logoSrc}
alt={t('home')}
width={120}
height={120}
className="h-10 md:h-14 w-auto transition-all duration-500 group-hover:scale-110"
priority
unoptimized
/>
</Link>
</motion.div>
<div className="flex items-center gap-4 md:gap-12">
<nav className="hidden lg:flex items-center space-x-10">
{menuItems.map((item) => (
<Link
key={item.href}
href={`/${currentLocale}${item.href === '/' ? '' : item.href}`}
className={cn(
textColorClass,
"hover:text-accent font-bold transition-all text-lg tracking-tight relative group"
)}
>
{item.label}
<span className="absolute -bottom-2 left-0 w-0 h-1 bg-accent transition-all duration-300 group-hover:w-full rounded-full" />
</Link>
<motion.div
className="flex items-center gap-4 md:gap-12"
initial="hidden"
animate="visible"
variants={{
visible: {
transition: {
staggerChildren: 0.08,
delayChildren: 0.3,
},
},
}}
>
<motion.nav className="hidden lg:flex items-center space-x-10" variants={navVariants}>
{menuItems.map((item, idx) => (
<motion.div key={item.href} variants={navLinkVariants}>
<Link
href={`/${currentLocale}${item.href === '/' ? '' : item.href}`}
className={cn(
textColorClass,
'hover:text-accent font-bold transition-all duration-500 text-base md:text-lg tracking-tight relative group inline-block hover:-translate-y-0.5',
)}
>
{item.label}
<span className="absolute -bottom-2 left-0 w-0 h-1 bg-accent transition-all duration-500 group-hover:w-full rounded-full shadow-[0_0_12px_rgba(130,237,32,0.6)]" />
</Link>
</motion.div>
))}
</nav>
</motion.nav>
<div className={cn("hidden lg:flex items-center space-x-8", textColorClass)}>
<div className="flex items-center space-x-4 text-sm font-extrabold tracking-widest uppercase">
<Link
href={getPathForLocale('en')}
className={`hover:text-accent transition-colors flex items-center gap-2 touch-target ${currentLocale === 'en' ? 'text-accent' : 'opacity-60'}`}
>
EN
</Link>
<div className="w-px h-4 bg-current opacity-20" />
<Link
href={getPathForLocale('de')}
className={`hover:text-accent transition-colors flex items-center gap-2 touch-target ${currentLocale === 'de' ? 'text-accent' : 'opacity-60'}`}
>
DE
</Link>
</div>
<Button
href={`/${currentLocale}/contact`}
variant="white"
size="md"
className="px-8 shadow-xl"
<motion.div
className={cn('hidden lg:flex items-center space-x-8', textColorClass)}
variants={headerRightVariants}
>
<motion.div
className="flex items-center space-x-4 text-xs md:text-sm font-extrabold tracking-widest uppercase"
initial={{ opacity: 0, x: 20 }}
animate={{ opacity: 1, x: 0 }}
transition={{ duration: 0.5, delay: 0.6 }}
>
{t('contact')}
</Button>
</div>
<motion.div
initial={{ opacity: 0, scale: 0.8 }}
animate={{ opacity: 1, scale: 1 }}
transition={{ duration: 0.4, delay: 0.65 }}
>
<Link
href={getPathForLocale('en')}
className={`hover:text-accent transition-colors flex items-center gap-2 touch-target ${currentLocale === 'en' ? 'text-accent' : 'opacity-60'}`}
>
EN
</Link>
</motion.div>
<motion.div
className="w-px h-4 bg-current opacity-20"
initial={{ scaleY: 0 }}
animate={{ scaleY: 1 }}
transition={{ duration: 0.4, delay: 0.7 }}
/>
<motion.div
initial={{ opacity: 0, scale: 0.8 }}
animate={{ opacity: 1, scale: 1 }}
transition={{ duration: 0.4, delay: 0.75 }}
>
<Link
href={getPathForLocale('de')}
className={`hover:text-accent transition-colors flex items-center gap-2 touch-target ${currentLocale === 'de' ? 'text-accent' : 'opacity-60'}`}
>
DE
</Link>
</motion.div>
</motion.div>
<motion.div
initial={{ scale: 0.9, opacity: 0 }}
animate={{ scale: 1, opacity: 1 }}
transition={{ duration: 0.6, type: 'spring', stiffness: 400, delay: 0.7 }}
>
<Button
href={`/${currentLocale}/contact`}
variant="white"
size="md"
className="px-8 shadow-xl"
>
{t('contact')}
</Button>
</motion.div>
</motion.div>
{/* Mobile Menu Button */}
<button
onClick={() => setIsMobileMenuOpen(!isMobileMenuOpen)}
className={cn("lg:hidden touch-target p-2 rounded-xl bg-white/10 backdrop-blur-md border border-white/20 z-50", textColorClass)}
<motion.button
className={cn(
'lg:hidden touch-target p-2 rounded-xl bg-white/10 border border-white/20 z-50',
textColorClass,
)}
aria-label={t('toggleMenu')}
initial={{ scale: 0.8, opacity: 0, rotate: -180 }}
animate={{ scale: 1, opacity: 1, rotate: 0 }}
transition={{
duration: 0.6,
type: 'spring',
stiffness: 300,
damping: 20,
delay: 0.5,
}}
onClick={() => setIsMobileMenuOpen(!isMobileMenuOpen)}
>
<svg className="w-7 h-7" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<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 ? (
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
<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 }}
/>
) : (
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M4 6h16M4 12h16M4 18h16" />
<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 }}
/>
)}
</svg>
</button>
</div>
</motion.svg>
</motion.button>
</motion.div>
</div>
{/* Mobile Menu Overlay */}
<div className={cn(
"fixed inset-0 bg-primary-dark 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"
)}>
<div className="flex-grow flex flex-col justify-center items-center p-8 space-y-8">
<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) => (
<Link
key={item.href}
href={`/${currentLocale}${item.href === '/' ? '' : item.href}`}
className={cn(
"text-3xl font-extrabold text-white hover:text-accent transition-all transform",
isMobileMenuOpen ? "translate-y-0 opacity-100" : "translate-y-10 opacity-0"
)}
style={{ transitionDelay: `${idx * 100}ms` }}
<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,
},
},
}}
>
{item.label}
</Link>
<Link
href={`/${currentLocale}${item.href === '/' ? '' : item.href}`}
className="text-2xl md:text-3xl font-extrabold text-white hover:text-accent transition-all transform block py-4"
>
{item.label}
</Link>
</motion.div>
))}
<div className={cn(
"pt-8 border-t border-white/10 w-full flex flex-col items-center space-y-8 transition-all duration-500 delay-500",
isMobileMenuOpen ? "translate-y-0 opacity-100" : "translate-y-10 opacity-0"
)}>
<div className="flex items-center space-x-8 text-xl font-extrabold tracking-widest uppercase text-white">
<Link
href={getPathForLocale('en')}
className={`hover:text-accent transition-colors ${currentLocale === 'en' ? 'text-accent' : 'opacity-60'}`}
>
EN
</Link>
<div className="w-px h-6 bg-white/20" />
<Link
href={getPathForLocale('de')}
className={`hover:text-accent transition-colors ${currentLocale === 'de' ? 'text-accent' : 'opacity-60'}`}
>
DE
</Link>
</div>
<Button
href={`/${currentLocale}/contact`}
variant="accent"
size="lg"
className="w-full max-w-xs py-6 text-xl shadow-2xl"
<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 }}
animate={{ opacity: 1, scale: 1 }}
transition={{ duration: 0.4, delay: 0.9 }}
>
{t('contact')}
</Button>
</div>
</div>
{/* Bottom Branding */}
<div className="p-12 flex justify-center opacity-20">
<Image src="/logo-white.svg" alt={t('home')} width={80} height={80} unoptimized />
</div>
<motion.div
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
transition={{ duration: 0.3, delay: 1.0 }}
>
<Link
href={getPathForLocale('en')}
className={`hover:text-accent transition-colors ${currentLocale === 'en' ? 'text-accent' : 'opacity-60'}`}
>
EN
</Link>
</motion.div>
<motion.div
className="w-px h-6 bg-white/20"
initial={{ scaleX: 0 }}
animate={{ scaleX: 1 }}
transition={{ duration: 0.4, delay: 1.05 }}
/>
<motion.div
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
transition={{ duration: 0.3, delay: 1.1 }}
>
<Link
href={getPathForLocale('de')}
className={`hover:text-accent transition-colors ${currentLocale === 'de' ? 'text-accent' : 'opacity-60'}`}
>
DE
</Link>
</motion.div>
</motion.div>
<motion.div
initial={{ scale: 0.9, opacity: 0, y: 20 }}
animate={{ scale: 1, opacity: 1, y: 0 }}
transition={{ type: 'spring', stiffness: 400, damping: 20, delay: 1.2 }}
>
<Button
href={`/${currentLocale}/contact`}
variant="accent"
size="lg"
className="w-full max-w-xs py-6 text-lg md:text-xl shadow-2xl"
>
{t('contact')}
</Button>
</motion.div>
</motion.div>
{/* Bottom Branding */}
<motion.div
className="p-12 flex justify-center opacity-20"
initial={{ opacity: 0, scale: 0.8 }}
animate={isMobileMenuOpen ? { opacity: 0.2, scale: 1 } : { opacity: 0, scale: 0.8 }}
transition={{ duration: 0.5, delay: 1.4 }}
>
<motion.div
initial={{ scale: 0.5 }}
animate={{ scale: 1 }}
transition={{ type: 'spring', stiffness: 300, delay: 1.5 }}
>
<Image src="/logo-white.svg" alt={t('home')} width={80} height={80} unoptimized />
</motion.div>
</motion.div>
</motion.div>
</div>
</header>
</motion.header>
</>
);
}
const navVariants = {
hidden: { opacity: 0 },
visible: {
opacity: 1,
transition: {
staggerChildren: 0.06,
delayChildren: 0.1,
},
},
} as const;
const navLinkVariants = {
hidden: { opacity: 0, y: 20, scale: 0.9 },
visible: {
opacity: 1,
y: 0,
scale: 1,
transition: {
duration: 0.5,
ease: 'easeOut',
},
},
} as const;
const headerRightVariants = {
hidden: { opacity: 0, x: 30 },
visible: {
opacity: 1,
x: 0,
transition: { duration: 0.6, ease: 'easeOut' },
},
} as const;

51
components/JsonLd.tsx Normal file
View File

@@ -0,0 +1,51 @@
import { Thing, WithContext } from 'schema-dts';
interface JsonLdProps {
id?: string;
data?: WithContext<Thing> | WithContext<Thing>[];
}
export default function JsonLd({ id, data }: JsonLdProps) {
// If data is provided, use it. Otherwise, use the default Organization + WebSite schema.
const schemaData = data || [
{
'@context': 'https://schema.org',
'@type': 'Organization',
name: 'KLZ Cables',
url: 'https://klz-cables.com',
logo: 'https://klz-cables.com/logo-blue.svg',
sameAs: [
'https://www.linkedin.com/company/klz-cables',
],
description: 'Premium Cable Solutions for Renewable Energy and Infrastructure.',
address: {
'@type': 'PostalAddress',
addressCountry: 'DE',
},
},
{
'@context': 'https://schema.org',
'@type': 'WebSite',
name: 'KLZ Cables',
url: 'https://klz-cables.com',
potentialAction: {
'@type': 'SearchAction',
target: {
'@type': 'EntryPoint',
urlTemplate: 'https://klz-cables.com/search?q={search_term_string}',
},
'query-input': 'required name=search_term_string',
},
}
];
return (
<script
id={id}
type="application/ld+json"
dangerouslySetInnerHTML={{
__html: JSON.stringify(schemaData),
}}
/>
);
}

45
components/LeafletMap.tsx Normal file
View File

@@ -0,0 +1,45 @@
'use client';
import { MapContainer, TileLayer, Marker, Popup } from 'react-leaflet';
import L from 'leaflet';
import 'leaflet/dist/leaflet.css';
// Fix for default marker icon in Leaflet with Next.js
const DefaultIcon = L.icon({
iconUrl: 'https://unpkg.com/leaflet@1.9.4/dist/images/marker-icon.png',
shadowUrl: 'https://unpkg.com/leaflet@1.9.4/dist/images/marker-shadow.png',
iconSize: [25, 41],
iconAnchor: [12, 41],
});
L.Marker.prototype.options.icon = DefaultIcon;
interface LeafletMapProps {
address: string;
lat: number;
lng: number;
}
export default function LeafletMap({ address, lat, lng }: LeafletMapProps) {
const position: [number, number] = [lat, lng];
return (
<MapContainer
center={position}
zoom={15}
scrollWheelZoom={false}
className="h-full w-full z-0"
>
<TileLayer
attribution='&copy; <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a> contributors'
url="https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png"
/>
<Marker position={position}>
<Popup>
<div className="text-primary font-bold">KLZ Cables</div>
<div className="text-sm whitespace-pre-line">{address}</div>
</Popup>
</Marker>
</MapContainer>
);
}

211
components/Lightbox.tsx Normal file
View File

@@ -0,0 +1,211 @@
'use client';
import React, { useEffect, useState, useCallback } from 'react';
import Image from 'next/image';
import { createPortal } from 'react-dom';
import { motion, AnimatePresence } from 'framer-motion';
import { useRouter, useSearchParams, usePathname } from 'next/navigation';
interface LightboxProps {
isOpen: boolean;
images: string[];
initialIndex: number;
onClose: () => void;
}
export default function Lightbox({ isOpen, images, initialIndex, onClose }: LightboxProps) {
const router = useRouter();
const searchParams = useSearchParams();
const pathname = usePathname();
const [currentIndex, setCurrentIndex] = useState(initialIndex);
const [mounted, setMounted] = useState(false);
useEffect(() => {
setMounted(true);
return () => setMounted(false);
}, []);
const updateUrl = useCallback(
(index: number | null) => {
const params = new URLSearchParams(searchParams.toString());
if (index !== null) {
params.set('photo', index.toString());
} else {
params.delete('photo');
}
router.replace(`${pathname}?${params.toString()}`, { scroll: false });
},
[pathname, router, searchParams],
);
const prevImage = useCallback(() => {
setCurrentIndex((prev) => {
const next = prev === 0 ? images.length - 1 : prev - 1;
updateUrl(next);
return next;
});
}, [images.length, updateUrl]);
const nextImage = useCallback(() => {
setCurrentIndex((prev) => {
const next = prev === images.length - 1 ? 0 : prev + 1;
updateUrl(next);
return next;
});
}, [images.length, updateUrl]);
useEffect(() => {
const photoParam = searchParams.get('photo');
if (photoParam !== null) {
const index = parseInt(photoParam, 10);
if (!isNaN(index) && index >= 0 && index < images.length) {
setCurrentIndex(index);
}
}
}, [searchParams, images.length]);
const handleClose = useCallback(() => {
updateUrl(null);
onClose();
}, [updateUrl, onClose]);
useEffect(() => {
if (isOpen) {
updateUrl(currentIndex);
}
}, [isOpen, currentIndex, updateUrl]);
useEffect(() => {
if (!isOpen) return;
const handleKeyDown = (e: KeyboardEvent) => {
if (e.key === 'Escape') handleClose();
if (e.key === 'ArrowLeft') prevImage();
if (e.key === 'ArrowRight') nextImage();
};
// Lock scroll
const originalStyle = window.getComputedStyle(document.body).overflow;
document.body.style.overflow = 'hidden';
window.addEventListener('keydown', handleKeyDown);
return () => {
document.body.style.overflow = originalStyle;
window.removeEventListener('keydown', handleKeyDown);
};
}, [isOpen, prevImage, nextImage, handleClose]);
if (!mounted) return null;
return createPortal(
<AnimatePresence>
{isOpen && (
<div className="fixed inset-0 z-[99999] flex items-center justify-center">
<motion.div
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
transition={{ duration: 0.5 }}
className="absolute inset-0 bg-primary/95 backdrop-blur-xl"
onClick={handleClose}
/>
<motion.button
initial={{ opacity: 0, scale: 0.5 }}
animate={{ opacity: 1, scale: 1 }}
exit={{ opacity: 0, scale: 0.5 }}
transition={{ delay: 0.1, duration: 0.4 }}
onClick={handleClose}
className="absolute top-6 right-6 text-white/60 hover:text-white transition-all duration-500 z-[10000] rounded-full w-14 h-14 flex items-center justify-center hover:bg-white/5 group border border-white/10"
aria-label="Close lightbox"
>
<div className="relative w-full h-full flex items-center justify-center group-hover:rotate-90 transition-transform duration-500">
<span className="text-3xl font-extralight leading-none mb-1">×</span>
</div>
</motion.button>
<motion.button
initial={{ opacity: 0, x: -20 }}
animate={{ opacity: 1, x: 0 }}
exit={{ opacity: 0, x: -20 }}
transition={{ delay: 0.2, duration: 0.4 }}
onClick={prevImage}
className="absolute left-6 top-1/2 -translate-y-1/2 text-white/60 hover:text-white transition-all duration-500 w-14 h-14 flex items-center justify-center hover:bg-white/5 rounded-full z-[10000] group border border-white/10"
aria-label="Previous image"
>
<span className="text-4xl font-extralight group-hover:-translate-x-1 transition-transform duration-500">
</span>
</motion.button>
<motion.button
initial={{ opacity: 0, x: 20 }}
animate={{ opacity: 1, x: 0 }}
exit={{ opacity: 0, x: 20 }}
transition={{ delay: 0.2, duration: 0.4 }}
onClick={nextImage}
className="absolute right-6 top-1/2 -translate-y-1/2 text-white/60 hover:text-white transition-all duration-500 w-14 h-14 flex items-center justify-center hover:bg-white/5 rounded-full z-[10000] group border border-white/10"
aria-label="Next image"
>
<span className="text-4xl font-extralight group-hover:translate-x-1 transition-transform duration-500">
</span>
</motion.button>
<motion.div
initial={{ opacity: 0, y: 40, scale: 0.95 }}
animate={{ opacity: 1, y: 0, scale: 1 }}
exit={{ opacity: 0, y: 20, scale: 0.98 }}
transition={{ duration: 0.6, ease: [0.25, 0.46, 0.45, 0.94] }}
className="relative w-full h-full max-w-6xl max-h-[85vh] flex flex-col items-center justify-center p-4 md:p-12 z-20 pointer-events-none"
>
<div className="pointer-events-auto w-full h-full flex flex-col items-center justify-center">
<div className="relative w-full h-full shadow-[0_40px_100px_-20px_rgba(0,0,0,0.6)] ring-1 ring-white/20 overflow-hidden bg-primary-dark/50 rounded-2xl flex items-center justify-center">
<AnimatePresence mode="wait" initial={false}>
<motion.div
key={currentIndex}
initial={{ opacity: 0, scale: 1.1, filter: 'blur(10px)' }}
animate={{ opacity: 1, scale: 1, filter: 'blur(0px)' }}
exit={{ opacity: 0, scale: 0.9, filter: 'blur(10px)' }}
transition={{ duration: 0.7, ease: [0.25, 0.46, 0.45, 0.94] }}
className="relative w-full h-full"
>
<Image
src={images[currentIndex]}
alt={`Gallery image ${currentIndex + 1}`}
fill
className="object-cover transition-transform duration-1000 hover:scale-[1.03]"
unoptimized
/>
</motion.div>
</AnimatePresence>
{/* Technical Detail: Subtle grid overlay to reinforce industrial precision */}
<div className="absolute inset-0 pointer-events-none opacity-[0.03] bg-[url('/grid.svg')] bg-repeat z-10" />
{/* Premium Reflection: Subtle gradient to give material feel */}
<div className="absolute inset-0 pointer-events-none bg-gradient-to-tr from-white/10 via-transparent to-transparent opacity-40 z-10" />
</div>
<motion.div
initial={{ opacity: 0, y: 10 }}
animate={{ opacity: 1, y: 0 }}
exit={{ opacity: 0, y: 10 }}
transition={{ delay: 0.3, duration: 0.4 }}
className="mt-8 flex items-center gap-4"
>
<div className="h-px w-12 bg-white/20" />
<div className="bg-white/5 backdrop-blur-2xl text-white px-6 py-2 rounded-full border border-white/10 text-[11px] font-bold tracking-[0.2em] uppercase">
{currentIndex + 1} <span className="text-accent mx-3">/</span> {images.length}
</div>
<div className="h-px w-12 bg-white/20" />
</motion.div>
</div>
</motion.div>
</div>
)}
</AnimatePresence>,
document.body,
);
}

View File

@@ -0,0 +1,185 @@
import React from 'react';
interface OGImageTemplateProps {
title: string;
description?: string;
label?: string;
image?: string;
mode?: 'dark' | 'light' | 'image';
}
export function OGImageTemplate({
title,
description,
label,
image,
mode = 'dark',
}: OGImageTemplateProps) {
const primaryBlue = '#001a4d';
const accentGreen = '#82ed20';
const saturatedBlue = '#011dff';
const containerStyle: React.CSSProperties = {
height: '100%',
width: '100%',
display: 'flex',
flexDirection: 'column',
alignItems: 'flex-start',
justifyContent: 'center',
backgroundColor: mode === 'light' ? '#ffffff' : primaryBlue,
padding: '80px',
position: 'relative',
fontFamily: 'Inter',
};
return (
<div style={containerStyle}>
{/* Background Image with Overlay */}
{image && (
<div
style={{
position: 'absolute',
top: 0,
left: 0,
right: 0,
bottom: 0,
display: 'flex',
}}
>
<img
src={image}
alt=""
width="1200"
height="630"
style={{
width: '100%',
height: '100%',
objectFit: 'cover',
}}
/>
<div
style={{
position: 'absolute',
top: 0,
left: 0,
right: 0,
bottom: 0,
background: 'linear-gradient(to right, rgba(0,26,77,0.95), rgba(0,26,77,0.6))',
}}
/>
</div>
)}
{/* Decorative Brand Accent (Top Right) */}
<div
style={{
position: 'absolute',
top: '-150px',
right: '-150px',
width: '600px',
height: '600px',
borderRadius: '300px',
backgroundColor: `${accentGreen}15`,
display: 'flex',
}}
/>
<div style={{ display: 'flex', flexDirection: 'column', position: 'relative', zIndex: 10 }}>
{/* Label / Category */}
{label && (
<div
style={{
fontSize: '24px',
fontWeight: 700,
color: accentGreen,
textTransform: 'uppercase',
letterSpacing: '0.3em',
marginBottom: '32px',
display: 'flex',
}}
>
{label}
</div>
)}
{/* Title */}
<div
style={{
fontSize: title.length > 40 ? '64px' : '82px',
fontWeight: 700,
color: 'white',
lineHeight: '1.05',
maxWidth: '950px',
marginBottom: '40px',
display: 'flex',
letterSpacing: '-0.02em',
}}
>
{title}
</div>
{/* Description */}
{description && (
<div
style={{
fontSize: '32px',
color: 'rgba(255,255,255,0.7)',
maxWidth: '850px',
lineHeight: '1.4',
display: 'flex',
fontWeight: 400,
}}
>
{description.length > 160 ? description.substring(0, 157) + '...' : description}
</div>
)}
</div>
{/* Brand Footer */}
<div
style={{
position: 'absolute',
bottom: '80px',
left: '80px',
display: 'flex',
alignItems: 'center',
}}
>
<div
style={{
width: '80px',
height: '6px',
backgroundColor: accentGreen,
borderRadius: '3px',
marginRight: '24px',
}}
/>
<div
style={{
fontSize: '24px',
fontWeight: 700,
color: 'white',
textTransform: 'uppercase',
letterSpacing: '0.15em',
display: 'flex',
}}
>
KLZ Cables
</div>
</div>
{/* Saturated Blue Brand Strip */}
<div
style={{
position: 'absolute',
top: 0,
right: 0,
width: '12px',
height: '100%',
backgroundColor: saturatedBlue,
}}
/>
</div>
);
}

View File

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

View File

@@ -3,18 +3,37 @@ import React from 'react';
interface ProductTabsProps {
children: React.ReactNode;
technicalData?: React.ReactNode;
sidebar?: React.ReactNode;
}
export default function ProductTabs({ children, technicalData }: ProductTabsProps) {
export default function ProductTabs({ children, technicalData, sidebar }: ProductTabsProps) {
return (
<div className="space-y-12">
<div className="prose max-w-none">
{children}
<div className="space-y-24">
<div className="flex flex-col lg:flex-row gap-12 items-start">
<div className="flex-1 min-w-0">
{sidebar && (
<div className="lg:hidden mb-12">
{sidebar}
</div>
)}
<div className="max-w-none">
{children}
</div>
</div>
{sidebar && (
<div className="hidden lg:block sticky top-32 w-[350px] xl:w-[400px] flex-shrink-0">
{sidebar}
</div>
)}
</div>
{technicalData && (
<div className="pt-8 border-t border-neutral-dark">
<h2 className="text-2xl font-bold text-primary-dark mb-6">Technical Specifications</h2>
<div className="pt-24 border-t-2 border-neutral-dark/5">
<div className="mb-16">
<h2 className="text-3xl md:text-4xl font-black text-primary tracking-tighter uppercase mb-4">Technical Specifications</h2>
<div className="h-1.5 w-24 bg-accent rounded-full" />
</div>
<div className="not-prose">
{technicalData}
</div>

View File

@@ -1,4 +1,7 @@
import React from 'react';
'use client';
import React, { useState } from 'react';
import { useTranslations } from 'next-intl';
interface KeyValueItem {
label: string;
@@ -21,20 +24,33 @@ interface ProductTechnicalDataProps {
}
export default function ProductTechnicalData({ data }: ProductTechnicalDataProps) {
const t = useTranslations('Products');
const [expandedTables, setExpandedTables] = useState<Record<number, boolean>>({});
if (!data) return null;
const { technicalItems = [], voltageTables = [] } = data;
const toggleTable = (idx: number) => {
setExpandedTables(prev => ({
...prev,
[idx]: !prev[idx]
}));
};
return (
<div className="space-y-8">
<div className="space-y-16">
{technicalItems.length > 0 && (
<div className="bg-neutral-light p-6 rounded-lg shadow-sm">
<h3 className="text-xl font-semibold mb-4">General Data</h3>
<dl className="grid grid-cols-1 sm:grid-cols-2 gap-x-4 gap-y-4">
<div className="bg-white p-8 md:p-12 rounded-[32px] shadow-sm border border-neutral-dark/5">
<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" />
General Data
</h3>
<dl className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-x-12 gap-y-8">
{technicalItems.map((item, idx) => (
<div key={idx} className="flex flex-col border-b border-neutral-dark pb-2 last:border-0">
<dt className="text-sm text-text-secondary">{item.label}</dt>
<dd className="text-base font-medium text-text-primary">
{item.value} {item.unit && <span className="text-sm text-text-secondary ml-1">{item.unit}</span>}
<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>
<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>}
</dd>
</div>
))}
@@ -42,57 +58,84 @@ export default function ProductTechnicalData({ data }: ProductTechnicalDataProps
</div>
)}
{voltageTables.map((table, idx) => (
<div key={idx} className="bg-neutral-light p-6 rounded-lg shadow-sm overflow-hidden">
<h3 className="text-xl font-semibold mb-4">
{table.voltageLabel !== 'Voltage unknown' && table.voltageLabel !== 'Spannung unbekannt'
? table.voltageLabel
: 'Technical Specifications'}
</h3>
{table.metaItems.length > 0 && (
<dl className="grid grid-cols-1 sm:grid-cols-2 md:grid-cols-3 gap-4 mb-6 bg-neutral p-4 rounded">
{table.metaItems.map((item, mIdx) => (
<div key={mIdx}>
<dt className="text-xs text-text-secondary uppercase tracking-wider">{item.label}</dt>
<dd className="font-medium">{item.value} {item.unit}</dd>
</div>
))}
</dl>
)}
<div className="overflow-x-auto">
<table className="min-w-full divide-y divide-neutral-dark">
<thead className="bg-neutral">
<tr>
<th scope="col" className="px-3 py-3 text-left text-xs font-medium text-text-secondary uppercase tracking-wider sticky left-0 bg-neutral z-10">
Configuration
</th>
{table.columns.map((col, cIdx) => (
<th key={cIdx} scope="col" className="px-3 py-3 text-left text-xs font-medium text-text-secondary uppercase tracking-wider whitespace-nowrap">
{col.label}
</th>
))}
</tr>
</thead>
<tbody className="bg-white divide-y divide-neutral-dark">
{table.rows.map((row, rIdx) => (
<tr key={rIdx} className="hover:bg-neutral-light transition-colors">
<td className="px-3 py-3 text-sm font-medium text-text-primary sticky left-0 bg-white z-10 whitespace-nowrap">
{row.configuration}
</td>
{row.cells.map((cell, cellIdx) => (
<td key={cellIdx} className="px-3 py-3 text-sm text-text-secondary whitespace-nowrap">
{cell}
</td>
))}
</tr>
{voltageTables.map((table, idx) => {
const isExpanded = expandedTables[idx];
const hasManyRows = table.rows.length > 10;
return (
<div key={idx} className="bg-white p-8 md:p-12 rounded-[32px] shadow-sm border border-neutral-dark/5 overflow-hidden">
<h3 className="text-2xl font-bold text-primary mb-8 flex items-center gap-3">
<div className="w-2 h-8 bg-accent rounded-full" />
{table.voltageLabel !== 'Voltage unknown' && table.voltageLabel !== 'Spannung unbekannt'
? table.voltageLabel
: 'Technical Specifications'}
</h3>
{table.metaItems.length > 0 && (
<dl className="grid grid-cols-1 sm:grid-cols-2 md:grid-cols-3 lg:grid-cols-4 gap-8 mb-12 bg-neutral-light/50 p-8 rounded-2xl border border-neutral-dark/5">
{table.metaItems.map((item, mIdx) => (
<div key={mIdx}>
<dt className="text-[10px] font-black uppercase tracking-[0.2em] text-primary/40 mb-1">{item.label}</dt>
<dd className="font-bold text-primary">{item.value} {item.unit}</dd>
</div>
))}
</tbody>
</table>
</dl>
)}
<div className="relative">
<div
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]'
}`}
>
<table className="min-w-full border-separate border-spacing-0">
<thead>
<tr>
<th scope="col" className="px-3 py-3 text-left text-[10px] font-black text-primary/40 uppercase tracking-[0.2em] sticky left-0 bg-white z-10 border-b border-neutral-dark/10">
Config.
</th>
{table.columns.map((col, cIdx) => (
<th key={cIdx} scope="col" className="px-3 py-3 text-left text-[10px] font-black text-primary/40 uppercase tracking-[0.2em] whitespace-nowrap border-b border-neutral-dark/10">
{col.label}
</th>
))}
</tr>
</thead>
<tbody className="divide-y divide-neutral-dark/5">
{table.rows.map((row, rIdx) => (
<tr key={rIdx} className="hover:bg-neutral-light/50 transition-colors group">
<td className="px-3 py-2 text-xs font-bold text-primary sticky left-0 bg-white group-hover:bg-neutral-light/50 z-10 whitespace-nowrap">
{row.configuration}
</td>
{row.cells.map((cell, cellIdx) => (
<td key={cellIdx} className="px-3 py-2 text-xs text-text-secondary whitespace-nowrap">
{cell}
</td>
))}
</tr>
))}
</tbody>
</table>
</div>
{!isExpanded && hasManyRows && (
<div className="absolute bottom-0 left-0 right-0 h-32 bg-gradient-to-t from-white via-white/80 to-transparent z-20 pointer-events-none" />
)}
</div>
{hasManyRows && (
<div className="mt-8 flex justify-center">
<button
onClick={() => toggleTable(idx)}
className="px-8 py-3 rounded-full bg-primary text-white text-sm font-bold uppercase tracking-widest hover:bg-accent hover:text-primary transition-all duration-300 shadow-lg hover:shadow-accent/20"
>
{isExpanded ? t('showLess') : t('showMore')}
</button>
</div>
)}
</div>
</div>
))}
);
})}
</div>
);
}

View File

@@ -1,7 +1,8 @@
import Link from 'next/link';
import Image from 'next/image';
import { getAllProducts } from '@/lib/mdx';
import { mapFileSlugToTranslated } from '@/lib/slugs';
import { getTranslations } from 'next-intl/server';
import Image from 'next/image';
import Link from 'next/link';
interface RelatedProductsProps {
currentSlug: string;
@@ -24,7 +25,7 @@ export default async function RelatedProducts({ currentSlug, categories, locale
if (related.length === 0) return null;
return (
<div className="mt-32 pt-32 border-t border-neutral-dark/10">
<div className="">
<div className="flex items-end justify-between mb-12">
<div>
<h2 className="text-3xl md:text-4xl font-extrabold text-primary tracking-tight mb-4">
@@ -35,14 +36,23 @@ export default async function RelatedProducts({ currentSlug, categories, locale
</div>
<div className="grid grid-cols-1 md:grid-cols-3 gap-8">
{related.map((product) => {
{related.map(async (product) => {
// Find the category slug for the link
const catSlug = product.frontmatter.categories[0].toLowerCase().replace(/\s+/g, '-');
const categorySlugs = ['low-voltage-cables', 'medium-voltage-cables', 'high-voltage-cables', 'solar-cables'];
const catSlug = categorySlugs.find(slug => {
const key = slug.replace(/-cables$/, '').replace(/-([a-z])/g, (g) => g[1].toUpperCase());
const title = t(`categories.${key}.title`);
return product.frontmatter.categories.some(cat =>
cat.toLowerCase().replace(/\s+/g, '-') === slug || cat === title
);
}) || 'low-voltage-cables';
const translatedProductSlug = await mapFileSlugToTranslated(product.slug, locale);
return (
<Link
key={product.slug}
href={`/${locale}/products/${catSlug}/${product.slug}`}
href={`/${locale}/products/${catSlug}/${translatedProductSlug}`}
className="group block bg-white rounded-[32px] overflow-hidden shadow-sm hover:shadow-2xl transition-all duration-500 border border-neutral-dark/5"
>
<div className="aspect-[16/10] relative bg-neutral-light/30 p-8 overflow-hidden">

View File

@@ -2,6 +2,9 @@
import React, { useState } from 'react';
import { useTranslations } from 'next-intl';
import { Input, Textarea, Button } from '@/components/ui';
import { sendContactFormAction } from '@/app/actions/contact';
import { useAnalytics } from '@/components/analytics/useAnalytics';
interface RequestQuoteFormProps {
productName: string;
@@ -9,6 +12,7 @@ interface RequestQuoteFormProps {
export default function RequestQuoteForm({ productName }: RequestQuoteFormProps) {
const t = useTranslations('Products.form');
const { trackEvent } = useAnalytics();
const [email, setEmail] = useState('');
const [request, setRequest] = useState('');
const [status, setStatus] = useState<'idle' | 'submitting' | 'success' | 'error'>('idle');
@@ -17,32 +21,61 @@ export default function RequestQuoteForm({ productName }: RequestQuoteFormProps)
e.preventDefault();
setStatus('submitting');
// Simulate API call
await new Promise((resolve) => setTimeout(resolve, 1000));
const formData = new FormData();
formData.append('name', 'Product Inquiry'); // Default name for product inquiries
formData.append('email', email);
formData.append('message', request);
formData.append('productName', productName);
// Here you would typically send the data to your backend
console.log('Form submitted:', { productName, email, request });
setStatus('success');
setEmail('');
setRequest('');
try {
const result = await sendContactFormAction(formData);
if (result.success) {
trackEvent('contact_form_submission', {
form_type: 'product_quote',
product_name: productName,
email: email,
});
setStatus('success');
setEmail('');
setRequest('');
} else {
setStatus('error');
}
} catch (error) {
console.error('Form submission error:', error);
setStatus('error');
}
};
if (status === 'success') {
return (
<div className="bg-accent/5 border border-accent/20 text-primary-dark p-6 rounded-[32px] text-center animate-fade-in">
<div className="w-12 h-12 bg-accent rounded-full flex items-center justify-center mx-auto mb-4 shadow-lg shadow-accent/20">
<svg className="w-6 h-6 text-primary-dark" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={3} d="M5 13l4 4L19 7" />
</svg>
<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="flex justify-center mb-3">
<div className="w-10 h-10 bg-accent rounded-full flex items-center justify-center shadow-lg shadow-accent/20">
<svg
className="w-5 h-5 text-primary-dark"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={3}
d="M5 13l4 4L19 7"
/>
</svg>
</div>
</div>
<h3 className="text-xl font-extrabold mb-2 tracking-tight">{t('successTitle')}</h3>
<p className="text-text-secondary text-sm leading-relaxed mb-6">
<h3 className="text-base font-extrabold mb-1 tracking-tight !mt-0 text-center w-full">
{t('successTitle')}
</h3>
<p className="text-text-secondary text-xs leading-tight mb-4 !mt-0 text-center w-full">
{t('successDesc', { productName })}
</p>
<button
<button
onClick={() => setStatus('idle')}
className="inline-flex items-center text-[10px] font-bold uppercase tracking-[0.2em] text-primary hover:text-accent transition-colors group"
className="inline-flex items-center text-[9px] font-bold uppercase tracking-[0.2em] text-primary hover:text-accent transition-colors group"
>
<span className="border-b-2 border-primary/10 group-hover:border-accent transition-colors pb-1">
{t('sendAnother')}
@@ -52,59 +85,121 @@ export default function RequestQuoteForm({ productName }: RequestQuoteFormProps)
);
}
if (status === 'error') {
return (
<div className="bg-destructive/5 border border-destructive/20 text-destructive p-4 rounded-xl text-center animate-fade-in !mt-0 w-full">
<div className="flex justify-center mb-3">
<div className="w-10 h-10 bg-destructive rounded-full flex items-center justify-center shadow-lg shadow-destructive/20">
<svg
className="w-5 h-5 text-destructive-foreground"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
strokeWidth="3"
>
<circle cx="12" cy="12" r="10" />
<line x1="15" y1="9" x2="9" y2="15" />
<line x1="9" y1="9" x2="15" y2="15" />
</svg>
</div>
</div>
<h3 className="text-base font-extrabold mb-1 tracking-tight !mt-0 text-destructive text-center w-full">
{t('errorTitle') || 'Submission Failed'}
</h3>
<p className="text-destructive/80 text-xs leading-tight mb-4 !mt-0 text-center w-full">
{t('errorDesc') || 'Something went wrong. Please try again.'}
</p>
<Button
onClick={() => setStatus('idle')}
variant="destructive"
size="sm"
className="w-full"
>
{t('tryAgain') || 'Try Again'}
</Button>
</div>
);
}
return (
<form onSubmit={handleSubmit} className="space-y-6">
<div className="space-y-4">
<div className="space-y-1.5">
<input
<form onSubmit={handleSubmit} className="space-y-3 !mt-0">
<div className="space-y-2 !mt-0">
<div className="space-y-1 !mt-0">
<Input
type="email"
id="email"
required
value={email}
onChange={(e) => setEmail(e.target.value)}
className="w-full px-4 py-3 bg-neutral-light/50 border border-neutral-dark/10 rounded-xl focus:outline-none focus:ring-2 focus:ring-accent/20 focus:border-accent focus:bg-white transition-all duration-300 placeholder:text-neutral-dark/40 text-sm"
placeholder={t('email')}
className="h-9 text-xs !mt-0"
/>
</div>
<div className="space-y-1.5">
<textarea
<div className="space-y-1 !mt-0">
<Textarea
id="request"
required
rows={4}
rows={3}
value={request}
onChange={(e) => setRequest(e.target.value)}
className="w-full px-4 py-3 bg-neutral-light/50 border border-neutral-dark/10 rounded-xl focus:outline-none focus:ring-2 focus:ring-accent/20 focus:border-accent focus:bg-white transition-all duration-300 placeholder:text-neutral-dark/40 text-sm resize-none"
placeholder={t('message')}
className="text-xs !mt-0"
/>
</div>
</div>
<div className="space-y-4">
<button
<div className="space-y-2 !mt-0">
<Button
type="submit"
disabled={status === 'submitting'}
className="w-full bg-primary text-white font-bold py-3.5 px-6 rounded-xl hover:bg-primary-dark hover:shadow-lg active:scale-[0.98] transition-all duration-300 disabled:opacity-50 disabled:cursor-not-allowed flex items-center justify-center gap-2 group"
className="w-full py-2 rounded-lg flex items-center justify-center gap-2 group !mt-0"
>
{status === 'submitting' ? (
<>
<svg className="animate-spin h-4 w-4 text-white" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24">
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4"></circle>
<path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
<svg
className="animate-spin h-3 w-3 text-white"
xmlns="http://www.w3.org/2000/svg"
fill="none"
viewBox="0 0 24 24"
>
<circle
className="opacity-25"
cx="12"
cy="12"
r="10"
stroke="currentColor"
strokeWidth="4"
></circle>
<path
className="opacity-75"
fill="currentColor"
d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"
></path>
</svg>
<span className="text-sm">{t('submitting')}</span>
<span className="text-xs">{t('submitting')}</span>
</>
) : (
<>
<span className="text-sm">{t('submit')}</span>
<svg className="w-4 h-4 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" />
<span className="text-xs">{t('submit')}</span>
<svg
className="w-3 h-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>
</>
)}
</button>
<p className="text-[8px] text-center text-text-secondary/40 uppercase tracking-[0.15em] font-medium px-2">
</Button>
<p className="text-[7px] text-center text-text-secondary/40 uppercase tracking-[0.15em] font-medium px-2 !mt-1 !mb-0">
{t('privacyNote')}
</p>
</div>

View File

@@ -25,13 +25,14 @@ export default function Reveal({ children, className, threshold = 0.1, delay = 0
{ threshold }
);
if (ref.current) {
observer.observe(ref.current);
const currentRef = ref.current;
if (currentRef) {
observer.observe(currentRef);
}
return () => {
if (ref.current) {
observer.unobserve(ref.current);
if (currentRef) {
observer.unobserve(currentRef);
}
};
}, [threshold]);

View File

@@ -1,4 +1,7 @@
'use client';
import React from 'react';
import { motion, Variants } from 'framer-motion';
import { cn } from '@/components/ui';
interface ScribbleProps {
@@ -8,6 +11,18 @@ interface ScribbleProps {
}
export default function Scribble({ variant, className, color = '#82ed20' }: ScribbleProps) {
const pathVariants: Variants = {
hidden: { pathLength: 0, opacity: 0 },
visible: {
pathLength: 1,
opacity: 1,
transition: {
duration: 1.8,
ease: "easeInOut",
}
}
};
if (variant === 'circle') {
return (
<svg
@@ -16,12 +31,14 @@ export default function Scribble({ variant, className, color = '#82ed20' }: Scri
viewBox="0 0 800 350"
preserveAspectRatio="none"
>
<path
style={{ animationDuration: '1.8s' }}
<motion.path
variants={pathVariants}
initial="hidden"
whileInView="visible"
viewport={{ once: true }}
transform="matrix(0.9791300296783447,0,0,0.9791300296783447,400,179)"
strokeLinejoin="miter"
fillOpacity="0"
pathLength="1"
strokeMiterlimit="4"
stroke={color}
strokeOpacity="1"
@@ -40,11 +57,13 @@ export default function Scribble({ variant, className, color = '#82ed20' }: Scri
viewBox="-400 -55 730 60"
preserveAspectRatio="none"
>
<path
style={{ animationDuration: '1.8s' }}
<motion.path
variants={pathVariants}
initial="hidden"
whileInView="visible"
viewport={{ once: true }}
d="m -383.25 -6 c 55.25 -22 130.75 -33.5 293.25 -38 c 54.5 -0.5 195 -2.5 401 15"
stroke={color}
pathLength="1"
strokeWidth="20"
fill="none"
/>

View File

@@ -0,0 +1,35 @@
'use client';
import { useEffect } from 'react';
import { usePathname, useSearchParams } from 'next/navigation';
import { getAppServices } from '@/lib/services/create-services';
/**
* AnalyticsProvider Component
*
* Automatically tracks pageviews on client-side route changes.
* This component handles navigation events for the Umami analytics service.
*
* Note: Website ID is now centrally managed on the server side via a proxy,
* so it's no longer needed as a prop here.
*/
export default function AnalyticsProvider() {
const pathname = usePathname();
const searchParams = useSearchParams();
useEffect(() => {
if (!pathname) return;
const services = getAppServices();
const url = `${pathname}${searchParams?.size ? `?${searchParams.toString()}` : ''}`;
// Track pageview with the full URL
// The service will relay this to our internal proxy which injects the Website ID
services.analytics.trackPageview(url);
// Services like logger are already sub-initialized in getAppServices()
// so we don't need to log here manually.
}, [pathname, searchParams]);
return null;
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,166 @@
# Umami Analytics Quick Reference
## Setup Checklist
- [ ] Add `NEXT_PUBLIC_UMAMI_WEBSITE_ID` to `.env` file
- [ ] Verify `UmamiScript` is in your layout
- [ ] Verify `AnalyticsProvider` is in your layout
- [ ] Test in development mode
- [ ] Check Umami dashboard for data
## Environment Variables
```bash
# Required
NEXT_PUBLIC_UMAMI_WEBSITE_ID=59a7db94-0100-4c7e-98ef-99f45b17f9c3
# Optional (defaults to https://analytics.infra.mintel.me/script.js)
NEXT_PUBLIC_UMAMI_SCRIPT_URL=https://analytics.infra.mintel.me/script.js
```
## Quick Usage Examples
### Track an Event
```tsx
'use client';
import { useAnalytics } from '@/components/analytics/useAnalytics';
import { AnalyticsEvents } from '@/components/analytics/analytics-events';
function MyComponent() {
const { trackEvent } = useAnalytics();
const handleClick = () => {
trackEvent(AnalyticsEvents.BUTTON_CLICK, {
button_id: 'my-button',
page: 'homepage',
});
};
return <button onClick={handleClick}>Click Me</button>;
}
```
### Track a Pageview
```tsx
'use client';
import { useAnalytics } from '@/components/analytics/useAnalytics';
function MyComponent() {
const { trackPageview } = useAnalytics();
const navigate = () => {
trackPageview('/custom-path');
// Navigate...
};
return <button onClick={navigate}>Go to Page</button>;
}
```
### Track E-commerce Events
```tsx
'use client';
import { useAnalytics } from '@/components/analytics/useAnalytics';
import { AnalyticsEvents } from '@/components/analytics/analytics-events';
function ProductCard({ product }) {
const { trackEvent } = useAnalytics();
const addToCart = () => {
trackEvent(AnalyticsEvents.PRODUCT_ADD_TO_CART, {
product_id: product.id,
product_name: product.name,
price: product.price,
});
};
return <button onClick={addToCart}>Add to Cart</button>;
}
```
## Common Events
| Event | When to Use | Example Properties |
|-------|-------------|-------------------|
| `pageview` | Page loads | `{ url: '/products/123' }` |
| `button_click` | Button clicked | `{ button_id: 'cta', page: 'homepage' }` |
| `form_submit` | Form submitted | `{ form_id: 'contact', form_name: 'Contact Us' }` |
| `product_view` | Product page viewed | `{ product_id: '123', price: 99.99 }` |
| `product_add_to_cart` | Add to cart | `{ product_id: '123', quantity: 1 }` |
| `search` | Search performed | `{ search_query: 'cable', results: 42 }` |
| `user_login` | User logged in | `{ user_email: 'user@example.com' }` |
| `error` | Error occurred | `{ error_message: 'Something went wrong' }` |
## Testing
### Development Mode
In development, you'll see console logs:
```
[Umami] Tracked event: button_click { button_id: 'my-button' }
[Umami] Tracked pageview: /products/123
```
### Disable Analytics (Development)
```bash
# .env.local
# NEXT_PUBLIC_UMAMI_WEBSITE_ID=
```
## Troubleshooting
### Analytics Not Working?
1. **Check environment variables:**
```bash
echo $NEXT_PUBLIC_UMAMI_WEBSITE_ID
```
2. **Verify script is loading:**
- Open DevTools → Network tab
- Look for `script.js` request
- Check Console for errors
3. **Check Umami dashboard:**
- Log into Umami
- Verify website ID matches
- Check if data is being received
### Common Issues
| Issue | Solution |
|-------|----------|
| No data in Umami | Check website ID and script URL |
| Events not tracking | Verify `useAnalytics` hook is used |
| Script not loading | Check network connection, CORS |
| Wrong data | Verify event properties are correct |
## Performance Tips
1. **Use `useCallback` for event handlers** to prevent unnecessary re-renders
2. **Debounce high-frequency events** (like search input)
3. **Don't track every interaction** - focus on meaningful events
4. **Use environment variables** to disable analytics in development
## Privacy & Compliance
- ✅ Don't track PII (personally identifiable information)
- ✅ Don't track sensitive data (passwords, credit cards)
- ✅ Follow GDPR and other privacy regulations
- ✅ Use anonymized IDs where possible
- ✅ Provide cookie consent if required
## Next Steps
1. Read [`README.md`](README.md) for detailed documentation
2. Check [`EXAMPLES.md`](EXAMPLES.md) for more use cases
3. Review [`analytics-events.ts`](analytics-events.ts) for event definitions
4. Explore [`useAnalytics.ts`](useAnalytics.ts) for the hook implementation

View File

@@ -0,0 +1,443 @@
# Umami Analytics Integration
This project uses [Umami Analytics](https://umami.is/) for privacy-focused website analytics. The implementation is modern, clean, and follows Next.js best practices.
## Overview
The analytics system consists of:
1. **`UmamiScript`** - Loads the Umami tracking script
2. **`AnalyticsProvider`** - Tracks pageviews on route changes
3. **`useAnalytics`** - Custom hook for tracking custom events
4. **`analytics-events.ts`** - Centralized event definitions
5. **`UmamiAnalyticsService`** - Service layer for analytics operations
## Setup
### Environment Variables
Add these to your `.env` file:
```bash
# Required: Your Umami website ID
NEXT_PUBLIC_UMAMI_WEBSITE_ID=59a7db94-0100-4c7e-98ef-99f45b17f9c3
# Optional: Custom Umami script URL (defaults to https://analytics.infra.mintel.me/script.js)
NEXT_PUBLIC_UMAMI_SCRIPT_URL=https://analytics.infra.mintel.me/script.js
```
### Docker Compose
The `docker-compose.yml` already includes the environment variables:
```yaml
environment:
- NEXT_PUBLIC_UMAMI_WEBSITE_ID=${NEXT_PUBLIC_UMAMI_WEBSITE_ID}
- NEXT_PUBLIC_UMAMI_SCRIPT_URL=${NEXT_PUBLIC_UMAMI_SCRIPT_URL:-https://analytics.infra.mintel.me/script.js}
```
## Usage
### 1. Automatic Pageview Tracking
The `AnalyticsProvider` component automatically tracks pageviews on client-side route changes. It's already included in your layout:
```tsx
// app/[locale]/layout.tsx
<NextIntlClientProvider messages={messages} locale={locale}>
<UmamiScript />
<Header />
<main>{children}</main>
<Footer />
<AnalyticsProvider />
</NextIntlClientProvider>
```
### 2. Tracking Custom Events
Use the `useAnalytics` hook to track custom events:
```tsx
'use client';
import { useAnalytics } from '@/components/analytics/useAnalytics';
import { AnalyticsEvents } from '@/components/analytics/analytics-events';
function ProductCard({ product }) {
const { trackEvent } = useAnalytics();
const handleAddToCart = () => {
trackEvent(AnalyticsEvents.PRODUCT_ADD_TO_CART, {
product_id: product.id,
product_name: product.name,
product_category: product.category,
price: product.price,
});
};
return (
<button onClick={handleAddToCart}>
Add to Cart
</button>
);
}
```
### 3. Tracking Pageviews Manually
```tsx
'use client';
import { useAnalytics } from '@/components/analytics/useAnalytics';
function CustomNavigation() {
const { trackPageview } = useAnalytics();
const navigateToCustomPage = () => {
// Track a custom pageview
trackPageview('/custom-path?param=value');
// Then perform navigation
window.location.href = '/custom-path?param=value';
};
return <button onClick={navigateToCustomPage}>Go to Custom Page</button>;
}
```
### 4. Using Predefined Events
The `analytics-events.ts` file provides a centralized list of events:
```tsx
'use client';
import { useAnalytics } from '@/components/analytics/useAnalytics';
import { AnalyticsEvents, AnalyticsEventProperties } from '@/components/analytics/analytics-events';
function ContactForm() {
const { trackEvent } = useAnalytics();
const handleSubmit = (formData: FormData) => {
// Track form submission
trackEvent(AnalyticsEvents.CONTACT_FORM_SUBMIT, {
form_id: 'contact-form',
form_name: 'Contact Us',
form_fields: {
name: formData.get('name'),
email: formData.get('email'),
message: formData.get('message'),
},
});
};
return <form onSubmit={handleSubmit}>{/* form fields */}</form>;
}
```
### 5. E-commerce Tracking Example
```tsx
'use client';
import { useAnalytics } from '@/components/analytics/useAnalytics';
import { AnalyticsEvents } from '@/components/analytics/analytics-events';
function ProductPage({ product }) {
const { trackEvent } = useAnalytics();
// Track product view on page load
useEffect(() => {
trackEvent(AnalyticsEvents.PRODUCT_VIEW, {
product_id: product.id,
product_name: product.name,
product_category: product.category,
price: product.price,
});
}, [product]);
const handleAddToCart = () => {
trackEvent(AnalyticsEvents.PRODUCT_ADD_TO_CART, {
product_id: product.id,
product_name: product.name,
product_category: product.category,
price: product.price,
quantity: 1,
});
};
const handlePurchase = () => {
trackEvent(AnalyticsEvents.PRODUCT_PURCHASE, {
product_id: product.id,
product_name: product.name,
product_category: product.category,
price: product.price,
transaction_id: 'TXN-12345',
currency: 'EUR',
});
};
return (
<div>
<h1>{product.name}</h1>
<p>{product.description}</p>
<button onClick={handleAddToCart}>Add to Cart</button>
<button onClick={handlePurchase}>Buy Now</button>
</div>
);
}
```
### 6. Search & Filter Tracking
```tsx
'use client';
import { useAnalytics } from '@/components/analytics/useAnalytics';
import { AnalyticsEvents } from '@/components/analytics/analytics-events';
function ProductFilter() {
const { trackEvent } = useAnalytics();
const handleFilterChange = (filters: Record<string, unknown>) => {
trackEvent(AnalyticsEvents.FILTER_APPLY, {
filter_type: 'category',
filter_value: filters.category,
filter_count: Object.keys(filters).length,
});
};
const handleSearch = (query: string) => {
trackEvent(AnalyticsEvents.SEARCH, {
search_query: query,
search_results_count: 42, // You'd get this from your search results
});
};
return (
<div>
<input onChange={(e) => handleSearch(e.target.value)} />
<select onChange={(e) => handleFilterChange({ category: e.target.value })}>
{/* filter options */}
</select>
</div>
);
}
```
### 7. User Account Events
```tsx
'use client';
import { useAnalytics } from '@/components/analytics/useAnalytics';
import { AnalyticsEvents } from '@/components/analytics/analytics-events';
function LoginForm() {
const { trackEvent } = useAnalytics();
const handleLogin = (email: string) => {
trackEvent(AnalyticsEvents.USER_LOGIN, {
user_email: email,
login_method: 'email',
});
};
const handleLogout = () => {
trackEvent(AnalyticsEvents.USER_LOGOUT, {
user_id: 'user-123',
});
};
return (
<div>
<button onClick={() => handleLogin('user@example.com')}>Login</button>
<button onClick={handleLogout}>Logout</button>
</div>
);
}
```
### 8. Error Tracking
```tsx
'use client';
import { useAnalytics } from '@/components/analytics/useAnalytics';
import { AnalyticsEvents } from '@/components/analytics/analytics-events';
function ErrorBoundary({ children }) {
const { trackEvent } = useAnalytics();
const handleError = (error: Error, errorInfo: React.ErrorInfo) => {
trackEvent(AnalyticsEvents.ERROR, {
error_message: error.message,
error_stack: error.stack,
error_component: errorInfo.componentStack,
});
};
return (
<ErrorBoundary onError={handleError}>
{children}
</ErrorBoundary>
);
}
```
## Event Reference
### Common Events
| Event Name | Description | Example Properties |
|------------|-------------|-------------------|
| `pageview` | Page view | `{ url: '/products/123' }` |
| `button_click` | Button clicked | `{ button_id: 'cta-primary', page: 'homepage' }` |
| `link_click` | Link clicked | `{ link_url: '/products', link_text: 'View Products' }` |
| `form_submit` | Form submitted | `{ form_id: 'contact-form', form_name: 'Contact Us' }` |
| `product_view` | Product page viewed | `{ product_id: '123', product_name: 'Cable', price: 99.99 }` |
| `product_add_to_cart` | Product added to cart | `{ product_id: '123', quantity: 1 }` |
| `product_purchase` | Product purchased | `{ product_id: '123', transaction_id: 'TXN-123' }` |
| `search` | Search performed | `{ search_query: 'cable', search_results_count: 42 }` |
| `filter_apply` | Filter applied | `{ filter_type: 'category', filter_value: 'high-voltage' }` |
| `user_login` | User logged in | `{ user_email: 'user@example.com' }` |
| `user_signup` | User signed up | `{ user_email: 'user@example.com' }` |
| `error` | Error occurred | `{ error_message: 'Something went wrong' }` |
### Custom Events
You can create any custom event by passing a string name:
```tsx
trackEvent('custom_event_name', {
custom_property: 'value',
another_property: 123,
});
```
## Best Practices
### 1. Use Centralized Event Definitions
Always use the `AnalyticsEvents` constant for consistency:
```tsx
// ✅ Good
import { AnalyticsEvents } from '@/components/analytics/analytics-events';
trackEvent(AnalyticsEvents.PRODUCT_ADD_TO_CART, { ... });
// ❌ Avoid
trackEvent('product_add_to_cart', { ... }); // Typo risk!
```
### 2. Include Relevant Context
Add context to your events to make them more useful:
```tsx
// ✅ Good
trackEvent(AnalyticsEvents.BUTTON_CLICK, {
button_id: 'cta-primary',
page: 'homepage',
section: 'hero',
user_type: 'guest',
});
// ❌ Less useful
trackEvent(AnalyticsEvents.BUTTON_CLICK, {
button_id: 'cta-primary',
});
```
### 3. Track Meaningful Events
Focus on business-critical events:
- ✅ Product views, add to cart, purchases
- ✅ Form submissions (contact, newsletter, quote requests)
- ✅ Search queries and filter usage
- ✅ User authentication events
- ✅ Error occurrences
- ❌ Every mouse move
- ❌ Every scroll event (unless specifically needed)
- ❌ Every hover state change
### 4. Respect Privacy
- Don't track personally identifiable information (PII)
- Don't track sensitive data (passwords, credit cards, etc.)
- Use anonymized IDs where possible
- Follow GDPR and other privacy regulations
### 5. Test in Development
The analytics system includes development mode logging:
```bash
# In development, you'll see console logs:
[Umami] Tracked event: product_add_to_cart { product_id: '123' }
[Umami] Tracked pageview: /products/123
```
## Troubleshooting
### Analytics Not Working
1. **Check environment variables:**
```bash
echo $NEXT_PUBLIC_UMAMI_WEBSITE_ID
```
2. **Verify the script is loading:**
- Open browser DevTools
- Check Network tab for `script.js` request
- Check Console for any errors
3. **Check Umami dashboard:**
- Log into your Umami instance
- Verify the website ID matches
- Check if data is being received
### Development Mode
In development mode, you'll see console logs for all tracked events. This helps you verify that events are being tracked correctly without affecting your production analytics.
### Disabling Analytics
To disable analytics (e.g., for local development), simply remove the `NEXT_PUBLIC_UMAMI_WEBSITE_ID` environment variable:
```bash
# .env.local (not committed to git)
# NEXT_PUBLIC_UMAMI_WEBSITE_ID=
```
## Performance
The analytics implementation is optimized for performance:
- ✅ Uses Next.js `Script` component with `afterInteractive` strategy
- ✅ Script loads after page is interactive
- ✅ No blocking of critical rendering path
- ✅ Minimal JavaScript bundle size
- ✅ Automatic cleanup on route changes
## Security
- ✅ Environment variables are not exposed to the client (except `NEXT_PUBLIC_` prefixed ones)
- ✅ Script URL can be customized for self-hosted Umami instances
- ✅ Error handling prevents analytics from breaking your app
- ✅ Type-safe event tracking with TypeScript
## Additional Resources
- [Umami Documentation](https://umami.is/docs)
- [Next.js Script Component](https://nextjs.org/docs/app/api-reference/components/script)
- [Analytics Best Practices](https://umami.is/docs/best-practices)
## Support
For issues or questions about the analytics implementation, check:
1. This README for usage examples
2. The component source code for implementation details
3. The Umami documentation for platform-specific questions

View File

@@ -0,0 +1,268 @@
# Umami Analytics Implementation Summary
## ✅ Implementation Status: COMPLETE
Your project now has a **modern, clean, and comprehensive** Umami analytics implementation.
## What Was Already Implemented
The project already had a solid foundation:
1. **`UmamiScript.tsx`** - Next.js Script component for loading analytics
2. **`AnalyticsProvider.tsx`** - Automatic pageview tracking on route changes
3. **`UmamiAnalyticsService.ts`** - Service layer for event tracking
4. **Environment variables** in `docker-compose.yml`
## What Was Enhanced
### 1. **Enhanced UmamiScript** (`components/analytics/UmamiScript.tsx`)
- ✅ Added TypeScript props interface for customization
- ✅ Added JSDoc documentation with usage examples
- ✅ Added error handling for script loading failures
- ✅ Added development mode warnings
- ✅ Improved type safety and comments
### 2. **Enhanced AnalyticsProvider** (`components/analytics/AnalyticsProvider.tsx`)
- ✅ Added comprehensive JSDoc documentation
- ✅ Added development mode logging
- ✅ Improved code comments
### 3. **New Custom Hook** (`components/analytics/useAnalytics.ts`)
- ✅ Type-safe `useAnalytics` hook for easy event tracking
-`trackEvent()` method for custom events
-`trackPageview()` method for manual pageview tracking
-`useCallback` optimization for performance
- ✅ Development mode logging
### 4. **Event Definitions** (`components/analytics/analytics-events.ts`)
- ✅ Centralized event constants for consistency
- ✅ Type-safe event names
- ✅ Helper functions for common event properties
- ✅ 30+ predefined events for various use cases
### 5. **Comprehensive Documentation**
-**README.md** - Full documentation with setup, usage, and best practices
-**EXAMPLES.md** - 20+ practical examples for different scenarios
-**QUICK_REFERENCE.md** - Quick start guide and troubleshooting
-**SUMMARY.md** - This file
## File Structure
```
components/analytics/
├── UmamiScript.tsx # Script loader component
├── AnalyticsProvider.tsx # Route change tracker
├── useAnalytics.ts # Custom hook for event tracking
├── analytics-events.ts # Event definitions and helpers
├── README.md # Full documentation
├── EXAMPLES.md # Practical examples
├── QUICK_REFERENCE.md # Quick start guide
└── SUMMARY.md # This summary
```
## Key Features
### 🚀 Modern Implementation
- Uses Next.js `Script` component (not old-school `<script>` tags)
- TypeScript for type safety
- React hooks for clean API
- Environment variable configuration
### 📊 Comprehensive Tracking
- Automatic pageview tracking on route changes
- Custom event tracking with properties
- E-commerce events (products, cart, purchases)
- User authentication events
- Search and filter tracking
- Error and performance tracking
### 🎯 Developer Experience
- Type-safe event tracking
- Centralized event definitions
- Development mode logging
- Comprehensive documentation
- 20+ practical examples
### 🔒 Privacy & Performance
- No PII tracking by default
- Script loads after page is interactive
- Minimal performance impact
- Easy to disable in development
## Environment Variables
The project is already configured in `docker-compose.yml`:
```yaml
environment:
- NEXT_PUBLIC_UMAMI_WEBSITE_ID=${NEXT_PUBLIC_UMAMI_WEBSITE_ID}
- NEXT_PUBLIC_UMAMI_SCRIPT_URL=${NEXT_PUBLIC_UMAMI_SCRIPT_URL:-https://analytics.infra.mintel.me/script.js}
```
### Required Setup
Add to your `.env` file:
```bash
NEXT_PUBLIC_UMAMI_WEBSITE_ID=59a7db94-0100-4c7e-98ef-99f45b17f9c3
```
## Usage Examples
### Basic Event Tracking
```tsx
'use client';
import { useAnalytics } from '@/components/analytics/useAnalytics';
import { AnalyticsEvents } from '@/components/analytics/analytics-events';
function MyComponent() {
const { trackEvent } = useAnalytics();
const handleClick = () => {
trackEvent(AnalyticsEvents.BUTTON_CLICK, {
button_id: 'my-button',
page: 'homepage',
});
};
return <button onClick={handleClick}>Click Me</button>;
}
```
### E-commerce Tracking
```tsx
'use client';
import { useAnalytics } from '@/components/analytics/useAnalytics';
import { AnalyticsEvents } from '@/components/analytics/analytics-events';
function ProductCard({ product }) {
const { trackEvent } = useAnalytics();
const addToCart = () => {
trackEvent(AnalyticsEvents.PRODUCT_ADD_TO_CART, {
product_id: product.id,
product_name: product.name,
price: product.price,
});
};
return <button onClick={addToCart}>Add to Cart</button>;
}
```
### Custom Pageview Tracking
```tsx
'use client';
import { useAnalytics } from '@/components/analytics/useAnalytics';
function CustomNavigation() {
const { trackPageview } = useAnalytics();
const navigate = () => {
trackPageview('/custom-path');
// Navigate...
};
return <button onClick={navigate}>Go to Page</button>;
}
```
## Testing & Development
### Development Mode
In development, you'll see console logs:
```
[Umami] Tracked event: button_click { button_id: 'my-button' }
[Umami] Tracked pageview: /products/123
```
### Disable Analytics (Development)
```bash
# .env.local
# NEXT_PUBLIC_UMAMI_WEBSITE_ID=
```
## Troubleshooting
### Analytics Not Working?
1. **Check environment variables:**
```bash
echo $NEXT_PUBLIC_UMAMI_WEBSITE_ID
```
2. **Verify script is loading:**
- Open DevTools → Network tab
- Look for `script.js` request
- Check Console for errors
3. **Check Umami dashboard:**
- Log into Umami
- Verify website ID matches
- Check if data is being received
### Common Issues
| Issue | Solution |
|-------|----------|
| No data in Umami | Check website ID and script URL |
| Events not tracking | Verify `useAnalytics` hook is used |
| Script not loading | Check network connection, CORS |
| Wrong data | Verify event properties are correct |
## Performance Tips
1. **Use `useCallback`** - The hook is already optimized
2. **Debounce high-frequency events** - See EXAMPLES.md
3. **Don't track every interaction** - Focus on meaningful events
4. **Use environment variables** - Disable in development
## Privacy & Compliance
- ✅ Don't track PII (personally identifiable information)
- ✅ Don't track sensitive data (passwords, credit cards)
- ✅ Follow GDPR and other privacy regulations
- ✅ Use anonymized IDs where possible
- ✅ Provide cookie consent if required
## Next Steps
1. ✅ **Setup complete** - All files are in place
2. ✅ **Documentation complete** - README, EXAMPLES, QUICK_REFERENCE
3. ✅ **Code enhanced** - Better TypeScript, error handling, docs
4. 📝 **Add to .env** - Set `NEXT_PUBLIC_UMAMI_WEBSITE_ID`
5. 🧪 **Test in development** - Verify events are tracked
6. 🚀 **Deploy** - Analytics will work in production
## Quick Start Checklist
- [ ] Add `NEXT_PUBLIC_UMAMI_WEBSITE_ID` to `.env` file
- [ ] Verify `UmamiScript` is in `app/[locale]/layout.tsx`
- [ ] Verify `AnalyticsProvider` is in `app/[locale]/layout.tsx`
- [ ] Test in development mode (check console logs)
- [ ] Check Umami dashboard for data
- [ ] Review EXAMPLES.md for specific use cases
- [ ] Start tracking custom events with `useAnalytics`
## Summary
Your Umami analytics implementation is now **production-ready** with:
-**Modern Next.js approach** (Script component, not old-school tags)
-**Type-safe API** (TypeScript throughout)
-**Comprehensive tracking** (pageviews, events, e-commerce, errors)
-**Excellent documentation** (README, examples, quick reference)
-**Developer-friendly** (hooks, helpers, development mode)
-**Performance optimized** (async loading, minimal impact)
-**Privacy conscious** (no PII, easy to disable)
The implementation is clean, maintainable, and follows Next.js best practices. You can now track any user interaction or business event with just a few lines of code.

View File

@@ -0,0 +1,130 @@
/**
* Analytics Events Utility
*
* Centralized definitions for common analytics events and their properties.
* This helps maintain consistency across the application and makes it easier
* to track meaningful events.
*
* @example
* ```tsx
* import { useAnalytics } from '@/components/analytics/useAnalytics';
* import { AnalyticsEvents } from '@/components/analytics/analytics-events';
*
* function ProductPage() {
* const { trackEvent } = useAnalytics();
*
* const handleAddToCart = (productId: string, productName: string) => {
* trackEvent(AnalyticsEvents.PRODUCT_ADD_TO_CART, {
* product_id: productId,
* product_name: productName,
* page: 'product-detail'
* });
* };
*
* return <button onClick={() => handleAddToCart('123', 'Cable')}>Add to Cart</button>;
* }
* ```
*/
export const AnalyticsEvents = {
// Page & Navigation Events
PAGE_VIEW: 'pageview',
PAGE_SCROLL: 'page_scroll',
PAGE_EXIT: 'page_exit',
// User Interaction Events
BUTTON_CLICK: 'button_click',
LINK_CLICK: 'link_click',
FORM_SUBMIT: 'form_submit',
FORM_START: 'form_start',
FORM_ERROR: 'form_error',
// E-commerce Events
PRODUCT_VIEW: 'product_view',
PRODUCT_ADD_TO_CART: 'product_add_to_cart',
PRODUCT_REMOVE_FROM_CART: 'product_remove_from_cart',
PRODUCT_PURCHASE: 'product_purchase',
PRODUCT_WISHLIST_ADD: 'product_wishlist_add',
PRODUCT_WISHLIST_REMOVE: 'product_wishlist_remove',
// Search & Filter Events
SEARCH: 'search',
FILTER_APPLY: 'filter_apply',
FILTER_CLEAR: 'filter_clear',
// User Account Events
USER_SIGNUP: 'user_signup',
USER_LOGIN: 'user_login',
USER_LOGOUT: 'user_logout',
USER_PROFILE_UPDATE: 'user_profile_update',
// Content Events
BLOG_POST_VIEW: 'blog_post_view',
VIDEO_PLAY: 'video_play',
VIDEO_PAUSE: 'video_pause',
VIDEO_COMPLETE: 'video_complete',
DOWNLOAD: 'download',
// UI Interaction Events
MODAL_OPEN: 'modal_open',
MODAL_CLOSE: 'modal_close',
TOGGLE_SWITCH: 'toggle_switch',
ACCORDION_TOGGLE: 'accordion_toggle',
TAB_SWITCH: 'tab_switch',
// Error & Performance Events
ERROR: 'error',
PERFORMANCE: 'performance',
API_ERROR: 'api_error',
API_SUCCESS: 'api_success',
// Custom Business Events
QUOTE_REQUEST: 'quote_request',
CONTACT_FORM_SUBMIT: 'contact_form_submit',
NEWSLETTER_SUBSCRIBE: 'newsletter_subscribe',
BROCHURE_DOWNLOAD: 'brochure_download',
} as const;
/**
* Type-safe event properties for common events
*/
export type AnalyticsEventName = (typeof AnalyticsEvents)[keyof typeof AnalyticsEvents];
/**
* Common event property helpers
*/
export const AnalyticsEventProperties = {
/**
* Create properties for a product event
*/
product: (productId: string, productName: string, category?: string) => ({
product_id: productId,
product_name: productName,
product_category: category,
}),
/**
* Create properties for a form event
*/
form: (formId: string, formName: string, fields?: Record<string, unknown>) => ({
form_id: formId,
form_name: formName,
form_fields: fields,
}),
/**
* Create properties for a search event
*/
search: (query: string, filters?: Record<string, unknown>) => ({
search_query: query,
search_filters: filters,
}),
/**
* Create properties for a navigation event
*/
navigation: (from: string, to: string) => ({
from_page: from,
to_page: to,
}),
} as const;

View File

@@ -0,0 +1,77 @@
'use client';
import { useCallback } from 'react';
import { getAppServices } from '@/lib/services/create-services';
import type { AnalyticsEventProperties } from '@/lib/services/analytics/analytics-service';
/**
* Custom hook for tracking analytics events with Umami.
*
* Provides a convenient way to track custom events throughout your application.
*
* @example
* ```tsx
* import { useAnalytics } from '@/components/analytics/useAnalytics';
*
* function MyComponent() {
* const { trackEvent, trackPageview } = useAnalytics();
*
* const handleButtonClick = () => {
* trackEvent('button_click', {
* button_id: 'cta-primary',
* page: 'homepage'
* });
* };
*
* return <button onClick={handleButtonClick}>Click me</button>;
* }
* ```
*
* @example
* ```tsx
* // Track a custom pageview
* const { trackPageview } = useAnalytics();
* trackPageview('/custom-path?param=value');
* ```
*/
export function useAnalytics() {
const services = getAppServices();
/**
* Track a custom event with optional properties.
*
* @param eventName - The name of the event to track
* @param properties - Optional event properties (metadata)
*/
const trackEvent = useCallback(
(eventName: string, properties?: AnalyticsEventProperties) => {
services.analytics.track(eventName, properties);
if (process.env.NODE_ENV === 'development') {
console.log('[Umami] Tracked event:', eventName, properties);
}
},
[services]
);
/**
* Track a pageview (useful for custom navigation or SPA routing).
*
* @param url - The URL to track (defaults to current location)
*/
const trackPageview = useCallback(
(url?: string) => {
services.analytics.trackPageview(url);
if (process.env.NODE_ENV === 'development') {
console.log('[Umami] Tracked pageview:', url ?? 'current location');
}
},
[services]
);
return {
trackEvent,
trackPageview,
};
}

View File

@@ -47,24 +47,36 @@ export default function AnimatedImage({
return (
<div
ref={containerRef}
className={`relative overflow-hidden rounded-xl shadow-lg my-12 ${className}`}
className={`relative overflow-hidden rounded-2xl shadow-2xl my-16 group ${className}`}
>
<div className={`
absolute inset-0 bg-primary/10 z-10 pointer-events-none transition-opacity duration-1000
${isLoaded && isInView ? 'opacity-0' : 'opacity-100'}
`} />
<Image
src={src}
alt={alt}
width={width}
height={height}
className={`
duration-1000 ease-out w-full h-auto
${isLoaded && isInView ? 'scale-100 blur-0 opacity-100' : 'scale-110 blur-xl opacity-0'}
duration-[1.5s] ease-out w-full h-auto transition-all
${isLoaded && isInView ? 'scale-100 blur-0 opacity-100' : 'scale-110 blur-2xl opacity-0'}
group-hover:scale-105
`}
onLoad={() => setIsLoaded(true)}
priority={priority}
/>
{/* Subtle reflection overlay */}
<div className="absolute inset-0 bg-gradient-to-tr from-white/5 to-transparent opacity-0 group-hover:opacity-100 transition-opacity duration-700 pointer-events-none" />
{alt && (
<p className="text-sm text-text-secondary text-center mt-3 italic px-4 pb-4">
{alt}
</p>
<div className="absolute bottom-0 left-0 right-0 p-6 bg-gradient-to-t from-black/60 to-transparent translate-y-full group-hover:translate-y-0 transition-transform duration-500">
<p className="text-sm text-white font-medium italic">
{alt}
</p>
</div>
)}
</div>
);

View File

@@ -41,20 +41,23 @@ export default function ChatBubble({
{/* Message Bubble */}
<div className={`flex flex-col max-w-[85%] ${isRight ? 'items-end' : 'items-start'}`}>
<div className="flex items-baseline gap-2 mb-1">
<span className="text-sm font-bold text-text-primary">{author}</span>
{role && <span className="text-xs text-text-secondary">{role}</span>}
<span className="text-sm md:text-base font-bold text-text-primary">{author}</span>
{role && <span className="text-xs md:text-sm text-text-secondary">{role}</span>}
</div>
<div className={`
relative px-6 py-4 rounded-2xl shadow-sm border
relative px-8 py-6 rounded-3xl shadow-sm border transition-all duration-300 hover:shadow-md
${isRight
? 'bg-primary text-white rounded-tr-none border-primary'
? 'bg-neutral-dark text-white rounded-tr-none border-neutral-dark'
: 'bg-white text-text-primary rounded-tl-none border-neutral-200'
}
`}>
<div className={`prose prose-sm max-w-none ${isRight ? 'prose-invert' : ''}`}>
<div className={`prose prose-base md:prose-lg max-w-none ${isRight ? 'prose-invert' : ''}`}>
{children}
</div>
{/* Industrial accent dot */}
<div className={`absolute top-4 ${isRight ? 'left-4' : 'right-4'} w-1.5 h-1.5 rounded-full ${isRight ? 'bg-primary' : 'bg-primary/30'}`} />
</div>
</div>
</div>

View File

@@ -0,0 +1,50 @@
import React from 'react';
interface ComparisonGridItem {
label: string;
leftValue: string;
rightValue: string;
}
interface ComparisonGridProps {
title?: string;
leftLabel: string;
rightLabel: string;
items: ComparisonGridItem[];
}
export default function ComparisonGrid({ title, leftLabel, rightLabel, items }: ComparisonGridProps) {
return (
<div className="my-16 not-prose">
{title && (
<h3 className="text-2xl font-bold text-text-primary mb-8">
{title}
</h3>
)}
<div className="border border-neutral-200 rounded-3xl overflow-hidden shadow-sm bg-white">
<div className="grid grid-cols-3 bg-neutral-50 border-b border-neutral-200">
<div className="p-4 md:p-6"></div>
<div className="p-4 md:p-6 text-center font-bold text-primary uppercase tracking-widest border-l border-neutral-200 text-xs md:text-sm">
{leftLabel}
</div>
<div className="p-4 md:p-6 text-center font-bold text-primary uppercase tracking-widest border-l border-neutral-200 text-xs md:text-sm">
{rightLabel}
</div>
</div>
{items.map((item, index) => (
<div key={index} className="grid grid-cols-3 border-b border-neutral-200 last:border-0 hover:bg-neutral-50 transition-colors group">
<div className="p-4 md:p-6 font-bold text-text-primary flex items-center text-sm md:text-base">
{item.label}
</div>
<div className="p-4 md:p-6 text-center text-text-secondary border-l border-neutral-200 flex items-center justify-center group-hover:text-text-primary transition-colors text-sm md:text-base">
{item.leftValue}
</div>
<div className="p-4 md:p-6 text-center text-text-secondary border-l border-neutral-200 flex items-center justify-center group-hover:text-text-primary transition-colors text-sm md:text-base">
{item.rightValue}
</div>
</div>
))}
</div>
</div>
);
}

View File

@@ -1,4 +1,5 @@
import React from 'react';
import Scribble from '@/components/Scribble';
interface HighlightBoxProps {
title?: string;
@@ -7,21 +8,31 @@ interface HighlightBoxProps {
}
const colorStyles = {
primary: 'bg-gradient-to-br from-primary/10 to-primary/5 border-primary/30',
secondary: 'bg-gradient-to-br from-blue-50 to-blue-100/50 border-blue-200',
accent: 'bg-gradient-to-br from-green-50 to-green-100/50 border-green-200',
primary: 'bg-gradient-to-br from-primary/5 to-transparent border-primary/20',
secondary: 'bg-gradient-to-br from-blue-50/50 to-transparent border-blue-200/50',
accent: 'bg-gradient-to-br from-accent/5 to-transparent border-accent/20',
};
export default function HighlightBox({ title, children, color = 'primary' }: HighlightBoxProps) {
return (
<div className={`my-10 p-8 rounded-2xl border-2 ${colorStyles[color]} shadow-sm`}>
<div className={`my-12 p-8 md:p-10 rounded-3xl border ${colorStyles[color]} shadow-sm relative overflow-hidden group`}>
{/* Industrial accent corner */}
<div className="absolute top-0 right-0 w-16 h-16 bg-primary/5 -mr-8 -mt-8 rotate-45 transition-transform group-hover:scale-110" />
{title && (
<h3 className="text-2xl font-bold text-text-primary mb-4 flex items-center gap-3">
<span className="w-1.5 h-8 bg-primary rounded-full"></span>
{title}
<h3 className="text-xl md:text-2xl font-bold text-text-primary mb-6 flex items-center gap-4 relative">
<span className="relative">
{title}
{color === 'accent' && (
<Scribble
variant="underline"
className="absolute -bottom-2 left-0 w-full h-3 text-accent/40"
/>
)}
</span>
</h3>
)}
<div className="prose prose-lg max-w-none">
<div className="prose prose-base md:prose-lg max-w-none relative z-10">
{children}
</div>
</div>

View File

@@ -0,0 +1,171 @@
import Link from 'next/link';
import VisualLinkPreview from '@/components/blog/VisualLinkPreview';
import { Callout } from '@/components/ui';
import HighlightBox from '@/components/blog/HighlightBox';
import Stats from '@/components/blog/Stats';
import AnimatedImage from '@/components/blog/AnimatedImage';
import ChatBubble from '@/components/blog/ChatBubble';
import SplitHeading from '@/components/blog/SplitHeading';
import PowerCTA from '@/components/blog/PowerCTA';
import StickyNarrative from '@/components/blog/StickyNarrative';
import TechnicalGrid from '@/components/blog/TechnicalGrid';
import ComparisonGrid from '@/components/blog/ComparisonGrid';
export const mdxComponents = {
VisualLinkPreview,
Callout,
HighlightBox,
Stats,
AnimatedImage,
ChatBubble,
PowerCTA,
SplitHeading,
StickyNarrative,
TechnicalGrid,
ComparisonGrid,
h1: () => null,
a: ({ href, children, ...props }: any) => {
// Special handling for PDF downloads to make them prominent
if (href?.endsWith('.pdf')) {
return (
<a
href={href}
{...props}
target="_blank"
rel="noopener noreferrer"
className="inline-flex items-center gap-3 px-6 py-3 bg-primary text-white font-bold rounded-xl hover:bg-accent hover:text-primary-dark transition-all duration-300 no-underline my-8 group shadow-lg hover:shadow-xl hover:-translate-y-1"
>
<svg className="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 10v6m0 0l-3-3m3 3l3-3m2 8H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z" />
</svg>
<span>{children}</span>
<span className="text-xs opacity-50 font-normal group-hover:opacity-100 transition-opacity">(PDF)</span>
</a>
);
}
if (href?.startsWith('/')) {
return (
<Link href={href} {...props} className="text-primary font-medium hover:underline decoration-2 underline-offset-2 transition-all">
{children}
</Link>
);
}
return (
<a
href={href}
{...props}
target="_blank"
rel="noopener noreferrer"
className="text-primary font-medium hover:underline decoration-2 underline-offset-2 transition-all inline-flex items-center gap-1"
>
{children}
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M10 6H6a2 2 0 00-2 2v10a2 2 0 002 2h10a2 2 0 002-2v-4M14 4h6m0 0v6m0-6L10 14" />
</svg>
</a>
);
},
img: (props: any) => (
<AnimatedImage src={props.src} alt={props.alt} />
),
h2: ({ children, ...props }: any) => {
const id = typeof children === 'string'
? children.toLowerCase().replace(/[^\w\s-]/g, '').replace(/\s+/g, '-')
: props.id;
return (
<SplitHeading {...props} id={id} className="mt-16 mb-6 pb-3 border-b-2 border-primary/20">
{children}
</SplitHeading>
);
},
h3: ({ children, ...props }: any) => {
const id = typeof children === 'string'
? children.toLowerCase().replace(/[^\w\s-]/g, '').replace(/\s+/g, '-')
: props.id;
return (
<h3 {...props} id={id} className="text-2xl font-bold text-text-primary mt-12 mb-4">
{children}
</h3>
);
},
p: ({ children, ...props }: any) => (
<p {...props} className="text-lg text-text-secondary leading-relaxed mb-6">
{children}
</p>
),
ul: ({ children, ...props }: any) => (
<ul {...props} className="my-8 space-y-3">
{children}
</ul>
),
ol: ({ children, ...props }: any) => (
<ol {...props} className="my-8 space-y-3 list-decimal list-inside">
{children}
</ol>
),
li: ({ children, ...props }: any) => (
<li {...props} className="text-lg text-text-secondary flex items-start gap-3">
<span className="text-primary mt-1.5 flex-shrink-0">
<svg className="w-5 h-5" fill="currentColor" viewBox="0 0 20 20">
<path fillRule="evenodd" d="M10 18a8 8 0 100-16 8 8 0 000 16zm3.707-9.293a1 1 0 00-1.414-1.414L9 10.586 7.707 9.293a1 1 0 00-1.414 1.414l2 2a1 1 0 001.414 0l4-4z" clipRule="evenodd" />
</svg>
</span>
<span className="flex-1">{children}</span>
</li>
),
blockquote: ({ children, ...props }: any) => (
<blockquote {...props} className="my-8 pl-6 border-l-4 border-primary bg-neutral-light/30 py-4 pr-6 rounded-r-lg">
<div className="text-lg text-text-primary italic">
{children}
</div>
</blockquote>
),
strong: ({ children, ...props }: any) => (
<strong {...props} className="font-bold text-primary">
{children}
</strong>
),
code: ({ children, ...props }: any) => (
<code {...props} className="px-2 py-1 bg-neutral-light text-primary rounded font-mono text-sm">
{children}
</code>
),
pre: ({ children, ...props }: any) => (
<pre {...props} className="my-8 p-6 bg-neutral-dark/5 rounded-xl overflow-x-auto">
{children}
</pre>
),
table: ({ children, ...props }: any) => (
<div className="my-8 overflow-x-auto rounded-lg border border-neutral-200 shadow-sm">
<table {...props} className="w-full text-left text-sm text-text-secondary">
{children}
</table>
</div>
),
thead: ({ children, ...props }: any) => (
<thead {...props} className="bg-neutral-50 text-text-primary font-semibold border-b border-neutral-200">
{children}
</thead>
),
tbody: ({ children, ...props }: any) => (
<tbody {...props} className="divide-y divide-neutral-200 bg-white">
{children}
</tbody>
),
tr: ({ children, ...props }: any) => (
<tr {...props} className="hover:bg-neutral-50/50 transition-colors">
{children}
</tr>
),
th: ({ children, ...props }: any) => (
<th {...props} className="px-6 py-4 whitespace-nowrap">
{children}
</th>
),
td: ({ children, ...props }: any) => (
<td {...props} className="px-6 py-4">
{children}
</td>
),
};

View File

@@ -9,47 +9,63 @@ export default function PowerCTA({ locale }: PowerCTAProps) {
const isDe = locale === 'de';
return (
<div className="my-12 p-8 md:p-10 bg-white rounded-xl shadow-lg border border-neutral-100 relative overflow-hidden">
{/* Decorative background element */}
<div className="absolute top-0 right-0 w-32 h-32 bg-primary/5 rounded-bl-full -mr-10 -mt-10" />
<div className="my-16 p-10 md:p-16 bg-neutral-dark rounded-[2rem] shadow-2xl relative overflow-hidden group">
{/* Industrial background pattern */}
<div className="absolute inset-0 opacity-10 pointer-events-none">
<div className="absolute top-0 left-0 w-full h-full bg-[radial-gradient(#3b82f6_1px,transparent_1px)] [background-size:40px_40px]" />
</div>
{/* Decorative accent */}
<div className="absolute top-0 right-0 w-64 h-64 bg-primary/20 rounded-full blur-3xl -mr-32 -mt-32 transition-transform group-hover:scale-110 duration-1000" />
<div className="relative z-10">
<h3 className="text-2xl font-bold text-text-primary mb-4 flex items-center gap-3">
<span className="text-3xl">💡</span>
{isDe ? 'Benötigen Sie Strom?' : 'Need power?'}
<span className="text-primary">{isDe ? 'Wir sind für Sie da!' : "We've got you covered!"}</span>
<div className="inline-block px-4 py-1 bg-accent/20 text-accent text-xs font-bold uppercase tracking-[0.2em] rounded-full mb-8">
{isDe ? 'Lösungen' : 'Solutions'}
</div>
<h3 className="text-2xl md:text-4xl font-bold text-white mb-8 leading-tight">
{isDe ? 'Bereit für die' : 'Ready for the'}
<span className="text-accent block">{isDe ? 'Energiewende?' : 'Energy Transition?'}</span>
</h3>
<p className="text-lg text-text-secondary mb-6 leading-relaxed">
<p className="text-xl text-white/70 mb-10 leading-relaxed max-w-2xl">
{isDe
? 'Von der Planung von Wind- und Solarparks bis zur Lieferung hochwertiger Energiekabel wie NA2XS(F)2Y, NAYY und NA2XY erwecken wir Energienetze zum Leben.'
: 'From wind and solar park planning to delivering high-quality energy cables like NA2XS(F)2Y, NAYY, and NA2XY, we bring energy networks to life.'
? '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.'
}
</p>
<ul className="space-y-2 mb-8">
<div className="grid grid-cols-1 md:grid-cols-2 gap-6 mb-12">
{[
isDe ? 'Schnelle Lieferung dank unseres strategischen Hubs.' : 'Fast delivery thanks to our strategic hub.',
isDe ? 'Nachhaltige Lösungen für eine grünere Zukunft.' : 'Sustainable solutions for a greener tomorrow.',
isDe ? 'Vertraut von Branchenführern in Deutschland, Österreich und den Niederlanden.' : 'Trusted by industry leaders in Germany, Austria and the Netherlands.'
isDe ? 'Strategischer Hub für schnelle Lieferung' : 'Strategic hub for fast delivery',
isDe ? 'Nachhaltige Kabelinfrastruktur' : 'Sustainable cable infrastructure',
isDe ? 'Expertenberatung für Großprojekte' : 'Expert consulting for large-scale projects',
isDe ? 'Zertifizierte Qualität nach EU-Standards' : 'Certified quality according to EU standards'
].map((item, i) => (
<li key={i} className="flex items-start gap-2 text-text-secondary">
<span className="text-green-500 mt-1"></span>
<span>{item}</span>
</li>
<div key={i} className="flex items-center gap-4 text-white/80">
<div className="w-6 h-6 rounded-full bg-accent/20 flex items-center justify-center flex-shrink-0">
<svg className="w-3 h-3 text-accent" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={3} d="M5 13l4 4L19 7" />
</svg>
</div>
<span className="text-sm font-medium">{item}</span>
</div>
))}
</ul>
</div>
<div className="flex flex-col sm:flex-row gap-4 items-start sm:items-center">
<p className="font-medium text-text-primary">
{isDe ? 'Lassen Sie uns gemeinsam eine grünere Zukunft gestalten.' : "Let's power a greener future together."}
</p>
<div className="flex flex-col sm:flex-row gap-6 items-start sm:items-center pt-8 border-t border-white/10">
<Link
href={`/${locale}/contact`}
className="inline-flex items-center gap-2 px-6 py-3 bg-primary text-white font-bold rounded-lg hover:bg-primary/90 transition-all shadow-md hover:shadow-lg transform hover:-translate-y-0.5"
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 ? '👉 Kontakt aufnehmen' : '👉 Get in touch'}
{isDe ? 'Projekt anfragen' : 'Inquire Project'}
<svg className="w-5 h-5 transition-transform group-hover/btn:translate-x-1" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M17 8l4 4m0 0l-4 4m4-4H3" />
</svg>
</Link>
<p className="text-white/50 text-sm font-medium">
{isDe ? 'Kostenlose Erstberatung für Ihr Vorhaben.' : 'Free initial consultation for your project.'}
</p>
</div>
</div>
</div>

View File

@@ -0,0 +1,34 @@
'use client';
import React, { useEffect, useState } from 'react';
export default function ReadingProgressBar() {
const [completion, setCompletion] = useState(0);
useEffect(() => {
const updateScrollCompletion = () => {
const currentProgress = window.scrollY;
const scrollHeight = document.body.scrollHeight - window.innerHeight;
if (scrollHeight) {
setCompletion(
Number((currentProgress / scrollHeight).toFixed(2)) * 100
);
}
};
window.addEventListener('scroll', updateScrollCompletion);
return () => {
window.removeEventListener('scroll', updateScrollCompletion);
};
}, []);
return (
<div className="fixed top-0 left-0 w-full h-1 z-50 bg-neutral-100">
<div
className="h-full bg-primary transition-all duration-150 ease-out"
style={{ width: `${completion}%` }}
/>
</div>
);
}

View File

@@ -1,46 +1,22 @@
'use client';
import React, { useEffect, useRef, useState } from 'react';
import React from 'react';
interface SplitHeadingProps {
children: React.ReactNode;
className?: string;
id?: string;
}
export default function SplitHeading({ children, className = '' }: SplitHeadingProps) {
const elementRef = useRef<HTMLDivElement>(null);
const [isVisible, setIsVisible] = useState(false);
useEffect(() => {
const observer = new IntersectionObserver(
(entries) => {
entries.forEach((entry) => {
if (entry.isIntersecting) {
setIsVisible(true);
observer.disconnect();
}
});
},
{ threshold: 0.1 }
);
if (elementRef.current) {
observer.observe(elementRef.current);
}
return () => observer.disconnect();
}, []);
export default function SplitHeading({ children, className = '', id }: SplitHeadingProps) {
return (
<div
ref={elementRef}
className={`transform transition-all duration-1000 ease-out ${
isVisible ? 'opacity-100 translate-y-0' : 'opacity-0 translate-y-10'
} ${className}`}
id={id}
className={className}
>
<h3 className="text-2xl md:text-3xl font-bold leading-tight text-text-primary">
<h2 className="text-xl md:text-2xl font-bold leading-tight text-text-primary">
{children}
</h3>
</h2>
</div>
);
}

View File

@@ -12,23 +12,28 @@ interface StatsProps {
export default function Stats({ stats }: StatsProps) {
return (
<div className="my-12 grid grid-cols-1 md:grid-cols-3 gap-6">
<div className="my-16 grid grid-cols-1 md:grid-cols-3 border border-neutral-200 rounded-2xl overflow-hidden shadow-sm">
{stats.map((stat, index) => (
<div
key={index}
className="bg-gradient-to-br from-primary/5 to-primary/10 p-6 rounded-xl border border-primary/20 text-center hover:shadow-md transition-shadow"
className={`p-8 flex flex-col items-center text-center transition-all duration-500 hover:bg-neutral-50 group ${
index !== stats.length - 1 ? 'border-b md:border-b-0 md:border-r border-neutral-200' : ''
}`}
>
{stat.icon && (
<div className="text-primary mb-3 flex justify-center">
<div className="text-primary mb-4 transform transition-transform group-hover:scale-110 duration-500">
{stat.icon}
</div>
)}
<div className="text-4xl font-bold text-primary mb-2">
<div className="text-5xl font-bold text-text-primary mb-3 tracking-tight">
{stat.value}
</div>
<div className="text-text-secondary font-medium">
<div className="text-xs font-bold text-text-secondary uppercase tracking-[0.2em]">
{stat.label}
</div>
{/* Industrial accent line */}
<div className="w-8 h-[2px] bg-primary/20 mt-6 transition-all group-hover:w-16 group-hover:bg-primary duration-500" />
</div>
))}
</div>

View File

@@ -0,0 +1,36 @@
import React from 'react';
interface StickyNarrativeItem {
title: string;
content: string;
}
interface StickyNarrativeProps {
title: string;
items: StickyNarrativeItem[];
}
export default function StickyNarrative({ title, items }: StickyNarrativeProps) {
return (
<div className="my-24 grid grid-cols-1 lg:grid-cols-12 gap-12 lg:gap-20 items-start not-prose">
<div className="lg:col-span-4 lg:sticky lg:top-32">
<h3 className="text-2xl md:text-3xl font-bold text-primary leading-tight">
{title}
</h3>
<div className="w-16 h-1.5 bg-accent mt-8 rounded-full" />
</div>
<div className="lg:col-span-8 space-y-12 md:space-y-16">
{items.map((item, index) => (
<div key={index} className="group border-b border-neutral-200 pb-12 last:border-0 last:pb-0">
<h4 className="text-xl md:text-2xl font-bold text-text-primary mb-4 group-hover:text-primary transition-colors">
{item.title}
</h4>
<div className="text-lg text-text-secondary leading-relaxed">
{item.content}
</div>
</div>
))}
</div>
</div>
);
}

View File

@@ -0,0 +1,92 @@
'use client';
import React, { useEffect, useState } from 'react';
import { cn } from '@/components/ui/utils';
interface TocItem {
id: string;
text: string;
level: number;
}
interface TableOfContentsProps {
headings: TocItem[];
locale: string;
}
export default function TableOfContents({ headings, locale }: TableOfContentsProps) {
const [activeId, setActiveId] = useState<string>('');
useEffect(() => {
const observerOptions = {
rootMargin: '-10% 0% -70% 0%',
threshold: 0
};
const observer = new IntersectionObserver((entries) => {
entries.forEach((entry) => {
if (entry.isIntersecting) {
setActiveId(entry.target.id);
}
});
}, observerOptions);
// Use a small delay to ensure MDX content is rendered and IDs are present
const timer = setTimeout(() => {
const elements = headings.map((h) => document.getElementById(h.id));
elements.forEach((el) => {
if (el) observer.observe(el);
});
}, 500);
return () => {
clearTimeout(timer);
observer.disconnect();
};
}, [headings]);
if (headings.length === 0) return null;
return (
<nav className="hidden lg:block w-full ml-12">
<div className="relative pl-6 border-l border-neutral-200">
<h4 className="text-xs md:text-sm font-bold uppercase tracking-[0.2em] text-text-primary/50 mb-6">
{locale === 'de' ? 'Inhalt' : 'Table of Contents'}
</h4>
<ul className="space-y-4">
{headings.map((heading) => (
<li
key={heading.id}
style={{ paddingLeft: `${(heading.level - 2) * 1}rem` }}
className="relative"
>
{activeId === heading.id && (
<div className="absolute -left-[25px] top-0 w-[2px] h-full bg-primary transition-all duration-300" />
)}
<a
href={`#${heading.id}`}
className={cn(
"text-sm md:text-base transition-all duration-300 hover:text-primary block leading-snug",
activeId === heading.id
? "text-primary font-bold translate-x-1"
: "text-text-secondary font-medium hover:translate-x-1"
)}
onClick={(e) => {
e.preventDefault();
const element = document.getElementById(heading.id);
if (element) {
const yOffset = -100;
const y = element.getBoundingClientRect().top + window.pageYOffset + yOffset;
window.scrollTo({ top: y, behavior: 'smooth' });
}
}}
>
{heading.text.replace(/\*\*(.*?)\*\*/g, '$1')}
</a>
</li>
))}
</ul>
</div>
</nav>
);
}

View File

@@ -0,0 +1,43 @@
import React from 'react';
import Scribble from '@/components/Scribble';
interface TechnicalGridItem {
label: string;
value: string;
}
interface TechnicalGridProps {
title?: string;
items: TechnicalGridItem[];
}
export default function TechnicalGrid({ title, items }: TechnicalGridProps) {
return (
<div className="my-16 not-prose">
{title && (
<h3 className="text-2xl font-bold text-text-primary mb-8 flex items-center gap-4 relative">
<span className="relative inline-block">
{title}
<Scribble
variant="underline"
className="absolute -bottom-2 left-0 w-full h-3 text-accent/40"
/>
</span>
</h3>
)}
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
{items.map((item, index) => (
<div key={index} className="bg-white p-8 rounded-2xl border border-neutral-200 shadow-sm hover:shadow-md transition-all duration-300 group relative overflow-hidden">
<div className="absolute top-0 right-0 w-16 h-16 bg-primary/5 -mr-8 -mt-8 rotate-45 transition-transform group-hover:scale-110" />
<span className="block text-xs font-bold text-primary uppercase tracking-[0.2em] mb-3 opacity-70">
{item.label}
</span>
<span className="text-lg text-text-secondary leading-relaxed group-hover:text-text-primary transition-colors">
{item.value}
</span>
</div>
))}
</div>
</div>
);
}

View File

@@ -10,36 +10,64 @@ interface VisualLinkPreviewProps {
}
export default function VisualLinkPreview({ url, title, summary, image }: VisualLinkPreviewProps) {
const hostname = (() => {
try {
return new URL(url).hostname;
} catch {
return url;
}
})();
return (
<Link href={url} target="_blank" rel="noopener noreferrer" className="block my-8 no-underline group">
<div className="flex flex-col md:flex-row border border-neutral-dark rounded-lg overflow-hidden bg-white hover:shadow-md transition-shadow">
<div className="relative w-full md:w-48 h-48 md:h-auto flex-shrink-0 bg-neutral-light flex items-center justify-center">
<Link href={url} target="_blank" rel="noopener noreferrer" className="block my-12 no-underline group">
<div className="flex flex-col md:flex-row border border-neutral-200 rounded-2xl overflow-hidden bg-white transition-all duration-500 hover:shadow-2xl hover:border-primary/20 hover:-translate-y-1 group">
<div className="relative w-full md:w-64 h-48 md:h-auto flex-shrink-0 bg-neutral-50 overflow-hidden">
{image ? (
<img
<Image
src={image}
alt={title}
className="w-full h-full object-cover"
fill
unoptimized
className="object-cover transition-transform duration-700 group-hover:scale-110"
/>
) : (
<div className="text-neutral-dark">No Image</div>
<div className="w-full h-full flex items-center justify-center bg-primary/5">
<svg className="w-12 h-12 text-primary/20" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.5} d="M13.828 10.172a4 4 0 00-5.656 0l-4 4a4 4 0 105.656 5.656l1.102-1.101m-.758-4.899a4 4 0 005.656 0l4-4a4 4 0 00-5.656-5.656l-1.1 1.1" />
</svg>
</div>
)}
{/* Industrial overlay */}
<div className="absolute inset-0 bg-primary/10 opacity-0 group-hover:opacity-100 transition-opacity duration-500 pointer-events-none" />
</div>
<div className="p-4 flex flex-col justify-center">
<h3 className="text-lg font-bold text-primary mb-2 group-hover:underline line-clamp-2">
<div className="p-8 flex flex-col justify-center relative">
{/* Industrial accent corner */}
<div className="absolute top-0 right-0 w-12 h-12 bg-primary/5 -mr-6 -mt-6 rotate-45 transition-transform group-hover:scale-110" />
<div className="flex items-center gap-2 mb-3">
<span className="text-[10px] font-bold uppercase tracking-[0.2em] text-primary/60 bg-primary/5 px-2 py-0.5 rounded">
External Link
</span>
<span className="text-[10px] font-bold uppercase tracking-[0.2em] text-text-secondary/40">
{hostname}
</span>
</div>
<h3 className="text-xl font-bold text-text-primary mb-3 group-hover:text-primary transition-colors line-clamp-2 leading-tight">
{title}
</h3>
<p className="text-text-secondary text-sm line-clamp-3">
<p className="text-text-secondary text-base line-clamp-2 leading-relaxed mb-4">
{summary}
</p>
<span className="text-xs text-text-secondary mt-2 opacity-70">
{(() => {
try {
return new URL(url).hostname;
} catch (e) {
return url;
}
})()}
</span>
<div className="flex items-center gap-2 text-primary font-bold text-xs uppercase tracking-widest">
<span>Read more</span>
<svg className="w-4 h-4 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>
</Link>

View File

@@ -0,0 +1,115 @@
import {
Body,
Container,
Head,
Heading,
Hr,
Html,
Preview,
Section,
Text,
} from "@react-email/components";
import * as React from "react";
interface ContactEmailProps {
name: string;
email: string;
message: string;
subject?: string;
productName?: string;
}
export const ContactEmail = ({
name,
email,
message,
subject = "New Contact Form Submission",
productName,
}: ContactEmailProps) => (
<Html>
<Head />
<Preview>{subject}</Preview>
<Body style={main}>
<Container style={container}>
<Heading style={h1}>{subject}</Heading>
{productName && (
<Text style={text}>
<strong>Product Inquiry:</strong> {productName}
</Text>
)}
<Section style={section}>
<Text style={text}>
<strong>Name:</strong> {name}
</Text>
<Text style={text}>
<strong>Email:</strong> {email}
</Text>
<Hr style={hr} />
<Text style={text}>
<strong>Message:</strong>
</Text>
<Text style={messageText}>{message}</Text>
</Section>
<Hr style={hr} />
<Text style={footer}>
This email was sent from the contact form on klz-cables.com
</Text>
</Container>
</Body>
</Html>
);
export default ContactEmail;
const main = {
backgroundColor: "#f6f9fc",
fontFamily:
'-apple-system,BlinkMacSystemFont,"Segoe UI",Roboto,"Helvetica Neue",Ubuntu,sans-serif',
};
const container = {
backgroundColor: "#ffffff",
margin: "0 auto",
padding: "20px 0 48px",
marginBottom: "64px",
};
const section = {
padding: "0 48px",
};
const h1 = {
color: "#333",
fontSize: "24px",
fontWeight: "bold",
padding: "0 48px",
margin: "30px 0",
};
const text = {
color: "#333",
fontSize: "16px",
lineHeight: "24px",
textAlign: "left" as const,
};
const messageText = {
...text,
backgroundColor: "#f4f4f4",
padding: "15px",
borderRadius: "4px",
whiteSpace: "pre-wrap" as const,
};
const hr = {
borderColor: "#e6ebf1",
margin: "20px 0",
};
const footer = {
color: "#8898aa",
fontSize: "12px",
lineHeight: "16px",
textAlign: "center" as const,
marginTop: "20px",
};

View File

@@ -7,7 +7,7 @@ export default function CTA() {
const locale = useLocale();
return (
<Section className="bg-primary-dark text-white py-32 relative overflow-hidden">
<Section className="bg-primary text-white py-32 relative overflow-hidden">
<div className="absolute top-0 right-0 w-1/3 h-full bg-accent/5 -skew-x-12 translate-x-1/2" />
<div className="absolute bottom-0 left-0 w-1/4 h-1/2 bg-primary/10 rounded-full blur-3xl -translate-x-1/2 translate-y-1/2" />
@@ -17,7 +17,7 @@ export default function CTA() {
<Heading level={2} subtitle={t('subtitle')} className="text-white mb-6">
<span className="text-white">{t('title')}</span>
</Heading>
<p className="text-xl text-white/70 leading-relaxed">
<p className="text-lg md:text-xl text-white/70 leading-relaxed">
{t('description')}
</p>
</div>

View File

@@ -16,8 +16,8 @@ export default function Experience() {
className="object-cover object-center scale-105 animate-slow-zoom"
unoptimized
/>
<div className="absolute inset-0 bg-primary-dark/80 mix-blend-multiply" />
<div className="absolute inset-0 bg-gradient-to-r from-primary-dark via-primary-dark/40 to-transparent" />
<div className="absolute inset-0 bg-primary/80 mix-blend-multiply" />
<div className="absolute inset-0 bg-gradient-to-r from-primary via-primary/40 to-transparent" />
</div>
<Container className="relative z-10">
@@ -25,7 +25,7 @@ export default function Experience() {
<Heading level={2} subtitle={t('subtitle')} className="text-white">
<span className="text-white">{t('title')}</span>
</Heading>
<div className="space-y-8 text-xl text-white/90 leading-relaxed font-medium">
<div className="space-y-8 text-lg md:text-xl text-white/90 leading-relaxed font-medium">
<p className="border-l-4 border-accent pl-8 py-2 bg-white/5 backdrop-blur-sm rounded-r-xl">
{t('p1')}
</p>
@@ -36,12 +36,12 @@ export default function Experience() {
<div className="mt-16 grid grid-cols-1 md:grid-cols-2 gap-12">
<div className="animate-fade-in">
<div className="text-4xl font-extrabold text-accent mb-4">{t('certifiedQuality')}</div>
<div className="text-lg font-bold uppercase tracking-widest text-white/60">{t('vdeApproved')}</div>
<div className="text-2xl md:text-3xl font-extrabold text-accent mb-4">{t('certifiedQuality')}</div>
<div className="text-base md:text-lg font-bold uppercase tracking-widest text-white/60">{t('vdeApproved')}</div>
</div>
<div className="animate-fade-in" style={{ animationDelay: '100ms' }}>
<div className="text-4xl font-extrabold text-accent mb-4">{t('fullSpectrum')}</div>
<div className="text-lg font-bold uppercase tracking-widest text-white/60">{t('solutionsRange')}</div>
<div className="text-2xl md:text-3xl font-extrabold text-accent mb-4">{t('fullSpectrum')}</div>
<div className="text-base md:text-lg font-bold uppercase tracking-widest text-white/60">{t('solutionsRange')}</div>
</div>
</div>
</div>

View File

@@ -1,10 +1,15 @@
import React from 'react';
'use client';
import React, { useState, useEffect } from 'react';
import Image from 'next/image';
import { useTranslations } from 'next-intl';
import { Section, Container, Heading } from '../../components/ui';
import Lightbox from '../Lightbox';
import { useSearchParams } from 'next/navigation';
export default function GallerySection() {
const t = useTranslations('Home.gallery');
const searchParams = useSearchParams();
const images = [
'/uploads/2024/12/DSC07433-Large-600x400.webp',
'/uploads/2024/12/DSC07460-Large-600x400.webp',
@@ -14,18 +19,39 @@ export default function GallerySection() {
'/uploads/2024/12/DSC07768-Large.webp',
];
const [lightboxOpen, setLightboxOpen] = useState(false);
const [lightboxIndex, setLightboxIndex] = useState(0);
useEffect(() => {
const photoParam = searchParams.get('photo');
if (photoParam !== null) {
const index = parseInt(photoParam, 10);
if (!isNaN(index) && index >= 0 && index < images.length) {
if (lightboxIndex !== index) setLightboxIndex(index);
if (!lightboxOpen) setLightboxOpen(true);
}
}
}, [searchParams, images.length, lightboxIndex, lightboxOpen]);
return (
<Section className="bg-white text-neutral-dark py-32">
<Section className="bg-white text-white py-32">
<Container>
<Heading level={2} subtitle={t('subtitle')} align="center">
{t('title')}
</Heading>
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-8">
{images.map((src, idx) => (
<div key={idx} className="relative aspect-[4/3] overflow-hidden rounded-3xl group shadow-lg hover:shadow-2xl transition-all duration-700">
<Image
src={src}
<button
key={idx}
onClick={() => {
setLightboxIndex(idx);
setLightboxOpen(true);
}}
className="relative aspect-[4/3] overflow-hidden rounded-3xl group shadow-lg hover:shadow-2xl transition-all duration-700 cursor-pointer"
>
<Image
src={src}
alt={`${t('alt')} ${idx + 1}`}
fill
className="object-cover transition-transform duration-1000 group-hover:scale-110"
@@ -33,10 +59,17 @@ export default function GallerySection() {
/>
<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>
</button>
))}
</div>
</Container>
<Lightbox
isOpen={lightboxOpen}
images={images}
initialIndex={lightboxIndex}
onClose={() => setLightboxOpen(false)}
/>
</Section>
);
}

View File

@@ -1,48 +1,168 @@
import React from 'react';
import { useTranslations } from 'next-intl';
import { Container, Button } from '@/components/ui';
'use client';
import Scribble from '@/components/Scribble';
import { Button, Container, Heading, Section } from '@/components/ui';
import { motion } from 'framer-motion';
import { useTranslations } from 'next-intl';
import HeroIllustration from './HeroIllustration';
export default function Hero() {
const t = useTranslations('Home.hero');
return (
<section className="relative h-[70vh] md:h-[90vh] flex items-center justify-center overflow-hidden bg-primary-dark">
<HeroIllustration />
<Container className="relative z-10 text-left text-white w-full">
<div className="max-w-5xl animate-slide-up">
<h1 className="text-4xl md:text-7xl lg:text-8xl font-extrabold mb-4 md:mb-8 tracking-tight leading-[1.05] max-w-[15ch] md:max-w-none">
{t.rich('title', {
green: (chunks) => (
<span className="relative inline-block">
<span className="relative z-10 text-accent italic">{chunks}</span>
<Scribble variant="circle" className="w-[140%] h-[140%] -top-[20%] -left-[20%] text-accent/30 hidden md:block" />
</span>
)
})}
</h1>
<p className="text-lg md:text-2xl text-white/70 leading-relaxed max-w-2xl mb-8 md:mb-12 line-clamp-2 md:line-clamp-none">
{t('subtitle')}
</p>
<div className="flex flex-col md:flex-row gap-3 md:gap-6">
<Button href="/contact" variant="accent" size="lg" className="group w-full md:w-auto md:h-16 md:px-10 md:text-xl">
{t('cta')}
<span className="ml-3 transition-transform group-hover:translate-x-1">&rarr;</span>
</Button>
<Button href="/products" variant="ghost" size="lg" className="group w-full md:w-auto text-white hover:bg-white/10 md:bg-white md:text-primary md:hover:bg-neutral-light md:h-16 md:px-10 md:text-xl">
{t('exploreProducts')}
</Button>
</div>
</div>
<Section className="relative min-h-[85vh] md:h-[90vh] flex flex-col items-center justify-center overflow-hidden bg-primary py-12 md:py-0 lg:py-0">
<Container className="relative z-10 text-center md:text-left text-white w-full order-2 md:order-none">
<motion.div
className="max-w-5xl mx-auto md:mx-0"
initial="hidden"
animate="visible"
variants={containerVariants}
>
<motion.div variants={headingVariants}>
<Heading level={1} className="text-center md:text-left mb-6 md:mb-8 md:max-w-none text-white text-4xl sm:text-5xl md:text-7xl font-extrabold [text-shadow:_-2px_-2px_0_#002b49,_2px_-2px_0_#002b49,_-2px_2px_0_#002b49,_2px_2px_0_#002b49,_-2px_0_0_#002b49,_2px_0_0_#002b49,_0_-2px_0_#002b49,_0_2px_0_#002b49]">
{t.rich('title', {
green: (chunks) => (
<span className="relative inline-block">
<motion.span
className="relative z-10 text-accent italic"
variants={accentVariants}
>
{chunks}
</motion.span>
<motion.div
variants={scribbleVariants}
className="w-[140%] h-[140%] -top-[20%] -left-[20%] text-accent/30 hidden md:block absolute -z-10"
>
<Scribble variant="circle" />
</motion.div>
</span>
)
})}
</Heading>
</motion.div>
<motion.div variants={subtitleVariants}>
<p className="text-lg md:text-xl text-white/90 leading-relaxed max-w-2xl mb-10 md:mb-12">
{t('subtitle')}
</p>
</motion.div>
<motion.div
className="flex flex-col sm:flex-row justify-center md:justify-start gap-4 md:gap-6"
variants={buttonContainerVariants}
>
<motion.div variants={buttonVariants}>
<Button href="/contact" variant="accent" size="lg" className="group w-full sm:w-auto h-14 md:h-16 px-8 md:px-10 text-base md:text-lg">
{t('cta')}
<span className="transition-transform group-hover/btn:translate-x-1">&rarr;</span>
</Button>
</motion.div>
<motion.div variants={buttonVariants}>
<Button href="/products" variant="white" size="lg" className="group w-full sm:w-auto h-14 md:h-16 px-8 md:px-10 text-base md:text-lg md:bg-white md:text-primary md:hover:bg-neutral-light md:border-none">
{t('exploreProducts')}
</Button>
</motion.div>
</motion.div>
</motion.div>
</Container>
<div className="absolute bottom-6 md:bottom-10 left-1/2 -translate-x-1/2 animate-bounce hidden sm:block">
<motion.div
className="relative md:absolute inset-0 z-0 w-full h-[40vh] md:h-full order-1 md:order-none mb-[-80px] md:mb-0 mt-[80px] md:mt-0 overflow-visible pointer-events-none"
initial={{ opacity: 0, scale: 0.95, filter: 'blur(20px)' }}
animate={{ opacity: 1, scale: 1, filter: 'blur(0px)' }}
transition={{ duration: 2.2, ease: 'easeOut', delay: 0.05 }}
>
<HeroIllustration />
</motion.div>
<motion.div
className="absolute bottom-6 md:bottom-10 left-1/2 -translate-x-1/2 hidden sm:block"
initial={{ opacity: 0, y: 16 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 1, ease: "easeOut", delay: 3 }}
>
<div className="w-6 h-10 border-2 border-white/30 rounded-full flex justify-center p-1">
<div className="w-1 h-2 bg-white rounded-full" />
<motion.div
className="w-1 h-2 bg-white rounded-full"
animate={{ y: [0, -10, 0] }}
transition={{
duration: 1.5,
repeat: Infinity,
ease: "easeInOut"
}}
/>
</div>
</div>
</section>
</motion.div>
</Section>
);
}
const containerVariants = {
hidden: { opacity: 1 },
visible: {
opacity: 1,
transition: {
staggerChildren: 0.12,
delayChildren: 0.4
}
}
} as const;
const headingVariants = {
hidden: { opacity: 0, y: 60, scale: 0.85 },
visible: {
opacity: 1,
y: 0,
scale: 1,
transition: { duration: 1.2, ease: [0.25, 0.46, 0.45, 0.94] }
}
} as const;
const accentVariants = {
hidden: { opacity: 0, scale: 0.9, rotate: -5 },
visible: {
opacity: 1,
scale: 1,
rotate: 0,
transition: { duration: 0.8, ease: [0.25, 0.46, 0.45, 0.94] }
}
} as const;
const scribbleVariants = {
hidden: { opacity: 0, scale: 0, rotate: 180 },
visible: {
opacity: 1,
scale: 1,
rotate: 0,
transition: { duration: 1, type: "spring", stiffness: 300, damping: 20 }
}
} as const;
const subtitleVariants = {
hidden: { opacity: 0, y: 40, scale: 0.95 },
visible: {
opacity: 1,
y: 0,
scale: 1,
transition: { duration: 1, ease: [0.25, 0.46, 0.45, 0.94] }
}
} as const;
const buttonContainerVariants = {
hidden: { opacity: 0 },
visible: {
opacity: 1,
transition: {
staggerChildren: 0.15,
delayChildren: 0.4
}
}
} as const;
const buttonVariants = {
hidden: { opacity: 0, y: 30, scale: 0.9 },
visible: {
opacity: 1,
y: 0,
scale: 1,
transition: { type: "spring", stiffness: 400, damping: 20 }
}
} as const;

View File

@@ -163,11 +163,589 @@ const POWER_LINES = [
export default function HeroIllustration() {
return (
<div className="absolute inset-0 z-0 overflow-hidden bg-primary">
<div className="absolute md:inset-0 z-0 overflow-visible bg-primary w-full h-full">
<svg
viewBox="400 0 1000 1100"
className="w-full h-full opacity-60 md:opacity-100 scale-[1.44] md:scale-100 translate-x-0 overflow-visible md:hidden"
preserveAspectRatio="xMidYMid meet"
fill="none"
xmlns="http://www.w3.org/2000/svg"
>
<defs>
{/* Electric energy flow gradient */}
<linearGradient id="energy-pulse" x1="0%" y1="0%" x2="100%" y2="0%">
<stop offset="0%" stopColor="#82ed20" stopOpacity="0" />
<stop offset="30%" stopColor="#82ed20" stopOpacity="0.6" />
<stop offset="50%" stopColor="#9bf14d" stopOpacity="1" />
<stop offset="70%" stopColor="#82ed20" stopOpacity="0.6" />
<stop offset="100%" stopColor="#82ed20" stopOpacity="0" />
</linearGradient>
{/* Wind flow gradient */}
<linearGradient id="wind-flow" x1="0%" y1="0%" x2="100%" y2="0%">
<stop offset="0%" stopColor="white" stopOpacity="0" />
<stop offset="30%" stopColor="white" stopOpacity="0.4" />
<stop offset="50%" stopColor="white" stopOpacity="0.6" />
<stop offset="70%" stopColor="white" stopOpacity="0.4" />
<stop offset="100%" stopColor="white" stopOpacity="0" />
</linearGradient>
{/* Sun ray gradient */}
<linearGradient id="sun-ray" x1="0%" y1="0%" x2="0%" y2="100%">
<stop offset="0%" stopColor="#FFD700" stopOpacity="0.6" />
<stop offset="50%" stopColor="#FFD700" stopOpacity="0.3" />
<stop offset="100%" stopColor="#82ed20" stopOpacity="0.1" />
</linearGradient>
{/* Glow filter */}
<filter id="glow" x="-50%" y="-50%" width="200%" height="200%">
<feGaussianBlur stdDeviation="3" result="blur" />
<feMerge>
<feMergeNode in="blur" />
<feMergeNode in="blur" />
<feMergeNode in="SourceGraphic" />
</feMerge>
</filter>
{/* Soft glow for nodes */}
<filter id="soft-glow" x="-100%" y="-100%" width="300%" height="300%">
<feGaussianBlur stdDeviation="2" result="blur" />
<feMerge>
<feMergeNode in="blur" />
<feMergeNode in="SourceGraphic" />
</feMerge>
</filter>
{/* Sun glow filter */}
<filter id="sun-glow" x="-50%" y="-50%" width="200%" height="200%">
<feGaussianBlur stdDeviation="4" result="blur" />
<feMerge>
<feMergeNode in="blur" />
<feMergeNode in="SourceGraphic" />
</feMerge>
</filter>
</defs>
{/* Main scene container - positioned to the right */}
<g transform="translate(900, 100)">
{/* === ISOMETRIC GRID === */}
<g opacity="0.15">
{/* Horizontal grid lines (going from top-left to bottom-right) */}
{[...Array(GRID.rows + 1)].map((_, row) => {
const start = gridToScreen(0, row);
const end = gridToScreen(GRID.cols, row);
return (
<line
key={`h-${row}`}
x1={start.x}
y1={start.y}
x2={end.x}
y2={end.y}
stroke="white"
strokeWidth="1"
/>
);
})}
{/* Vertical grid lines (going from top-right to bottom-left) */}
{[...Array(GRID.cols + 1)].map((_, col) => {
const start = gridToScreen(col, 0);
const end = gridToScreen(col, GRID.rows);
return (
<line
key={`v-${col}`}
x1={start.x}
y1={start.y}
x2={end.x}
y2={end.y}
stroke="white"
strokeWidth="1"
/>
);
})}
</g>
{/* Grid intersection nodes */}
<g opacity="0.2">
{[...Array(GRID.cols + 1)].map((_, col) =>
[...Array(GRID.rows + 1)].map((_, row) => {
const pos = gridToScreen(col, row);
return (
<circle
key={`node-${col}-${row}`}
cx={pos.x}
cy={pos.y}
r="2"
fill="white"
/>
);
})
)}
</g>
{/* === POWER LINES (Base cables) === */}
<g stroke="white" strokeWidth="2" strokeOpacity="0.25">
{POWER_LINES.map((line, i) => {
const from = gridToScreen(line.from.col, line.from.row);
const to = gridToScreen(line.to.col, line.to.row);
return (
<line
key={`cable-${i}`}
x1={from.x}
y1={from.y}
x2={to.x}
y2={to.y}
/>
);
})}
</g>
{/* === ANIMATED ENERGY FLOW === */}
<g filter="url(#glow)">
{POWER_LINES.map((line, i) => {
const from = gridToScreen(line.from.col, line.from.row);
const to = gridToScreen(line.to.col, line.to.row);
const length = Math.sqrt(
Math.pow(to.x - from.x, 2) + Math.pow(to.y - from.y, 2)
);
return (
<line
key={`flow-${i}`}
x1={from.x}
y1={from.y}
x2={to.x}
y2={to.y}
stroke="url(#energy-pulse)"
strokeWidth="3"
strokeLinecap="round"
strokeDasharray={`${length * 0.2} ${length * 0.8}`}
>
<animate
attributeName="stroke-dashoffset"
from={length}
to={0}
dur={`${1.5 + (i % 3) * 0.5}s`}
repeatCount="indefinite"
/>
</line>
);
})}
</g>
{/* === SOLAR PANELS === */}
{INFRASTRUCTURE.solar.map((panel, i) => {
const pos = gridToScreen(panel.col, panel.row);
return (
<g key={`solar-${i}`} transform={`translate(${pos.x}, ${pos.y})`}>
{/* Panel base */}
<path
d="M -20 0 L 0 -10 L 20 0 L 0 10 Z"
fill="white"
fillOpacity="0.1"
stroke="white"
strokeWidth="1"
strokeOpacity="0.4"
/>
{/* Panel surface (tilted) */}
<path
d="M -15 -5 L 0 -15 L 15 -5 L 0 5 Z"
fill="white"
fillOpacity="0.15"
stroke="white"
strokeWidth="1"
strokeOpacity="0.5"
/>
{/* Panel grid lines */}
<line x1="-7" y1="-10" x2="7" y2="0" stroke="white" strokeWidth="0.5" strokeOpacity="0.3" />
<line x1="0" y1="-15" x2="0" y2="5" stroke="white" strokeWidth="0.5" strokeOpacity="0.3" />
{/* Connection glow */}
<circle r="4" fill="#82ed20" fillOpacity="0.4" filter="url(#soft-glow)">
<animate attributeName="fillOpacity" values="0.3;0.6;0.3" dur="2s" repeatCount="indefinite" />
</circle>
</g>
);
})}
{/* === WIND TURBINES === */}
{INFRASTRUCTURE.wind.map((turbine, i) => {
const pos = gridToScreen(turbine.col, turbine.row);
return (
<g key={`wind-${i}`} transform={`translate(${pos.x}, ${pos.y})`}>
{/* Base */}
<ellipse cx="0" cy="0" rx="10" ry="5" fill="white" fillOpacity="0.1" stroke="white" strokeWidth="1" strokeOpacity="0.3" />
{/* Tower */}
<line x1="0" y1="0" x2="0" y2="-60" stroke="white" strokeWidth="2" strokeOpacity="0.5" />
{/* Nacelle */}
<ellipse cx="0" cy="-60" rx="6" ry="3" fill="white" fillOpacity="0.3" stroke="white" strokeWidth="1" />
{/* Blades */}
<g transform="translate(0, -60)">
{[0, 120, 240].map((angle, j) => (
<line
key={`blade-${i}-${j}`}
x1="0"
y1="0"
x2="0"
y2="-30"
stroke="white"
strokeWidth="1.5"
strokeOpacity="0.6"
transform={`rotate(${angle})`}
>
<animateTransform
attributeName="transform"
type="rotate"
from={`${angle} 0 0`}
to={`${angle + 360} 0 0`}
dur={`${3 + i}s`}
repeatCount="indefinite"
/>
</line>
))}
<circle r="3" fill="white" fillOpacity="0.4" />
</g>
{/* Connection glow */}
<circle r="5" fill="#82ed20" fillOpacity="0.4" filter="url(#soft-glow)">
<animate attributeName="fillOpacity" values="0.3;0.6;0.3" dur="2.5s" repeatCount="indefinite" />
</circle>
</g>
);
})}
{/* === SUBSTATIONS === */}
{INFRASTRUCTURE.substations.map((sub, i) => {
const pos = gridToScreen(sub.col, sub.row);
const isCollection = sub.type === 'collection';
return (
<g key={`substation-${i}`} transform={`translate(${pos.x}, ${pos.y})`}>
{/* Base platform */}
<path
d="M -25 0 L 0 -12 L 25 0 L 0 12 Z"
fill="white"
fillOpacity="0.1"
stroke="white"
strokeWidth="1"
strokeOpacity="0.4"
/>
{/* Building */}
<path
d={isCollection
? "M -18 0 L -18 -20 L 0 -32 L 18 -20 L 18 0"
: "M -22 0 L -22 -25 L 0 -37 L 22 -25 L 22 0"
}
fill="white"
fillOpacity="0.08"
stroke="white"
strokeWidth="1"
strokeOpacity="0.5"
/>
{/* Equipment */}
<rect x="-10" y="-12" width="6" height="8" fill="white" fillOpacity="0.2" stroke="white" strokeWidth="0.5" />
<rect x="4" y="-12" width="6" height="8" fill="white" fillOpacity="0.2" stroke="white" strokeWidth="0.5" />
{/* Insulators */}
<line x1="-7" y1="-12" x2="-7" y2="-22" stroke="white" strokeWidth="1" strokeOpacity="0.4" />
<line x1="7" y1="-12" x2="7" y2="-22" stroke="white" strokeWidth="1" strokeOpacity="0.4" />
<circle cx="-7" cy="-22" r="2" fill="white" fillOpacity="0.4" />
<circle cx="7" cy="-22" r="2" fill="white" fillOpacity="0.4" />
{/* Connection glow */}
<circle r="8" fill="#82ed20" fillOpacity="0.3" filter="url(#soft-glow)">
<animate attributeName="r" values="6;10;6" dur="3s" repeatCount="indefinite" />
<animate attributeName="fillOpacity" values="0.2;0.5;0.2" dur="3s" repeatCount="indefinite" />
</circle>
</g>
);
})}
{/* === TRANSMISSION TOWERS === */}
{INFRASTRUCTURE.towers.map((tower, i) => {
const pos = gridToScreen(tower.col, tower.row);
return (
<g key={`tower-${i}`} transform={`translate(${pos.x}, ${pos.y})`}>
{/* Base */}
<ellipse cx="0" cy="0" rx="8" ry="4" fill="white" fillOpacity="0.1" stroke="white" strokeWidth="1" strokeOpacity="0.3" />
{/* Tower legs */}
<path d="M -6 0 L -3 -45 M 6 0 L 3 -45" stroke="white" strokeWidth="1.5" strokeOpacity="0.5" />
{/* Cross braces */}
<path d="M -5 -10 L 5 -10 M -4 -20 L 4 -20 M -3 -30 L 3 -30 M -3 -45 L 3 -45" stroke="white" strokeWidth="1" strokeOpacity="0.3" />
{/* Cross arms */}
<line x1="-12" y1="-40" x2="12" y2="-40" stroke="white" strokeWidth="1" strokeOpacity="0.4" />
<line x1="-10" y1="-32" x2="10" y2="-32" stroke="white" strokeWidth="1" strokeOpacity="0.4" />
{/* Insulators */}
<circle cx="-10" cy="-40" r="1.5" fill="white" fillOpacity="0.4" />
<circle cx="10" cy="-40" r="1.5" fill="white" fillOpacity="0.4" />
{/* Connection glow */}
<circle r="5" fill="#82ed20" fillOpacity="0.3" filter="url(#soft-glow)">
<animate attributeName="fillOpacity" values="0.2;0.5;0.2" dur="2s" repeatCount="indefinite" />
</circle>
</g>
);
})}
{/* === CITY BUILDINGS === */}
{INFRASTRUCTURE.city.map((building, i) => {
const pos = gridToScreen(building.col, building.row);
const heights = { tall: 70, medium: 45, small: 30 };
const height = heights[building.type as keyof typeof heights];
return (
<g key={`building-${i}`} transform={`translate(${pos.x}, ${pos.y})`}>
{/* Base */}
<path
d="M -12 0 L 0 -6 L 12 0 L 0 6 Z"
fill="white"
fillOpacity="0.1"
stroke="white"
strokeWidth="1"
strokeOpacity="0.3"
/>
{/* Building front */}
<path
d={`M -12 0 L -12 -${height} L 0 -${height + 6} L 0 -6 Z`}
fill="white"
fillOpacity="0.1"
stroke="white"
strokeWidth="1"
strokeOpacity="0.4"
/>
{/* Building side */}
<path
d={`M 0 -6 L 0 -${height + 6} L 12 -${height} L 12 0 Z`}
fill="white"
fillOpacity="0.05"
stroke="white"
strokeWidth="1"
strokeOpacity="0.3"
/>
{/* Windows */}
{[...Array(Math.floor(height / 15))].map((_, w) => (
<g key={`window-${i}-${w}`}>
<rect x="-9" y={-12 - w * 15} width="3" height="4" fill="white" fillOpacity="0.2" />
<rect x="-4" y={-12 - w * 15} width="3" height="4" fill="white" fillOpacity="0.2" />
<rect x="3" y={-15 - w * 15} width="3" height="4" fill="white" fillOpacity="0.15" />
<rect x="7" y={-15 - w * 15} width="3" height="4" fill="white" fillOpacity="0.15" />
</g>
))}
{/* Connection glow */}
<circle r="4" fill="#82ed20" fillOpacity="0.3" filter="url(#soft-glow)">
<animate attributeName="fillOpacity" values="0.2;0.5;0.2" dur={`${2 + i * 0.3}s`} repeatCount="indefinite" />
</circle>
</g>
);
})}
{/* === CITY 2 BUILDINGS (bottom-left) === */}
{INFRASTRUCTURE.city2.map((building, i) => {
const pos = gridToScreen(building.col, building.row);
const heights = { tall: 70, medium: 45, small: 30 };
const height = heights[building.type as keyof typeof heights];
return (
<g key={`building2-${i}`} transform={`translate(${pos.x}, ${pos.y})`}>
{/* Base */}
<path
d="M -12 0 L 0 -6 L 12 0 L 0 6 Z"
fill="white"
fillOpacity="0.1"
stroke="white"
strokeWidth="1"
strokeOpacity="0.3"
/>
{/* Building front */}
<path
d={`M -12 0 L -12 -${height} L 0 -${height + 6} L 0 -6 Z`}
fill="white"
fillOpacity="0.1"
stroke="white"
strokeWidth="1"
strokeOpacity="0.4"
/>
{/* Building side */}
<path
d={`M 0 -6 L 0 -${height + 6} L 12 -${height} L 12 0 Z`}
fill="white"
fillOpacity="0.05"
stroke="white"
strokeWidth="1"
strokeOpacity="0.3"
/>
{/* Windows */}
{[...Array(Math.floor(height / 15))].map((_, w) => (
<g key={`window2-${i}-${w}`}>
<rect x="-9" y={-12 - w * 15} width="3" height="4" fill="white" fillOpacity="0.2" />
<rect x="-4" y={-12 - w * 15} width="3" height="4" fill="white" fillOpacity="0.2" />
<rect x="3" y={-15 - w * 15} width="3" height="4" fill="white" fillOpacity="0.15" />
<rect x="7" y={-15 - w * 15} width="3" height="4" fill="white" fillOpacity="0.15" />
</g>
))}
{/* Connection glow */}
<circle r="4" fill="#82ed20" fillOpacity="0.3" filter="url(#soft-glow)">
<animate attributeName="fillOpacity" values="0.2;0.5;0.2" dur={`${2.5 + i * 0.3}s`} repeatCount="indefinite" />
</circle>
</g>
);
})}
{/* === TREES === */}
{INFRASTRUCTURE.trees.map((tree, i) => {
const pos = gridToScreen(tree.col, tree.row);
return (
<g key={`tree-${i}`} transform={`translate(${pos.x}, ${pos.y})`}>
{/* Trunk */}
<line x1="0" y1="0" x2="0" y2="-15" stroke="white" strokeWidth="2" strokeOpacity="0.3" />
{/* Foliage - layered circles for tree crown */}
<ellipse cx="0" cy="-22" rx="10" ry="8" fill="white" fillOpacity="0.12" stroke="white" strokeWidth="0.5" strokeOpacity="0.2" />
<ellipse cx="-5" cy="-26" rx="7" ry="6" fill="white" fillOpacity="0.1" stroke="white" strokeWidth="0.5" strokeOpacity="0.15" />
<ellipse cx="5" cy="-26" rx="7" ry="6" fill="white" fillOpacity="0.1" stroke="white" strokeWidth="0.5" strokeOpacity="0.15" />
<ellipse cx="0" cy="-30" rx="6" ry="5" fill="white" fillOpacity="0.08" stroke="white" strokeWidth="0.5" strokeOpacity="0.1" />
</g>
);
})}
{/* === ABSTRACT WIND EFFECTS === */}
{INFRASTRUCTURE.wind.map((turbine, i) => {
const pos = gridToScreen(turbine.col, turbine.row);
return (
<g key={`wind-effect-${i}`} transform={`translate(${pos.x}, ${pos.y})`}>
{/* Wind swoosh lines - curved paths flowing toward turbine */}
{[0, 1, 2].map((j) => (
<path
key={`wind-line-${i}-${j}`}
d={`M ${-80 - j * 15} ${-70 - j * 8} Q ${-50 - j * 10} ${-65 - j * 5} ${-20} ${-60}`}
stroke="url(#wind-flow)"
strokeWidth="2"
fill="none"
strokeLinecap="round"
opacity="0"
>
<animate
attributeName="opacity"
values="0;0.6;0"
dur={`${2 + j * 0.5}s`}
begin={`${j * 0.7 + i * 0.3}s`}
repeatCount="indefinite"
/>
<animate
attributeName="stroke-dashoffset"
from="100"
to="0"
dur={`${2 + j * 0.5}s`}
begin={`${j * 0.7 + i * 0.3}s`}
repeatCount="indefinite"
/>
</path>
))}
{/* Additional wind particles */}
{[0, 1, 2, 3].map((j) => (
<circle
key={`wind-particle-${i}-${j}`}
r="1.5"
fill="white"
opacity="0"
>
<animate
attributeName="cx"
values={`${-70 - j * 10};${-10}`}
dur={`${1.5 + j * 0.3}s`}
begin={`${j * 0.4 + i * 0.2}s`}
repeatCount="indefinite"
/>
<animate
attributeName="cy"
values={`${-75 - j * 5};${-60}`}
dur={`${1.5 + j * 0.3}s`}
begin={`${j * 0.4 + i * 0.2}s`}
repeatCount="indefinite"
/>
<animate
attributeName="opacity"
values="0;0.5;0"
dur={`${1.5 + j * 0.3}s`}
begin={`${j * 0.4 + i * 0.2}s`}
repeatCount="indefinite"
/>
</circle>
))}
</g>
);
})}
{/* === SCHEMATIC SUN RAYS === */}
{/* Simple downward rays above each solar panel */}
{INFRASTRUCTURE.solar.map((panel, i) => {
const pos = gridToScreen(panel.col, panel.row);
return (
<g key={`sun-ray-${i}`} transform={`translate(${pos.x}, ${pos.y})`}>
{/* Three short schematic rays coming down to panel */}
{[-8, 0, 8].map((offset, j) => (
<line
key={`ray-${i}-${j}`}
x1={offset}
y1={-45}
x2={offset * 0.3}
y2={-18}
stroke="#FFD700"
strokeWidth="1.5"
strokeOpacity="0.4"
strokeLinecap="round"
strokeDasharray="4 6"
>
<animate
attributeName="strokeOpacity"
values="0.2;0.5;0.2"
dur={`${2 + j * 0.3}s`}
begin={`${i * 0.2}s`}
repeatCount="indefinite"
/>
<animate
attributeName="stroke-dashoffset"
from="10"
to="0"
dur="1.5s"
repeatCount="indefinite"
/>
</line>
))}
</g>
);
})}
{/* === ENERGY PARTICLES === */}
{POWER_LINES.map((line, i) => {
const from = gridToScreen(line.from.col, line.from.row);
const to = gridToScreen(line.to.col, line.to.row);
return (
<circle
key={`particle-${i}`}
r="3"
fill="#82ed20"
filter="url(#soft-glow)"
>
<animate
attributeName="cx"
values={`${from.x};${to.x}`}
dur={`${1 + (i % 4) * 0.3}s`}
repeatCount="indefinite"
/>
<animate
attributeName="cy"
values={`${from.y};${to.y}`}
dur={`${1 + (i % 4) * 0.3}s`}
repeatCount="indefinite"
/>
<animate
attributeName="opacity"
values="0;0.8;0"
dur={`${1 + (i % 4) * 0.3}s`}
repeatCount="indefinite"
/>
</circle>
);
})}
</g>
</svg>
{/* Desktop SVG - Original */}
<svg
viewBox="-400 -200 1800 1100"
className="w-full h-full opacity-40 md:opacity-100 scale-150 md:scale-100 translate-x-1/4 md:translate-x-0"
preserveAspectRatio="xMidYMid slice"
className="w-full h-full opacity-100 scale-100 translate-x-0 hidden md:block"
fill="none"
xmlns="http://www.w3.org/2000/svg"
>
@@ -741,7 +1319,7 @@ export default function HeroIllustration() {
</g>
</svg>
<div className="absolute inset-0 bg-gradient-to-b from-primary/10 via-transparent to-primary/90" />
<div className="absolute inset-0 bg-gradient-to-b from-primary/10 via-transparent to-primary/90 pointer-events-none" />
</div>
);
}

View File

@@ -18,8 +18,8 @@ export default function MeetTheTeam() {
className="object-cover scale-105 animate-slow-zoom"
unoptimized
/>
<div className="absolute inset-0 bg-primary-dark/70 mix-blend-multiply" />
<div className="absolute inset-0 bg-gradient-to-t from-primary-dark via-primary-dark/20 to-transparent" />
<div className="absolute inset-0 bg-primary/70 mix-blend-multiply" />
<div className="absolute inset-0 bg-gradient-to-t from-primary via-primary/20 to-transparent" />
</div>
<Container className="relative z-10">
@@ -30,7 +30,7 @@ export default function MeetTheTeam() {
<div className="relative mb-12">
<div className="absolute -left-8 top-0 bottom-0 w-1 bg-accent rounded-full" />
<p className="text-2xl md:text-3xl leading-relaxed font-medium italic text-white/90 pl-8">
<p className="text-xl md:text-2xl leading-relaxed font-medium italic text-white/90 pl-8">
"{t('description')}"
</p>
</div>
@@ -43,14 +43,14 @@ export default function MeetTheTeam() {
<div className="flex items-center gap-4">
<div className="flex -space-x-4">
<div className="w-14 h-14 rounded-full border-4 border-primary-dark overflow-hidden relative">
<div className="w-14 h-14 rounded-full border-4 border-primary overflow-hidden relative">
<Image src="/uploads/2024/12/DSC07768-Large.webp" alt={teamT('michael.name')} fill className="object-cover" />
</div>
<div className="w-14 h-14 rounded-full border-4 border-primary-dark overflow-hidden relative">
<div className="w-14 h-14 rounded-full border-4 border-primary overflow-hidden relative">
<Image src="/uploads/2024/12/DSC07963-Large.webp" alt={teamT('klaus.name')} fill className="object-cover" />
</div>
</div>
<span className="text-white/60 font-bold text-sm uppercase tracking-widest">
<span className="text-white/60 font-bold text-xs md:text-sm uppercase tracking-widest">
{t('andNetwork')}
</span>
</div>

View File

@@ -2,7 +2,7 @@ import React from 'react';
import Link from 'next/link';
import Image from 'next/image';
import { useTranslations, useLocale } from 'next-intl';
import { Section, Heading } from '../../components/ui';
import { Section } from '../../components/ui';
export default function ProductCategories() {
const t = useTranslations('Products');
@@ -40,7 +40,7 @@ export default function ProductCategories() {
];
return (
<Section className="bg-neutral-light py-0 -mt-px">
<Section className="bg-neutral-light py-0 md:py-0 lg:py-0 -mt-px">
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4">
{categories.map((category, idx) => (
<Link key={idx} href={category.href} className="group block relative h-[400px] md:h-[500px] lg:h-[650px] overflow-hidden border-b md:border-b-0 md:border-r border-white/10 last:border-0">
@@ -56,14 +56,14 @@ export default function ProductCategories() {
<div className="absolute inset-0 p-8 md:p-10 flex flex-col justify-end text-white">
<div className="mb-4 md:mb-6 transform transition-all duration-500 group-hover:-translate-y-4">
<div className="w-12 h-12 md:w-16 md:h-16 bg-white/10 backdrop-blur-md rounded-xl flex items-center justify-center mb-4 md:mb-6 border border-white/20">
<img src={category.icon} alt="" className="w-8 h-8 md:w-10 md:h-10 brightness-0 invert" />
<Image src={category.icon} alt="" width={40} height={40} className="w-8 h-8 md:w-10 md:h-10 brightness-0 invert" unoptimized />
</div>
<h3 className="text-2xl md:text-3xl font-bold mb-2 md:mb-4 leading-tight">{category.title}</h3>
<p className="text-white/80 text-base md:text-lg line-clamp-3 opacity-100 md:opacity-0 group-hover:opacity-100 transition-all duration-500 max-h-24 md:max-h-0 group-hover:max-h-32">
<h3 className="text-xl md:text-2xl font-bold mb-2 md:mb-4 leading-tight">{category.title}</h3>
<p className="text-white/80 text-sm md:text-base line-clamp-3 opacity-100 md:opacity-0 group-hover:opacity-100 transition-all duration-500 max-h-24 md:max-h-0 group-hover:max-h-32">
{category.desc}
</p>
</div>
<div className="flex items-center text-accent font-bold tracking-wider uppercase text-xs md:text-sm opacity-100 md:opacity-0 group-hover:opacity-100 transition-all duration-500 delay-100">
<div className="flex items-center text-accent font-bold tracking-wider uppercase text-xs md:text-xs opacity-100 md:opacity-0 group-hover:opacity-100 transition-all duration-500 delay-100">
{t('exploreCategory')} <span className="ml-2 transition-transform group-hover:translate-x-2">&rarr;</span>
</div>
</div>

View File

@@ -19,10 +19,10 @@ export default async function RecentPosts({ locale }: RecentPostsProps) {
<Section className="bg-neutral py-16 md:py-24">
<Container>
<div className="flex flex-col md:flex-row items-start md:items-end justify-between mb-12 md:mb-16 gap-6">
<Heading level={2} subtitle={t('latestNews')} className="mb-0">
<Heading level={2} subtitle={t('latestNews')} className="mb-0 text-primary">
{t('allArticles')}
</Heading>
<Link href={`/${locale}/blog`} className="group flex items-center text-primary font-bold text-lg touch-target">
<Link href={`/${locale}/blog`} className="group flex items-center text-primary font-bold text-base md:text-lg touch-target">
{t('allArticles')}
<span className="ml-2 transition-transform group-hover:translate-x-2">&rarr;</span>
</Link>
@@ -56,7 +56,7 @@ export default async function RecentPosts({ locale }: RecentPostsProps) {
day: 'numeric'
})}
</div>
<h3 className="text-xl md:text-2xl font-bold text-primary group-hover:text-accent-dark transition-colors line-clamp-2 mb-4 md:mb-6 leading-tight">
<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">

View File

@@ -5,7 +5,7 @@ export default function VideoSection() {
const t = useTranslations('Home.video');
return (
<section className="relative h-[70vh] overflow-hidden bg-primary-dark">
<section className="relative h-[70vh] overflow-hidden bg-primary">
<video
className="w-full h-full object-cover opacity-60"
autoPlay
@@ -15,9 +15,9 @@ export default function VideoSection() {
>
<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-dark/60 via-transparent to-primary-dark/60 flex items-center justify-center">
<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">
<h2 className="text-4xl md:text-6xl lg:text-7xl 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', {
future: (chunks) => (
<span className="relative inline-block mx-2">

View File

@@ -6,19 +6,19 @@ export default function WhatWeDo() {
const t = useTranslations('Home.whatWeDo');
return (
<Section className="bg-white text-neutral-dark">
<Section className="bg-white">
<Container>
<div className="sticky-narrative-container">
<div className="sticky-narrative-sidebar">
<div className="lg:sticky lg:top-32">
<Heading level={2} subtitle={t('expertise')}>
<Heading level={2} subtitle={t('expertise')} className="text-primary-dark">
{t('title')}
</Heading>
<p className="text-lg md:text-xl text-text-secondary leading-relaxed">
<p className="text-base md:text-lg text-text-secondary leading-relaxed">
{t('subtitle')}
</p>
<div className="mt-8 md:mt-12 p-6 md:p-8 bg-primary-light rounded-2xl border border-primary/10">
<p className="text-primary font-bold text-base md:text-lg italic">
<div className="mt-8 md:mt-12 p-6 md:p-8 bg-saturated/10 rounded-2xl border border-saturated/10">
<p className="text-saturated font-bold text-base md:text-base italic">
"{t('quote')}"
</p>
</div>
@@ -28,13 +28,13 @@ export default function WhatWeDo() {
{[0, 1, 2, 3].map((idx) => (
<div key={idx} className="group">
<div className="flex items-center gap-4 mb-4 md:mb-6">
<span className="flex items-center justify-center w-10 h-10 md:w-12 md:h-12 rounded-full bg-primary text-white font-bold text-base md:text-lg shadow-lg shadow-primary/20 group-hover:scale-110 transition-transform">
<span className="flex items-center justify-center w-10 h-10 md:w-12 md:h-12 rounded-full bg-saturated text-white font-bold text-base md:text-lg shadow-lg shadow-saturated/20 group-hover:scale-110 transition-transform">
{idx + 1}
</span>
<div className="h-px flex-grow bg-neutral-medium" />
</div>
<h3 className="text-xl md:text-2xl font-bold mb-3 md:mb-4 text-primary group-hover:text-accent-dark transition-colors">{t(`items.${idx}.title`)}</h3>
<p className="text-text-secondary text-base md:text-lg leading-relaxed">{t(`items.${idx}.description`)}</p>
<h3 className="text-lg md:text-xl font-bold mb-3 md:mb-4 text-primary-dark group-hover:text-accent-dark transition-colors">{t(`items.${idx}.title`)}</h3>
<p className="text-text-secondary text-base md:text-base leading-relaxed">{t(`items.${idx}.description`)}</p>
</div>
))}
</div>

View File

@@ -6,15 +6,15 @@ export default function WhyChooseUs() {
const t = useTranslations('Home.whyChooseUs');
return (
<Section className="bg-neutral-light text-neutral-dark">
<Section className="bg-neutral-light">
<Container>
<div className="grid grid-cols-1 lg:grid-cols-12 gap-16 lg:gap-24">
<div className="lg:col-span-4 order-1 lg:order-2">
<div className="sticky top-32">
<Heading level={2} subtitle={t('whyKlz')}>
<Heading level={2} subtitle={t('whyKlz')} className="text-primary-dark">
{t('title')}
</Heading>
<p className="text-xl text-text-secondary leading-relaxed">
<p className="text-base md:text-lg text-text-secondary leading-relaxed">
{t('subtitle')}
</p>
@@ -26,7 +26,7 @@ export default function WhyChooseUs() {
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={3} d="M5 13l4 4L19 7" />
</svg>
</div>
<span className="font-bold text-primary">{t(`features.${i}`)}</span>
<span className="font-bold text-primary-dark text-base md:text-base">{t(`features.${i}`)}</span>
</div>
))}
</div>
@@ -35,11 +35,11 @@ export default function WhyChooseUs() {
<div className="lg:col-span-8 grid grid-cols-1 md:grid-cols-2 gap-8 order-2 lg:order-1">
{[0, 1, 2, 3].map((idx) => (
<div key={idx} className="p-10 bg-white rounded-3xl border border-neutral-medium hover:border-accent transition-all duration-500 hover:shadow-xl group">
<div className="w-14 h-14 bg-primary-light rounded-2xl flex items-center justify-center mb-8 group-hover:bg-accent transition-colors duration-500">
<span className="text-primary font-bold text-xl group-hover:text-primary-dark">0{idx + 1}</span>
<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>
</div>
<h3 className="text-2xl font-bold mb-4 text-primary">{t(`items.${idx}.title`)}</h3>
<p className="text-text-secondary text-lg leading-relaxed">{t(`items.${idx}.description`)}</p>
<h3 className="text-xl font-bold mb-4 text-primary-dark">{t(`items.${idx}.title`)}</h3>
<p className="text-text-secondary text-base md:text-base leading-relaxed">{t(`items.${idx}.description`)}</p>
</div>
))}
</div>

View File

@@ -0,0 +1,55 @@
'use client';
import { useTranslations } from 'next-intl';
import { useState } from 'react';
import Image from 'next/image';
import Lightbox from '@/components/Lightbox';
import { Section, Container, Heading } from '@/components/ui';
export default function Gallery() {
const t = useTranslations('Team');
const [lightboxOpen, setLightboxOpen] = useState(false);
const [lightboxIndex, setLightboxIndex] = useState(0);
const teamGalleryImages = [
'/uploads/2024/12/DSC07539-Large-600x400.webp',
'/uploads/2024/12/DSC07460-Large-600x400.webp',
'/uploads/2024/12/DSC07469-Large-600x400.webp',
'/uploads/2024/12/DSC07433-Large-600x400.webp',
'/uploads/2024/12/DSC07387-Large-600x400.webp'
];
return (
<Section className="bg-primary-dark py-16 md:py-32">
<Container>
<Heading level={2} subtitle={t('gallery.subtitle')} align="center" className="text-white mb-12 md:mb-20">
<span className="text-white">{t('gallery.title')}</span>
</Heading>
<div className="grid grid-cols-2 md:grid-cols-5 gap-3 md:gap-6">
{teamGalleryImages.map((src, idx) => (
<button
key={idx}
onClick={() => {
setLightboxIndex(idx);
setLightboxOpen(true);
}}
className="relative aspect-[3/4] rounded-2xl md:rounded-[32px] overflow-hidden group shadow-2xl cursor-pointer"
>
<Image src={src} alt={t('gallery.title')} fill className="object-cover transition-transform duration-1000 group-hover:scale-110" />
<div className="absolute inset-0 bg-primary-dark/40 group-hover:bg-transparent transition-all duration-500" />
<div className="absolute inset-0 border-0 group-hover:border-[8px] md:group-hover:border-[12px] border-white/10 transition-all duration-500 pointer-events-none" />
</button>
))}
</div>
</Container>
<Lightbox
isOpen={lightboxOpen}
images={teamGalleryImages}
initialIndex={lightboxIndex}
onClose={() => setLightboxOpen(false)}
/>
</Section>
);
}

View File

@@ -1,130 +1 @@
import React from 'react';
import Link from 'next/link';
import { clsx, type ClassValue } from 'clsx';
import { twMerge } from 'tailwind-merge';
export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs));
}
interface ButtonProps extends React.ButtonHTMLAttributes<HTMLButtonElement> {
variant?: 'primary' | 'secondary' | 'accent' | 'outline' | 'ghost' | 'white';
size?: 'sm' | 'md' | 'lg' | 'xl';
href?: string;
className?: string;
children?: React.ReactNode;
}
export function Button({ className, variant = 'primary', size = 'md', href, ...props }: ButtonProps) {
const baseStyles = 'inline-flex items-center justify-center rounded-full font-semibold transition-all duration-300 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary focus-visible:ring-offset-2 disabled:opacity-50 disabled:pointer-events-none active:scale-95';
const variants = {
primary: 'bg-primary text-white hover:bg-primary-dark shadow-md hover:shadow-lg',
secondary: 'bg-secondary text-white hover:bg-secondary-light shadow-md hover:shadow-lg',
accent: 'bg-accent text-primary-dark hover:bg-accent-dark shadow-md hover:shadow-lg',
outline: 'border-2 border-primary bg-transparent hover:bg-primary hover:text-white text-primary',
ghost: 'hover:bg-primary-light text-primary',
white: 'bg-white text-primary hover:bg-neutral-light shadow-md hover:shadow-lg',
};
const sizes = {
sm: 'h-9 px-4 text-sm',
md: 'h-11 px-6 text-base',
lg: 'h-14 px-8 text-lg',
xl: 'h-16 px-10 text-xl',
};
const styles = cn(baseStyles, variants[variant], sizes[size], className);
if (href) {
return (
<Link href={href} className={styles}>
{props.children}
</Link>
);
}
return (
<button className={styles} {...props} />
);
}
export function Section({ className, children, ...props }: React.HTMLAttributes<HTMLElement>) {
return (
<section className={cn('py-16 md:py-28 lg:py-36 overflow-hidden content-visibility-auto', className)} {...props}>
{children}
</section>
);
}
export function Container({ className, children, ...props }: React.HTMLAttributes<HTMLDivElement>) {
return (
<div className={cn('container mx-auto px-4 md:px-12 lg:px-16 max-w-7xl', className)} {...props}>
{children}
</div>
);
}
export function Heading({
level = 2,
children,
className,
subtitle,
align = 'left'
}: {
level?: 1 | 2 | 3 | 4;
children: React.ReactNode;
className?: string;
subtitle?: string;
align?: 'left' | 'center' | 'right';
}) {
const Tag = `h${level}` as any;
const sizes = {
1: 'text-4xl md:text-6xl lg:text-7xl xl:text-8xl font-extrabold leading-[1.1]',
2: 'text-3xl md:text-5xl lg:text-6xl font-bold leading-tight',
3: 'text-2xl md:text-3xl lg:text-4xl font-bold',
4: 'text-xl md:text-2xl font-bold',
};
const alignments = {
left: 'text-left',
center: 'text-center mx-auto',
right: 'text-right',
};
return (
<div className={cn('mb-8 md:mb-16', alignments[align], className)}>
{subtitle && (
<span className="inline-block text-accent font-bold tracking-widest uppercase text-xs md:text-sm mb-3 md:mb-4 animate-fade-in">
{subtitle}
</span>
)}
<Tag className={cn(sizes[level as keyof typeof sizes], 'text-primary')}>
{children}
</Tag>
</div>
);
}
export function Card({ className, children, ...props }: React.HTMLAttributes<HTMLDivElement>) {
return (
<div className={cn('premium-card overflow-hidden', className)} {...props}>
{children}
</div>
);
}
export function Badge({ children, className, variant = 'primary' }: { children: React.ReactNode, className?: string, variant?: 'primary' | 'accent' | 'neutral' }) {
const variants = {
primary: 'bg-primary-light text-primary',
accent: 'bg-accent-light text-accent-dark',
neutral: 'bg-neutral-medium text-text-secondary',
};
return (
<span className={cn('inline-flex items-center px-3 py-1 rounded-full text-xs font-bold uppercase tracking-wider', variants[variant], className)}>
{children}
</span>
);
}
export * from './ui/index';

17
components/ui/Badge.tsx Normal file
View File

@@ -0,0 +1,17 @@
import React from 'react';
import { cn } from './utils';
export function Badge({ children, className, variant = 'primary' }: { children: React.ReactNode, className?: string, variant?: 'primary' | 'accent' | 'neutral' | 'saturated' }) {
const variants = {
primary: 'bg-primary-light text-primary',
accent: 'bg-accent-light text-accent-dark',
neutral: 'bg-neutral-medium text-text-secondary',
saturated: 'bg-saturated text-white',
};
return (
<span className={cn('inline-flex items-center px-3 py-1 rounded-full text-xs font-bold uppercase tracking-wider', variants[variant], className)}>
{children}
</span>
);
}

97
components/ui/Button.tsx Normal file
View File

@@ -0,0 +1,97 @@
import React from 'react';
import Link from 'next/link';
import { cn } from './utils';
export interface ButtonProps extends React.ButtonHTMLAttributes<HTMLButtonElement> {
variant?:
| 'primary'
| 'secondary'
| 'accent'
| 'saturated'
| 'outline'
| 'ghost'
| 'white'
| 'destructive';
size?: 'sm' | 'md' | 'lg' | 'xl';
href?: string;
className?: string;
children?: React.ReactNode;
}
export function Button({
className,
variant = 'primary',
size = 'md',
href,
...props
}: ButtonProps) {
const baseStyles =
'inline-flex items-center justify-center rounded-full font-semibold transition-all duration-500 ease-out focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary focus-visible:ring-offset-2 disabled:opacity-50 disabled:pointer-events-none active:scale-95 hover:-translate-y-1 hover:scale-[1.02] relative overflow-hidden group/btn isolate';
const variants = {
primary: 'bg-primary text-white shadow-md hover:shadow-primary/30 hover:shadow-2xl',
secondary: 'bg-secondary text-white shadow-md hover:shadow-secondary/30 hover:shadow-2xl',
accent: 'bg-accent text-primary-dark shadow-md hover:shadow-accent/30 hover:shadow-2xl',
saturated: 'bg-saturated text-white shadow-md hover:shadow-primary/30 hover:shadow-2xl',
outline:
'border-2 border-primary bg-transparent text-primary hover:text-white hover:shadow-primary/20 hover:shadow-xl',
ghost: 'text-primary hover:shadow-lg',
white:
'bg-white text-primary shadow-md hover:shadow-primary/30 hover:shadow-2xl hover:text-white',
destructive:
'bg-destructive text-destructive-foreground shadow-md hover:shadow-destructive/30 hover:shadow-2xl',
};
const sizes = {
sm: 'h-9 px-4 text-sm md:text-base',
md: 'h-11 px-6 text-base md:text-lg',
lg: 'h-14 px-8 text-base md:text-lg',
xl: 'h-16 px-10 text-lg md:text-xl',
};
const styles = cn(baseStyles, variants[variant], sizes[size], className);
const overlayColors = {
primary: 'bg-primary-dark',
secondary: 'bg-secondary-light',
accent: 'bg-accent-dark',
saturated: 'bg-primary',
outline: 'bg-primary',
ghost: 'bg-primary-light/10',
white: 'bg-primary-light',
destructive: 'bg-destructive/90',
};
const content = (
<>
<span
className={cn(
'relative z-10 flex items-center justify-center gap-2 transition-colors duration-500',
variant === 'white' ? 'group-hover/btn:text-primary-dark' : '',
)}
>
{props.children}
</span>
<span
className={cn(
'absolute inset-0 translate-y-[101%] group-hover/btn:translate-y-0 transition-transform duration-500 ease-out',
overlayColors[variant],
)}
/>
</>
);
if (href) {
return (
<Link href={href} className={styles}>
{content}
</Link>
);
}
return (
<button className={styles} {...props}>
{content}
</button>
);
}

View File

@@ -1,9 +1,11 @@
import React from 'react';
import { cn } from './utils';
interface CalloutProps {
type?: 'info' | 'warning' | 'success' | 'tip';
title?: string;
children: React.ReactNode;
className?: string;
}
const icons = {
@@ -43,18 +45,18 @@ const iconColors = {
tip: 'text-purple-500',
};
export default function Callout({ type = 'info', title, children }: CalloutProps) {
export function Callout({ type = 'info', title, children, className }: CalloutProps) {
return (
<div className={`my-8 p-6 rounded-xl border-2 ${styles[type]}`}>
<div className={cn('my-8 p-6 rounded-xl border-2', styles[type], className)}>
<div className="flex gap-4">
<div className={`flex-shrink-0 ${iconColors[type]}`}>
<div className={cn('flex-shrink-0', iconColors[type])}>
{icons[type]}
</div>
<div className="flex-1">
{title && (
<h4 className="font-bold text-lg mb-2">{title}</h4>
<h4 className="font-bold text-base md:text-lg mb-2">{title}</h4>
)}
<div className="prose prose-sm max-w-none">
<div className="prose prose-sm md:prose-base max-w-none">
{children}
</div>
</div>

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