Compare commits

..

60 Commits

Author SHA1 Message Date
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
138 changed files with 17968 additions and 31218 deletions

21
.env
View File

@@ -1,16 +1,10 @@
# Application
NODE_ENV=production
NEXT_PUBLIC_BASE_URL=https://klz-cables.com
UMAMI_SCRIPT_URL=https://analytics.infra.mintel.me/script.js
NEXT_PUBLIC_UMAMI_WEBSITE_ID=59a7db94-0100-4c7e-98ef-99f45b17f9c3
UMAMI_API_ENDPOINT=https://analytics.infra.mintel.me
SENTRY_DSN=https://c10957d0182245b1a2a806ac2d34a197@errors.infra.mintel.me/1
LOG_LEVEL=info
# WooCommerce & WordPress
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"
NEXT_PUBLIC_FEEDBACK_ENABLED=true
# SMTP Configuration
MAIL_HOST=smtp.eu.mailgun.org
@@ -26,6 +20,15 @@ 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

View File

@@ -10,13 +10,18 @@
# ────────────────────────────────────────────────────────────────────────────
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
NEXT_PUBLIC_UMAMI_WEBSITE_ID=
NEXT_PUBLIC_UMAMI_SCRIPT_URL=https://analytics.infra.mintel.me/script.js
UMAMI_WEBSITE_ID=
UMAMI_API_ENDPOINT=https://analytics.infra.mintel.me
# ────────────────────────────────────────────────────────────────────────────
# Error Tracking (GlitchTip/Sentry)
@@ -52,6 +57,11 @@ IMAGE_TAG=latest
TRAEFIK_HOST=klz-cables.com
ENV_FILE=.env
# ────────────────────────────────────────────────────────────────────────────
# Varnish Configuration
# ────────────────────────────────────────────────────────────────────────────
VARNISH_CACHE_SIZE=256M
# ============================================================================
# IMPORTANT NOTES
# ============================================================================

View File

@@ -12,8 +12,8 @@ NODE_ENV=production
NEXT_PUBLIC_BASE_URL=https://klz-cables.com
# Analytics (Umami)
NEXT_PUBLIC_UMAMI_WEBSITE_ID=
NEXT_PUBLIC_UMAMI_SCRIPT_URL=https://analytics.infra.mintel.me/script.js
UMAMI_WEBSITE_ID=
UMAMI_API_ENDPOINT=https://analytics.infra.mintel.me
# Error Tracking (GlitchTip/Sentry)
SENTRY_DSN=
@@ -26,15 +26,5 @@ MAIL_PASSWORD=
MAIL_FROM=KLZ Cables <noreply@klz-cables.com>
MAIL_RECIPIENTS=info@klz-cables.com
# Strapi
STRAPI_DATABASE_NAME=strapi
STRAPI_DATABASE_USERNAME=strapi
STRAPI_DATABASE_PASSWORD=
APP_KEYS=
API_TOKEN_SALT=
ADMIN_JWT_SECRET=
TRANSFER_TOKEN_SALT=
JWT_SECRET=
# Varnish Cache Size (optional)
VARNISH_CACHE_SIZE=256m

View File

@@ -1,5 +0,0 @@
.next/
node_modules/
reference/
public/
dist/

View File

@@ -1,16 +0,0 @@
{
"extends": ["next/core-web-vitals", "next/typescript", "prettier"],
"rules": {
"@typescript-eslint/no-explicit-any": "off",
"@typescript-eslint/no-unused-vars": [
"warn",
{
"argsIgnorePattern": "^_"
}
],
"@typescript-eslint/no-require-imports": "off",
"prefer-const": "warn",
"react/no-unescaped-entities": "off",
"@next/next/no-img-element": "warn"
}
}

View File

@@ -14,8 +14,8 @@ on:
default: 'false'
concurrency:
group: ${{ github.workflow }}
cancel-in-progress: false
group: ${{ github.workflow }}-${{ (github.ref_type == 'tag' && !contains(github.ref_name, '-')) && 'prod' || (github.ref_type == 'tag' && 'staging' || 'testing') }}
cancel-in-progress: true
jobs:
# ──────────────────────────────────────────────────────────────────────────────
@@ -29,6 +29,8 @@ jobs:
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 }}
@@ -38,11 +40,21 @@ jobs:
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:
- 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: 0
fetch-depth: 2
- name: 🔍 Environment & Version ermitteln
id: determine
@@ -62,10 +74,10 @@ jobs:
TARGET="testing"
IMAGE_TAG="main-${SHORT_SHA}"
ENV_FILE=".env.testing"
TRAEFIK_HOST='`testing.klz-cables.com`'
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`'
DIRECTUS_HOST="cms.testing.klz-cables.com"
PROJECT_NAME="klz-cables-testing"
IS_PROD="false"
GOTIFY_TITLE="🧪 Testing-Deploy"
@@ -76,10 +88,10 @@ jobs:
TARGET="production"
IMAGE_TAG="$TAG"
ENV_FILE=".env.prod"
TRAEFIK_HOST='`klz-cables.com`, `www.klz-cables.com`'
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`'
DIRECTUS_HOST="cms.klz-cables.com"
PROJECT_NAME="klz-cables-prod"
IS_PROD="true"
GOTIFY_TITLE="🚀 Production-Release"
@@ -88,10 +100,10 @@ jobs:
TARGET="staging"
IMAGE_TAG="$TAG"
ENV_FILE=".env.staging"
TRAEFIK_HOST='`staging.klz-cables.com`'
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`'
DIRECTUS_HOST="cms.staging.klz-cables.com"
PROJECT_NAME="klz-cables-staging"
IS_PROD="false"
GOTIFY_TITLE="🧪 Staging-Deploy (Pre-Release)"
@@ -105,19 +117,33 @@ jobs:
TARGET="skip"
fi
echo "target=$TARGET" >> $GITHUB_OUTPUT
echo "image_tag=$IMAGE_TAG" >> $GITHUB_OUTPUT
echo "env_file=$ENV_FILE" >> $GITHUB_OUTPUT
echo "traefik_host=$TRAEFIK_HOST" >> $GITHUB_OUTPUT
echo "next_public_base_url=$NEXT_PUBLIC_BASE_URL" >> $GITHUB_OUTPUT
echo "directus_url=$DIRECTUS_URL" >> $GITHUB_OUTPUT
echo "directus_host=$DIRECTUS_HOST" >> $GITHUB_OUTPUT
echo "project_name=$PROJECT_NAME" >> $GITHUB_OUTPUT
echo "is_prod=$IS_PROD" >> $GITHUB_OUTPUT
echo "gotify_title=$GOTIFY_TITLE" >> $GITHUB_OUTPUT
echo "gotify_priority=$GOTIFY_PRIORITY" >> $GITHUB_OUTPUT
echo "short_sha=$SHORT_SHA" >> $GITHUB_OUTPUT
echo "commit_msg=$COMMIT_MSG" >> $GITHUB_OUTPUT
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)
@@ -127,26 +153,21 @@ jobs:
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
cache: 'npm'
- name: 📦 Restore npm cache
uses: actions/cache@v4
with:
path: ~/.npm
key: ${{ runner.os }}-node-${{ hashFiles('**/package-lock.json') }}
restore-keys: |
${{ runner.os }}-node-
- name: Install dependencies
run: npm ci
run: npm ci --legacy-peer-deps
- name: 🧪 Run Checks in Parallel
if: github.event.inputs.skip_long_checks != 'true'
@@ -166,8 +187,8 @@ jobs:
# ──────────────────────────────────────────────────────────────────────────────
# JOB 3: Build & Push Docker Image
# ──────────────────────────────────────────────────────────────────────────────
build:
name: 🏗️ Build & Push
build-app:
name: 🏗️ Build App
needs: prepare
if: ${{ needs.prepare.outputs.target != 'skip' }}
runs-on: docker
@@ -176,88 +197,79 @@ jobs:
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: 🏗️ Docker Image bauen & pushen
- 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 }}
NEXT_PUBLIC_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) }}
NEXT_PUBLIC_UMAMI_SCRIPT_URL: ${{ 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) }}
DIRECTUS_URL: ${{ needs.prepare.outputs.directus_url }}
run: |
echo "🏗️ Building → $TARGET / $IMAGE_TAG"
docker buildx build \
--pull \
--platform linux/arm64 \
--build-arg NEXT_PUBLIC_BASE_URL="$NEXT_PUBLIC_BASE_URL" \
--build-arg NEXT_PUBLIC_UMAMI_WEBSITE_ID="$NEXT_PUBLIC_UMAMI_WEBSITE_ID" \
--build-arg NEXT_PUBLIC_UMAMI_SCRIPT_URL="$NEXT_PUBLIC_UMAMI_SCRIPT_URL" \
--build-arg NEXT_PUBLIC_TARGET="$TARGET" \
--build-arg DIRECTUS_URL="$DIRECTUS_URL" \
-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 .
- name: 🏗️ Gatekeeper bauen & pushen
env:
IMAGE_TAG: ${{ needs.prepare.outputs.image_tag }}
run: |
docker buildx build \
--pull \
--platform linux/arm64 \
-t registry.infra.mintel.me/mintel/klz-cables-gatekeeper:$IMAGE_TAG \
--push ./gatekeeper
# ──────────────────────────────────────────────────────────────────────────────
# JOB 4: Deploy via SSH
# ──────────────────────────────────────────────────────────────────────────────
deploy:
name: 🚀 Deploy
needs: [prepare, build, qa]
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.traefik_host }}
TRAEFIK_HOST: ${{ needs.prepare.outputs.primary_host }}
NEXT_PUBLIC_BASE_URL: ${{ needs.prepare.outputs.next_public_base_url }}
NEXT_PUBLIC_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) }}
NEXT_PUBLIC_UMAMI_SCRIPT_URL: ${{ 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: ${{ needs.prepare.outputs.target == 'production' && secrets.SENTRY_DSN || (needs.prepare.outputs.target == 'staging' && secrets.STAGING_SENTRY_DSN || secrets.TESTING_SENTRY_DSN || secrets.SENTRY_DSN) }}
MAIL_HOST: ${{ secrets.MAIL_HOST }}
MAIL_PORT: ${{ secrets.MAIL_PORT }}
MAIL_USERNAME: ${{ secrets.MAIL_USERNAME }}
MAIL_PASSWORD: ${{ secrets.MAIL_PASSWORD }}
MAIL_FROM: ${{ secrets.MAIL_FROM }}
MAIL_RECIPIENTS: ${{ secrets.MAIL_RECIPIENTS }}
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 }}
DIRECTUS_SECRET: ${{ secrets.DIRECTUS_SECRET }}
DIRECTUS_ADMIN_EMAIL: ${{ secrets.DIRECTUS_ADMIN_EMAIL }}
DIRECTUS_ADMIN_PASSWORD: ${{ secrets.DIRECTUS_ADMIN_PASSWORD }}
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 }}
DIRECTUS_API_TOKEN: ${{ secrets.DIRECTUS_API_TOKEN }}
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"
@@ -266,13 +278,19 @@ jobs:
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)
NODE_ENV=production
IMAGE_TAG=$IMAGE_TAG
NEXT_PUBLIC_BASE_URL=$NEXT_PUBLIC_BASE_URL
NEXT_PUBLIC_UMAMI_WEBSITE_ID=$NEXT_PUBLIC_UMAMI_WEBSITE_ID
NEXT_PUBLIC_UMAMI_SCRIPT_URL=$NEXT_PUBLIC_UMAMI_SCRIPT_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
@@ -294,26 +312,49 @@ jobs:
INTERNAL_DIRECTUS_URL=http://directus:8055
GATEKEEPER_PASSWORD=$GATEKEEPER_PASSWORD
IMAGE_TAG=$IMAGE_TAG
TARGET=$TARGET
SENTRY_ENVIRONMENT=$TARGET
PROJECT_NAME=$PROJECT_NAME
COOKIE_DOMAIN=$COOKIE_DOMAIN
TRAEFIK_HOST=$TRAEFIK_HOST
ENV_FILE=$ENV_FILE
AUTH_MIDDLEWARE=$( [[ "$TARGET" == "production" ]] && echo "compress" || echo "${PROJECT_NAME}-auth,compress" )
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=168h"
docker system prune -f --filter "until=24h"
echo "→ Waiting 15s for warmup..."
sleep 15
echo "→ Container status:"
@@ -324,6 +365,23 @@ jobs:
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
# ──────────────────────────────────────────────────────────────────────────────
@@ -336,20 +394,23 @@ jobs:
needs.deploy.result == 'success' &&
github.event.inputs.skip_long_checks != 'true'
runs-on: docker
outputs:
report_url: ${{ steps.save.outputs.report_url }}
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
cache: 'npm'
- name: Install dependencies
run: npm ci
run: npm ci --legacy-peer-deps
- name: 🔍 Install Chromium (Native & ARM64)
run: |
@@ -418,24 +479,18 @@ jobs:
CHROME_PATH: /usr/bin/chromium
run: npm run pagespeed:test
- name: 💾 Save Report URL
id: save
if: always()
run: |
if [ -f pagespeed-report-url.txt ]; then
URL=$(cat pagespeed-report-url.txt)
echo "report_url=$URL" >> $GITHUB_OUTPUT
echo "✅ Report URL found: $URL"
fi
# ──────────────────────────────────────────────────────────────────────────────
# JOB 6: Notifications
# ──────────────────────────────────────────────────────────────────────────────
notifications:
name: 🔔 Notifications
needs: [prepare, qa, build, deploy, pagespeed]
needs: [prepare, qa, build-app, deploy, pagespeed]
if: always()
runs-on: docker
container:
image: catthehacker/ubuntu:act-latest
steps:
- name: 📊 Deployment Summary
run: |
@@ -452,18 +507,18 @@ jobs:
- name: 🔔 Gotify - Success
if: needs.deploy.result == 'success'
run: |
REPORT_MSG=""
if [ -n "${{ needs.pagespeed.outputs.report_url }}" ]; then
REPORT_MSG="\n\n⚡ **PageSpeed Report:**\n${{ needs.pagespeed.outputs.report_url }}"
fi
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 }}${REPORT_MSG}" \
-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
- name: 🔔 Gotify - Failure
if: needs.deploy.result == 'failure' || needs.build.result == 'failure' || needs.qa.result == 'failure'
if: |
needs.prepare.result == 'failure' ||
needs.qa.result == 'failure' ||
needs.build-app.result == 'failure' ||
needs.deploy.result == 'failure' ||
needs.pagespeed.result == 'failure'
run: |
curl -s -k -X POST "${{ secrets.GOTIFY_URL }}/message?token=${{ secrets.GOTIFY_TOKEN }}" \
-F "title=❌ Deployment FEHLGESCHLAGEN ${{ needs.prepare.outputs.target || 'unknown' }}" \

4
.gitignore vendored
View File

@@ -4,4 +4,6 @@ node_modules
# Directus
directus/uploads
!directus/extensions/
!directus/extensions/
!directus/schema/
!directus/migrations/

View File

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

View File

@@ -1 +0,0 @@
Sheet 1

File diff suppressed because one or more lines are too long

View File

@@ -1,237 +0,0 @@
# Analytics Migration Complete ✅
## Summary
Successfully migrated analytics data from Independent Analytics (WordPress) to Umami.
## Files Created
### 1. Migration Script
**Location:** `scripts/migrate-analytics-to-umami.py`
- Converts Independent Analytics CSV to Umami format
- Supports 3 output formats: JSON (API), SQL (database), API payload
- Preserves page view counts and average duration data
### 2. Deployment Script
**Location:** `scripts/deploy-analytics-to-umami.sh`
- Tailored for your server setup (`deploy@alpha.mintel.me`)
- Copies files to your Umami server
- Provides import instructions for your specific environment
### 3. Output Files
#### JSON Import File
**Location:** `data/umami-import.json`
- **Size:** 2.1 MB
- **Records:** 7,634 page view events
- **Website ID:** `59a7db94-0100-4c7e-98ef-99f45b17f9c3`
- **Use:** Import via Umami API
#### SQL Import File
**Location:** `data/umami-import.sql`
- **Size:** 1.8 MB
- **Records:** 5,250 SQL statements
- **Website ID:** `59a7db94-0100-4c7e-98ef-99f45b17f9c3`
- **Use:** Direct database import
### 4. Documentation
**Location:** `scripts/README-migration.md`
- Step-by-step migration guide
- Prerequisites and setup instructions
- Import methods (API and database)
- Troubleshooting tips
**Location:** `MIGRATION_SUMMARY.md`
- Complete migration overview
- Data summary and limitations
- Verification steps
- Next steps
**Location:** `ANALYTICS_MIGRATION_COMPLETE.md` (this file)
- Quick reference guide
- Deployment instructions
## Quick Start
### Option 1: Automated Deployment (Recommended)
```bash
# Run the deployment script
./scripts/deploy-analytics-to-umami.sh
```
This script will:
1. Copy files to your server
2. Provide import instructions
3. Show you the exact commands to run
### Option 2: Manual Deployment
#### Step 1: Copy files to server
```bash
scp data/umami-import.json deploy@alpha.mintel.me:/home/deploy/sites/klz-cables.com/data/
```
#### Step 2: SSH into server
```bash
ssh deploy@alpha.mintel.me
cd /home/deploy/sites/klz-cables.com
```
#### Step 3: Import data
**Method A: API Import (if API key is available)**
```bash
# Get your API key from Umami dashboard
# Add to .env: UMAMI_API_KEY=your-api-key
curl -X POST \
-H "Content-Type: application/json" \
-H "Authorization: Bearer YOUR_API_KEY" \
-d @data/umami-import.json \
http://localhost:3000/api/import
```
**Method B: Database Import (direct)**
```bash
# Import SQL file into PostgreSQL
docker exec -i $(docker compose ps -q postgres) psql -U umami -d umami < data/umami-import.sql
```
**Method C: Manual via Umami Dashboard**
1. Access Umami dashboard: https://analytics.infra.mintel.me
2. Go to Settings → Import
3. Upload `data/umami-import.json`
4. Select website ID: `59a7db94-0100-4c7e-98ef-99f45b17f9c3`
5. Click Import
## Your Umami Configuration
**Website ID:** `59a7db94-0100-4c7e-98ef-99f45b17f9c3`
**Environment Variables** (from docker-compose.yml):
```bash
NEXT_PUBLIC_UMAMI_WEBSITE_ID=59a7db94-0100-4c7e-98ef-99f45b17f9c3
NEXT_PUBLIC_UMAMI_SCRIPT_URL=https://analytics.infra.mintel.me/script.js
```
**Server Details:**
- **Host:** alpha.mintel.me
- **User:** deploy
- **Path:** /home/deploy/sites/klz-cables.com
- **Umami API:** http://localhost:3000/api/import
## Data Summary
### What Was Migrated
- **Source:** Independent Analytics CSV (220 unique pages)
- **Migrated:** 7,634 simulated page view events
- **Metrics:** Page views, visitor counts, average duration
- **Website ID:** `59a7db94-0100-4c7e-98ef-99f45b17f9c3`
### What Was NOT Migrated
- Individual user sessions
- Real-time data
- Geographic data
- Referrer data
- Device/browser data
- Custom events
**Note:** The CSV contains aggregated data, not raw event data. The migration creates simulated historical data for reference only.
## Verification
### After Import
1. **Check Umami dashboard:** https://analytics.infra.mintel.me
2. **Verify page view counts** match your expectations
3. **Check top pages** appear correctly
4. **Monitor for a few days** to ensure new data is being collected
### Expected Results
- ✅ 7,634 events imported
- ✅ 220 unique pages
- ✅ Historical view counts preserved
- ✅ Duration data maintained
## Troubleshooting
### Issue: "SSH connection failed"
**Solution:** Check your SSH key and ensure `deploy@alpha.mintel.me` has access
### Issue: "API import failed"
**Solution:**
1. Check if Umami API is running: `docker compose ps`
2. Verify API key in `.env`: `UMAMI_API_KEY=your-key`
3. Try database import instead
### Issue: "Database import failed"
**Solution:**
1. Ensure PostgreSQL is running: `docker compose ps`
2. Check database credentials
3. Run migrations first: `docker exec -it $(docker compose ps -q postgres) psql -U umami -d umami -c "SELECT 1;"`
### Issue: "No data appears in dashboard"
**Solution:**
1. Verify import completed successfully
2. Check Umami logs: `docker compose logs app`
3. Ensure website ID matches: `59a7db94-0100-4c7e-98ef-99f45b17f9c3`
## Next Steps
### 1. Import the Data
Choose one of the import methods above and run it.
### 2. Verify the Migration
- Check Umami dashboard
- Verify page view counts
- Confirm data appears correctly
### 3. Update Your Website
Your website is already configured with:
```bash
NEXT_PUBLIC_UMAMI_WEBSITE_ID=59a7db94-0100-4c7e-98ef-99f45b17f9c3
NEXT_PUBLIC_UMAMI_SCRIPT_URL=https://analytics.infra.mintel.me/script.js
```
### 4. Monitor for a Few Days
- Ensure Umami is collecting new data
- Compare with any remaining Independent Analytics data
- Verify tracking code is working
### 5. Clean Up
- Keep the original CSV as backup: `data/pages(1).csv`
- Store migration files for future reference
- Remove old Independent Analytics plugin from WordPress
## Support Resources
- **Umami Documentation:** https://umami.is/docs
- **Umami GitHub:** https://github.com/umami-software/umami
- **Independent Analytics:** https://independentanalytics.com/
## Migration Details
**Migration Date:** 2026-01-25
**Source Plugin:** Independent Analytics v2.9.7
**Target Platform:** Umami Analytics
**Website ID:** `59a7db94-0100-4c7e-98ef-99f45b17f9c3`
**Server:** alpha.mintel.me (deploy user)
**Status:** ✅ Ready for import
---
**Quick Command Reference:**
```bash
# Deploy to server
./scripts/deploy-analytics-to-umami.sh
# Or manually:
scp data/umami-import.json deploy@alpha.mintel.me:/home/deploy/sites/klz-cables.com/data/
ssh deploy@alpha.mintel.me
cd /home/deploy/sites/klz-cables.com
docker exec -i $(docker compose ps -q postgres) psql -U umami -d umami < data/umami-import.sql
```
**Need help?** Check `scripts/README-migration.md` for detailed instructions.

View File

@@ -8,7 +8,7 @@ WORKDIR /app
# Install dependencies based on the preferred package manager
COPY package.json package-lock.json* ./
RUN --mount=type=cache,target=/root/.npm npm ci
RUN --mount=type=cache,target=/root/.npm npm ci --legacy-peer-deps
# Rebuild the source code only when needed
@@ -25,17 +25,15 @@ ENV NEXT_TELEMETRY_DISABLED=1
# Build-time environment variables for Next.js
# These are baked into the client bundle during build
ARG NEXT_PUBLIC_BASE_URL
ARG NEXT_PUBLIC_UMAMI_WEBSITE_ID
ARG NEXT_PUBLIC_UMAMI_SCRIPT_URL
ARG NEXT_PUBLIC_TARGET
ARG DIRECTUS_URL
ENV NEXT_PUBLIC_BASE_URL=$NEXT_PUBLIC_BASE_URL
ENV NEXT_PUBLIC_UMAMI_WEBSITE_ID=$NEXT_PUBLIC_UMAMI_WEBSITE_ID
ENV NEXT_PUBLIC_UMAMI_SCRIPT_URL=$NEXT_PUBLIC_UMAMI_SCRIPT_URL
ENV NEXT_PUBLIC_TARGET=$NEXT_PUBLIC_TARGET
ENV DIRECTUS_URL=$DIRECTUS_URL
# Validate environment variables during build
RUN npx tsx scripts/validate-env.ts
RUN SKIP_RUNTIME_ENV_VALIDATION=true npx tsx scripts/validate-env.ts
RUN --mount=type=cache,target=/app/.next/cache npm run build

View File

@@ -1,272 +0,0 @@
# Environment Variables Cleanup - Summary
## What Was Done
Cleaned up the fragile, overkill environment variable mess and replaced it with a simple, clean, robust **fully automated** system.
## Changes Made
### 1. Dockerfile ✅
**Before**: 4 build args including runtime-only variables (SENTRY_DSN)
**After**: 3 build args - only `NEXT_PUBLIC_*` variables that need to be baked into the client bundle
```dockerfile
# Only these build args now:
ARG NEXT_PUBLIC_BASE_URL
ARG NEXT_PUBLIC_UMAMI_WEBSITE_ID
ARG NEXT_PUBLIC_UMAMI_SCRIPT_URL
```
### 2. docker-compose.yml ✅
**Before**: 12+ individual environment variables listed
**After**: Single `env_file: .env` directive
```yaml
app:
image: registry.infra.mintel.me/mintel/klz-cables.com:latest
env_file:
- .env # All runtime vars loaded from here
```
### 3. .gitea/workflows/deploy.yml ✅
**Before**: Passing 12+ environment variables individually via SSH command (fragile!)
**After**: **Fully automated** - workflow creates `.env` file from Gitea secrets and uploads it
```yaml
# Before (FRAGILE):
ssh root@alpha.mintel.me \
"MAIL_FROM='${{ secrets.MAIL_FROM }}' \
MAIL_HOST='${{ secrets.MAIL_HOST }}' \
... (12+ variables) \
/home/deploy/deploy.sh"
# After (AUTOMATED):
# 1. Create .env from secrets
cat > /tmp/klz-cables.env << EOF
NODE_ENV=production
NEXT_PUBLIC_BASE_URL=${{ secrets.NEXT_PUBLIC_BASE_URL }}
# ... all other vars from secrets
EOF
# 2. Upload to server
scp /tmp/klz-cables.env root@alpha.mintel.me:/home/deploy/sites/klz-cables.com/.env
# 3. Deploy
ssh root@alpha.mintel.me "cd /home/deploy/sites/klz-cables.com && docker-compose up -d"
```
### 4. New Files Created ✅
- **`.env.production`** - Template for reference (not used in automation)
- **`docs/DEPLOYMENT.md`** - Complete deployment guide
- **`docs/SERVER_SETUP.md`** - Server setup instructions
- **`docs/ENV_MIGRATION.md`** - Migration guide from old to new system
### 5. Updated Files ✅
- **`.env.example`** - Clear documentation of all variables with build-time vs runtime notes
## Architecture
### Build Time (CI/CD)
```
Gitea Workflow
Only passes NEXT_PUBLIC_* as --build-arg
Docker Build
Validates env vars
Bakes NEXT_PUBLIC_* into client bundle
Push to Registry
```
### Runtime (Production Server) - FULLY AUTOMATED
```
Gitea Secrets
Workflow creates .env file
SCP uploads to server
Secured (chmod 600, chown deploy:deploy)
docker-compose.yml (env_file: .env)
Loads .env into container
Application runs with full config
```
## Key Benefits
### 1. Simplicity
- **Before**: 15+ Gitea secrets, variables in 3+ places
- **After**: All secrets in Gitea, automatically deployed
### 2. Clarity
- **Before**: Confusing duplication, unclear which vars go where
- **After**: Clear separation - build args vs runtime env file
### 3. Robustness
- **Before**: Fragile SSH command with 12+ inline variables
- **After**: Robust automated file generation and upload
### 4. Security
- **Before**: Secrets potentially exposed in CI logs
- **After**: Secrets masked in logs, .env auto-secured on server
### 5. Maintainability
- **Before**: Update in 3 places (Dockerfile, docker-compose.yml, deploy.yml)
- **After**: Update Gitea secrets only - deployment is automatic
### 6. **Zero Manual Steps** 🎉
- **Before**: Manual .env file creation on server (error-prone, can be forgotten)
- **After**: **Fully automated** - .env file created and uploaded on every deployment
## What You Need to Do
### Required Gitea Secrets
Ensure these secrets are configured in your Gitea repository:
**Build-Time (NEXT_PUBLIC_*):**
- `NEXT_PUBLIC_BASE_URL` - Production URL (e.g., `https://klz-cables.com`)
- `NEXT_PUBLIC_UMAMI_WEBSITE_ID` - Umami analytics ID
- `NEXT_PUBLIC_UMAMI_SCRIPT_URL` - Umami script URL
**Runtime:**
- `SENTRY_DSN` - Error tracking DSN
- `MAIL_HOST` - SMTP server
- `MAIL_PORT` - SMTP port (e.g., `587`)
- `MAIL_USERNAME` - SMTP username
- `MAIL_PASSWORD` - SMTP password
- `MAIL_FROM` - Sender email
- `MAIL_RECIPIENTS` - Recipient emails (comma-separated)
**Infrastructure:**
- `REGISTRY_USER` - Docker registry username
- `REGISTRY_PASS` - Docker registry password
- `ALPHA_SSH_KEY` - SSH private key for deployment server
**Notifications:**
- `GOTIFY_URL` - Gotify notification server URL
- `GOTIFY_TOKEN` - Gotify application token
### That's It!
**No manual steps required.** Just push to main branch and the workflow will:
1. ✅ Build Docker image with NEXT_PUBLIC_* build args
2. ✅ Create .env file from all secrets
3. ✅ Upload .env to server
4. ✅ Secure .env file (600 permissions, deploy:deploy ownership)
5. ✅ Pull latest image
6. ✅ Deploy with docker-compose
## Files Changed
```
Modified:
├── Dockerfile (removed redundant build args)
├── docker-compose.yml (use env_file instead of individual vars)
├── .gitea/workflows/deploy.yml (automated .env creation & upload)
├── .env.example (clear documentation)
├── lib/services/create-services.ts (removed redundant dotenv usage)
└── scripts/migrate-*.ts (removed redundant dotenv usage)
Created:
├── .env.production (reference template)
├── docs/DEPLOYMENT.md (deployment guide)
├── docs/SERVER_SETUP.md (server setup guide)
├── docs/ENV_MIGRATION.md (migration guide)
└── ENV_CLEANUP_SUMMARY.md (this file)
```
## Deployment Flow
```
┌─────────────────────────────────────────────────────────────┐
│ Developer pushes to main branch │
└─────────────────────────────────────────────────────────────┘
┌─────────────────────────────────────────────────────────────┐
│ Gitea Workflow Triggered │
│ │
│ 1. Build Docker image (NEXT_PUBLIC_* build args) │
│ 2. Push to registry │
│ 3. Generate .env from secrets │
│ 4. Upload .env to server via SCP │
│ 5. SSH to server and deploy │
└─────────────────────────────────────────────────────────────┘
┌─────────────────────────────────────────────────────────────┐
│ Production Server │
│ │
│ 1. .env file secured (600, deploy:deploy) │
│ 2. Docker login to registry │
│ 3. Pull latest image │
│ 4. docker-compose down │
│ 5. docker-compose up -d (loads .env) │
│ 6. Health checks pass │
└─────────────────────────────────────────────────────────────┘
┌─────────────────────────────────────────────────────────────┐
│ ✅ Deployment Complete - Gotify Notification Sent │
└─────────────────────────────────────────────────────────────┘
```
## Comparison: Before vs After
| Aspect | Before | After |
|--------|--------|-------|
| **Gitea Secrets** | 15+ secrets | Same secrets, better organized |
| **Build Args** | 4 vars (including runtime-only) | 3 vars (NEXT_PUBLIC_* only) |
| **Runtime Vars** | Passed via SSH command | Auto-generated .env file |
| **Manual Steps** | ❌ Manual .env creation | ✅ Fully automated |
| **Maintenance** | Update in 3 places | Update Gitea secrets only |
| **Security** | Secrets in CI logs | Secrets masked, .env secured |
| **Clarity** | Confusing duplication | Clear separation |
| **Robustness** | Fragile SSH command | Robust automation |
| **Error-Prone** | ❌ Can forget .env | ✅ Impossible to forget |
## Documentation
- **[DEPLOYMENT.md](docs/DEPLOYMENT.md)** - Complete deployment guide
- **[SERVER_SETUP.md](docs/SERVER_SETUP.md)** - Server setup instructions (mostly automated now)
- **[ENV_MIGRATION.md](docs/ENV_MIGRATION.md)** - Migration from old to new system
- **[.env.example](.env.example)** - Environment variables reference
- **[.env.production](.env.production)** - Production template (for reference)
## Troubleshooting
### Deployment Fails
1. **Check Gitea secrets** - Ensure all required secrets are set
2. **Check workflow logs** - Look for specific error messages
3. **SSH to server** - Verify .env file exists and has correct permissions
4. **Check container logs** - `docker-compose logs -f app`
### .env File Issues
The workflow automatically:
- Creates .env from secrets
- Uploads to server
- Sets 600 permissions
- Sets deploy:deploy ownership
If there are issues, check the workflow logs for the "📝 Preparing environment configuration" step.
### Missing Environment Variables
If a variable is missing:
1. Add it to Gitea secrets
2. Update `.gitea/workflows/deploy.yml` to include it in the .env generation
3. Push to trigger new deployment
---
**Result**: Environment variable management is now simple, clean, robust, and **fully automated**! 🎉
No more manual .env file creation. No more forgotten configuration. No more fragile SSH commands. Just push and deploy!

View File

@@ -1,193 +0,0 @@
# Analytics Migration Summary: Independent Analytics → Umami
## Overview
Successfully migrated analytics data from Independent Analytics WordPress plugin to Umami format.
## Files Created
### 1. Migration Script
- **Location:** `scripts/migrate-analytics-to-umami.py`
- **Purpose:** Converts Independent Analytics CSV data to Umami format
- **Features:**
- JSON format (for API import)
- SQL format (for direct database import)
- API payload format (for manual import)
### 2. Migration Documentation
- **Location:** `scripts/README-migration.md`
- **Purpose:** Step-by-step guide for migration
- **Contents:**
- Prerequisites
- Migration options
- Import instructions
- Troubleshooting guide
### 3. Output Files
#### JSON Import File
- **Location:** `data/umami-import.json`
- **Size:** 2.1 MB
- **Records:** 7,634 simulated page view events
- **Format:** JSON array of Umami-compatible events
- **Use Case:** Import via Umami API
#### SQL Import File
- **Location:** `data/umami-import.sql`
- **Size:** 1.8 MB
- **Records:** 5,250 SQL INSERT statements
- **Format:** PostgreSQL-compatible SQL
- **Use Case:** Direct database import
## Data Migrated
### Source Data
- **File:** `data/pages(1).csv`
- **Records:** 220 unique pages
- **Metrics:**
- Page titles
- Visitor counts
- View counts
- Average view duration
- Bounce rates
- URLs
- Page types (Page, Post, Product, Category, etc.)
### Migrated Data
- **Total Events:** 7,634 simulated page views
- **Unique Pages:** 220
- **Data Points:**
- Website ID: `klz-cables`
- Path: Page URLs
- Duration: Preserved from average view duration
- Timestamp: Current time (for historical reference)
## Migration Process
### Step 1: Run Migration Script
```bash
python3 scripts/migrate-analytics-to-umami.py \
--input data/pages\(1\).csv \
--output data/umami-import.json \
--format json \
--site-id klz-cables
```
### Step 2: Choose Import Method
#### Option A: API Import (Recommended)
```bash
curl -X POST \
-H "Content-Type: application/json" \
-H "Authorization: Bearer YOUR_API_KEY" \
-d @data/umami-import.json \
https://your-umami-instance.com/api/import
```
#### Option B: Database Import
```bash
psql -U umami -d umami -f data/umami-import.sql
```
### Step 3: Verify Migration
1. Check Umami dashboard
2. Verify page view counts
3. Confirm data appears correctly
## Important Notes
### Data Limitations
The CSV export contains **aggregated data**, not raw event data:
- ✅ Page views (total counts)
- ✅ Visitor counts
- ✅ Average view duration
- ❌ Individual user sessions
- ❌ Real-time data
- ❌ Geographic data
- ❌ Referrer data
- ❌ Device/browser data
### What Gets Imported
The migration creates **simulated historical data**:
- Each page view becomes a separate event
- Timestamps are set to current time
- Duration is preserved from average view duration
- No session tracking (each view is independent)
### Recommendations
1. **Start fresh with Umami** - Let Umami collect new data going forward
2. **Keep the original CSV** - Store as backup for future reference
3. **Update your website** - Replace Independent Analytics tracking with Umami tracking
4. **Monitor for a few days** - Verify Umami is collecting data correctly
## Verification
### Check Generated Files
```bash
# Verify JSON file
ls -lh data/umami-import.json
head -20 data/umami-import.json
# Verify SQL file
ls -lh data/umami-import.sql
head -20 data/umami-import.sql
```
### Expected Results
- ✅ JSON file: ~2.1 MB, 7,634 records
- ✅ SQL file: ~1.8 MB, 5,250 statements
- ✅ Both files contain valid data for Umami import
## Next Steps
1. **Set up Umami instance** (if not already done)
2. **Create a website** in Umami dashboard
3. **Get your Website ID** and API key
4. **Run the migration script** with your credentials
5. **Import the data** using your preferred method
6. **Verify the migration** in Umami dashboard
7. **Update your website** to use Umami tracking code
8. **Monitor for a few days** to ensure data collection works
## Troubleshooting
### Issue: "ModuleNotFoundError"
**Solution:** Ensure Python 3 is installed: `python3 --version`
### Issue: "Permission denied"
**Solution:** Make script executable: `chmod +x scripts/migrate-analytics-to-umami.py`
### Issue: API import fails
**Solution:** Check API key, website ID, and Umami instance accessibility
### Issue: SQL import fails
**Solution:** Verify database credentials and run migrations first
## Support Resources
- **Umami Documentation:** https://umami.is/docs
- **Umami GitHub:** https://github.com/umami-software/umami
- **Independent Analytics:** https://independentanalytics.com/
## Summary
**Completed:**
- Created migration script with 3 output formats
- Generated JSON import file (2.1 MB, 7,634 events)
- Generated SQL import file (1.8 MB, 5,250 statements)
- Created comprehensive documentation
📊 **Data Migrated:**
- 220 unique pages
- 7,634 simulated page view events
- Historical view counts and durations
🎯 **Ready for Import:**
- Choose API or SQL import method
- Follow instructions in `scripts/README-migration.md`
- Verify data in Umami dashboard
**Migration Date:** 2026-01-25
**Source:** Independent Analytics v2.9.7
**Target:** Umami Analytics
**Site ID:** klz-cables

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
@@ -42,11 +44,12 @@ 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
@@ -61,6 +64,7 @@ npm run cms:migrate
```
### Environment Variables
```bash
# .env
SITE_URL=https://klz-cables.com
@@ -69,8 +73,8 @@ TURNSTILE_SITE_KEY=your_turnstile_key
TURNSTILE_SECRET_KEY=your_turnstile_secret
# Umami
NEXT_PUBLIC_UMAMI_WEBSITE_ID=your_umami_website_id
NEXT_PUBLIC_UMAMI_SCRIPT_URL=https://analytics.infra.mintel.me/script.js
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
@@ -81,6 +85,7 @@ 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)
@@ -91,6 +96,7 @@ NEXT_PUBLIC_SENTRY_DSN=https://PUBLIC_KEY@errors.infra.mintel.me/PROJECT_ID
- **Translation Pairs**: 16
### Performance Benefits
- **Before**: Dynamic WordPress with database queries
- **After**: Static HTML with CDN delivery
- **Load Time**: <100ms (vs 500ms+)
@@ -99,6 +105,7 @@ NEXT_PUBLIC_SENTRY_DSN=https://PUBLIC_KEY@errors.infra.mintel.me/PROJECT_ID
## 🏗️ Architecture
### Tech Stack
- **Framework**: Next.js 14 (App Router)
- **Language**: TypeScript
- **Styling**: SCSS
@@ -109,6 +116,7 @@ NEXT_PUBLIC_SENTRY_DSN=https://PUBLIC_KEY@errors.infra.mintel.me/PROJECT_ID
- **CAPTCHA**: Cloudflare Turnstile
### Project Structure
```
app/
├── layout.tsx # Root layout
@@ -163,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
@@ -175,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
@@ -188,6 +199,7 @@ scripts/
## 📝 Content Management
### Data Export
```bash
# Export from WordPress
npm run data:export
@@ -203,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
@@ -210,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
@@ -228,6 +244,7 @@ npm run data:improve-mapping
## 🔧 API Endpoints
### Contact Form
```
POST /api/contact
{
@@ -239,11 +256,13 @@ POST /api/contact
```
### Sitemap
```
GET /sitemap.xml
```
### Robots
```
GET /robots.txt
```
@@ -261,6 +280,7 @@ The project uses **Gitea Actions** for CI/CD. Every push to `main` or `staging`
**Workflow**: `.gitea/workflows/deploy.yml`
**Branch Deployments**:
- `main` branch: Deploys to production using `.env.prod`
- `staging` branch: Deploys to staging using `.env.staging`
@@ -268,12 +288,13 @@ The project uses **Gitea Actions** for CI/CD. Every push to `main` or `staging`
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`)
- `NEXT_PUBLIC_UMAMI_WEBSITE_ID` - Analytics ID
- `NEXT_PUBLIC_UMAMI_SCRIPT_URL` - Analytics script URL
- `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
@@ -293,6 +314,7 @@ docker image prune -f
```
Or use the convenience script:
```bash
bash scripts/deploy-webhook.sh
```
@@ -304,11 +326,13 @@ 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)
@@ -317,25 +341,30 @@ For detailed deployment documentation, see [`docs/DEPLOYMENT.md`](docs/DEPLOYMEN
## 📈 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)
@@ -343,6 +372,7 @@ For detailed deployment documentation, see [`docs/DEPLOYMENT.md`](docs/DEPLOYMEN
## 🎓 WordPress Specifics
### WPBakery Shortcodes Removed
- `[vc_row]`, `[vc_column]`, `[vc_column_text]`
- `[nectar_*]` (Salient theme)
- `[image_with_animation]`
@@ -350,13 +380,16 @@ For detailed deployment documentation, see [`docs/DEPLOYMENT.md`](docs/DEPLOYMEN
- `[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/...
```
@@ -364,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)
@@ -379,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
@@ -404,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

View File

@@ -9,10 +9,10 @@ import { getOGImageMetadata } from '@/lib/metadata';
import { SITE_URL } from '@/lib/schema';
interface PageProps {
params: {
params: Promise<{
locale: string;
slug: string;
};
}>;
}
export async function generateStaticParams() {
@@ -29,7 +29,8 @@ export async function generateStaticParams() {
return params;
}
export async function generateMetadata({ params: { locale, slug } }: PageProps): Promise<Metadata> {
export async function generateMetadata({ params }: PageProps): Promise<Metadata> {
const { locale, slug } = await params;
const pageData = await getPageBySlug(slug, locale);
if (!pageData) return {};
@@ -59,7 +60,8 @@ export async function generateMetadata({ params: { locale, slug } }: PageProps):
};
}
export default async function StandardPage({ params: { locale, slug } }: PageProps) {
export default async function StandardPage({ params }: PageProps) {
const { locale, slug } = await params;
const pageData = await getPageBySlug(slug, locale);
const t = await getTranslations('StandardPage');

View File

@@ -9,11 +9,11 @@ export const runtime = 'nodejs';
export async function GET(
request: NextRequest,
{ params }: { params: { locale: string } }
{ params }: { params: Promise<{ locale: string }> },
) {
const { searchParams, origin } = new URL(request.url);
const slug = searchParams.get('slug');
const locale = params.locale || 'en';
const { locale } = await params;
if (!slug) {
return new Response('Missing slug', { status: 400 });
@@ -23,24 +23,29 @@ export async function GET(
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'];
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`) : '';
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"
/>
),
<OGImageTemplate title={categoryTitle} description={categoryDesc} label="Product Category" />,
{
...OG_IMAGE_SIZE,
fonts,
}
},
);
}
@@ -51,24 +56,21 @@ export async function GET(
}
const featuredImage = product.frontmatter.images?.[0]
? (product.frontmatter.images[0].startsWith('http')
? product.frontmatter.images[0].startsWith('http')
? product.frontmatter.images[0]
: `${origin}${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}
/>
),
<OGImageTemplate
title={product.frontmatter.title}
description={product.frontmatter.description}
label={product.frontmatter.categories?.[0] || 'Product'}
image={featuredImage}
/>,
{
...OG_IMAGE_SIZE,
fonts,
}
},
);
}

View File

@@ -14,15 +14,14 @@ 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 {};
@@ -56,7 +55,8 @@ export async function generateMetadata({
};
}
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);

View File

@@ -7,12 +7,13 @@ 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'),
@@ -39,7 +40,8 @@ export async function generateMetadata({ params: { locale } }: BlogIndexProps) {
};
}
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);

View File

@@ -7,25 +7,16 @@ import { getTranslations } from 'next-intl/server';
import { SITE_URL } from '@/lib/schema';
import { getOGImageMetadata } from '@/lib/metadata';
import { Suspense } 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>
),
});
import ContactMap from '@/components/ContactMap';
interface ContactPageProps {
params: {
params: Promise<{
locale: string;
};
}>;
}
export async function generateMetadata({
params: { locale },
}: ContactPageProps): Promise<Metadata> {
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');
@@ -66,7 +57,7 @@ export async function generateStaticParams() {
}
export default async function ContactPage({ params }: ContactPageProps) {
const { locale } = params;
const { locale } = await params;
const t = await getTranslations({ locale, namespace: 'Contact' });
return (
<div className="flex flex-col min-h-screen bg-neutral-light">
@@ -249,7 +240,7 @@ export default async function ContactPage({ params }: ContactPageProps) {
</div>
}
>
<LeafletMap address={t('info.address')} lat={48.8144} lng={9.4144} />
<ContactMap address={t('info.address')} lat={48.8144} lng={9.4144} />
</Suspense>
</section>
</div>

View File

@@ -3,11 +3,13 @@ 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 const metadata: Metadata = {
metadataBase: new URL(SITE_URL),
@@ -31,27 +33,57 @@ export const viewport: Viewport = {
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>
<Footer />
<CMSConnectivityNotice />
{/* Sends pageviews for client-side navigations */}
<AnalyticsProvider />
{config.feedbackEnabled && <FeedbackOverlay />}
</NextIntlClientProvider>
</body>
</html>

View File

@@ -15,7 +15,12 @@ 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
@@ -55,10 +60,11 @@ export default function HomePage({ params: { locale } }: { params: { locale: str
}
export async function generateMetadata({
params: { locale },
params,
}: {
params: { locale: string };
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;

View File

@@ -19,14 +19,14 @@ import Link from 'next/link';
import { notFound } from 'next/navigation';
interface ProductPageProps {
params: {
params: Promise<{
locale: string;
slug: string[];
};
}>;
}
export async function generateMetadata({ params }: ProductPageProps): Promise<Metadata> {
const { locale, slug } = params;
const { locale, slug } = await params;
const productSlug = slug[slug.length - 1];
const t = await getTranslations('Products');
@@ -169,7 +169,7 @@ const components = {
};
export default async function ProductPage({ params }: ProductPageProps) {
const { locale, slug } = params;
const { locale, slug } = await params;
const productSlug = slug[slug.length - 1];
const t = await getTranslations('Products');

View File

@@ -10,14 +10,13 @@ import { getOGImageMetadata } from '@/lib/metadata';
import { SITE_URL } from '@/lib/schema';
interface ProductsPageProps {
params: {
params: Promise<{
locale: string;
};
}>;
}
export async function generateMetadata({
params: { locale },
}: ProductsPageProps): Promise<Metadata> {
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');
@@ -47,13 +46,14 @@ export async function generateMetadata({
}
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', params.locale);
const mediumVoltageSlug = await mapFileSlugToTranslated('medium-voltage-cables', params.locale);
const highVoltageSlug = await mapFileSlugToTranslated('high-voltage-cables', params.locale);
const solarSlug = await mapFileSlugToTranslated('solar-cables', params.locale);
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 = [
{
@@ -61,28 +61,28 @@ export default async function ProductsPage({ params }: ProductsPageProps) {
desc: t('categories.lowVoltage.description'),
img: '/uploads/2024/11/low-voltage-category.webp',
icon: '/uploads/2024/11/Low-Voltage.svg',
href: `/${params.locale}/products/${lowVoltageSlug}`,
href: `/${locale}/products/${lowVoltageSlug}`,
},
{
title: t('categories.mediumVoltage.title'),
desc: t('categories.mediumVoltage.description'),
img: '/uploads/2024/11/medium-voltage-category.webp',
icon: '/uploads/2024/11/Medium-Voltage.svg',
href: `/${params.locale}/products/${mediumVoltageSlug}`,
href: `/${locale}/products/${mediumVoltageSlug}`,
},
{
title: t('categories.highVoltage.title'),
desc: t('categories.highVoltage.description'),
img: '/uploads/2024/11/high-voltage-category.webp',
icon: '/uploads/2024/11/High-Voltage.svg',
href: `/${params.locale}/products/${highVoltageSlug}`,
href: `/${locale}/products/${highVoltageSlug}`,
},
{
title: t('categories.solar.title'),
desc: t('categories.solar.description'),
img: '/uploads/2024/11/solar-category.webp',
icon: '/uploads/2024/11/Solar.svg',
href: `/${params.locale}/products/${solarSlug}`,
href: `/${locale}/products/${solarSlug}`,
},
];
@@ -218,7 +218,7 @@ export default async function ProductsPage({ params }: ProductsPageProps) {
</p>
</div>
<Button
href={`/${params.locale}/contact`}
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"

View File

@@ -9,12 +9,13 @@ import Reveal from '@/components/Reveal';
import Gallery from '@/components/team/Gallery';
interface TeamPageProps {
params: {
params: Promise<{
locale: string;
};
}>;
}
export async function generateMetadata({ params: { locale } }: TeamPageProps): Promise<Metadata> {
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');
@@ -43,7 +44,8 @@ export async function generateMetadata({ params: { locale } }: TeamPageProps): P
};
}
export default async function TeamPage({ params: { locale } }: TeamPageProps) {
export default async function TeamPage({ params }: TeamPageProps) {
const { locale } = await params;
const t = await getTranslations({ locale, namespace: 'Team' });
return (

View File

@@ -1,72 +1,153 @@
"use server";
'use server';
import client, { ensureAuthenticated } from "@/lib/directus";
import { createItem } from "@directus/sdk";
import { sendEmail } from "@/lib/mail/mailer";
import ContactEmail from "@/components/emails/ContactEmail";
import React from "react";
import { getServerAppServices } from "@/lib/services/create-services.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' });
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;
// 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" };
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
}));
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
}));
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 });
// We continue anyway to try sending the email, but maybe we should report this
services.errors.captureException(error, { action: 'directus_store_submission' });
}
// 2. Send Email
logger.info('Sending contact form email', { email, productName });
// 2. Send Emails
logger.info('Sending branded emails', { email, productName });
const subject = productName
const notificationSubject = productName
? `Product Inquiry: ${productName}`
: "New Contact Form Submission";
: 'New Contact Form Submission';
const confirmationSubject = 'Thank you for your inquiry';
const result = await sendEmail({
subject,
template: React.createElement(ContactEmail, {
name,
email,
message,
productName: productName || undefined,
subject,
}),
});
try {
// 2a. Send notification to Mintel/Client
const notificationHtml = await render(
React.createElement(ContactFormNotification, {
name,
email,
message,
productName: productName || undefined,
}),
);
if (result.success) {
logger.info('Contact form email sent successfully', { messageId: result.messageId });
} else {
logger.error('Failed to send contact form email', { error: result.error });
services.errors.captureException(result.error, { action: 'sendContactFormAction', email });
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 };
}
return result;
}

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,
});
}

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

View File

@@ -1,8 +1,10 @@
import { config } from '@/lib/config';
import { MetadataRoute } from 'next';
import { getAllProducts } from '@/lib/mdx';
import { getAllPosts } from '@/lib/blog';
import { getAllPages } from '@/lib/pages';
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';
@@ -34,11 +36,10 @@ export default async function sitemap(): Promise<MetadataRoute.Sitemap> {
}
// Products
const products = await getAllProducts(locale);
for (const product of products) {
// We need to find the category for the product to build the URL
// In this project, products are under /products/[category]/[slug]
// The category is in product.frontmatter.categories
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({
@@ -50,8 +51,10 @@ export default async function sitemap(): Promise<MetadataRoute.Sitemap> {
}
// Blog posts
const posts = await getAllPosts(locale);
for (const post of 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),
@@ -61,8 +64,10 @@ export default async function sitemap(): Promise<MetadataRoute.Sitemap> {
}
// Static pages
const pages = await getAllPages(locale);
for (const page of 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(),

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

View File

@@ -2,6 +2,7 @@
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');
@@ -12,14 +13,12 @@ export default function CMSConnectivityNotice() {
// 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 =
window.location.hostname === 'localhost' || window.location.hostname.includes('127.0.0.1');
const isStaging =
window.location.hostname.includes('staging') ||
window.location.hostname.includes('testing');
const isLocal = config.isDevelopment;
const isTesting = config.isTesting;
// Only proceed with check if it's developer context
if (!isLocal && !isStaging && !isDebug) return;
// 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');

View File

@@ -17,10 +17,10 @@ export default function ContactForm() {
const formData = new FormData(e.currentTarget);
const email = formData.get('email') as string;
try {
const result = await sendContactFormAction(formData);
if (result.success) {
if (result?.success) {
trackEvent('contact_form_submission', {
form_type: 'general',
email,
@@ -41,7 +41,12 @@ export default function ContactForm() {
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">
<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>
@@ -49,7 +54,8 @@ export default function ContactForm() {
{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.'}
{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'}
@@ -62,7 +68,13 @@ export default function ContactForm() {
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">
<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" />
@@ -74,7 +86,12 @@ export default function ContactForm() {
<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="saturated" size="lg" className="w-full bg-destructive hover:bg-destructive/90 text-destructive-foreground border-destructive shadow-lg shadow-destructive/20">
<Button
onClick={() => setStatus('idle')}
variant="destructive"
size="lg"
className="w-full"
>
{t('form.tryAgain') || 'Try Again'}
</Button>
</Card>
@@ -89,9 +106,9 @@ export default function ContactForm() {
<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"
<Input
type="text"
id="name"
name="name"
autoComplete="name"
enterKeyHint="next"
@@ -101,9 +118,9 @@ export default function ContactForm() {
</div>
<div className="space-y-1 md:space-y-2">
<Label htmlFor="email">{t('form.email')}</Label>
<Input
type="email"
id="email"
<Input
type="email"
id="email"
name="email"
autoComplete="email"
inputMode="email"
@@ -114,32 +131,50 @@ export default function ContactForm() {
</div>
<div className="md:col-span-2 space-y-1 md:space-y-2">
<Label htmlFor="message">{t('form.message')}</Label>
<Textarea
id="message"
<Textarea
id="message"
name="message"
rows={4}
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"
<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
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')}
) : (
t('form.submit')
)}
</Button>
</div>
</form>

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

@@ -49,14 +49,28 @@ export default function RequestQuoteForm({ productName }: RequestQuoteFormProps)
if (status === 'success') {
return (
<div className="bg-accent/5 border border-accent/20 text-primary-dark p-4 rounded-xl text-center animate-fade-in !mt-0">
<div className="w-10 h-10 bg-accent rounded-full flex items-center justify-center mx-auto mb-3 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 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-base font-extrabold mb-1 tracking-tight !mt-0">{t('successTitle')}</h3>
<p className="text-text-secondary text-xs leading-tight mb-4 !mt-0">
<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
@@ -73,26 +87,36 @@ 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">
<div className="w-10 h-10 bg-destructive rounded-full flex items-center justify-center mx-auto mb-3 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 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">{t('errorTitle') || 'Submission Failed'}</h3>
<p className="text-destructive/80 text-xs leading-tight mb-4 !mt-0">
<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
<Button
onClick={() => setStatus('idle')}
className="inline-flex items-center text-[9px] font-bold uppercase tracking-[0.2em] text-destructive hover:text-destructive/80 transition-colors group"
variant="destructive"
size="sm"
className="w-full"
>
<span className="border-b-2 border-destructive/10 group-hover:border-destructive/30 transition-colors pb-1">
{t('tryAgain') || 'Try Again'}
</span>
</button>
{t('tryAgain') || 'Try Again'}
</Button>
</div>
);
}
@@ -133,22 +157,48 @@ export default function RequestQuoteForm({ productName }: RequestQuoteFormProps)
>
{status === 'submitting' ? (
<>
<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
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-xs">{t('submitting')}</span>
</>
) : (
<>
<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
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-[7px] text-center text-text-secondary/40 uppercase tracking-[0.15em] font-medium px-2 !mt-1 !mb-0">
{t('privacyNote')}
</p>

View File

@@ -3,25 +3,15 @@
import { useEffect } from 'react';
import { usePathname, useSearchParams } from 'next/navigation';
import { getAppServices } from '@/lib/services/create-services';
import Script from 'next/script';
/**
* AnalyticsProvider Component
*
* Automatically tracks pageviews on client-side route changes.
* This component should be placed inside your layout to handle navigation events.
* This component handles navigation events for the Umami analytics service.
*
* @example
* ```tsx
* // In your layout.tsx
* <NextIntlClientProvider messages={messages} locale={locale}>
* <UmamiScript />
* <Header />
* <main>{children}</main>
* <Footer />
* <AnalyticsProvider />
* </NextIntlClientProvider>
* ```
* 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();
@@ -29,31 +19,17 @@ export default function AnalyticsProvider() {
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);
if (process.env.NODE_ENV === 'development') {
console.log('[Umami] Tracked pageview:', url);
}
// Services like logger are already sub-initialized in getAppServices()
// so we don't need to log here manually.
}, [pathname, searchParams]);
const websiteId = process.env.NEXT_PUBLIC_UMAMI_WEBSITE_ID;
if (!websiteId) return null;
return (
<Script
id="umami-analytics"
src="/stats/script.js"
data-website-id={websiteId}
data-host-url="/stats"
strategy="afterInteractive"
data-domains="klz-cables.com"
defer
/>
);
return null;
}

View File

@@ -72,7 +72,7 @@ export default function Hero() {
>
<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 }}

View File

@@ -3,24 +3,43 @@ import Link from 'next/link';
import { cn } from './utils';
export interface ButtonProps extends React.ButtonHTMLAttributes<HTMLButtonElement> {
variant?: 'primary' | 'secondary' | 'accent' | 'saturated' | 'outline' | 'ghost' | 'white';
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';
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',
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',
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 = {
@@ -40,20 +59,25 @@ export function Button({ className, variant = 'primary', size = 'md', href, ...p
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" : ""
)}>
<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]
)} />
<span
className={cn(
'absolute inset-0 translate-y-[101%] group-hover/btn:translate-y-0 transition-transform duration-500 ease-out',
overlayColors[variant],
)}
/>
</>
);

Binary file not shown.

View File

@@ -398,6 +398,24 @@ locale: de
"55",
"4195"
]
},
{
"configuration": "1x1200/35",
"cells": [
"Al",
"RM",
"0,95",
"48,5",
"0,0247",
"3,4",
"Auf Anfrage",
"Auf Anfrage",
"113",
"2,4",
"885",
"59",
"4800"
]
}
]
},
@@ -737,6 +755,24 @@ locale: de
"60",
"4634"
]
},
{
"configuration": "1x1200/35",
"cells": [
"Al",
"RM",
"1,05",
"52,3",
"0,0247",
"5,5",
"Auf Anfrage",
"Auf Anfrage",
"113",
"2,4",
"990",
"66",
"5200"
]
}
]
},
@@ -1076,6 +1112,24 @@ locale: de
"65",
"5093"
]
},
{
"configuration": "1x1200/35",
"cells": [
"Al",
"RM",
"1,15",
"57,5",
"0,0247",
"8,0",
"Auf Anfrage",
"Auf Anfrage",
"113",
"2,4",
"1065",
"71",
"5900"
]
}
]
}

View File

@@ -398,6 +398,24 @@ locale: en
"55",
"4195"
]
},
{
"configuration": "1x1200/35",
"cells": [
"Al",
"RM",
"0.95",
"48.5",
"0.0247",
"3.4",
"On Request",
"On Request",
"113",
"2.4",
"885",
"59",
"4800"
]
}
]
},
@@ -737,6 +755,24 @@ locale: en
"60",
"4634"
]
},
{
"configuration": "1x1200/35",
"cells": [
"Al",
"RM",
"1.05",
"52.3",
"0.0247",
"5.5",
"On Request",
"On Request",
"113",
"2.4",
"990",
"66",
"5200"
]
}
]
},
@@ -1076,6 +1112,24 @@ locale: en
"65",
"5093"
]
},
{
"configuration": "1x1200/35",
"cells": [
"Al",
"RM",
"1.15",
"57.5",
"0.0247",
"8",
"On Request",
"On Request",
"113",
"2.4",
"1065",
"71",
"5900"
]
}
]
}

View File

View File

@@ -0,0 +1,590 @@
version: 1
directus: 11.14.1
vendor: postgres
collections:
- collection: contact_submissions
meta:
accountability: all
archive_app_filter: true
archive_field: null
archive_value: null
collapse: open
collection: contact_submissions
color: '#002b49'
display_template: '{{first_name}} {{last_name}} | {{subject}}'
group: null
hidden: false
icon: contact_mail
item_duplication_fields: null
note: null
preview_url: null
singleton: false
sort: null
sort_field: null
translations: null
unarchive_value: null
versioning: false
schema:
name: contact_submissions
- collection: product_requests
meta:
accountability: all
archive_app_filter: true
archive_field: null
archive_value: null
collapse: open
collection: product_requests
color: '#002b49'
display_template: null
group: null
hidden: false
icon: inventory
item_duplication_fields: null
note: null
preview_url: null
singleton: false
sort: null
sort_field: null
translations: null
unarchive_value: null
versioning: false
schema:
name: product_requests
- collection: visual_feedback
meta:
accountability: all
archive_app_filter: true
archive_field: null
archive_value: null
collapse: open
collection: visual_feedback
color: '#002b49'
display_template: '{{user_name}} | {{type}}: {{text}}'
group: null
hidden: false
icon: feedback
item_duplication_fields: null
note: null
preview_url: null
singleton: false
sort: null
sort_field: null
translations: null
unarchive_value: null
versioning: false
schema:
name: visual_feedback
- collection: visual_feedback_comments
meta:
accountability: all
archive_app_filter: true
archive_field: null
archive_value: null
collapse: open
collection: visual_feedback_comments
color: '#002b49'
display_template: '{{user_name}}: {{text}}'
group: null
hidden: false
icon: comment
item_duplication_fields: null
note: null
preview_url: null
singleton: false
sort: null
sort_field: null
translations: null
unarchive_value: null
versioning: false
schema:
name: visual_feedback_comments
fields:
- collection: visual_feedback
field: id
type: uuid
meta:
collection: visual_feedback
conditions: null
display: null
display_options: null
field: id
group: null
hidden: true
interface: null
note: null
options: null
readonly: false
required: false
searchable: true
sort: 1
special: null
translations: null
validation: null
validation_message: null
width: full
schema:
name: id
table: visual_feedback
data_type: uuid
default_value: null
max_length: null
numeric_precision: null
numeric_scale: null
is_nullable: false
is_unique: true
is_indexed: false
is_primary_key: true
is_generated: false
generation_expression: null
has_auto_increment: false
foreign_key_table: null
foreign_key_column: null
- collection: visual_feedback
field: status
type: string
meta:
collection: visual_feedback
conditions: null
display: labels
display_options:
choices:
- background: '#e1f5fe'
color: '#01579b'
text: Open
value: open
- background: '#e8f5e9'
color: '#1b5e20'
text: Resolved
value: resolved
- background: '#fafafa'
color: '#212121'
text: Closed
value: closed
show_as_dot: true
field: status
group: null
hidden: false
interface: select-dropdown
note: null
options:
choices:
- text: Open
value: open
- text: Resolved
value: resolved
- text: Closed
value: closed
readonly: false
required: false
searchable: true
sort: 2
special: null
translations: null
validation: null
validation_message: null
width: half
schema:
name: status
table: visual_feedback
data_type: character varying
default_value: open
max_length: 255
numeric_precision: null
numeric_scale: null
is_nullable: true
is_unique: false
is_indexed: false
is_primary_key: false
is_generated: false
generation_expression: null
has_auto_increment: false
foreign_key_table: null
foreign_key_column: null
- collection: visual_feedback
field: type
type: string
meta:
collection: visual_feedback
conditions: null
display: labels
display_options:
choices:
- background: '#fff9c4'
color: '#fbc02d'
text: Design
value: design
- background: '#f3e5f5'
color: '#7b1fa2'
text: Content
value: content
show_as_dot: true
field: type
group: null
hidden: false
interface: select-dropdown
note: null
options:
choices:
- text: Design
value: design
- text: Content
value: content
readonly: false
required: false
searchable: true
sort: 3
special: null
translations: null
validation: null
validation_message: null
width: half
schema:
name: type
table: visual_feedback
data_type: character varying
default_value: null
max_length: 255
numeric_precision: null
numeric_scale: null
is_nullable: true
is_unique: false
is_indexed: false
is_primary_key: false
is_generated: false
generation_expression: null
has_auto_increment: false
foreign_key_table: null
foreign_key_column: null
- collection: visual_feedback
field: text
type: text
meta:
collection: visual_feedback
conditions: null
display: formatted-text
display_options:
soft_limit: 100
field: text
group: null
hidden: false
interface: input-multiline
note: null
options: null
readonly: false
required: false
searchable: true
sort: 4
special: null
translations: null
validation: null
validation_message: null
width: full
schema:
name: text
table: visual_feedback
data_type: text
default_value: null
max_length: null
numeric_precision: null
numeric_scale: null
is_nullable: true
is_unique: false
is_indexed: false
is_primary_key: false
is_generated: false
generation_expression: null
has_auto_increment: false
foreign_key_table: null
foreign_key_column: null
- collection: visual_feedback
field: url
type: string
meta:
collection: visual_feedback
conditions: null
display: link
display_options:
url: '{{url}}'
target: _blank
icon: open_in_new
field: url
group: null
hidden: false
interface: input
note: null
options: null
readonly: true
required: false
searchable: true
sort: 5
special: null
translations: null
validation: null
validation_message: null
width: full
schema:
name: url
table: visual_feedback
data_type: character varying
default_value: null
max_length: 255
numeric_precision: null
numeric_scale: null
is_nullable: true
is_unique: false
is_indexed: false
is_primary_key: false
is_generated: false
generation_expression: null
has_auto_increment: false
foreign_key_table: null
foreign_key_column: null
- collection: visual_feedback
field: user_info_group
type: alias
meta:
collection: visual_feedback
field: user_info_group
group: null
hidden: false
interface: group-detail
options:
header_icon: person
header_text: User Information
sort: 6
special:
- alias
- no-data
- group
width: full
- collection: visual_feedback
field: user_name
type: string
meta:
collection: visual_feedback
field: user_name
group: user_info_group
hidden: false
interface: input
sort: 1
width: half
schema:
name: user_name
table: visual_feedback
data_type: character varying
is_nullable: true
- collection: visual_feedback
field: user_identity
type: string
meta:
collection: visual_feedback
field: user_identity
group: user_info_group
hidden: false
interface: input
readonly: true
sort: 2
width: half
schema:
name: user_identity
table: visual_feedback
data_type: character varying
is_nullable: true
- collection: visual_feedback
field: technical_details_group
type: alias
meta:
collection: visual_feedback
field: technical_details_group
group: null
hidden: false
interface: group-detail
options:
header_icon: psychology
header_text: Technical Context
sort: 7
special:
- alias
- no-data
- group
width: full
- collection: visual_feedback
field: selector
type: string
meta:
collection: visual_feedback
field: selector
group: technical_details_group
hidden: false
interface: input
readonly: true
sort: 1
width: full
schema:
name: selector
table: visual_feedback
data_type: character varying
is_nullable: true
- collection: visual_feedback
field: x
type: float
meta:
collection: visual_feedback
field: x
group: technical_details_group
hidden: false
interface: input
sort: 2
width: half
schema:
name: x
table: visual_feedback
data_type: real
is_nullable: true
- collection: visual_feedback
field: 'y'
type: float
meta:
collection: visual_feedback
field: 'y'
group: technical_details_group
hidden: false
interface: input
sort: 3
width: half
schema:
name: 'y'
table: visual_feedback
data_type: real
is_nullable: true
- collection: visual_feedback
field: date_created
type: timestamp
meta:
collection: visual_feedback
conditions: null
display: datetime
display_options:
relative: true
field: date_created
group: null
hidden: false
interface: datetime
note: null
options: null
readonly: true
required: false
searchable: true
sort: 8
special: null
translations: null
validation: null
validation_message: null
width: full
schema:
name: date_created
table: visual_feedback
data_type: timestamp with time zone
default_value: CURRENT_TIMESTAMP
is_nullable: true
- collection: visual_feedback_comments
field: id
type: uuid
meta:
collection: visual_feedback_comments
field: id
hidden: true
schema:
name: id
table: visual_feedback_comments
data_type: uuid
is_primary_key: true
- collection: visual_feedback_comments
field: feedback_id
type: uuid
meta:
collection: visual_feedback_comments
display: null
field: feedback_id
interface: select-relational
sort: 2
width: full
schema:
name: feedback_id
table: visual_feedback_comments
data_type: uuid
- collection: visual_feedback_comments
field: user_name
type: string
meta:
collection: visual_feedback_comments
field: user_name
interface: input
sort: 3
width: half
schema:
name: user_name
table: visual_feedback_comments
data_type: character varying
- collection: visual_feedback_comments
field: text
type: text
meta:
collection: visual_feedback_comments
field: text
interface: input-multiline
sort: 4
width: full
schema:
name: text
table: visual_feedback_comments
data_type: text
- collection: visual_feedback_comments
field: date_created
type: timestamp
meta:
collection: visual_feedback_comments
display: datetime
display_options:
relative: true
field: date_created
interface: datetime
readonly: true
sort: 5
width: full
schema:
name: date_created
table: visual_feedback_comments
data_type: timestamp with time zone
default_value: CURRENT_TIMESTAMP
systemFields:
- collection: directus_activity
field: timestamp
schema:
is_indexed: true
- collection: directus_revisions
field: activity
schema:
is_indexed: true
- collection: directus_revisions
field: parent
schema:
is_indexed: true
relations:
- collection: visual_feedback_comments
field: feedback_id
related_collection: visual_feedback
schema:
column: feedback_id
foreign_key_column: id
foreign_key_table: visual_feedback
table: visual_feedback_comments
meta:
junction_field: null
many_collection: visual_feedback_comments
many_field: feedback_id
one_allowed_m2m: false
one_collection: visual_feedback
one_deselect_action: nullify
one_field: null
sort_field: null

View File

@@ -0,0 +1,590 @@
version: 1
directus: 11.14.1
vendor: postgres
collections:
- collection: contact_submissions
meta:
accountability: all
archive_app_filter: true
archive_field: null
archive_value: null
collapse: open
collection: contact_submissions
color: '#002b49'
display_template: '{{first_name}} {{last_name}} | {{subject}}'
group: null
hidden: false
icon: contact_mail
item_duplication_fields: null
note: null
preview_url: null
singleton: false
sort: null
sort_field: null
translations: null
unarchive_value: null
versioning: false
schema:
name: contact_submissions
- collection: product_requests
meta:
accountability: all
archive_app_filter: true
archive_field: null
archive_value: null
collapse: open
collection: product_requests
color: '#002b49'
display_template: null
group: null
hidden: false
icon: inventory
item_duplication_fields: null
note: null
preview_url: null
singleton: false
sort: null
sort_field: null
translations: null
unarchive_value: null
versioning: false
schema:
name: product_requests
- collection: visual_feedback
meta:
accountability: all
archive_app_filter: true
archive_field: null
archive_value: null
collapse: open
collection: visual_feedback
color: '#002b49'
display_template: '{{user_name}} | {{type}}: {{text}}'
group: null
hidden: false
icon: feedback
item_duplication_fields: null
note: null
preview_url: null
singleton: false
sort: null
sort_field: null
translations: null
unarchive_value: null
versioning: false
schema:
name: visual_feedback
- collection: visual_feedback_comments
meta:
accountability: all
archive_app_filter: true
archive_field: null
archive_value: null
collapse: open
collection: visual_feedback_comments
color: '#002b49'
display_template: '{{user_name}}: {{text}}'
group: null
hidden: false
icon: comment
item_duplication_fields: null
note: null
preview_url: null
singleton: false
sort: null
sort_field: null
translations: null
unarchive_value: null
versioning: false
schema:
name: visual_feedback_comments
fields:
- collection: visual_feedback
field: id
type: uuid
meta:
collection: visual_feedback
conditions: null
display: null
display_options: null
field: id
group: null
hidden: true
interface: null
note: null
options: null
readonly: false
required: false
searchable: true
sort: 1
special: null
translations: null
validation: null
validation_message: null
width: full
schema:
name: id
table: visual_feedback
data_type: uuid
default_value: null
max_length: null
numeric_precision: null
numeric_scale: null
is_nullable: false
is_unique: true
is_indexed: false
is_primary_key: true
is_generated: false
generation_expression: null
has_auto_increment: false
foreign_key_table: null
foreign_key_column: null
- collection: visual_feedback
field: status
type: string
meta:
collection: visual_feedback
conditions: null
display: labels
display_options:
choices:
- background: '#e1f5fe'
color: '#01579b'
text: Open
value: open
- background: '#e8f5e9'
color: '#1b5e20'
text: Resolved
value: resolved
- background: '#fafafa'
color: '#212121'
text: Closed
value: closed
show_as_dot: true
field: status
group: null
hidden: false
interface: select-dropdown
note: null
options:
choices:
- text: Open
value: open
- text: Resolved
value: resolved
- text: Closed
value: closed
readonly: false
required: false
searchable: true
sort: 2
special: null
translations: null
validation: null
validation_message: null
width: half
schema:
name: status
table: visual_feedback
data_type: character varying
default_value: open
max_length: 255
numeric_precision: null
numeric_scale: null
is_nullable: true
is_unique: false
is_indexed: false
is_primary_key: false
is_generated: false
generation_expression: null
has_auto_increment: false
foreign_key_table: null
foreign_key_column: null
- collection: visual_feedback
field: type
type: string
meta:
collection: visual_feedback
conditions: null
display: labels
display_options:
choices:
- background: '#fff9c4'
color: '#fbc02d'
text: Design
value: design
- background: '#f3e5f5'
color: '#7b1fa2'
text: Content
value: content
show_as_dot: true
field: type
group: null
hidden: false
interface: select-dropdown
note: null
options:
choices:
- text: Design
value: design
- text: Content
value: content
readonly: false
required: false
searchable: true
sort: 3
special: null
translations: null
validation: null
validation_message: null
width: half
schema:
name: type
table: visual_feedback
data_type: character varying
default_value: null
max_length: 255
numeric_precision: null
numeric_scale: null
is_nullable: true
is_unique: false
is_indexed: false
is_primary_key: false
is_generated: false
generation_expression: null
has_auto_increment: false
foreign_key_table: null
foreign_key_column: null
- collection: visual_feedback
field: text
type: text
meta:
collection: visual_feedback
conditions: null
display: formatted-text
display_options:
soft_limit: 100
field: text
group: null
hidden: false
interface: input-multiline
note: null
options: null
readonly: false
required: false
searchable: true
sort: 4
special: null
translations: null
validation: null
validation_message: null
width: full
schema:
name: text
table: visual_feedback
data_type: text
default_value: null
max_length: null
numeric_precision: null
numeric_scale: null
is_nullable: true
is_unique: false
is_indexed: false
is_primary_key: false
is_generated: false
generation_expression: null
has_auto_increment: false
foreign_key_table: null
foreign_key_column: null
- collection: visual_feedback
field: url
type: string
meta:
collection: visual_feedback
conditions: null
display: link
display_options:
url: '{{url}}'
target: _blank
icon: open_in_new
field: url
group: null
hidden: false
interface: input
note: null
options: null
readonly: true
required: false
searchable: true
sort: 5
special: null
translations: null
validation: null
validation_message: null
width: full
schema:
name: url
table: visual_feedback
data_type: character varying
default_value: null
max_length: 255
numeric_precision: null
numeric_scale: null
is_nullable: true
is_unique: false
is_indexed: false
is_primary_key: false
is_generated: false
generation_expression: null
has_auto_increment: false
foreign_key_table: null
foreign_key_column: null
- collection: visual_feedback
field: user_info_group
type: alias
meta:
collection: visual_feedback
field: user_info_group
group: null
hidden: false
interface: group-detail
options:
header_icon: person
header_text: User Information
sort: 6
special:
- alias
- no-data
- group
width: full
- collection: visual_feedback
field: user_name
type: string
meta:
collection: visual_feedback
field: user_name
group: user_info_group
hidden: false
interface: input
sort: 1
width: half
schema:
name: user_name
table: visual_feedback
data_type: character varying
is_nullable: true
- collection: visual_feedback
field: user_identity
type: string
meta:
collection: visual_feedback
field: user_identity
group: user_info_group
hidden: false
interface: input
readonly: true
sort: 2
width: half
schema:
name: user_identity
table: visual_feedback
data_type: character varying
is_nullable: true
- collection: visual_feedback
field: technical_details_group
type: alias
meta:
collection: visual_feedback
field: technical_details_group
group: null
hidden: false
interface: group-detail
options:
header_icon: psychology
header_text: Technical Context
sort: 7
special:
- alias
- no-data
- group
width: full
- collection: visual_feedback
field: selector
type: string
meta:
collection: visual_feedback
field: selector
group: technical_details_group
hidden: false
interface: input
readonly: true
sort: 1
width: full
schema:
name: selector
table: visual_feedback
data_type: character varying
is_nullable: true
- collection: visual_feedback
field: x
type: float
meta:
collection: visual_feedback
field: x
group: technical_details_group
hidden: false
interface: input
sort: 2
width: half
schema:
name: x
table: visual_feedback
data_type: real
is_nullable: true
- collection: visual_feedback
field: 'y'
type: float
meta:
collection: visual_feedback
field: 'y'
group: technical_details_group
hidden: false
interface: input
sort: 3
width: half
schema:
name: 'y'
table: visual_feedback
data_type: real
is_nullable: true
- collection: visual_feedback
field: date_created
type: timestamp
meta:
collection: visual_feedback
conditions: null
display: datetime
display_options:
relative: true
field: date_created
group: null
hidden: false
interface: datetime
note: null
options: null
readonly: true
required: false
searchable: true
sort: 8
special: null
translations: null
validation: null
validation_message: null
width: full
schema:
name: date_created
table: visual_feedback
data_type: timestamp with time zone
default_value: CURRENT_TIMESTAMP
is_nullable: true
- collection: visual_feedback_comments
field: id
type: uuid
meta:
collection: visual_feedback_comments
field: id
hidden: true
schema:
name: id
table: visual_feedback_comments
data_type: uuid
is_primary_key: true
- collection: visual_feedback_comments
field: feedback_id
type: uuid
meta:
collection: visual_feedback_comments
display: null
field: feedback_id
interface: select-relational
sort: 2
width: full
schema:
name: feedback_id
table: visual_feedback_comments
data_type: uuid
- collection: visual_feedback_comments
field: user_name
type: string
meta:
collection: visual_feedback_comments
field: user_name
interface: input
sort: 3
width: half
schema:
name: user_name
table: visual_feedback_comments
data_type: character varying
- collection: visual_feedback_comments
field: text
type: text
meta:
collection: visual_feedback_comments
field: text
interface: input-multiline
sort: 4
width: full
schema:
name: text
table: visual_feedback_comments
data_type: text
- collection: visual_feedback_comments
field: date_created
type: timestamp
meta:
collection: visual_feedback_comments
display: datetime
display_options:
relative: true
field: date_created
interface: datetime
readonly: true
sort: 5
width: full
schema:
name: date_created
table: visual_feedback_comments
data_type: timestamp with time zone
default_value: CURRENT_TIMESTAMP
systemFields:
- collection: directus_activity
field: timestamp
schema:
is_indexed: true
- collection: directus_revisions
field: activity
schema:
is_indexed: true
- collection: directus_revisions
field: parent
schema:
is_indexed: true
relations:
- collection: visual_feedback_comments
field: feedback_id
related_collection: visual_feedback
schema:
column: feedback_id
foreign_key_column: id
foreign_key_table: visual_feedback
table: visual_feedback_comments
meta:
junction_field: null
many_collection: visual_feedback_comments
many_field: feedback_id
one_allowed_m2m: false
one_collection: visual_feedback
one_deselect_action: nullify
one_field: null
sort_field: null

View File

@@ -1,27 +1,83 @@
services:
app:
klz-app:
image: node:20-alpine
working_dir: /app
command: sh -c "npm install && npx next dev"
command: sh -c "npm install --legacy-peer-deps && npx next dev"
networks:
- default
- infra
volumes:
- .:/app
environment:
NODE_ENV: development
# Docker Internal Communication
DIRECTUS_URL: http://directus:8055
INTERNAL_DIRECTUS_URL: http://directus:8055
INFRA_DIRECTUS_URL: http://cms-infra-infra-cms-1:8055
GATEKEEPER_URL: http://gatekeeper:3000
DIRECTUS_API_TOKEN: ${DIRECTUS_API_TOKEN}
INFRA_DIRECTUS_TOKEN: ${INFRA_DIRECTUS_TOKEN}
NEXT_PUBLIC_FEEDBACK_ENABLED: ${NEXT_PUBLIC_FEEDBACK_ENABLED}
GATEKEEPER_BYPASS_ENABLED: ${GATEKEEPER_BYPASS_ENABLED}
ports:
- "3000:3000"
labels:
- "traefik.enable=true"
- "traefik.http.routers.klz-app-local.rule=Host(`klz.localhost`)"
- "traefik.http.routers.klz-app-local.entrypoints=web"
- "traefik.http.routers.klz-app-local.service=klz-cables"
# Global local settings
- "traefik.http.routers.klz-cables-local.entrypoints=web"
- "traefik.http.routers.klz-cables-local.rule=Host(`klz.localhost`)"
- "traefik.http.routers.klz-cables-local.tls=false"
- "traefik.http.routers.klz-cables-local.middlewares="
- "traefik.http.routers.klz-cables-local.service=klz-cables-local"
- "traefik.http.services.klz-cables-local.loadbalancer.server.port=3000"
- "traefik.docker.network=infra"
# Web direct router
- "traefik.http.routers.klz-cables-local-web.entrypoints=web"
- "traefik.http.routers.klz-cables-local-web.rule=Host(`klz.localhost`)"
- "traefik.http.routers.klz-cables-local-web.tls=false"
- "traefik.http.routers.klz-cables-local-web.middlewares="
- "traefik.http.routers.klz-cables-local-web.service=klz-cables-local"
directus:
networks:
- default
- infra
labels:
- "traefik.enable=true"
- "traefik.http.routers.klz-directus-local.rule=Host(`cms.klz.localhost`)"
- "traefik.http.routers.klz-directus-local.entrypoints=web"
- "traefik.http.routers.klz-directus-local.service=klz-directus"
- "traefik.http.routers.klz-cables-directus-local.entrypoints=web"
- "traefik.http.routers.klz-cables-directus-local.rule=Host(`cms.klz.localhost`)"
- "traefik.http.routers.klz-cables-directus-local.tls=false"
- "traefik.http.routers.klz-cables-directus-local.middlewares="
- "traefik.http.routers.klz-cables-directus-local.service=klz-cables-directus-local"
- "traefik.http.services.klz-cables-directus-local.loadbalancer.server.port=8055"
- "traefik.docker.network=infra"
ports:
- "8055:8055"
- "${DIRECTUS_PORT:-8055}:8055"
environment:
PUBLIC_URL: http://cms.klz.localhost
gatekeeper:
image: node:20-alpine
working_dir: /app/packages/gatekeeper
command: sh -c "corepack enable && CI=true NPM_TOKEN=dummy pnpm install --no-frozen-lockfile && pnpm dev"
volumes:
- /Users/marcmintel/Projects/at-mintel:/app
networks:
- default
- infra
environment:
DIRECTUS_URL: http://directus:8055
NEXT_PUBLIC_BASE_URL: http://gatekeeper.klz.localhost
DIRECTUS_ADMIN_EMAIL: ${DIRECTUS_ADMIN_EMAIL}
DIRECTUS_ADMIN_PASSWORD: ${DIRECTUS_ADMIN_PASSWORD}
COOKIE_DOMAIN: localhost
NODE_ENV: development
labels:
- "traefik.enable=true"
- "traefik.http.routers.klz-cables-gatekeeper-local.entrypoints=web"
- "traefik.http.routers.klz-cables-gatekeeper-local.rule=Host(`gatekeeper.klz.localhost`)"
- "traefik.http.routers.klz-cables-gatekeeper-local.tls=false"
- "traefik.http.routers.klz-cables-gatekeeper-local.service=klz-cables-gatekeeper-local"
- "traefik.http.services.klz-cables-gatekeeper-local.loadbalancer.server.port=3000"
- "traefik.docker.network=infra"

View File

@@ -1,61 +1,102 @@
services:
app:
klz-app:
image: registry.infra.mintel.me/mintel/klz-cables.com:${IMAGE_TAG:-latest}
restart: always
networks:
- infra
- default
env_file:
- ${ENV_FILE:-.env}
labels:
- "traefik.enable=false"
varnish:
image: varnish:7
restart: always
networks:
- default
- infra
volumes:
- ./varnish/default.vcl:/etc/varnish/default.vcl:ro
tmpfs:
- /var/lib/varnish:exec,mode=1777
environment:
VARNISH_SIZE: ${VARNISH_CACHE_SIZE:-256M}
APP_VERSION: ${IMAGE_TAG:-latest}
labels:
- "traefik.enable=true"
# HTTP ⇒ HTTPS redirect
- "traefik.http.routers.${PROJECT_NAME:-klz-cables}-web.rule=Host(${TRAEFIK_HOST}) && !PathPrefix(`/.well-known/acme-challenge/`)"
- "traefik.http.routers.${PROJECT_NAME:-klz-cables}-web.rule=${TRAEFIK_HOST_RULE:-Host(`klz-cables.com`)}"
- "traefik.http.routers.${PROJECT_NAME:-klz-cables}-web.entrypoints=web"
- "traefik.http.routers.${PROJECT_NAME:-klz-cables}-web.middlewares=redirect-https"
# HTTPS router
- "traefik.http.routers.${PROJECT_NAME:-klz-cables}.rule=Host(${TRAEFIK_HOST})"
# HTTPS router (Protected)
- "traefik.http.routers.${PROJECT_NAME:-klz-cables}.rule=${TRAEFIK_HOST_RULE:-Host(`klz-cables.com`)}"
- "traefik.http.routers.${PROJECT_NAME:-klz-cables}.entrypoints=websecure"
- "traefik.http.routers.${PROJECT_NAME:-klz-cables}.tls.certresolver=le"
- "traefik.http.routers.${PROJECT_NAME:-klz-cables}.tls=true"
- "traefik.http.routers.${PROJECT_NAME:-klz-cables}.service=${PROJECT_NAME:-klz-cables}"
- "traefik.http.services.${PROJECT_NAME:-klz-cables}.loadbalancer.server.port=3000"
- "traefik.http.routers.${PROJECT_NAME:-klz-cables}.middlewares=${PROJECT_NAME:-klz-cables}-ratelimit,${PROJECT_NAME:-klz-cables}-forward,${AUTH_MIDDLEWARE:-${PROJECT_NAME:-klz-cables}-compress}"
# HTTPS router (Unprotected - for Analytics & Errors)
- "traefik.http.routers.${PROJECT_NAME:-klz-cables}-unprotected.rule=${TRAEFIK_HOST_RULE:-Host(`klz-cables.com`)} && PathPrefix(`/stats`, `/errors`)"
- "traefik.http.routers.${PROJECT_NAME:-klz-cables}-unprotected.entrypoints=websecure"
- "traefik.http.routers.${PROJECT_NAME:-klz-cables}-unprotected.tls.certresolver=le"
- "traefik.http.routers.${PROJECT_NAME:-klz-cables}-unprotected.tls=true"
- "traefik.http.routers.${PROJECT_NAME:-klz-cables}-unprotected.service=${PROJECT_NAME:-klz-cables}"
- "traefik.http.routers.${PROJECT_NAME:-klz-cables}-unprotected.middlewares=${PROJECT_NAME:-klz-cables}-ratelimit,${PROJECT_NAME:-klz-cables}-forward,${PROJECT_NAME:-klz-cables}-compress"
- "traefik.http.services.${PROJECT_NAME:-klz-cables}.loadbalancer.server.port=80"
- "traefik.http.services.${PROJECT_NAME:-klz-cables}.loadbalancer.server.scheme=http"
# Forwarded Headers
- "traefik.http.middlewares.${PROJECT_NAME:-klz-cables}-forward.headers.customrequestheaders.X-Forwarded-Proto=https"
- "traefik.http.middlewares.${PROJECT_NAME:-klz-cables}-forward.headers.customrequestheaders.X-Forwarded-Ssl=on"
# Middlewares
- "traefik.http.routers.${PROJECT_NAME:-klz-cables}.middlewares=${PROJECT_NAME:-klz-cables}-forward,${AUTH_MIDDLEWARE:-compress}"
- "traefik.docker.network=infra"
# Middleware Definitions
- "traefik.http.middlewares.${PROJECT_NAME:-klz-cables}-compress.compress=true"
# Gatekeeper Router (to show the login page)
- "traefik.http.routers.${PROJECT_NAME:-klz-cables}-gatekeeper.rule=Host(${TRAEFIK_HOST}) && PathPrefix(`/gatekeeper`)"
- "traefik.http.routers.${PROJECT_NAME:-klz-cables}-gatekeeper.rule=Host(`gatekeeper.${TRAEFIK_HOST:-klz-cables.com}`)"
- "traefik.http.routers.${PROJECT_NAME:-klz-cables}-gatekeeper.entrypoints=websecure"
- "traefik.http.routers.${PROJECT_NAME:-klz-cables}-gatekeeper.tls.certresolver=le"
- "traefik.http.routers.${PROJECT_NAME:-klz-cables}-gatekeeper.tls=true"
- "traefik.http.routers.${PROJECT_NAME:-klz-cables}-gatekeeper.service=${PROJECT_NAME:-klz-cables}-gatekeeper"
# Forwarded Headers
- "traefik.http.middlewares.${PROJECT_NAME:-klz-cables}-forward.headers.customrequestheaders.X-Forwarded-Proto=https"
- "traefik.http.middlewares.${PROJECT_NAME:-klz-cables}-forward.headers.customrequestheaders.X-Forwarded-Ssl=on"
# Middleware Definitions
- "traefik.http.middlewares.${PROJECT_NAME:-klz-cables}-auth.forwardauth.address=http://${PROJECT_NAME}-gatekeeper:3000/verify"
- "traefik.http.middlewares.${PROJECT_NAME:-klz-cables}-ratelimit.ratelimit.average=100"
- "traefik.http.middlewares.${PROJECT_NAME:-klz-cables}-ratelimit.ratelimit.burst=50"
- "traefik.http.middlewares.${PROJECT_NAME:-klz-cables}-auth.forwardauth.address=http://${PROJECT_NAME}-gatekeeper:3000/api/verify"
- "traefik.http.middlewares.${PROJECT_NAME:-klz-cables}-auth.forwardauth.trustForwardHeader=true"
- "traefik.http.middlewares.${PROJECT_NAME:-klz-cables}-auth.forwardauth.authResponseHeaders=X-Auth-User"
gatekeeper:
image: registry.infra.mintel.me/mintel/klz-cables-gatekeeper:${IMAGE_TAG:-latest}
image: registry.infra.mintel.me/mintel/gatekeeper:1.4.0
container_name: ${PROJECT_NAME:-klz-cables}-gatekeeper
restart: always
networks:
- default
- infra
env_file:
- ${ENV_FILE:-.env}
environment:
PORT: 3000
COOKIE_DOMAIN: ${COOKIE_DOMAIN}
AUTH_COOKIE_NAME: klz_gatekeeper_session
NEXT_PUBLIC_BASE_URL: https://gatekeeper.${TRAEFIK_HOST:-klz-cables.com}
GATEKEEPER_PASSWORD: ${GATEKEEPER_PASSWORD:-klz2026}
DIRECTUS_URL: ${DIRECTUS_URL}
DIRECTUS_ADMIN_EMAIL: ${DIRECTUS_ADMIN_EMAIL}
DIRECTUS_ADMIN_PASSWORD: ${DIRECTUS_ADMIN_PASSWORD}
labels:
- "traefik.enable=true"
- "traefik.http.services.${PROJECT_NAME:-klz-cables}-gatekeeper.loadbalancer.server.port=3000"
- "traefik.docker.network=infra"
directus:
image: directus/directus:11
restart: always
networks:
- default
- infra
env_file:
- ${ENV_FILE:-.env}
@@ -75,23 +116,27 @@ services:
# Error Tracking
SENTRY_DSN: ${SENTRY_DSN}
SENTRY_ENVIRONMENT: ${TARGET:-development}
LOGGER_LEVEL: ${LOG_LEVEL:-info}
volumes:
- ./directus/uploads:/directus/uploads
- ./directus/extensions:/directus/extensions
- ./directus/schema:/directus/schema
- ./directus/migrations:/directus/migrations
labels:
- "traefik.enable=true"
- "traefik.http.routers.${PROJECT_NAME:-klz-cables}-directus.rule=Host(${DIRECTUS_HOST})"
- "traefik.http.routers.${PROJECT_NAME:-klz-cables}-directus.rule=Host(`${DIRECTUS_HOST}`)"
- "traefik.http.routers.${PROJECT_NAME:-klz-cables}-directus.entrypoints=websecure"
- "traefik.http.routers.${PROJECT_NAME:-klz-cables}-directus.tls.certresolver=le"
- "traefik.http.routers.${PROJECT_NAME:-klz-cables}-directus.tls=true"
- "traefik.http.routers.${PROJECT_NAME:-klz-cables}-directus.middlewares=${PROJECT_NAME:-klz-cables}-forward,${AUTH_MIDDLEWARE:-compress}"
- "traefik.http.routers.${PROJECT_NAME:-klz-cables}-directus.middlewares=${PROJECT_NAME:-klz-cables}-forward,compress"
- "traefik.http.services.${PROJECT_NAME:-klz-cables}-directus.loadbalancer.server.port=8055"
- "traefik.docker.network=infra"
directus-db:
image: postgres:15-alpine
restart: always
networks:
- infra
- default
env_file:
- ${ENV_FILE:-.env}
environment:
@@ -102,6 +147,8 @@ services:
- directus-db-data:/var/lib/postgresql/data
networks:
default:
name: ${PROJECT_NAME:-klz-cables}-internal
infra:
external: true

View File

@@ -1,81 +0,0 @@
services:
app:
image: registry.infra.mintel.me/mintel/klz-cables.com:latest
restart: always
networks:
- infra
env_file:
- .env
labels:
- "traefik.enable=true"
# HTTP ⇒ HTTPS redirect
- "traefik.http.routers.klz-cables-web.rule=(Host(`klz-cables.com`) || Host(`www.klz-cables.com`) || Host(`staging.klz-cables.com`)) && !PathPrefix(`/.well-known/acme-challenge/`)"
- "traefik.http.routers.klz-cables-web.entrypoints=web"
- "traefik.http.routers.klz-cables-web.middlewares=redirect-https"
# HTTPS router
- "traefik.http.routers.klz-cables.rule=Host(`klz-cables.com`) || Host(`www.klz-cables.com`) || Host(`staging.klz-cables.com`)"
- "traefik.http.routers.klz-cables.entrypoints=websecure"
- "traefik.http.routers.klz-cables.tls.certresolver=le"
- "traefik.http.routers.klz-cables.tls=true"
- "traefik.http.routers.klz-cables.service=klz-cables"
- "traefik.http.services.klz-cables.loadbalancer.server.port=3000"
- "traefik.http.services.klz-cables.loadbalancer.server.scheme=http"
# Forwarded Headers
- "traefik.http.middlewares.klz-forward.headers.customrequestheaders.X-Forwarded-Proto=https"
- "traefik.http.middlewares.klz-forward.headers.customrequestheaders.X-Forwarded-Ssl=on"
# Middlewares
- "traefik.http.routers.klz-cables.middlewares=klz-forward,compress"
cms:
build:
context: ./cms
dockerfile: Dockerfile
restart: always
networks:
- infra
env_file:
- .env
environment:
DATABASE_CLIENT: postgres
DATABASE_HOST: cms-db
DATABASE_PORT: 5432
DATABASE_NAME: ${STRAPI_DATABASE_NAME:-strapi}
DATABASE_USERNAME: ${STRAPI_DATABASE_USERNAME:-strapi}
DATABASE_PASSWORD: ${STRAPI_DATABASE_PASSWORD:-strapi}
NODE_ENV: ${NODE_ENV:-development}
STRAPI_URL: ${STRAPI_URL:-https://cms.klz-cables.com}
volumes:
- ./cms/config:/opt/app/config
- ./cms/src:/opt/app/src
- ./cms/package.json:/opt/app/package.json
- ./cms/package-lock.json:/opt/app/package-lock.json
- ./cms/public/uploads:/opt/app/public/uploads
- ./cms/dist:/opt/app/dist
labels:
- "traefik.enable=true"
- "traefik.http.routers.klz-cms.rule=Host(`cms.klz-cables.com`) || Host(`cms-staging.klz-cables.com`)"
- "traefik.http.routers.klz-cms.entrypoints=websecure"
- "traefik.http.routers.klz-cms.tls.certresolver=le"
- "traefik.http.routers.klz-cms.tls=true"
- "traefik.http.services.klz-cms.loadbalancer.server.port=1337"
cms-db:
image: postgres:16-alpine
restart: always
networks:
- infra
env_file:
- .env
environment:
POSTGRES_DB: ${STRAPI_DATABASE_NAME:-strapi}
POSTGRES_USER: ${STRAPI_DATABASE_USERNAME:-strapi}
POSTGRES_PASSWORD: ${STRAPI_DATABASE_PASSWORD:-strapi}
volumes:
- cms-db-data:/var/lib/postgresql/data
networks:
infra:
external: true
volumes:
cms-db-data:

View File

@@ -42,15 +42,15 @@ The application uses a clean, robust, **fully automated** environment variable s
## Environment Variables
### Build-Time Variables (NEXT_PUBLIC_*)
### Build-Time Variables (NEXT*PUBLIC*\*)
These are embedded into the JavaScript bundle during build and are visible to the client:
| Variable | Required | Description |
|----------|----------|-------------|
| `NEXT_PUBLIC_BASE_URL` | ✅ Yes | Base URL of the application (e.g., `https://klz-cables.com`) |
| `NEXT_PUBLIC_UMAMI_WEBSITE_ID` | ❌ No | Umami analytics website ID |
| `NEXT_PUBLIC_UMAMI_SCRIPT_URL` | ❌ No | Umami analytics script URL (default: `https://analytics.infra.mintel.me/script.js`) |
| Variable | Required | Description |
| ---------------------- | -------- | ------------------------------------------------------------ |
| `NEXT_PUBLIC_BASE_URL` | ✅ Yes | Base URL of the application (e.g., `https://klz-cables.com`) |
| `UMAMI_WEBSITE_ID` | ❌ No | Umami analytics website ID (passed as prop) |
| `UMAMI_API_ENDPOINT` | ❌ No | Backend-only Umami analytics API target (internal) |
**Important**: These must be provided as `--build-arg` when building the Docker image.
@@ -58,38 +58,40 @@ These are embedded into the JavaScript bundle during build and are visible to th
These are loaded from the `.env` file at runtime and are only available on the server:
| Variable | Required | Description |
|----------|----------|-------------|
| `NODE_ENV` | ✅ Yes | Environment mode (`production`, `development`, `test`) |
| `SENTRY_DSN` | ❌ No | GlitchTip/Sentry error tracking DSN |
| `MAIL_HOST` | ❌ No | SMTP server hostname |
| `MAIL_PORT` | ❌ No | SMTP server port (default: `587`) |
| `MAIL_USERNAME` | ❌ No | SMTP authentication username |
| `MAIL_PASSWORD` | ❌ No | SMTP authentication password |
| `MAIL_FROM` | ❌ No | Email sender address |
| `MAIL_RECIPIENTS` | ❌ No | Comma-separated list of recipient emails |
| `REDIS_URL` | ❌ No | Redis connection URL (e.g., `redis://redis:6379/2`) |
| `REDIS_KEY_PREFIX` | ❌ No | Redis key prefix (default: `klz:`) |
| `STRAPI_DATABASE_NAME` | ✅ Yes | Strapi database name |
| `STRAPI_DATABASE_USERNAME` | ✅ Yes | Strapi database username |
| `STRAPI_DATABASE_PASSWORD` | ✅ Yes | Strapi database password |
| `STRAPI_URL` | ✅ Yes | URL of the Strapi CMS |
| `APP_KEYS` | ✅ Yes | Strapi application keys (comma-separated) |
| `API_TOKEN_SALT` | ✅ Yes | Strapi API token salt |
| `ADMIN_JWT_SECRET` | ✅ Yes | Strapi admin JWT secret |
| `TRANSFER_TOKEN_SALT` | ✅ Yes | Strapi transfer token salt |
| `JWT_SECRET` | ✅ Yes | Strapi JWT secret |
| Variable | Required | Description |
| -------------------------- | -------- | ------------------------------------------------------ |
| `NODE_ENV` | ✅ Yes | Environment mode (`production`, `development`, `test`) |
| `SENTRY_DSN` | ❌ No | GlitchTip/Sentry error tracking DSN |
| `MAIL_HOST` | ❌ No | SMTP server hostname |
| `MAIL_PORT` | ❌ No | SMTP server port (default: `587`) |
| `MAIL_USERNAME` | ❌ No | SMTP authentication username |
| `MAIL_PASSWORD` | ❌ No | SMTP authentication password |
| `MAIL_FROM` | ❌ No | Email sender address |
| `MAIL_RECIPIENTS` | ❌ No | Comma-separated list of recipient emails |
| `REDIS_URL` | ❌ No | Redis connection URL (e.g., `redis://redis:6379/2`) |
| `REDIS_KEY_PREFIX` | ❌ No | Redis key prefix (default: `klz:`) |
| `STRAPI_DATABASE_NAME` | ✅ Yes | Strapi database name |
| `STRAPI_DATABASE_USERNAME` | ✅ Yes | Strapi database username |
| `STRAPI_DATABASE_PASSWORD` | ✅ Yes | Strapi database password |
| `STRAPI_URL` | ✅ Yes | URL of the Strapi CMS |
| `APP_KEYS` | ✅ Yes | Strapi application keys (comma-separated) |
| `API_TOKEN_SALT` | ✅ Yes | Strapi API token salt |
| `ADMIN_JWT_SECRET` | ✅ Yes | Strapi admin JWT secret |
| `TRANSFER_TOKEN_SALT` | ✅ Yes | Strapi transfer token salt |
| `JWT_SECRET` | ✅ Yes | Strapi JWT secret |
## Local Development
### Setup
1. Copy the example environment file:
```bash
cp .env.example .env
```
2. Edit `.env` and fill in your local configuration:
```bash
NODE_ENV=development
NEXT_PUBLIC_BASE_URL=http://localhost:3000
@@ -97,6 +99,7 @@ These are loaded from the `.env` file at runtime and are only available on the s
```
3. Install dependencies:
```bash
npm install
```
@@ -112,8 +115,8 @@ These are loaded from the `.env` file at runtime and are only available on the s
# Build with build-time arguments
docker build \
--build-arg NEXT_PUBLIC_BASE_URL=http://localhost:3000 \
--build-arg NEXT_PUBLIC_UMAMI_WEBSITE_ID=your-id \
--build-arg NEXT_PUBLIC_UMAMI_SCRIPT_URL=https://analytics.infra.mintel.me/script.js \
--build-arg UMAMI_WEBSITE_ID=your-id \
--build-arg UMAMI_API_ENDPOINT=https://analytics.infra.mintel.me \
-t klz-cables:local .
# Run with runtime environment file
@@ -138,8 +141,8 @@ docker run --env-file .env -p 3000:3000 klz-cables:local
**Build-Time Variables:**
- `NEXT_PUBLIC_BASE_URL` - Production URL (e.g., `https://klz-cables.com`)
- `NEXT_PUBLIC_UMAMI_WEBSITE_ID` - Umami analytics ID
- `NEXT_PUBLIC_UMAMI_SCRIPT_URL` - Umami script URL
- `UMAMI_WEBSITE_ID` - Umami analytics ID
- `UMAMI_API_ENDPOINT` - Umami API endpoint
**Runtime Variables:**
- `SENTRY_DSN` - Error tracking DSN
@@ -209,11 +212,12 @@ docker-compose logs -f app
**Problem**: Build fails with "Environment validation failed"
**Solution**: Ensure all required `NEXT_PUBLIC_*` variables are provided as build arguments:
```bash
docker build \
--build-arg NEXT_PUBLIC_BASE_URL=https://klz-cables.com \
--build-arg NEXT_PUBLIC_UMAMI_WEBSITE_ID=your-id \
--build-arg NEXT_PUBLIC_UMAMI_SCRIPT_URL=https://analytics.infra.mintel.me/script.js \
--build-arg UMAMI_WEBSITE_ID=your-id \
--build-arg UMAMI_API_ENDPOINT=https://analytics.infra.mintel.me \
-t klz-cables .
```
@@ -222,6 +226,7 @@ docker build \
**Problem**: Container starts but application crashes
**Solution**: Check that the `.env` file exists and contains all required runtime variables:
```bash
# On the server
cat /home/deploy/sites/klz-cables.com/.env
@@ -235,9 +240,11 @@ docker-compose logs app
**Problem**: Features not working (email, analytics, etc.)
**Solution**:
1. Check that the secret is configured in Gitea
2. Verify the workflow includes it in the `.env` generation (see `.gitea/workflows/deploy.yml`)
3. Redeploy to regenerate the `.env` file:
```bash
git commit --allow-empty -m "Trigger redeploy"
git push origin main
@@ -255,6 +262,7 @@ docker-compose logs app
**Problem**: `docker-compose up` fails with "env file not found"
**Solution**: The `.env` file should be automatically created by the workflow. If it's missing:
1. Check the workflow logs for errors in the "📝 Preparing environment configuration" step
2. Manually trigger a deployment by pushing to main
3. If still missing, check server permissions and disk space
@@ -264,6 +272,7 @@ docker-compose logs app
**Problem**: Container can't connect to Traefik
**Solution**: Verify the `infra` network exists:
```bash
docker network ls | grep infra
docker network inspect infra

View File

@@ -7,29 +7,31 @@ This guide helps you migrate from the old fragile environment variable setup to
### Before (Fragile & Overkill)
**Problems:**
- Environment variables passed individually via SSH (12+ vars)
- Duplicate definitions in Dockerfile, docker-compose.yml, and deploy.yml
- Build args included runtime-only variables (SENTRY_DSN, MAIL_*, REDIS_*)
- Build args included runtime-only variables (SENTRY*DSN, MAIL*_, REDIS\__)
- No single source of truth
- Difficult to maintain and error-prone
```yaml
# Old deploy.yml - FRAGILE!
ssh root@alpha.mintel.me \
"MAIL_FROM='${{ secrets.MAIL_FROM }}' \
MAIL_HOST='${{ secrets.MAIL_HOST }}' \
MAIL_PASSWORD='${{ secrets.MAIL_PASSWORD }}' \
MAIL_PORT='${{ secrets.MAIL_PORT }}' \
MAIL_RECIPIENTS='${{ secrets.MAIL_RECIPIENTS }}' \
MAIL_USERNAME='${{ secrets.MAIL_USERNAME }}' \
NEXT_PUBLIC_BASE_URL='${{ secrets.NEXT_PUBLIC_BASE_URL }}' \
... (12+ variables) \
/home/deploy/deploy.sh"
"MAIL_FROM='${{ secrets.MAIL_FROM }}' \
MAIL_HOST='${{ secrets.MAIL_HOST }}' \
MAIL_PASSWORD='${{ secrets.MAIL_PASSWORD }}' \
MAIL_PORT='${{ secrets.MAIL_PORT }}' \
MAIL_RECIPIENTS='${{ secrets.MAIL_RECIPIENTS }}' \
MAIL_USERNAME='${{ secrets.MAIL_USERNAME }}' \
NEXT_PUBLIC_BASE_URL='${{ secrets.NEXT_PUBLIC_BASE_URL }}' \
... (12+ variables) \
/home/deploy/deploy.sh"
```
### After (Clean & Robust)
**Benefits:**
- Single `.env` file on server contains all runtime variables
- Only `NEXT_PUBLIC_*` variables passed as build args (3 vars)
- Clear separation: build-time vs runtime
@@ -46,6 +48,7 @@ ssh root@alpha.mintel.me "/home/deploy/deploy.sh"
### Step 1: Update Gitea Secrets
**Remove these secrets** (no longer needed in CI/CD):
-`MAIL_FROM`
-`MAIL_HOST`
-`MAIL_PASSWORD`
@@ -58,9 +61,11 @@ ssh root@alpha.mintel.me "/home/deploy/deploy.sh"
-`SENTRY_DSN` (from build args)
**Keep these secrets** (still needed for build):
-`NEXT_PUBLIC_BASE_URL`
-`NEXT_PUBLIC_UMAMI_WEBSITE_ID`
-`NEXT_PUBLIC_UMAMI_SCRIPT_URL`
-`NEXT_PUBLIC_BASE_URL`
-`UMAMI_WEBSITE_ID`
-`UMAMI_API_ENDPOINT`
-`REGISTRY_USER`
-`REGISTRY_PASS`
-`ALPHA_SSH_KEY`
@@ -81,8 +86,8 @@ NODE_ENV=production
NEXT_PUBLIC_BASE_URL=https://klz-cables.com
# Analytics
NEXT_PUBLIC_UMAMI_WEBSITE_ID=your-actual-id
NEXT_PUBLIC_UMAMI_SCRIPT_URL=https://analytics.infra.mintel.me/script.js
UMAMI_WEBSITE_ID=your-actual-id
UMAMI_API_ENDPOINT=https://analytics.infra.mintel.me
# Error Tracking
SENTRY_DSN=your-actual-dsn
@@ -168,6 +173,7 @@ git push origin main
```
The CI/CD workflow will:
1. Build with only `NEXT_PUBLIC_*` build args
2. Push to registry
3. SSH to server and run deploy.sh
@@ -197,21 +203,22 @@ curl -I https://klz-cables.com
## Comparison Table
| Aspect | Before | After |
|--------|--------|-------|
| **Gitea Secrets** | 15+ secrets | 8 secrets |
| **Build Args** | 4 vars (including runtime-only) | 3 vars (NEXT_PUBLIC_* only) |
| **Runtime Vars** | Passed via SSH command | Loaded from .env file |
| **Maintenance** | Update in 3 places | Update in 1 place |
| **Security** | Secrets in CI logs | Secrets only on server |
| **Clarity** | Confusing duplication | Clear separation |
| **Robustness** | Fragile SSH command | Robust file-based config |
| Aspect | Before | After |
| ----------------- | ------------------------------- | ---------------------------- |
| **Gitea Secrets** | 15+ secrets | 8 secrets |
| **Build Args** | 4 vars (including runtime-only) | 3 vars (NEXT*PUBLIC*\* only) |
| **Runtime Vars** | Passed via SSH command | Loaded from .env file |
| **Maintenance** | Update in 3 places | Update in 1 place |
| **Security** | Secrets in CI logs | Secrets only on server |
| **Clarity** | Confusing duplication | Clear separation |
| **Robustness** | Fragile SSH command | Robust file-based config |
## Rollback Plan
If you need to rollback to the old system:
1. Revert the changes in git:
```bash
git revert HEAD
git push origin main
@@ -229,7 +236,8 @@ A: `NEXT_PUBLIC_*` variables are special in Next.js - they're embedded into the
**Q: Can I update environment variables without rebuilding?**
A: Yes, for runtime-only variables (MAIL_*, REDIS_*, SENTRY_DSN, etc.). Just edit the `.env` file on the server and restart containers:
A: Yes, for runtime-only variables (MAIL*\*, REDIS*\*, SENTRY_DSN, etc.). Just edit the `.env` file on the server and restart containers:
```bash
nano /home/deploy/sites/klz-cables.com/.env
docker-compose down && docker-compose up -d
@@ -240,6 +248,7 @@ For `NEXT_PUBLIC_*` variables, you need to rebuild the Docker image since they'r
**Q: Where should I store the .env file backup?**
A: Keep a secure backup outside the server:
```bash
# Download from server
scp root@alpha.mintel.me:/home/deploy/sites/klz-cables.com/.env \
@@ -250,7 +259,8 @@ scp root@alpha.mintel.me:/home/deploy/sites/klz-cables.com/.env \
**Q: What if I accidentally commit .env to git?**
A:
A:
1. Remove it immediately: `git rm .env && git commit -m "Remove .env"`
2. Rotate all credentials in the file
3. Update the `.gitignore` to ensure it doesn't happen again (already done)
@@ -267,6 +277,7 @@ If you encounter issues during migration:
## Summary
The new system is:
-**Simpler**: One .env file instead of scattered variables
-**Cleaner**: Clear separation of build vs runtime
-**Robust**: File-based config instead of fragile SSH commands

View File

@@ -36,6 +36,31 @@ https://logs.infra.mintel.me
---
## SMTP
# SMTP Config
SMTP_HOST=
SMTP_PORT=587
SMTP_SECURE=false
SMTP_USER=
SMTP_PASS=
SMTP_FROM= # muss im projekt gesetzt werden
---
## Shared Image Optimization (imgproxy)
Alle Bilder werden zentral über **imgproxy** optimiert, resized und in moderne Formate (WebP, AVIF) konvertiert.
**Basis-URL**
https://img.infra.mintel.me
```text
https://img.infra.mintel.me/unsafe/rs:800x600/plain/https://example.com/bild.jpg
https://img.infra.mintel.me/rs:400x/plain/https://picsum.photos/2000/1333
---
## Production Platform (Alpha)
Alpha runs all customer websites and is publicly reachable.

45
eslint.config.mjs Normal file
View File

@@ -0,0 +1,45 @@
import baseConfig from "@mintel/eslint-config";
import { nextConfig } from "@mintel/eslint-config/next";
export default [
{
ignores: [
"**/node_modules/**",
"node_modules/**",
"**/.next/**",
".next/**",
"**/dist/**",
"dist/**",
"**/out/**",
"out/**",
"**/.pnpm-store/**",
"**/at-mintel/**",
"at-mintel/**",
"**/.git/**",
"*.js",
"*.mjs",
"scripts/**",
"tests/**",
"next-env.d.ts"
],
},
...baseConfig,
...nextConfig.map((config) => ({
...config,
files: ["**/*.{ts,tsx}"],
rules: {
...config.rules,
"@typescript-eslint/no-explicit-any": "off",
"@typescript-eslint/no-unused-vars": [
"warn",
{
"argsIgnorePattern": "^_"
}
],
"@typescript-eslint/no-require-imports": "off",
"prefer-const": "warn",
"react/no-unescaped-entities": "off",
"@next/next/no-img-element": "warn"
}
})),
];

View File

@@ -1,171 +0,0 @@
[
{
"url": "https://shop.faberkabel.de/Starkstromkabel-1-30-kV/Mittelspannungskabel/Mittelspannungskabel-N2XS-FL-2Y/",
"verwendung": "Zur Verlegung in Erde, in Wasser, im Freien, in Innenräumen und Kabelkanälen für EVU-Netze, Industrie- und Verteilernetze. Bei Verlegung in Kabelkanälen und Innenräumen muss berücksichtigt werden, dass der PE-Mantel nach DIN VDE 0482-332-1 nicht flammwidrig ist. Das Kabel ist für ungünstige Einsatzbedingungen geeignet, insbesondere wenn nach mechanischen Beschädigungen das Eindringen von Wasser in Quer- und Längsrichtung vermieden werden soll.",
"technischeDaten": {
"Zolltarifnummer (Warennummer)": "85446010900000000",
"Norm": "VDE 0276-620",
"Leitermaterial": "Cu, blank",
"Leiterklasse": "Kl.2 = mehrdrähtig",
"Aderisolation": "VPE DIX8",
"Feldsteuerung": "innere und äußere Leitschicht aus halbleitendem Kunststoff (Dreifachextrusion)",
"Schirm": "Cu-Drahtumspinnung + Querleitwendel",
"Mantelmaterial": "Polyethylen DMP2",
"Schichtenmantel": "ja",
"Kabel querwasserdicht": "ja",
"Kabel längswasserdicht": "ja",
"Mantelfarbe": "schwarz",
"UV-beständig": "ja",
"Als Außenkabel zulässig": "ja",
"Max. zulässige Leitertemperatur, °C": "90 °C",
"Zul. Kabelaußentemperatur, fest verlegt, °C": "70 °C",
"Zul. Kabelaußentemperatur, in Bewegung, °C": "-20 - +70 °C",
"Min. Biegeradius, fest verlegt": "15 x Ø",
"Aderzahl": "1",
"Mantelwanddicke": "2.1 mm",
"Metallbasis Cu (de)": "0 EUR/100 kg",
"Maßeinheit": "Meter"
}
},
{
"url": "https://shop.faberkabel.de/Starkstromkabel-1-30-kV/Mittelspannungskabel/Mittelspannungskabel-N2XS2Y/",
"verwendung": "Zur Verlegung in Erde, in Wasser, im Freien, in Innenräumen und Kabelkanälen für Kraftwerks-, Industrie- und Verteilernetze. Bei Verlegung in Kabelkanälen und Innenräumen muss berücksichtigt werden, dass der PE-Mantel halogenfrei ist, jedoch nicht flammwidrig nach DIN VDE 0482-332-1. Das Kabel kann infolge des widerstandsfähigen PE-Mantels bei der Verlegung und im Betrieb stark mechanisch beansprucht werden.",
"technischeDaten": {
"Norm": "VDE 0276-620",
"Leitermaterial": "Cu, blank",
"Leiterklasse": "Kl.2 = mehrdrähtig",
"Aderisolation": "VPE DIX8",
"Feldsteuerung": "innere und äußere Leitschicht aus halbleitendem Kunststoff (Dreifachextrusion)",
"Schirm": "Cu-Drahtumspinnung + Querleitwendel",
"Mantelmaterial": "Polyethylen DMP2",
"Mantelfarbe": "schwarz",
"Flammwidrigkeit": "keine",
"UV-beständig": "ja",
"Als Außenkabel zulässig": "ja",
"Max. zulässige Leitertemperatur, °C": "90 °C",
"Zul. Kabelaußentemperatur, fest verlegt, °C": "70 °C",
"Zul. Kabelaußentemperatur, in Bewegung, °C": "-20 - +70 °C",
"Min. Biegeradius, fest verlegt": "15 x Ø",
"Leiterform": "rund",
"Aderzahl": "1",
"Mantelwanddicke": "2.1 mm",
"Metallbasis Cu (de)": "0 EUR/100 kg",
"Maßeinheit": "Meter"
}
},
{
"url": "https://shop.faberkabel.de/Starkstromkabel-1-30-kV/Mittelspannungskabel/Mittelspannungskabel-N2XSF2Y/",
"verwendung": "",
"technischeDaten": {}
},
{
"url": "https://shop.faberkabel.de/Starkstromkabel-1-30-kV/Mittelspannungskabel/Mittelspannungskabel-N2XSY/",
"verwendung": "Zur Verlegung in Erde, in Wasser, im Freien, in Innenräumen und Kabelkanälen für Kraftwerks-, Industrie- und Verteilernetze. Das Kabel lässt sich aufgrund der guten Verlegeeigenschaften auch bei schwieriger Trassenführung leicht verlegen. Gemäß VDE 0276 müssen die Kabel vor direkter Sonneneinstrahlung geschützt sein.",
"technischeDaten": {
"Zolltarifnummer (Warennummer)": "85446010900000000",
"Norm": "VDE 0276-620",
"Leitermaterial": "Cu, blank",
"Leiterklasse": "Kl.2 = mehrdrähtig",
"Aderisolation": "VPE DIX8",
"Feldsteuerung": "innere und äußere Leitschicht aus halbleitendem Kunststoff (Dreifachextrusion)",
"Schirm": "Cu-Drahtumspinnung + Querleitwendel",
"Mantelmaterial": "PVC DMV6",
"Mantelfarbe": "rot",
"Flammwidrigkeit": "VDE 0482-332-1-2/IEC 60332-1-2",
"Als Außenkabel zulässig": "ja",
"Max. zulässige Leitertemperatur, °C": "90 °C",
"Zul. Kabelaußentemperatur, fest verlegt, °C": "70 °C",
"Zul. Kabelaußentemperatur, in Bewegung, °C": "-5 - +70 °C",
"Min. Biegeradius, fest verlegt": "15 x Ø",
"Leiterform": "rund",
"Aderzahl": "1",
"Metallbasis Cu (de)": "0 EUR/100 kg",
"Maßeinheit": "Meter"
}
},
{
"url": "https://shop.faberkabel.de/Starkstromkabel-1-30-kV/Mittelspannungskabel/Mittelspannungskabel-NA2XS2Y/",
"verwendung": "Zur Verlegung in Erde, in Wasser, im Freien, in Innenräumen und Kabelkanälen für Kraftwerks-, Industrie- und Verteilernetze. Bei Verlegung in Kabelkanälen und Innenräumen muss berücksichtigt werden, dass der PE-Mantel halogenfrei ist, jedoch nicht flammwidrig nach DIN VDE 0482-332-1. Das Kabel kann infolge des widerstandsfähigen PE-Mantels bei der Verlegung und im Betrieb stark mechanisch beansprucht werden.",
"technischeDaten": {
"Zolltarifnummer (Warennummer)": "85446090000000000",
"Norm": "VDE 0276-620",
"Leitermaterial": "Aluminium",
"Leiterklasse": "Kl.2 = mehrdrähtig",
"Aderisolation": "VPE DIX8",
"Feldsteuerung": "innere und äußere Leitschicht aus halbleitendem Kunststoff (Dreifachextrusion)",
"Schirm": "Cu-Drahtumspinnung + Querleitwendel",
"Mantelmaterial": "Polyethylen DMP2",
"Mantelfarbe": "schwarz",
"Flammwidrigkeit": "keine",
"UV-beständig": "ja",
"Als Außenkabel zulässig": "ja",
"Max. zulässige Leitertemperatur, °C": "90 °C",
"Zul. Kabelaußentemperatur, fest verlegt, °C": "70 °C",
"Zul. Kabelaußentemperatur, in Bewegung, °C": "-20 - +70 °C",
"Min. Biegeradius, fest verlegt": "15 x Ø",
"Aderzahl": "1",
"Metallbasis Al (de)": "0 EUR/100 kg",
"Metallbasis Cu (de)": "0 EUR/100 kg",
"Maßeinheit": "Meter"
}
},
{
"url": "https://shop.faberkabel.de/Starkstromkabel-1-30-kV/Mittelspannungskabel/Mittelspannungskabel-NA2XSF2Y/",
"verwendung": "",
"technischeDaten": {}
},
{
"url": "https://shop.faberkabel.de/Starkstromkabel-1-30-kV/Mittelspannungskabel/Mittelspannungskabel-NA2XS-FL-2Y/",
"verwendung": "Zur Verlegung in Erde, in Wasser, im Freien, in Innenräumen und Kabelkanälen für EVU-Netze, Industrie- und Verteilernetze. Bei Verlegung in Kabelkanälen und Innenräumen muss berücksichtigt werden, dass der PE-Mantel nach DIN VDE 0482-332-1 nicht flammwidrig ist. Das Kabel ist für ungünstige Einsatzbedingungen geeignet, insbesondere wenn nach mechanischen Beschädigungen das Eindringen von Wasser in Quer- und Längsrichtung vermieden werden soll.",
"technischeDaten": {
"Zolltarifnummer (Warennummer)": "85446090000000000",
"Norm": "VDE 0276-620",
"Leitermaterial": "Aluminium",
"Leiterklasse": "Kl.2 = mehrdrähtig",
"Aderisolation": "VPE DIX8",
"Mantelmaterial": "Polyethylen DMP2",
"Schichtenmantel": "ja",
"Kabel querwasserdicht": "ja",
"Kabel längswasserdicht": "ja",
"Mantelfarbe": "schwarz",
"Flammwidrigkeit": "keine",
"UV-beständig": "ja",
"Als Außenkabel zulässig": "ja",
"Max. zulässige Leitertemperatur, °C": "90 °C",
"Zul. Kabelaußentemperatur, fest verlegt, °C": "70 °C",
"Zul. Kabelaußentemperatur, in Bewegung, °C": "-20 - +70 °C",
"Min. Biegeradius, fest verlegt": "15 x Ø",
"Leiterform (Faber)": "RMv",
"Aderzahl": "1",
"Metallbasis Al (de)": "0 EUR/100 kg",
"Metallbasis Cu (de)": "0 EUR/100 kg",
"Maßeinheit": "Meter"
}
},
{
"url": "https://shop.faberkabel.de/Starkstromkabel-1-30-kV/Mittelspannungskabel/Mittelspannungskabel-NA2XSY/",
"verwendung": "Zur Verlegung in Erde, in Wasser, im Freien, in Innenräumen und Kabelkanälen für Kraftwerks-, Industrie- und Verteilernetze. Das Kabel lässt sich aufgrund der guten Verlegeeigenschaften auch bei schwieriger Trassenführung leicht verlegen. Gemäß VDE 0276 müssen die Kabel vor direkter Sonneneinstrahlung geschützt sein.",
"technischeDaten": {
"Zolltarifnummer (Warennummer)": "85446090000000000",
"Norm": "VDE 0276-620",
"Leitermaterial": "Aluminium",
"Leiterklasse": "Kl.2 = mehrdrähtig",
"Aderisolation": "VPE DIX8",
"Feldsteuerung": "innere und äußere Leitschicht aus halbleitendem Kunststoff (Dreifachextrusion)",
"Schirm": "Cu-Drahtumspinnung + Querleitwendel",
"Mantelmaterial": "PVC DMV6",
"Mantelfarbe": "rot",
"Flammwidrigkeit": "VDE 0482-332-1-2/IEC 60332-1-2",
"Als Außenkabel zulässig": "ja",
"Max. zulässige Leitertemperatur, °C": "90 °C",
"Zul. Kabelaußentemperatur, fest verlegt, °C": "70 °C",
"Zul. Kabelaußentemperatur, in Bewegung, °C": "-5 - +70 °C",
"Min. Biegeradius, fest verlegt": "15 x Ø",
"Leiterform (Faber)": "RMv",
"Aderzahl": "1",
"Metallbasis Al (de)": "0 EUR/100 kg",
"Metallbasis Cu (de)": "0 EUR/100 kg",
"Maßeinheit": "Meter"
}
}
]

View File

@@ -1,7 +0,0 @@
FROM node:20-alpine
WORKDIR /app
COPY package*.json ./
RUN npm ci --omit=dev
COPY . .
EXPOSE 3000
CMD ["node", "index.js"]

View File

@@ -1,60 +0,0 @@
const express = require('express');
const cookieParser = require('cookie-parser');
const path = require('path');
const app = express();
const PORT = process.env.PORT || 3000;
const GATEKEEPER_PASSWORD = process.env.GATEKEEPER_PASSWORD || 'klz2026';
const AUTH_COOKIE_NAME = 'klz_gatekeeper_session';
app.set('view engine', 'ejs');
app.set('views', path.join(__dirname, 'views'));
app.use(express.urlencoded({ extended: true }));
app.use(cookieParser());
app.use(express.static(path.join(__dirname, 'public')));
// ForwardAuth check endpoint
app.get('/verify', (req, res) => {
const session = req.cookies[AUTH_COOKIE_NAME];
if (session === GATEKEEPER_PASSWORD) {
return res.status(200).send('OK');
}
// Traefik will use this to redirect if requested
const originalUrl = req.headers['x-forwarded-uri'] || '/';
const host = req.headers['x-forwarded-host'] || '';
const proto = req.headers['x-forwarded-proto'] || 'https';
// Redirect to login
res.redirect(`${proto}://${host}/gatekeeper/login?redirect=${encodeURIComponent(originalUrl)}`);
});
// Login page
app.get('/gatekeeper/login', (req, res) => {
res.render('login', {
error: req.query.error ? 'Invalid password' : null,
redirect: req.query.redirect || '/'
});
});
// Handle login
app.post('/gatekeeper/login', (req, res) => {
const { password, redirect } = req.body;
if (password === GATEKEEPER_PASSWORD) {
res.cookie(AUTH_COOKIE_NAME, GATEKEEPER_PASSWORD, {
httpOnly: true,
secure: true,
path: '/',
maxAge: 30 * 24 * 60 * 60 * 1000 // 30 days
});
return res.redirect(redirect || '/');
}
res.redirect(`/gatekeeper/login?error=1&redirect=${encodeURIComponent(redirect || '/')}`);
});
app.listen(PORT, () => {
console.log(`Gatekeeper listening on port ${PORT}`);
});

View File

@@ -1,922 +0,0 @@
{
"name": "klz-gatekeeper",
"version": "1.0.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "klz-gatekeeper",
"version": "1.0.0",
"dependencies": {
"cookie-parser": "^1.4.6",
"ejs": "^3.1.9",
"express": "^4.18.2"
}
},
"node_modules/accepts": {
"version": "1.3.8",
"resolved": "https://registry.npmjs.org/accepts/-/accepts-1.3.8.tgz",
"integrity": "sha512-PYAthTa2m2VKxuvSD3DPC/Gy+U+sOA1LAuT8mkmRuvw+NACSaeXEQ+NHcVF7rONl6qcaxV3Uuemwawk+7+SJLw==",
"license": "MIT",
"dependencies": {
"mime-types": "~2.1.34",
"negotiator": "0.6.3"
},
"engines": {
"node": ">= 0.6"
}
},
"node_modules/array-flatten": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/array-flatten/-/array-flatten-1.1.1.tgz",
"integrity": "sha512-PCVAQswWemu6UdxsDFFX/+gVeYqKAod3D3UVm91jHwynguOwAvYPhx8nNlM++NqRcK6CxxpUafjmhIdKiHibqg==",
"license": "MIT"
},
"node_modules/async": {
"version": "3.2.6",
"resolved": "https://registry.npmjs.org/async/-/async-3.2.6.tgz",
"integrity": "sha512-htCUDlxyyCLMgaM3xXg0C0LW2xqfuQ6p05pCEIsXuyQ+a1koYKTuBMzRNwmybfLgvJDMd0r1LTn4+E0Ti6C2AA==",
"license": "MIT"
},
"node_modules/balanced-match": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz",
"integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==",
"license": "MIT"
},
"node_modules/body-parser": {
"version": "1.20.4",
"resolved": "https://registry.npmjs.org/body-parser/-/body-parser-1.20.4.tgz",
"integrity": "sha512-ZTgYYLMOXY9qKU/57FAo8F+HA2dGX7bqGc71txDRC1rS4frdFI5R7NhluHxH6M0YItAP0sHB4uqAOcYKxO6uGA==",
"license": "MIT",
"dependencies": {
"bytes": "~3.1.2",
"content-type": "~1.0.5",
"debug": "2.6.9",
"depd": "2.0.0",
"destroy": "~1.2.0",
"http-errors": "~2.0.1",
"iconv-lite": "~0.4.24",
"on-finished": "~2.4.1",
"qs": "~6.14.0",
"raw-body": "~2.5.3",
"type-is": "~1.6.18",
"unpipe": "~1.0.0"
},
"engines": {
"node": ">= 0.8",
"npm": "1.2.8000 || >= 1.4.16"
}
},
"node_modules/brace-expansion": {
"version": "2.0.2",
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.2.tgz",
"integrity": "sha512-Jt0vHyM+jmUBqojB7E1NIYadt0vI0Qxjxd2TErW94wDz+E2LAm5vKMXXwg6ZZBTHPuUlDgQHKXvjGBdfcF1ZDQ==",
"license": "MIT",
"dependencies": {
"balanced-match": "^1.0.0"
}
},
"node_modules/bytes": {
"version": "3.1.2",
"resolved": "https://registry.npmjs.org/bytes/-/bytes-3.1.2.tgz",
"integrity": "sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg==",
"license": "MIT",
"engines": {
"node": ">= 0.8"
}
},
"node_modules/call-bind-apply-helpers": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz",
"integrity": "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==",
"license": "MIT",
"dependencies": {
"es-errors": "^1.3.0",
"function-bind": "^1.1.2"
},
"engines": {
"node": ">= 0.4"
}
},
"node_modules/call-bound": {
"version": "1.0.4",
"resolved": "https://registry.npmjs.org/call-bound/-/call-bound-1.0.4.tgz",
"integrity": "sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg==",
"license": "MIT",
"dependencies": {
"call-bind-apply-helpers": "^1.0.2",
"get-intrinsic": "^1.3.0"
},
"engines": {
"node": ">= 0.4"
},
"funding": {
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/content-disposition": {
"version": "0.5.4",
"resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-0.5.4.tgz",
"integrity": "sha512-FveZTNuGw04cxlAiWbzi6zTAL/lhehaWbTtgluJh4/E95DqMwTmha3KZN1aAWA8cFIhHzMZUvLevkw5Rqk+tSQ==",
"license": "MIT",
"dependencies": {
"safe-buffer": "5.2.1"
},
"engines": {
"node": ">= 0.6"
}
},
"node_modules/content-type": {
"version": "1.0.5",
"resolved": "https://registry.npmjs.org/content-type/-/content-type-1.0.5.tgz",
"integrity": "sha512-nTjqfcBFEipKdXCv4YDQWCfmcLZKm81ldF0pAopTvyrFGVbcR6P/VAAd5G7N+0tTr8QqiU0tFadD6FK4NtJwOA==",
"license": "MIT",
"engines": {
"node": ">= 0.6"
}
},
"node_modules/cookie": {
"version": "0.7.2",
"resolved": "https://registry.npmjs.org/cookie/-/cookie-0.7.2.tgz",
"integrity": "sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w==",
"license": "MIT",
"engines": {
"node": ">= 0.6"
}
},
"node_modules/cookie-parser": {
"version": "1.4.7",
"resolved": "https://registry.npmjs.org/cookie-parser/-/cookie-parser-1.4.7.tgz",
"integrity": "sha512-nGUvgXnotP3BsjiLX2ypbQnWoGUPIIfHQNZkkC668ntrzGWEZVW70HDEB1qnNGMicPje6EttlIgzo51YSwNQGw==",
"license": "MIT",
"dependencies": {
"cookie": "0.7.2",
"cookie-signature": "1.0.6"
},
"engines": {
"node": ">= 0.8.0"
}
},
"node_modules/cookie-signature": {
"version": "1.0.6",
"resolved": "https://registry.npmjs.org/cookie-signature/-/cookie-signature-1.0.6.tgz",
"integrity": "sha512-QADzlaHc8icV8I7vbaJXJwod9HWYp8uCqf1xa4OfNu1T7JVxQIrUgOWtHdNDtPiywmFbiS12VjotIXLrKM3orQ==",
"license": "MIT"
},
"node_modules/debug": {
"version": "2.6.9",
"resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz",
"integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==",
"license": "MIT",
"dependencies": {
"ms": "2.0.0"
}
},
"node_modules/depd": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/depd/-/depd-2.0.0.tgz",
"integrity": "sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw==",
"license": "MIT",
"engines": {
"node": ">= 0.8"
}
},
"node_modules/destroy": {
"version": "1.2.0",
"resolved": "https://registry.npmjs.org/destroy/-/destroy-1.2.0.tgz",
"integrity": "sha512-2sJGJTaXIIaR1w4iJSNoN0hnMY7Gpc/n8D4qSCJw8QqFWXf7cuAgnEHxBpweaVcPevC2l3KpjYCx3NypQQgaJg==",
"license": "MIT",
"engines": {
"node": ">= 0.8",
"npm": "1.2.8000 || >= 1.4.16"
}
},
"node_modules/dunder-proto": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz",
"integrity": "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==",
"license": "MIT",
"dependencies": {
"call-bind-apply-helpers": "^1.0.1",
"es-errors": "^1.3.0",
"gopd": "^1.2.0"
},
"engines": {
"node": ">= 0.4"
}
},
"node_modules/ee-first": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/ee-first/-/ee-first-1.1.1.tgz",
"integrity": "sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow==",
"license": "MIT"
},
"node_modules/ejs": {
"version": "3.1.10",
"resolved": "https://registry.npmjs.org/ejs/-/ejs-3.1.10.tgz",
"integrity": "sha512-UeJmFfOrAQS8OJWPZ4qtgHyWExa088/MtK5UEyoJGFH67cDEXkZSviOiKRCZ4Xij0zxI3JECgYs3oKx+AizQBA==",
"license": "Apache-2.0",
"dependencies": {
"jake": "^10.8.5"
},
"bin": {
"ejs": "bin/cli.js"
},
"engines": {
"node": ">=0.10.0"
}
},
"node_modules/encodeurl": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/encodeurl/-/encodeurl-2.0.0.tgz",
"integrity": "sha512-Q0n9HRi4m6JuGIV1eFlmvJB7ZEVxu93IrMyiMsGC0lrMJMWzRgx6WGquyfQgZVb31vhGgXnfmPNNXmxnOkRBrg==",
"license": "MIT",
"engines": {
"node": ">= 0.8"
}
},
"node_modules/es-define-property": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz",
"integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==",
"license": "MIT",
"engines": {
"node": ">= 0.4"
}
},
"node_modules/es-errors": {
"version": "1.3.0",
"resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz",
"integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==",
"license": "MIT",
"engines": {
"node": ">= 0.4"
}
},
"node_modules/es-object-atoms": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.1.tgz",
"integrity": "sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA==",
"license": "MIT",
"dependencies": {
"es-errors": "^1.3.0"
},
"engines": {
"node": ">= 0.4"
}
},
"node_modules/escape-html": {
"version": "1.0.3",
"resolved": "https://registry.npmjs.org/escape-html/-/escape-html-1.0.3.tgz",
"integrity": "sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow==",
"license": "MIT"
},
"node_modules/etag": {
"version": "1.8.1",
"resolved": "https://registry.npmjs.org/etag/-/etag-1.8.1.tgz",
"integrity": "sha512-aIL5Fx7mawVa300al2BnEE4iNvo1qETxLrPI/o05L7z6go7fCw1J6EQmbK4FmJ2AS7kgVF/KEZWufBfdClMcPg==",
"license": "MIT",
"engines": {
"node": ">= 0.6"
}
},
"node_modules/express": {
"version": "4.22.1",
"resolved": "https://registry.npmjs.org/express/-/express-4.22.1.tgz",
"integrity": "sha512-F2X8g9P1X7uCPZMA3MVf9wcTqlyNp7IhH5qPCI0izhaOIYXaW9L535tGA3qmjRzpH+bZczqq7hVKxTR4NWnu+g==",
"license": "MIT",
"dependencies": {
"accepts": "~1.3.8",
"array-flatten": "1.1.1",
"body-parser": "~1.20.3",
"content-disposition": "~0.5.4",
"content-type": "~1.0.4",
"cookie": "~0.7.1",
"cookie-signature": "~1.0.6",
"debug": "2.6.9",
"depd": "2.0.0",
"encodeurl": "~2.0.0",
"escape-html": "~1.0.3",
"etag": "~1.8.1",
"finalhandler": "~1.3.1",
"fresh": "~0.5.2",
"http-errors": "~2.0.0",
"merge-descriptors": "1.0.3",
"methods": "~1.1.2",
"on-finished": "~2.4.1",
"parseurl": "~1.3.3",
"path-to-regexp": "~0.1.12",
"proxy-addr": "~2.0.7",
"qs": "~6.14.0",
"range-parser": "~1.2.1",
"safe-buffer": "5.2.1",
"send": "~0.19.0",
"serve-static": "~1.16.2",
"setprototypeof": "1.2.0",
"statuses": "~2.0.1",
"type-is": "~1.6.18",
"utils-merge": "1.0.1",
"vary": "~1.1.2"
},
"engines": {
"node": ">= 0.10.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/express"
}
},
"node_modules/filelist": {
"version": "1.0.4",
"resolved": "https://registry.npmjs.org/filelist/-/filelist-1.0.4.tgz",
"integrity": "sha512-w1cEuf3S+DrLCQL7ET6kz+gmlJdbq9J7yXCSjK/OZCPA+qEN1WyF4ZAf0YYJa4/shHJra2t/d/r8SV4Ji+x+8Q==",
"license": "Apache-2.0",
"dependencies": {
"minimatch": "^5.0.1"
}
},
"node_modules/finalhandler": {
"version": "1.3.2",
"resolved": "https://registry.npmjs.org/finalhandler/-/finalhandler-1.3.2.tgz",
"integrity": "sha512-aA4RyPcd3badbdABGDuTXCMTtOneUCAYH/gxoYRTZlIJdF0YPWuGqiAsIrhNnnqdXGswYk6dGujem4w80UJFhg==",
"license": "MIT",
"dependencies": {
"debug": "2.6.9",
"encodeurl": "~2.0.0",
"escape-html": "~1.0.3",
"on-finished": "~2.4.1",
"parseurl": "~1.3.3",
"statuses": "~2.0.2",
"unpipe": "~1.0.0"
},
"engines": {
"node": ">= 0.8"
}
},
"node_modules/forwarded": {
"version": "0.2.0",
"resolved": "https://registry.npmjs.org/forwarded/-/forwarded-0.2.0.tgz",
"integrity": "sha512-buRG0fpBtRHSTCOASe6hD258tEubFoRLb4ZNA6NxMVHNw2gOcwHo9wyablzMzOA5z9xA9L1KNjk/Nt6MT9aYow==",
"license": "MIT",
"engines": {
"node": ">= 0.6"
}
},
"node_modules/fresh": {
"version": "0.5.2",
"resolved": "https://registry.npmjs.org/fresh/-/fresh-0.5.2.tgz",
"integrity": "sha512-zJ2mQYM18rEFOudeV4GShTGIQ7RbzA7ozbU9I/XBpm7kqgMywgmylMwXHxZJmkVoYkna9d2pVXVXPdYTP9ej8Q==",
"license": "MIT",
"engines": {
"node": ">= 0.6"
}
},
"node_modules/function-bind": {
"version": "1.1.2",
"resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz",
"integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==",
"license": "MIT",
"funding": {
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/get-intrinsic": {
"version": "1.3.0",
"resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz",
"integrity": "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==",
"license": "MIT",
"dependencies": {
"call-bind-apply-helpers": "^1.0.2",
"es-define-property": "^1.0.1",
"es-errors": "^1.3.0",
"es-object-atoms": "^1.1.1",
"function-bind": "^1.1.2",
"get-proto": "^1.0.1",
"gopd": "^1.2.0",
"has-symbols": "^1.1.0",
"hasown": "^2.0.2",
"math-intrinsics": "^1.1.0"
},
"engines": {
"node": ">= 0.4"
},
"funding": {
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/get-proto": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/get-proto/-/get-proto-1.0.1.tgz",
"integrity": "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==",
"license": "MIT",
"dependencies": {
"dunder-proto": "^1.0.1",
"es-object-atoms": "^1.0.0"
},
"engines": {
"node": ">= 0.4"
}
},
"node_modules/gopd": {
"version": "1.2.0",
"resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz",
"integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==",
"license": "MIT",
"engines": {
"node": ">= 0.4"
},
"funding": {
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/has-symbols": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz",
"integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==",
"license": "MIT",
"engines": {
"node": ">= 0.4"
},
"funding": {
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/hasown": {
"version": "2.0.2",
"resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.2.tgz",
"integrity": "sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==",
"license": "MIT",
"dependencies": {
"function-bind": "^1.1.2"
},
"engines": {
"node": ">= 0.4"
}
},
"node_modules/http-errors": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/http-errors/-/http-errors-2.0.1.tgz",
"integrity": "sha512-4FbRdAX+bSdmo4AUFuS0WNiPz8NgFt+r8ThgNWmlrjQjt1Q7ZR9+zTlce2859x4KSXrwIsaeTqDoKQmtP8pLmQ==",
"license": "MIT",
"dependencies": {
"depd": "~2.0.0",
"inherits": "~2.0.4",
"setprototypeof": "~1.2.0",
"statuses": "~2.0.2",
"toidentifier": "~1.0.1"
},
"engines": {
"node": ">= 0.8"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/express"
}
},
"node_modules/iconv-lite": {
"version": "0.4.24",
"resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.4.24.tgz",
"integrity": "sha512-v3MXnZAcvnywkTUEZomIActle7RXXeedOR31wwl7VlyoXO4Qi9arvSenNQWne1TcRwhCL1HwLI21bEqdpj8/rA==",
"license": "MIT",
"dependencies": {
"safer-buffer": ">= 2.1.2 < 3"
},
"engines": {
"node": ">=0.10.0"
}
},
"node_modules/inherits": {
"version": "2.0.4",
"resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz",
"integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==",
"license": "ISC"
},
"node_modules/ipaddr.js": {
"version": "1.9.1",
"resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-1.9.1.tgz",
"integrity": "sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g==",
"license": "MIT",
"engines": {
"node": ">= 0.10"
}
},
"node_modules/jake": {
"version": "10.9.4",
"resolved": "https://registry.npmjs.org/jake/-/jake-10.9.4.tgz",
"integrity": "sha512-wpHYzhxiVQL+IV05BLE2Xn34zW1S223hvjtqk0+gsPrwd/8JNLXJgZZM/iPFsYc1xyphF+6M6EvdE5E9MBGkDA==",
"license": "Apache-2.0",
"dependencies": {
"async": "^3.2.6",
"filelist": "^1.0.4",
"picocolors": "^1.1.1"
},
"bin": {
"jake": "bin/cli.js"
},
"engines": {
"node": ">=10"
}
},
"node_modules/math-intrinsics": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz",
"integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==",
"license": "MIT",
"engines": {
"node": ">= 0.4"
}
},
"node_modules/media-typer": {
"version": "0.3.0",
"resolved": "https://registry.npmjs.org/media-typer/-/media-typer-0.3.0.tgz",
"integrity": "sha512-dq+qelQ9akHpcOl/gUVRTxVIOkAJ1wR3QAvb4RsVjS8oVoFjDGTc679wJYmUmknUF5HwMLOgb5O+a3KxfWapPQ==",
"license": "MIT",
"engines": {
"node": ">= 0.6"
}
},
"node_modules/merge-descriptors": {
"version": "1.0.3",
"resolved": "https://registry.npmjs.org/merge-descriptors/-/merge-descriptors-1.0.3.tgz",
"integrity": "sha512-gaNvAS7TZ897/rVaZ0nMtAyxNyi/pdbjbAwUpFQpN70GqnVfOiXpeUUMKRBmzXaSQ8DdTX4/0ms62r2K+hE6mQ==",
"license": "MIT",
"funding": {
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/methods": {
"version": "1.1.2",
"resolved": "https://registry.npmjs.org/methods/-/methods-1.1.2.tgz",
"integrity": "sha512-iclAHeNqNm68zFtnZ0e+1L2yUIdvzNoauKU4WBA3VvH/vPFieF7qfRlwUZU+DA9P9bPXIS90ulxoUoCH23sV2w==",
"license": "MIT",
"engines": {
"node": ">= 0.6"
}
},
"node_modules/mime": {
"version": "1.6.0",
"resolved": "https://registry.npmjs.org/mime/-/mime-1.6.0.tgz",
"integrity": "sha512-x0Vn8spI+wuJ1O6S7gnbaQg8Pxh4NNHb7KSINmEWKiPE4RKOplvijn+NkmYmmRgP68mc70j2EbeTFRsrswaQeg==",
"license": "MIT",
"bin": {
"mime": "cli.js"
},
"engines": {
"node": ">=4"
}
},
"node_modules/mime-db": {
"version": "1.52.0",
"resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz",
"integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==",
"license": "MIT",
"engines": {
"node": ">= 0.6"
}
},
"node_modules/mime-types": {
"version": "2.1.35",
"resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz",
"integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==",
"license": "MIT",
"dependencies": {
"mime-db": "1.52.0"
},
"engines": {
"node": ">= 0.6"
}
},
"node_modules/minimatch": {
"version": "5.1.6",
"resolved": "https://registry.npmjs.org/minimatch/-/minimatch-5.1.6.tgz",
"integrity": "sha512-lKwV/1brpG6mBUFHtb7NUmtABCb2WZZmm2wNiOA5hAb8VdCS4B3dtMWyvcoViccwAW/COERjXLt0zP1zXUN26g==",
"license": "ISC",
"dependencies": {
"brace-expansion": "^2.0.1"
},
"engines": {
"node": ">=10"
}
},
"node_modules/ms": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz",
"integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==",
"license": "MIT"
},
"node_modules/negotiator": {
"version": "0.6.3",
"resolved": "https://registry.npmjs.org/negotiator/-/negotiator-0.6.3.tgz",
"integrity": "sha512-+EUsqGPLsM+j/zdChZjsnX51g4XrHFOIXwfnCVPGlQk/k5giakcKsuxCObBRu6DSm9opw/O6slWbJdghQM4bBg==",
"license": "MIT",
"engines": {
"node": ">= 0.6"
}
},
"node_modules/object-inspect": {
"version": "1.13.4",
"resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.13.4.tgz",
"integrity": "sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew==",
"license": "MIT",
"engines": {
"node": ">= 0.4"
},
"funding": {
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/on-finished": {
"version": "2.4.1",
"resolved": "https://registry.npmjs.org/on-finished/-/on-finished-2.4.1.tgz",
"integrity": "sha512-oVlzkg3ENAhCk2zdv7IJwd/QUD4z2RxRwpkcGY8psCVcCYZNq4wYnVWALHM+brtuJjePWiYF/ClmuDr8Ch5+kg==",
"license": "MIT",
"dependencies": {
"ee-first": "1.1.1"
},
"engines": {
"node": ">= 0.8"
}
},
"node_modules/parseurl": {
"version": "1.3.3",
"resolved": "https://registry.npmjs.org/parseurl/-/parseurl-1.3.3.tgz",
"integrity": "sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ==",
"license": "MIT",
"engines": {
"node": ">= 0.8"
}
},
"node_modules/path-to-regexp": {
"version": "0.1.12",
"resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-0.1.12.tgz",
"integrity": "sha512-RA1GjUVMnvYFxuqovrEqZoxxW5NUZqbwKtYz/Tt7nXerk0LbLblQmrsgdeOxV5SFHf0UDggjS/bSeOZwt1pmEQ==",
"license": "MIT"
},
"node_modules/picocolors": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz",
"integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==",
"license": "ISC"
},
"node_modules/proxy-addr": {
"version": "2.0.7",
"resolved": "https://registry.npmjs.org/proxy-addr/-/proxy-addr-2.0.7.tgz",
"integrity": "sha512-llQsMLSUDUPT44jdrU/O37qlnifitDP+ZwrmmZcoSKyLKvtZxpyV0n2/bD/N4tBAAZ/gJEdZU7KMraoK1+XYAg==",
"license": "MIT",
"dependencies": {
"forwarded": "0.2.0",
"ipaddr.js": "1.9.1"
},
"engines": {
"node": ">= 0.10"
}
},
"node_modules/qs": {
"version": "6.14.1",
"resolved": "https://registry.npmjs.org/qs/-/qs-6.14.1.tgz",
"integrity": "sha512-4EK3+xJl8Ts67nLYNwqw/dsFVnCf+qR7RgXSK9jEEm9unao3njwMDdmsdvoKBKHzxd7tCYz5e5M+SnMjdtXGQQ==",
"license": "BSD-3-Clause",
"dependencies": {
"side-channel": "^1.1.0"
},
"engines": {
"node": ">=0.6"
},
"funding": {
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/range-parser": {
"version": "1.2.1",
"resolved": "https://registry.npmjs.org/range-parser/-/range-parser-1.2.1.tgz",
"integrity": "sha512-Hrgsx+orqoygnmhFbKaHE6c296J+HTAQXoxEF6gNupROmmGJRoyzfG3ccAveqCBrwr/2yxQ5BVd/GTl5agOwSg==",
"license": "MIT",
"engines": {
"node": ">= 0.6"
}
},
"node_modules/raw-body": {
"version": "2.5.3",
"resolved": "https://registry.npmjs.org/raw-body/-/raw-body-2.5.3.tgz",
"integrity": "sha512-s4VSOf6yN0rvbRZGxs8Om5CWj6seneMwK3oDb4lWDH0UPhWcxwOWw5+qk24bxq87szX1ydrwylIOp2uG1ojUpA==",
"license": "MIT",
"dependencies": {
"bytes": "~3.1.2",
"http-errors": "~2.0.1",
"iconv-lite": "~0.4.24",
"unpipe": "~1.0.0"
},
"engines": {
"node": ">= 0.8"
}
},
"node_modules/safe-buffer": {
"version": "5.2.1",
"resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz",
"integrity": "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==",
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/feross"
},
{
"type": "patreon",
"url": "https://www.patreon.com/feross"
},
{
"type": "consulting",
"url": "https://feross.org/support"
}
],
"license": "MIT"
},
"node_modules/safer-buffer": {
"version": "2.1.2",
"resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz",
"integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==",
"license": "MIT"
},
"node_modules/send": {
"version": "0.19.2",
"resolved": "https://registry.npmjs.org/send/-/send-0.19.2.tgz",
"integrity": "sha512-VMbMxbDeehAxpOtWJXlcUS5E8iXh6QmN+BkRX1GARS3wRaXEEgzCcB10gTQazO42tpNIya8xIyNx8fll1OFPrg==",
"license": "MIT",
"dependencies": {
"debug": "2.6.9",
"depd": "2.0.0",
"destroy": "1.2.0",
"encodeurl": "~2.0.0",
"escape-html": "~1.0.3",
"etag": "~1.8.1",
"fresh": "~0.5.2",
"http-errors": "~2.0.1",
"mime": "1.6.0",
"ms": "2.1.3",
"on-finished": "~2.4.1",
"range-parser": "~1.2.1",
"statuses": "~2.0.2"
},
"engines": {
"node": ">= 0.8.0"
}
},
"node_modules/send/node_modules/ms": {
"version": "2.1.3",
"resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
"integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==",
"license": "MIT"
},
"node_modules/serve-static": {
"version": "1.16.3",
"resolved": "https://registry.npmjs.org/serve-static/-/serve-static-1.16.3.tgz",
"integrity": "sha512-x0RTqQel6g5SY7Lg6ZreMmsOzncHFU7nhnRWkKgWuMTu5NN0DR5oruckMqRvacAN9d5w6ARnRBXl9xhDCgfMeA==",
"license": "MIT",
"dependencies": {
"encodeurl": "~2.0.0",
"escape-html": "~1.0.3",
"parseurl": "~1.3.3",
"send": "~0.19.1"
},
"engines": {
"node": ">= 0.8.0"
}
},
"node_modules/setprototypeof": {
"version": "1.2.0",
"resolved": "https://registry.npmjs.org/setprototypeof/-/setprototypeof-1.2.0.tgz",
"integrity": "sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw==",
"license": "ISC"
},
"node_modules/side-channel": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.1.0.tgz",
"integrity": "sha512-ZX99e6tRweoUXqR+VBrslhda51Nh5MTQwou5tnUDgbtyM0dBgmhEDtWGP/xbKn6hqfPRHujUNwz5fy/wbbhnpw==",
"license": "MIT",
"dependencies": {
"es-errors": "^1.3.0",
"object-inspect": "^1.13.3",
"side-channel-list": "^1.0.0",
"side-channel-map": "^1.0.1",
"side-channel-weakmap": "^1.0.2"
},
"engines": {
"node": ">= 0.4"
},
"funding": {
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/side-channel-list": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/side-channel-list/-/side-channel-list-1.0.0.tgz",
"integrity": "sha512-FCLHtRD/gnpCiCHEiJLOwdmFP+wzCmDEkc9y7NsYxeF4u7Btsn1ZuwgwJGxImImHicJArLP4R0yX4c2KCrMrTA==",
"license": "MIT",
"dependencies": {
"es-errors": "^1.3.0",
"object-inspect": "^1.13.3"
},
"engines": {
"node": ">= 0.4"
},
"funding": {
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/side-channel-map": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/side-channel-map/-/side-channel-map-1.0.1.tgz",
"integrity": "sha512-VCjCNfgMsby3tTdo02nbjtM/ewra6jPHmpThenkTYh8pG9ucZ/1P8So4u4FGBek/BjpOVsDCMoLA/iuBKIFXRA==",
"license": "MIT",
"dependencies": {
"call-bound": "^1.0.2",
"es-errors": "^1.3.0",
"get-intrinsic": "^1.2.5",
"object-inspect": "^1.13.3"
},
"engines": {
"node": ">= 0.4"
},
"funding": {
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/side-channel-weakmap": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/side-channel-weakmap/-/side-channel-weakmap-1.0.2.tgz",
"integrity": "sha512-WPS/HvHQTYnHisLo9McqBHOJk2FkHO/tlpvldyrnem4aeQp4hai3gythswg6p01oSoTl58rcpiFAjF2br2Ak2A==",
"license": "MIT",
"dependencies": {
"call-bound": "^1.0.2",
"es-errors": "^1.3.0",
"get-intrinsic": "^1.2.5",
"object-inspect": "^1.13.3",
"side-channel-map": "^1.0.1"
},
"engines": {
"node": ">= 0.4"
},
"funding": {
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/statuses": {
"version": "2.0.2",
"resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.2.tgz",
"integrity": "sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw==",
"license": "MIT",
"engines": {
"node": ">= 0.8"
}
},
"node_modules/toidentifier": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/toidentifier/-/toidentifier-1.0.1.tgz",
"integrity": "sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA==",
"license": "MIT",
"engines": {
"node": ">=0.6"
}
},
"node_modules/type-is": {
"version": "1.6.18",
"resolved": "https://registry.npmjs.org/type-is/-/type-is-1.6.18.tgz",
"integrity": "sha512-TkRKr9sUTxEH8MdfuCSP7VizJyzRNMjj2J2do2Jr3Kym598JVdEksuzPQCnlFPW4ky9Q+iA+ma9BGm06XQBy8g==",
"license": "MIT",
"dependencies": {
"media-typer": "0.3.0",
"mime-types": "~2.1.24"
},
"engines": {
"node": ">= 0.6"
}
},
"node_modules/unpipe": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/unpipe/-/unpipe-1.0.0.tgz",
"integrity": "sha512-pjy2bYhSsufwWlKwPc+l3cN7+wuJlK6uz0YdJEOlQDbl6jo/YlPi4mb8agUkVC8BF7V8NuzeyPNqRksA3hztKQ==",
"license": "MIT",
"engines": {
"node": ">= 0.8"
}
},
"node_modules/utils-merge": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/utils-merge/-/utils-merge-1.0.1.tgz",
"integrity": "sha512-pMZTvIkT1d+TFGvDOqodOclx0QWkkgi6Tdoa8gC8ffGAAqz9pzPTZWAybbsHHoED/ztMtkv/VoYTYyShUn81hA==",
"license": "MIT",
"engines": {
"node": ">= 0.4.0"
}
},
"node_modules/vary": {
"version": "1.1.2",
"resolved": "https://registry.npmjs.org/vary/-/vary-1.1.2.tgz",
"integrity": "sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg==",
"license": "MIT",
"engines": {
"node": ">= 0.8"
}
}
}
}

View File

@@ -1,11 +0,0 @@
{
"name": "klz-gatekeeper",
"version": "1.0.0",
"description": "Simple branded gatekeeper for Traefik ForwardAuth",
"main": "index.js",
"dependencies": {
"cookie-parser": "^1.4.6",
"ejs": "^3.1.9",
"express": "^4.18.2"
}
}

View File

@@ -1,97 +0,0 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>KLZ Cables | Access Control</title>
<script src="https://cdn.tailwindcss.com"></script>
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;700;900&display=swap" rel="stylesheet">
<style>
body {
font-family: 'Inter', sans-serif;
background-color: #001a4d;
color: white;
overflow: hidden;
}
.bg-grid {
background-image:
linear-gradient(to right, rgba(255,255,255,0.05) 1px, transparent 1px),
linear-gradient(to bottom, rgba(255,255,255,0.05) 1px, transparent 1px);
background-size: 40px 40px;
}
.accent-glow {
box-shadow: 0 0 20px rgba(130, 237, 32, 0.4);
}
.scribble-animation {
stroke-dasharray: 1000;
stroke-dashoffset: 1000;
animation: draw 2s ease-out forwards;
}
@keyframes draw {
to { stroke-dashoffset: 0; }
}
</style>
</head>
<body class="min-h-screen flex items-center justify-center relative">
<!-- Background Elements -->
<div class="absolute inset-0 bg-grid pointer-events-none"></div>
<div class="absolute top-0 right-0 w-96 h-96 bg-[#82ed20]/5 blur-[120px] rounded-full -translate-y-1/2 translate-x-1/2"></div>
<div class="absolute bottom-0 left-0 w-96 h-96 bg-[#82ed20]/5 blur-[120px] rounded-full translate-y-1/2 -translate-x-1/2"></div>
<div class="relative z-10 w-full max-w-md px-6">
<!-- Logo -->
<div class="flex justify-center mb-12">
<svg class="h-16 w-auto" viewBox="0 0 100 100" fill="none" xmlns="http://www.w3.org/2000/svg">
<rect width="100" height="100" rx="20" fill="#001a4d" />
<path d="M30 30L70 70" stroke="#82ed20" stroke-width="8" stroke-linecap="round" />
<path d="M70 30L30 70" stroke="#82ed20" stroke-width="8" stroke-linecap="round" />
</svg>
</div>
<div class="bg-white/5 backdrop-blur-xl border border-white/10 p-10 rounded-[40px] shadow-2xl">
<h1 class="text-3xl font-black mb-2 tracking-tighter uppercase italic">
KLZ <span class="text-[#82ed20]">Gatekeeper</span>
</h1>
<p class="text-white/60 text-sm mb-8">This environment is strictly protected.</p>
<% if (error) { %>
<div class="bg-red-500/20 border border-red-500/50 text-red-200 p-4 rounded-2xl mb-6 text-sm flex items-center gap-3">
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 8v4m0 4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z" /></svg>
<%= error %>
</div>
<% } %>
<form action="/gatekeeper/login" method="POST" class="space-y-6">
<input type="hidden" name="redirect" value="<%= redirect %>">
<div>
<label class="block text-[10px] font-black uppercase tracking-[0.2em] text-white/40 mb-2 ml-4">Access Password</label>
<input
type="password"
name="password"
required
autofocus
class="w-full bg-white/5 border border-white/10 rounded-2xl px-6 py-4 focus:outline-none focus:border-[#82ed20]/50 transition-all text-lg tracking-widest text-center"
placeholder="••••••••"
>
</div>
<button
type="submit"
class="w-full bg-[#82ed20] text-[#001a4d] font-black uppercase tracking-[0.2em] py-5 rounded-2xl hover:bg-[#82ed20]/90 transition-all accent-glow active:scale-[0.98]"
>
Enter Workspace &rarr;
</button>
</form>
</div>
<div class="mt-8 text-center">
<p class="text-[10px] font-bold text-white/20 uppercase tracking-[0.4em]">
&copy; 2026 KLZ Vertriebs GmbH
</p>
</div>
</div>
</body>
</html>

View File

@@ -1,15 +1,14 @@
import {getRequestConfig} from 'next-intl/server';
import { getRequestConfig } from 'next-intl/server';
import * as Sentry from '@sentry/nextjs';
export default getRequestConfig(async ({requestLocale}) => {
// This typically corresponds to the `[locale]` segment
export default getRequestConfig(async ({ requestLocale }) => {
let locale = await requestLocale;
// Ensure that a valid locale is used
if (!locale || !['en', 'de'].includes(locale)) {
locale = 'en';
}
return {
locale,
messages: (await import(`../messages/${locale}.json`)).default,
@@ -21,12 +20,12 @@ export default getRequestConfig(async ({requestLocale}) => {
}
Sentry.captureException(error);
},
getMessageFallback({namespace, key, error}) {
getMessageFallback({ namespace, key, error }) {
const path = [namespace, key].filter((part) => part != null).join('.');
if (error.code === 'MISSING_MESSAGE') {
return path;
}
return 'fallback';
}
};
} as any;
});

View File

@@ -41,11 +41,11 @@ export async function getPostBySlug(slug: string, locale: string): Promise<PostM
export async function getAllPosts(locale: string): Promise<PostMdx[]> {
const postsDir = path.join(process.cwd(), 'data', 'blog', locale);
if (!fs.existsSync(postsDir)) return [];
const files = fs.readdirSync(postsDir);
const posts = files
.filter(file => file.endsWith('.mdx'))
.map(file => {
.filter((file) => file.endsWith('.mdx'))
.map((file) => {
const filePath = path.join(postsDir, file);
const fileContent = fs.readFileSync(filePath, 'utf8');
const { data, content } = matter(fileContent);
@@ -55,14 +55,42 @@ export async function getAllPosts(locale: string): Promise<PostMdx[]> {
content,
};
})
.sort((a, b) => new Date(b.frontmatter.date).getTime() - new Date(a.frontmatter.date).getTime());
.sort(
(a, b) => new Date(b.frontmatter.date).getTime() - new Date(a.frontmatter.date).getTime(),
);
return posts;
}
export async function getAdjacentPosts(slug: string, locale: string): Promise<{ prev: PostMdx | null; next: PostMdx | null }> {
export async function getAllPostsMetadata(locale: string): Promise<Partial<PostMdx>[]> {
const postsDir = path.join(process.cwd(), 'data', 'blog', locale);
if (!fs.existsSync(postsDir)) return [];
const files = fs.readdirSync(postsDir);
return files
.filter((file) => file.endsWith('.mdx'))
.map((file) => {
const filePath = path.join(postsDir, file);
const fileContent = fs.readFileSync(filePath, 'utf8');
const { data } = matter(fileContent);
return {
slug: file.replace(/\.mdx$/, ''),
frontmatter: data as PostFrontmatter,
};
})
.sort(
(a, b) =>
new Date(b.frontmatter.date as string).getTime() -
new Date(a.frontmatter.date as string).getTime(),
);
}
export async function getAdjacentPosts(
slug: string,
locale: string,
): Promise<{ prev: PostMdx | null; next: PostMdx | null }> {
const posts = await getAllPosts(locale);
const currentIndex = posts.findIndex(post => post.slug === slug);
const currentIndex = posts.findIndex((post) => post.slug === slug);
if (currentIndex === -1) {
return { prev: null, next: null };

View File

@@ -13,31 +13,35 @@ let memoizedConfig: ReturnType<typeof createConfig> | undefined;
function createConfig() {
const env = envSchema.parse(getRawEnv());
const target = env.NEXT_PUBLIC_TARGET || env.TARGET;
return {
env: env.NODE_ENV,
isProduction: env.NODE_ENV === 'production',
isDevelopment: env.NODE_ENV === 'development',
isTest: env.NODE_ENV === 'test',
target,
isProduction: target === 'production' || !target,
isStaging: target === 'staging',
isTesting: target === 'testing',
isDevelopment: target === 'development',
feedbackEnabled: env.NEXT_PUBLIC_FEEDBACK_ENABLED,
gatekeeperUrl: env.GATEKEEPER_URL,
baseUrl: env.NEXT_PUBLIC_BASE_URL,
analytics: {
umami: {
websiteId: env.NEXT_PUBLIC_UMAMI_WEBSITE_ID,
scriptUrl: env.NEXT_PUBLIC_UMAMI_SCRIPT_URL,
// The proxied path used in the frontend
proxyPath: '/stats/script.js',
enabled: Boolean(env.NEXT_PUBLIC_UMAMI_WEBSITE_ID),
websiteId: env.UMAMI_WEBSITE_ID,
apiEndpoint: env.UMAMI_API_ENDPOINT,
enabled: Boolean(env.UMAMI_WEBSITE_ID),
},
},
errors: {
glitchtip: {
// Use SENTRY_DSN for both server and client (proxied)
dsn: env.SENTRY_DSN,
// The proxied origin used in the frontend
proxyPath: '/errors',
enabled: Boolean(env.SENTRY_DSN),
// On the client, we always enable it (it uses the tunnel / proxy defined in sentry.client.config.ts)
// On the server, we only enable it if the DSN is provided.
enabled: typeof window !== 'undefined' || Boolean(env.SENTRY_DSN),
},
},
@@ -65,6 +69,17 @@ function createConfig() {
internalUrl: env.INTERNAL_DIRECTUS_URL,
proxyPath: '/cms',
},
infraCMS: {
url: env.INFRA_DIRECTUS_URL || env.DIRECTUS_URL,
token: env.INFRA_DIRECTUS_TOKEN || env.DIRECTUS_API_TOKEN,
},
notifications: {
gotify: {
url: env.GOTIFY_URL,
token: env.GOTIFY_TOKEN,
enabled: Boolean(env.GOTIFY_URL && env.GOTIFY_TOKEN),
},
},
} as const;
}
@@ -87,15 +102,21 @@ export const config = {
get env() {
return getConfig().env;
},
get target() {
return getConfig().target;
},
get isProduction() {
return getConfig().isProduction;
},
get isStaging() {
return getConfig().isStaging;
},
get isTesting() {
return getConfig().isTesting;
},
get isDevelopment() {
return getConfig().isDevelopment;
},
get isTest() {
return getConfig().isTest;
},
get baseUrl() {
return getConfig().baseUrl;
},
@@ -117,6 +138,18 @@ export const config = {
get directus() {
return getConfig().directus;
},
get notifications() {
return getConfig().notifications;
},
get feedbackEnabled() {
return getConfig().feedbackEnabled;
},
get infraCMS() {
return getConfig().infraCMS;
},
get gatekeeperUrl() {
return getConfig().gatekeeperUrl;
},
};
/**
@@ -132,7 +165,7 @@ export function getMaskedConfig() {
analytics: {
umami: {
websiteId: mask(c.analytics.umami.websiteId),
scriptUrl: c.analytics.umami.scriptUrl,
apiEndpoint: c.analytics.umami.apiEndpoint,
enabled: c.analytics.umami.enabled,
},
},
@@ -161,5 +194,12 @@ export function getMaskedConfig() {
password: mask(c.directus.password),
token: mask(c.directus.token),
},
notifications: {
gotify: {
url: c.notifications.gotify.url,
token: mask(c.notifications.gotify.token),
enabled: c.notifications.gotify.enabled,
},
},
};
}

View File

@@ -1,24 +1,78 @@
import { createDirectus, rest, authentication, readItems, readCollections } from '@directus/sdk';
import { createDirectus, rest, authentication, staticToken, readItems, readCollections } from '@directus/sdk';
import { config } from './config';
import { getServerAppServices } from './services/create-services.server';
const { url, adminEmail, password, token, proxyPath, internalUrl } = config.directus;
// Use internal URL if on server to bypass Gatekeeper/Auth
const effectiveUrl = typeof window === 'undefined' && internalUrl ? internalUrl : url;
// Use proxy path in browser to stay on the same origin
const effectiveUrl =
typeof window === 'undefined'
? internalUrl || url
: typeof window !== 'undefined'
? `${window.location.origin}${proxyPath}`
: proxyPath;
// Initialize client with authentication plugin
const client = createDirectus(effectiveUrl).with(rest()).with(authentication());
/**
* Helper to determine if we should show detailed errors
*/
const shouldShowDevErrors = config.isTesting || config.isDevelopment;
/**
* Genericizes error messages for production/staging
*/
function formatError(error: any) {
if (shouldShowDevErrors) {
return error.errors?.[0]?.message || error.message || 'An unexpected error occurred.';
}
return 'A system error occurred. Our team has been notified.';
}
let authPromise: Promise<void> | null = null;
export async function ensureAuthenticated() {
if (token) {
client.setToken(token);
(client as any).setToken(token);
return;
}
// Check if we already have a valid session token in memory (for login flow)
const existingToken = await (client as any).getToken();
if (existingToken) {
return;
}
if (adminEmail && password) {
try {
await client.login(adminEmail, password);
} catch (e) {
console.error('Failed to authenticate with Directus:', e);
if (authPromise) {
return authPromise;
}
authPromise = (async () => {
try {
client.setToken(null as any);
await client.login(adminEmail, password);
console.log(`✅ Directus: Authenticated successfully as ${adminEmail}`);
} catch (e: any) {
if (typeof window === 'undefined') {
getServerAppServices().errors.captureException(e, { part: 'directus_auth' });
}
console.error(`Failed to authenticate with Directus (${adminEmail}):`, e.message);
if (shouldShowDevErrors && e.errors) {
console.error('Directus Auth Details:', JSON.stringify(e.errors, null, 2));
}
// Clear the promise on failure (especially on invalid credentials)
// so we can retry on next request if credentials were updated
authPromise = null;
throw e;
}
})();
return authPromise;
} else if (shouldShowDevErrors && !adminEmail && !password && !token) {
console.warn('Directus: No token or admin credentials provided.');
}
}
@@ -61,6 +115,9 @@ export async function getProducts(locale: string = 'de') {
);
return items.map((item) => mapDirectusProduct(item, locale));
} catch (error) {
if (typeof window === 'undefined') {
getServerAppServices().errors.captureException(error, { part: 'directus_get_products' });
}
console.error('Error fetching products:', error);
return [];
}
@@ -86,6 +143,12 @@ export async function getProductBySlug(slug: string, locale: string = 'de') {
if (!items || items.length === 0) return null;
return mapDirectusProduct(items[0], locale);
} catch (error) {
if (typeof window === 'undefined') {
getServerAppServices().errors.captureException(error, {
part: 'directus_get_product_by_slug',
slug,
});
}
console.error(`Error fetching product ${slug}:`, error);
return null;
}
@@ -98,20 +161,27 @@ export async function checkHealth() {
await ensureAuthenticated();
await client.request(readCollections());
} catch (e: any) {
if (typeof window === 'undefined') {
getServerAppServices().errors.captureException(e, { part: 'directus_health_auth' });
}
console.error('Directus authentication failed during health check:', e);
return {
status: 'error',
message:
'Authentication failed. Check your DIRECTUS_ADMIN_EMAIL and DIRECTUS_ADMIN_PASSWORD.',
message: shouldShowDevErrors
? 'Authentication failed. Check your DIRECTUS_ADMIN_EMAIL and DIRECTUS_ADMIN_PASSWORD.'
: 'CMS is currently unavailable due to an internal authentication error.',
code: 'AUTH_FAILED',
details: e.message,
details: shouldShowDevErrors ? e.message : undefined,
};
}
// 2. Schema check (does the products table exist?)
// 2. Schema check (does the contact_submissions table exist?)
try {
await client.request(readItems('products', { limit: 1 }));
await client.request(readItems('contact_submissions', { limit: 1 }));
} catch (e: any) {
if (typeof window === 'undefined') {
getServerAppServices().errors.captureException(e, { part: 'directus_health_schema' });
}
if (
e.message?.includes('does not exist') ||
e.code === 'INVALID_PAYLOAD' ||
@@ -119,23 +189,30 @@ export async function checkHealth() {
) {
return {
status: 'error',
message: 'The "products" collection is missing or inaccessible. Please sync your data.',
message: shouldShowDevErrors
? `The "contact_submissions" collection is missing or inaccessible. Error: ${e.message || 'Unknown'}`
: 'Required data structures are currently unavailable.',
code: 'SCHEMA_MISSING',
};
}
return {
status: 'error',
message: `Schema error: ${e.message}`,
message: shouldShowDevErrors
? `Schema error: ${e.errors?.[0]?.message || e.message || 'Unknown error'}`
: 'The data schema is currently misconfigured.',
code: 'SCHEMA_ERROR',
};
}
return { status: 'ok', message: 'Directus is reachable and responding.' };
} catch (error: any) {
if (typeof window === 'undefined') {
getServerAppServices().errors.captureException(error, { part: 'directus_health_critical' });
}
console.error('Directus health check failed with unexpected error:', error);
return {
status: 'error',
message: error.message || 'An unexpected error occurred while connecting to the CMS.',
message: formatError(error),
code: error.code || 'UNKNOWN',
};
}

70
lib/env.test.ts Normal file
View File

@@ -0,0 +1,70 @@
import { describe, it, expect } from 'vitest';
import { envSchema } from './env';
describe('envSchema', () => {
it('should allow missing MAIL_HOST in development', () => {
const result = envSchema.safeParse({
NEXT_PUBLIC_BASE_URL: 'http://localhost:3000',
TARGET: 'development',
});
expect(result.success).toBe(true);
});
it('should require MAIL_HOST in production', () => {
const result = envSchema.safeParse({
NEXT_PUBLIC_BASE_URL: 'https://example.com',
TARGET: 'production',
});
expect(result.success).toBe(false);
if (!result.success) {
expect(result.error.issues[0].message).toBe(
'MAIL_HOST is required in non-development environments',
);
}
});
it('should require MAIL_HOST in testing', () => {
const result = envSchema.safeParse({
NEXT_PUBLIC_BASE_URL: 'https://testing.example.com',
TARGET: 'testing',
});
expect(result.success).toBe(false);
if (!result.success) {
expect(result.error.issues[0].message).toBe(
'MAIL_HOST is required in non-development environments',
);
}
});
it('should require MAIL_HOST in staging', () => {
const result = envSchema.safeParse({
NEXT_PUBLIC_BASE_URL: 'https://staging.example.com',
TARGET: 'staging',
});
expect(result.success).toBe(false);
if (!result.success) {
expect(result.error.issues[0].message).toBe(
'MAIL_HOST is required in non-development environments',
);
}
});
it('should pass if MAIL_HOST is provided in production', () => {
const result = envSchema.safeParse({
NEXT_PUBLIC_BASE_URL: 'https://example.com',
TARGET: 'production',
MAIL_HOST: 'smtp.example.com',
});
expect(result.success).toBe(true);
});
it('should skip MAIL_HOST requirement if SKIP_RUNTIME_ENV_VALIDATION is true', () => {
process.env.SKIP_RUNTIME_ENV_VALIDATION = 'true';
const result = envSchema.safeParse({
NEXT_PUBLIC_BASE_URL: 'https://example.com',
TARGET: 'production',
});
expect(result.success).toBe(true);
delete process.env.SKIP_RUNTIME_ENV_VALIDATION;
});
});

View File

@@ -8,44 +8,83 @@ const preprocessEmptyString = (val: unknown) => (val === '' ? undefined : val);
/**
* Environment variable schema.
*/
export const envSchema = z.object({
NODE_ENV: z.enum(['development', 'production', 'test']).default('development'),
NEXT_PUBLIC_BASE_URL: z.preprocess(preprocessEmptyString, z.string().url()),
export const envSchema = z
.object({
NODE_ENV: z.enum(['development', 'production', 'test']).default('development'),
NEXT_PUBLIC_BASE_URL: z.preprocess(preprocessEmptyString, z.string().url()),
NEXT_PUBLIC_TARGET: z.enum(['development', 'testing', 'staging', 'production']).optional(),
// Analytics
NEXT_PUBLIC_UMAMI_WEBSITE_ID: z.preprocess(preprocessEmptyString, z.string().optional()),
NEXT_PUBLIC_UMAMI_SCRIPT_URL: z.preprocess(
preprocessEmptyString,
z.string().url().default('https://analytics.infra.mintel.me/script.js'),
),
// Analytics
UMAMI_WEBSITE_ID: z.preprocess(preprocessEmptyString, z.string().optional()),
UMAMI_API_ENDPOINT: z.preprocess(
preprocessEmptyString,
z.string().url().default('https://analytics.infra.mintel.me'),
),
// Error Tracking
SENTRY_DSN: z.preprocess(preprocessEmptyString, z.string().optional()),
// Error Tracking
SENTRY_DSN: z.preprocess(preprocessEmptyString, z.string().optional()),
// Logging
LOG_LEVEL: z.enum(['debug', 'info', 'warn', 'error']).default('info'),
// Logging
LOG_LEVEL: z.enum(['debug', 'info', 'warn', 'error']).default('info'),
// Mail
MAIL_HOST: z.preprocess(preprocessEmptyString, z.string().optional()),
MAIL_PORT: z.preprocess(preprocessEmptyString, z.coerce.number().default(587)),
MAIL_USERNAME: z.preprocess(preprocessEmptyString, z.string().optional()),
MAIL_PASSWORD: z.preprocess(preprocessEmptyString, z.string().optional()),
MAIL_FROM: z.preprocess(preprocessEmptyString, z.string().optional()),
MAIL_RECIPIENTS: z.preprocess(
(val) => (typeof val === 'string' ? val.split(',').filter(Boolean) : val),
z.array(z.string()).default([]),
),
// Mail
MAIL_HOST: z.preprocess(preprocessEmptyString, z.string().optional()),
MAIL_PORT: z.preprocess(preprocessEmptyString, z.coerce.number().default(587)),
MAIL_USERNAME: z.preprocess(preprocessEmptyString, z.string().optional()),
MAIL_PASSWORD: z.preprocess(preprocessEmptyString, z.string().optional()),
MAIL_FROM: z.preprocess(preprocessEmptyString, z.string().optional()),
MAIL_RECIPIENTS: z.preprocess(
(val) => (typeof val === 'string' ? val.split(',').filter(Boolean) : val),
z.array(z.string()).default([]),
),
// Directus
DIRECTUS_URL: z.preprocess(
preprocessEmptyString,
z.string().url().default('http://localhost:8055'),
),
DIRECTUS_ADMIN_EMAIL: z.preprocess(preprocessEmptyString, z.string().optional()),
DIRECTUS_ADMIN_PASSWORD: z.preprocess(preprocessEmptyString, z.string().optional()),
DIRECTUS_API_TOKEN: z.preprocess(preprocessEmptyString, z.string().optional()),
INTERNAL_DIRECTUS_URL: z.preprocess(preprocessEmptyString, z.string().url().optional()),
});
// Directus
DIRECTUS_URL: z.preprocess(
preprocessEmptyString,
z.string().url().default('http://localhost:8055'),
),
DIRECTUS_ADMIN_EMAIL: z.preprocess(preprocessEmptyString, z.string().optional()),
DIRECTUS_ADMIN_PASSWORD: z.preprocess(preprocessEmptyString, z.string().optional()),
DIRECTUS_API_TOKEN: z.preprocess(preprocessEmptyString, z.string().optional()),
INTERNAL_DIRECTUS_URL: z.preprocess(preprocessEmptyString, z.string().url().optional()),
// Deploy Target
TARGET: z.enum(['development', 'testing', 'staging', 'production']).optional(),
// Gotify
GOTIFY_URL: z.preprocess(preprocessEmptyString, z.string().url().optional()),
GOTIFY_TOKEN: z.preprocess(preprocessEmptyString, z.string().optional()),
// Gatekeeper
GATEKEEPER_URL: z.preprocess(
preprocessEmptyString,
z.string().url().default('http://gatekeeper:3000'),
),
NEXT_PUBLIC_FEEDBACK_ENABLED: z.preprocess(
(val) => val === 'true' || val === true,
z.boolean().default(false)
),
GATEKEEPER_BYPASS_ENABLED: z.preprocess(
(val) => val === 'true' || val === true,
z.boolean().default(false)
),
INFRA_DIRECTUS_URL: z.preprocess(preprocessEmptyString, z.string().url().optional()),
INFRA_DIRECTUS_TOKEN: z.preprocess(preprocessEmptyString, z.string().optional()),
})
.superRefine((data, ctx) => {
const target = data.NEXT_PUBLIC_TARGET || data.TARGET;
const isDev = target === 'development' || !target;
const isBuildTimeValidation = process.env.SKIP_RUNTIME_ENV_VALIDATION === 'true';
const isServer = typeof window === 'undefined';
// Only enforce server-only variables when running on the server.
// In the browser, non-NEXT_PUBLIC_ variables are undefined and should not trigger validation errors.
if (isServer && !isDev && !isBuildTimeValidation && !data.MAIL_HOST) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
message: 'MAIL_HOST is required in non-development environments',
path: ['MAIL_HOST'],
});
}
});
export type Env = z.infer<typeof envSchema>;
@@ -57,8 +96,9 @@ export function getRawEnv() {
return {
NODE_ENV: process.env.NODE_ENV,
NEXT_PUBLIC_BASE_URL: process.env.NEXT_PUBLIC_BASE_URL,
NEXT_PUBLIC_UMAMI_WEBSITE_ID: process.env.NEXT_PUBLIC_UMAMI_WEBSITE_ID,
NEXT_PUBLIC_UMAMI_SCRIPT_URL: process.env.NEXT_PUBLIC_UMAMI_SCRIPT_URL,
NEXT_PUBLIC_TARGET: process.env.NEXT_PUBLIC_TARGET,
UMAMI_WEBSITE_ID: process.env.UMAMI_WEBSITE_ID,
UMAMI_API_ENDPOINT: process.env.UMAMI_API_ENDPOINT,
SENTRY_DSN: process.env.SENTRY_DSN,
LOG_LEVEL: process.env.LOG_LEVEL,
MAIL_HOST: process.env.MAIL_HOST,
@@ -72,5 +112,13 @@ export function getRawEnv() {
DIRECTUS_ADMIN_PASSWORD: process.env.DIRECTUS_ADMIN_PASSWORD,
DIRECTUS_API_TOKEN: process.env.DIRECTUS_API_TOKEN,
INTERNAL_DIRECTUS_URL: process.env.INTERNAL_DIRECTUS_URL,
TARGET: process.env.TARGET,
GOTIFY_URL: process.env.GOTIFY_URL,
GOTIFY_TOKEN: process.env.GOTIFY_TOKEN,
GATEKEEPER_URL: process.env.GATEKEEPER_URL,
NEXT_PUBLIC_FEEDBACK_ENABLED: process.env.NEXT_PUBLIC_FEEDBACK_ENABLED,
GATEKEEPER_BYPASS_ENABLED: process.env.GATEKEEPER_BYPASS_ENABLED,
INFRA_DIRECTUS_URL: process.env.INFRA_DIRECTUS_URL,
INFRA_DIRECTUS_TOKEN: process.env.INFRA_DIRECTUS_TOKEN,
};
}

59
lib/mail/mailer.test.ts Normal file
View File

@@ -0,0 +1,59 @@
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { sendEmail } from './mailer';
import { config } from '../config';
// Mock getServerAppServices to avoid full app initialization
vi.mock('@/lib/services/create-services.server', () => ({
getServerAppServices: () => ({
logger: {
child: () => ({
info: vi.fn(),
error: vi.fn(),
warn: vi.fn(),
}),
},
}),
}));
// Mock config
vi.mock('../config', () => ({
config: {
mail: {
host: 'smtp.example.com',
port: 587,
user: 'user',
pass: 'pass',
from: 'from@example.com',
recipients: ['to@example.com'],
},
},
getConfig: vi.fn(),
}));
describe('mailer', () => {
beforeEach(() => {
vi.clearAllMocks();
});
describe('sendEmail', () => {
it('should throw error if MAIL_HOST is missing', async () => {
// Temporarily nullify host
const originalHost = config.mail.host;
(config.mail as any).host = '';
const result = await sendEmail({
subject: 'Test',
html: '<p>Test</p>',
});
expect(result.success).toBe(false);
expect(result.error).toContain('MAIL_HOST is not configured');
// Restore host
(config.mail as any).host = originalHost;
});
// In a real environment, we'd mock nodemailer, but for now we focus on the validation logic
// we added. Full SMTP integration tests are usually out of scope for unit tests.
});
});

View File

@@ -1,33 +1,44 @@
import nodemailer from "nodemailer";
import { render } from "@react-email/components";
import { ReactElement } from "react";
import { getServerAppServices } from "@/lib/services/create-services.server";
import { config } from "../config";
import nodemailer from 'nodemailer';
import { getServerAppServices } from '@/lib/services/create-services.server';
import { config } from '../config';
import { ReactElement } from 'react';
const transporter = nodemailer.createTransport({
host: config.mail.host,
port: config.mail.port,
secure: config.mail.port === 465,
auth: {
user: config.mail.user,
pass: config.mail.pass,
},
});
let transporterInstance: nodemailer.Transporter | null = null;
function getTransporter() {
if (transporterInstance) return transporterInstance;
if (!config.mail.host) {
throw new Error('MAIL_HOST is not configured. Please check your environment variables.');
}
transporterInstance = nodemailer.createTransport({
host: config.mail.host,
port: config.mail.port,
secure: config.mail.port === 465,
auth: {
user: config.mail.user,
pass: config.mail.pass,
},
});
return transporterInstance;
}
interface SendEmailOptions {
to?: string | string[];
replyTo?: string;
subject: string;
template: ReactElement;
html: string;
}
export async function sendEmail({ to, subject, template }: SendEmailOptions) {
const html = await render(template);
export async function sendEmail({ to, replyTo, subject, html }: SendEmailOptions) {
const recipients = to || config.mail.recipients;
const mailOptions = {
from: config.mail.from,
to: recipients,
replyTo,
subject,
html,
};
@@ -35,11 +46,12 @@ export async function sendEmail({ to, subject, template }: SendEmailOptions) {
const logger = getServerAppServices().logger.child({ component: 'mailer' });
try {
const info = await transporter.sendMail(mailOptions);
logger.info("Email sent successfully", { messageId: info.messageId, subject, recipients });
const info = await getTransporter().sendMail(mailOptions);
logger.info('Email sent successfully', { messageId: info.messageId, subject, recipients });
return { success: true, messageId: info.messageId };
} catch (error) {
logger.error("Error sending email", { error, subject, recipients });
return { success: false, error };
const errorMsg = error instanceof Error ? error.message : String(error);
logger.error('Error sending email', { error: errorMsg, subject, recipients });
return { success: false, error: errorMsg };
}
}

View File

@@ -18,11 +18,61 @@ export interface ProductMdx {
content: string;
}
export async function getProductMetadata(
slug: string,
locale: string,
): Promise<Partial<ProductMdx> | null> {
// Map translated slug to file slug
const fileSlug = await mapSlugToFileSlug(slug, locale);
const productsDir = path.join(process.cwd(), 'data', 'products', locale);
// Try exact slug first
let filePath = path.join(productsDir, `${fileSlug}.mdx`);
if (!fs.existsSync(filePath)) {
// Try with -2 suffix (common in the dumped files)
filePath = path.join(productsDir, `${fileSlug}-2.mdx`);
}
if (!fs.existsSync(filePath)) {
// Fallback to English if locale is not 'en'
if (locale !== 'en') {
const enProductsDir = path.join(process.cwd(), 'data', 'products', 'en');
let enFilePath = path.join(enProductsDir, `${fileSlug}.mdx`);
if (!fs.existsSync(enFilePath)) {
enFilePath = path.join(enProductsDir, `${fileSlug}-2.mdx`);
}
if (fs.existsSync(enFilePath)) {
const fileContent = fs.readFileSync(enFilePath, 'utf8');
const { data } = matter(fileContent);
return {
slug: fileSlug,
frontmatter: {
...data,
isFallback: true,
} as ProductFrontmatter & { isFallback?: boolean },
};
}
}
} else {
const fileContent = fs.readFileSync(filePath, 'utf8');
const { data } = matter(fileContent);
return {
slug: fileSlug,
frontmatter: data as ProductFrontmatter,
};
}
return null;
}
export async function getProductBySlug(slug: string, locale: string): Promise<ProductMdx | null> {
// Map translated slug to file slug
const fileSlug = await mapSlugToFileSlug(slug, locale);
const productsDir = path.join(process.cwd(), 'data', 'products', locale);
// Try exact slug first
let filePath = path.join(productsDir, `${fileSlug}.mdx`);
@@ -41,7 +91,7 @@ export async function getProductBySlug(slug: string, locale: string): Promise<Pr
if (!fs.existsSync(enFilePath)) {
enFilePath = path.join(enProductsDir, `${fileSlug}-2.mdx`);
}
if (fs.existsSync(enFilePath)) {
const fileContent = fs.readFileSync(enFilePath, 'utf8');
const { data, content } = matter(fileContent);
@@ -49,7 +99,7 @@ export async function getProductBySlug(slug: string, locale: string): Promise<Pr
slug: fileSlug,
frontmatter: {
...data,
isFallback: true
isFallback: true,
} as ProductFrontmatter & { isFallback?: boolean },
content,
};
@@ -67,7 +117,12 @@ export async function getProductBySlug(slug: string, locale: string): Promise<Pr
}
// Filter out products without images
if (product && (!product.frontmatter.images || product.frontmatter.images.length === 0 || !product.frontmatter.images[0])) {
if (
product &&
(!product.frontmatter.images ||
product.frontmatter.images.length === 0 ||
!product.frontmatter.images[0])
) {
return null;
}
@@ -77,9 +132,9 @@ export async function getProductBySlug(slug: string, locale: string): Promise<Pr
export async function getAllProductSlugs(locale: string): Promise<string[]> {
const productsDir = path.join(process.cwd(), 'data', 'products', locale);
if (!fs.existsSync(productsDir)) return [];
const files = fs.readdirSync(productsDir);
return files.filter(file => file.endsWith('.mdx')).map(file => file.replace(/\.mdx$/, ''));
return files.filter((file) => file.endsWith('.mdx')).map((file) => file.replace(/\.mdx$/, ''));
}
export async function getAllProducts(locale: string): Promise<ProductMdx[]> {
@@ -91,6 +146,19 @@ export async function getAllProducts(locale: string): Promise<ProductMdx[]> {
allSlugs = Array.from(new Set([...slugs, ...enSlugs]));
}
const products = await Promise.all(allSlugs.map(slug => getProductBySlug(slug, locale)));
const products = await Promise.all(allSlugs.map((slug) => getProductBySlug(slug, locale)));
return products.filter((p): p is ProductMdx => p !== null);
}
export async function getAllProductsMetadata(locale: string): Promise<Partial<ProductMdx>[]> {
const slugs = await getAllProductSlugs(locale);
let allSlugs = slugs;
if (locale !== 'en') {
const enSlugs = await getAllProductSlugs('en');
allSlugs = Array.from(new Set([...slugs, ...enSlugs]));
}
const metadata = await Promise.all(allSlugs.map((slug) => getProductMetadata(slug, locale)));
return metadata.filter((m): m is Partial<ProductMdx> => m !== null);
}

View File

@@ -39,23 +39,42 @@ export async function getPageBySlug(slug: string, locale: string): Promise<PageM
export async function getAllPages(locale: string): Promise<PageMdx[]> {
const pagesDir = path.join(process.cwd(), 'data', 'pages', locale);
if (!fs.existsSync(pagesDir)) return [];
const files = fs.readdirSync(pagesDir);
const pages = await Promise.all(
files
.filter(file => file.endsWith('.mdx'))
.map(file => {
.filter((file) => file.endsWith('.mdx'))
.map((file) => {
const fileSlug = file.replace(/\.mdx$/, '');
const filePath = path.join(pagesDir, file);
const fileContent = { content: fs.readFileSync(filePath, 'utf8') };
const { data, content } = matter(fileContent.content);
const fileContent = fs.readFileSync(filePath, 'utf8');
const { data, content } = matter(fileContent);
return {
slug: fileSlug,
frontmatter: data as PageFrontmatter,
content,
};
})
}),
);
return pages.filter((p): p is PageMdx => p !== null);
}
export async function getAllPagesMetadata(locale: string): Promise<Partial<PageMdx>[]> {
const pagesDir = path.join(process.cwd(), 'data', 'pages', locale);
if (!fs.existsSync(pagesDir)) return [];
const files = fs.readdirSync(pagesDir);
return files
.filter((file) => file.endsWith('.mdx'))
.map((file) => {
const fileSlug = file.replace(/\.mdx$/, '');
const filePath = path.join(pagesDir, file);
const fileContent = fs.readFileSync(filePath, 'utf8');
const { data } = matter(fileContent);
return {
slug: fileSlug,
frontmatter: data as PageFrontmatter,
};
});
}

View File

@@ -1,14 +1,6 @@
import type { AnalyticsEventProperties, AnalyticsService } from './analytics-service';
/**
* Type definition for the Umami global object.
*
* This represents the `window.umami` object that the Umami script exposes.
* The `track` function can accept either an event name or a URL.
*/
type UmamiGlobal = {
track?: (eventOrUrl: string, props?: AnalyticsEventProperties) => void;
};
import { config } from '../../config';
import type { LoggerService } from '../logging/logger-service';
/**
* Configuration options for UmamiAnalyticsService.
@@ -20,133 +12,162 @@ export type UmamiAnalyticsServiceOptions = {
};
/**
* Umami Analytics Service Implementation.
* Umami Analytics Service Implementation (Script-less/Proxy edition).
*
* This service implements the AnalyticsService interface for Umami analytics.
* It provides type-safe event tracking and pageview tracking.
* This version implements the Umami tracking protocol directly via fetch,
* eliminating the need to load an external script.js file.
*
* @example
* ```typescript
* // Service creation (usually done by create-services.ts)
* const service = new UmamiAnalyticsService({ enabled: true });
*
* // Track events
* service.track('button_click', { button_id: 'cta' });
* service.trackPageview('/products/123');
* ```
*
* @example
* ```typescript
* // Using through the service layer (recommended)
* import { getAppServices } from '@/lib/services/create-services';
*
* const services = getAppServices();
* services.analytics.track('product_add_to_cart', {
* product_id: '123',
* price: 99.99,
* });
* ```
* In the browser, it gathers standard metadata (screen, language, referrer)
* and sends it to the proxied '/stats/api/send' endpoint.
* On the server, it sends directly to the internal Umami API.
*/
export class UmamiAnalyticsService implements AnalyticsService {
constructor(private readonly options: UmamiAnalyticsServiceOptions) {}
private websiteId?: string;
private endpoint: string;
private logger: LoggerService;
private serverContext?: {
userAgent?: string;
language?: string;
referrer?: string;
ip?: string;
};
constructor(
private readonly options: UmamiAnalyticsServiceOptions,
logger: LoggerService,
) {
this.websiteId = config.analytics.umami.websiteId;
this.logger = logger.child({ component: 'analytics-umami' });
// On server, use the full internal URL; on client, use the proxied path
this.endpoint = typeof window === 'undefined' ? config.analytics.umami.apiEndpoint : '/stats';
this.logger.debug('Umami service initialized', {
enabled: this.options.enabled,
websiteId: this.websiteId ? 'configured' : 'not configured (client-side proxy mode)',
endpoint: this.endpoint,
});
}
/**
* Track a custom event with optional properties.
*
* This method checks if analytics are enabled and if we're in a browser environment
* before attempting to track the event.
*
* @param eventName - The name of the event to track
* @param props - Optional event properties
*
* @example
* ```typescript
* service.track('product_add_to_cart', {
* product_id: '123',
* product_name: 'Cable',
* price: 99.99,
* quantity: 1,
* });
* ```
* Set the server-side context for the current request.
* This allows the service to use real request headers for tracking.
*/
track(eventName: string, props?: AnalyticsEventProperties) {
setServerContext(context: {
userAgent?: string;
language?: string;
referrer?: string;
ip?: string;
}) {
this.serverContext = context;
}
/**
* Internal method to send the payload to Umami API.
*/
private async sendPayload(type: 'event', data: Record<string, any>) {
if (!this.options.enabled) return;
// Server-side tracking via proxy
if (typeof window === 'undefined') {
const { getServerAppServices } = require('../create-services.server');
const { config } = require('../../config');
const websiteId = config.analytics.umami.websiteId;
const umamiUrl = config.analytics.umami.scriptUrl.replace('/script.js', '');
if (!websiteId) return;
const logger = getServerAppServices().logger.child({ component: 'analytics' });
logger.info('Sending analytics event', { eventName, props });
// On the client, we don't need the websiteId (it's injected by the server-side proxy handler).
// On the server, we need it because we're calling the Umami API directly.
const isClient = typeof window !== 'undefined';
fetch(`${umamiUrl}/api/send`, {
method: 'POST',
headers: { 'Content-Type': 'application/json', 'User-Agent': 'KLZ-Server' },
body: JSON.stringify({ type: 'event', payload: { website: websiteId, name: eventName, data: props } }),
}).catch((error) => {
logger.error('Failed to send analytics event', { eventName, props, error });
});
if (!isClient && !this.websiteId) {
this.logger.warn('Umami tracking called on server but no Website ID configured');
return;
}
const umami = (window as unknown as { umami?: UmamiGlobal }).umami;
umami?.track?.(eventName, props);
try {
const payload = {
website: this.websiteId,
hostname: isClient ? window.location.hostname : 'server',
screen: isClient ? `${window.screen.width}x${window.screen.height}` : undefined,
language: isClient ? navigator.language : this.serverContext?.language,
referrer: isClient ? document.referrer : this.serverContext?.referrer,
...data,
};
this.logger.trace('Sending analytics payload', { type, url: data.url });
// Add a timeout to prevent hanging requests
const controller = new AbortController();
const timeoutId = setTimeout(() => controller.abort(), 10000); // 10s timeout
const headers: Record<string, string> = {
'Content-Type': 'application/json',
};
// Set User-Agent
if (isClient) {
headers['User-Agent'] = navigator.userAgent;
} else if (this.serverContext?.userAgent) {
headers['User-Agent'] = this.serverContext.userAgent;
} else {
headers['User-Agent'] = 'KLZ-Server-Proxy';
}
// Forward client IP if available (Umami must be configured to trust this)
if (this.serverContext?.ip) {
headers['X-Forwarded-For'] = this.serverContext.ip;
}
try {
const response = await fetch(`${this.endpoint}/api/send`, {
method: 'POST',
headers,
body: JSON.stringify({ type, payload }),
keepalive: true,
signal: controller.signal,
} as any);
clearTimeout(timeoutId);
if (!response.ok) {
const errorText = await response.text();
this.logger.warn('Umami API responded with error', {
status: response.status,
error: errorText.slice(0, 100),
});
}
} catch (fetchError) {
clearTimeout(timeoutId);
if ((fetchError as Error).name === 'AbortError') {
this.logger.error('Umami request timed out');
} else {
throw fetchError;
}
}
} catch (error) {
this.logger.error('Failed to send analytics', {
error: (error as Error).message,
});
}
}
/**
* Track a custom event.
*/
track(eventName: string, props?: AnalyticsEventProperties) {
this.sendPayload('event', {
name: eventName,
data: props,
url:
typeof window !== 'undefined'
? window.location.pathname + window.location.search
: undefined,
});
}
/**
* Track a pageview.
*
* This method checks if analytics are enabled and if we're in a browser environment
* before attempting to track the pageview.
*
* Umami treats `track(url)` as a pageview override, so we can use the same
* `track` function for both events and pageviews.
*
* @param url - The URL to track (defaults to current location)
*
* @example
* ```typescript
* // Track current page
* service.trackPageview();
*
* // Track custom URL
* service.trackPageview('/products/123?category=cables');
* ```
*/
trackPageview(url?: string) {
if (!this.options.enabled) return;
// Server-side tracking via proxy
if (typeof window === 'undefined') {
const { getServerAppServices } = require('../create-services.server');
const { config } = require('../../config');
const websiteId = config.analytics.umami.websiteId;
const umamiUrl = config.analytics.umami.scriptUrl.replace('/script.js', '');
if (!websiteId || !url) return;
const logger = getServerAppServices().logger.child({ component: 'analytics' });
logger.info('Sending analytics pageview', { url });
fetch(`${umamiUrl}/api/send`, {
method: 'POST',
headers: { 'Content-Type': 'application/json', 'User-Agent': 'KLZ-Server' },
body: JSON.stringify({ type: 'event', payload: { website: websiteId, url } }),
}).catch((error) => {
logger.error('Failed to send analytics pageview', { url, error });
});
return;
}
const umami = (window as unknown as { umami?: UmamiGlobal }).umami;
// Umami treats `track(url)` as a pageview override.
if (url) umami?.track?.(url);
else umami?.track?.(window.location.pathname + window.location.search);
this.sendPayload('event', {
url:
url ||
(typeof window !== 'undefined'
? window.location.pathname + window.location.search
: undefined),
});
}
}

View File

@@ -2,6 +2,7 @@ import type { AnalyticsService } from './analytics/analytics-service';
import type { CacheService } from './cache/cache-service';
import type { ErrorReportingService } from './errors/error-reporting-service';
import type { LoggerService } from './logging/logger-service';
import type { NotificationService } from './notifications/notification-service';
// Simple constructor-based DI container.
export class AppServices {
@@ -9,6 +10,7 @@ export class AppServices {
public readonly analytics: AnalyticsService,
public readonly errors: ErrorReportingService,
public readonly cache: CacheService,
public readonly logger: LoggerService
public readonly logger: LoggerService,
public readonly notifications: NotificationService,
) {}
}

View File

@@ -4,6 +4,10 @@ import { UmamiAnalyticsService } from './analytics/umami-analytics-service';
import { MemoryCacheService } from './cache/memory-cache-service';
import { GlitchtipErrorReportingService } from './errors/glitchtip-error-reporting-service';
import { NoopErrorReportingService } from './errors/noop-error-reporting-service';
import {
GotifyNotificationService,
NoopNotificationService,
} from './notifications/gotify-notification-service';
import { PinoLoggerService } from './logging/pino-logger-service';
import { config, getMaskedConfig } from '../config';
@@ -13,7 +17,7 @@ export function getServerAppServices(): AppServices {
// Create logger first to log initialization
const logger = new PinoLoggerService('server');
logger.info('Initializing server application services', {
environment: getMaskedConfig(),
timestamp: new Date().toISOString(),
@@ -23,10 +27,11 @@ export function getServerAppServices(): AppServices {
umamiEnabled: config.analytics.umami.enabled,
sentryEnabled: config.errors.glitchtip.enabled,
mailEnabled: Boolean(config.mail.host && config.mail.user),
gotifyEnabled: config.notifications.gotify.enabled,
});
const analytics = config.analytics.umami.enabled
? new UmamiAnalyticsService({ enabled: true })
? new UmamiAnalyticsService({ enabled: true }, logger)
: new NoopAnalyticsService();
if (config.analytics.umami.enabled) {
@@ -35,12 +40,28 @@ export function getServerAppServices(): AppServices {
logger.info('Noop analytics service initialized (analytics disabled)');
}
const notifications = config.notifications.gotify.enabled
? new GotifyNotificationService({
url: config.notifications.gotify.url!,
token: config.notifications.gotify.token!,
enabled: true,
})
: new NoopNotificationService();
if (config.notifications.gotify.enabled) {
logger.info('Gotify notification service initialized');
} else {
logger.info('Noop notification service initialized (notifications disabled)');
}
const errors = config.errors.glitchtip.enabled
? new GlitchtipErrorReportingService({ enabled: true })
? new GlitchtipErrorReportingService({ enabled: true }, logger, notifications)
: new NoopErrorReportingService();
if (config.errors.glitchtip.enabled) {
logger.info('GlitchTip error reporting service initialized');
logger.info('GlitchTip error reporting service initialized', {
dsnPresent: Boolean(config.errors.glitchtip.dsn),
});
} else {
logger.info('Noop error reporting service initialized (error reporting disabled)');
}
@@ -53,10 +74,9 @@ export function getServerAppServices(): AppServices {
level: config.logging.level,
});
singleton = new AppServices(analytics, errors, cache, logger);
singleton = new AppServices(analytics, errors, cache, logger, notifications);
logger.info('All application services initialized successfully');
return singleton;
}

View File

@@ -1,10 +1,12 @@
import { AppServices } from './app-services';
import { NoopAnalyticsService } from './analytics/noop-analytics-service';
import { UmamiAnalyticsService } from './analytics/umami-analytics-service';
import { MemoryCacheService } from './cache/memory-cache-service';
import { GlitchtipErrorReportingService } from './errors/glitchtip-error-reporting-service';
import { NoopErrorReportingService } from './errors/noop-error-reporting-service';
import { NoopLoggerService } from './logging/noop-logger-service';
import { PinoLoggerService } from './logging/pino-logger-service';
import { NoopNotificationService } from './notifications/gotify-notification-service';
import { config, getMaskedConfig } from '../config';
/**
@@ -27,9 +29,8 @@ let singleton: AppServices | undefined;
* - Cache service (in-memory)
*
* The services are configured based on environment variables:
* - `NEXT_PUBLIC_UMAMI_WEBSITE_ID` - Enables Umami analytics
* - `NEXT_PUBLIC_SENTRY_DSN` - Enables client-side error reporting
* - `SENTRY_DSN` - Enables server-side error reporting
* - `UMAMI_WEBSITE_ID` - Enables Umami analytics
* - `SENTRY_DSN` - Enables error reporting (server-side direct, client-side via relay)
*
* @returns {AppServices} The application services singleton
*
@@ -71,9 +72,7 @@ export function getAppServices(): AppServices {
// Create logger first to log initialization
const logger =
typeof window === 'undefined'
? new PinoLoggerService('server')
: new NoopLoggerService();
typeof window === 'undefined' ? new PinoLoggerService('server') : new NoopLoggerService();
// Log initialization
if (typeof window === 'undefined') {
@@ -101,12 +100,8 @@ export function getAppServices(): AppServices {
});
// Create analytics service (Umami or no-op)
// Use dynamic import to avoid importing server-only code in client components
const analytics = umamiEnabled
? (() => {
const { UmamiAnalyticsService } = require('./analytics/umami-analytics-service');
return new UmamiAnalyticsService({ enabled: true });
})()
? new UmamiAnalyticsService({ enabled: true }, logger)
: new NoopAnalyticsService();
if (umamiEnabled) {
@@ -115,13 +110,19 @@ export function getAppServices(): AppServices {
logger.info('Noop analytics service initialized (analytics disabled)');
}
// Create notification service
const notifications = new NoopNotificationService();
logger.info('Notification service initialized (noop)');
// Create error reporting service (GlitchTip/Sentry or no-op)
const errors = sentryEnabled
? new GlitchtipErrorReportingService({ enabled: true })
? new GlitchtipErrorReportingService({ enabled: true }, logger, notifications)
: new NoopErrorReportingService();
if (sentryEnabled) {
logger.info(`GlitchTip error reporting service initialized (${typeof window === 'undefined' ? 'server' : 'client'})`);
logger.info(
`GlitchTip error reporting service initialized (${typeof window === 'undefined' ? 'server' : 'client'})`,
);
} else {
logger.info('Noop error reporting service initialized (error reporting disabled)');
}
@@ -138,9 +139,9 @@ export function getAppServices(): AppServices {
});
// Create and cache the singleton
singleton = new AppServices(analytics, errors, cache, logger);
singleton = new AppServices(analytics, errors, cache, logger, notifications);
logger.info('All application services initialized successfully');
return singleton;
}

View File

@@ -7,10 +7,15 @@ export type ErrorReportingUser = {
export type ErrorReportingLevel = 'fatal' | 'error' | 'warning' | 'info' | 'debug' | 'log';
export interface ErrorReportingService {
captureException(error: unknown, context?: Record<string, unknown>): string | undefined;
captureMessage(message: string, level?: ErrorReportingLevel): string | undefined;
captureException(
error: unknown,
context?: Record<string, unknown>,
): Promise<string | undefined> | string | undefined;
captureMessage(
message: string,
level?: ErrorReportingLevel,
): Promise<string | undefined> | string | undefined;
setUser(user: ErrorReportingUser | null): void;
setTag(key: string, value: string): void;
withScope<T>(fn: () => T, context?: Record<string, unknown>): T;
}

View File

@@ -4,6 +4,8 @@ import type {
ErrorReportingService,
ErrorReportingUser,
} from './error-reporting-service';
import type { NotificationService } from '../notifications/notification-service';
import type { LoggerService } from '../logging/logger-service';
type SentryLike = typeof Sentry;
@@ -13,14 +15,36 @@ export type GlitchtipErrorReportingServiceOptions = {
// GlitchTip speaks the Sentry protocol; @sentry/nextjs can send to GlitchTip via DSN.
export class GlitchtipErrorReportingService implements ErrorReportingService {
private logger: LoggerService;
constructor(
private readonly options: GlitchtipErrorReportingServiceOptions,
private readonly sentry: SentryLike = Sentry
) {}
logger: LoggerService,
private readonly notifications?: NotificationService,
private readonly sentry: SentryLike = Sentry,
) {
this.logger = logger.child({ component: 'error-reporting-glitchtip' });
}
captureException(error: unknown, context?: Record<string, unknown>) {
async captureException(error: unknown, context?: Record<string, unknown>) {
if (!this.options.enabled) return undefined;
return this.sentry.captureException(error, context as any) as any;
const result = this.sentry.captureException(error, context as any) as any;
// Send to Gotify if it's considered critical or if we just want all exceptions there
// For now, let's send all exceptions to Gotify as requested "notify me via gotify about critical error messages"
// We'll treat all captureException calls as potentially critical or at least noteworthy
if (this.notifications) {
const errorMessage = error instanceof Error ? error.message : String(error);
const contextStr = context ? `\nContext: ${JSON.stringify(context, null, 2)}` : '';
await this.notifications.notify({
title: '🔥 Critical Error Captured',
message: `Error: ${errorMessage}${contextStr}`,
priority: 7,
});
}
return result;
}
captureMessage(message: string, level: ErrorReportingLevel = 'error') {

View File

@@ -1,11 +1,15 @@
import type { ErrorReportingLevel, ErrorReportingService, ErrorReportingUser } from './error-reporting-service';
import type {
ErrorReportingLevel,
ErrorReportingService,
ErrorReportingUser,
} from './error-reporting-service';
export class NoopErrorReportingService implements ErrorReportingService {
captureException(_error: unknown, _context?: Record<string, unknown>) {
async captureException(_error: unknown, _context?: Record<string, unknown>) {
return undefined;
}
captureMessage(_message: string, _level?: ErrorReportingLevel) {
async captureMessage(_message: string, _level?: ErrorReportingLevel) {
return undefined;
}

View File

@@ -12,20 +12,19 @@ export class PinoLoggerService implements LoggerService {
// In Next.js, especially in the Edge runtime or during instrumentation,
// pino transports (which use worker threads) can cause issues.
// We disable transport in production and during instrumentation.
const useTransport = !config.isProduction && typeof window === 'undefined';
const useTransport = config.isDevelopment && typeof window === 'undefined';
this.logger = pino({
name: name || 'app',
level: config.logging.level,
transport:
useTransport
? {
target: 'pino-pretty',
options: {
colorize: true,
},
}
: undefined,
transport: useTransport
? {
target: 'pino-pretty',
options: {
colorize: true,
},
}
: undefined,
});
}
}

View File

@@ -0,0 +1,49 @@
import { NotificationOptions, NotificationService } from './notification-service';
export interface GotifyConfig {
url: string;
token: string;
enabled: boolean;
}
export class GotifyNotificationService implements NotificationService {
constructor(private config: GotifyConfig) {}
async notify(options: NotificationOptions): Promise<void> {
if (!this.config.enabled) return;
try {
const { title, message, priority = 4 } = options;
const url = new URL('message', this.config.url);
url.searchParams.set('token', this.config.token);
const response = await fetch(url.toString(), {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({
title,
message,
priority,
}),
});
if (!response.ok) {
const errorText = await response.text();
console.error('Gotify notification failed:', {
status: response.status,
error: errorText,
});
}
} catch (error) {
console.error('Gotify notification error:', error);
}
}
}
export class NoopNotificationService implements NotificationService {
async notify(): Promise<void> {
// Do nothing
}
}

View File

@@ -0,0 +1,9 @@
export interface NotificationOptions {
title: string;
message: string;
priority?: number;
}
export interface NotificationService {
notify(options: NotificationOptions): Promise<void>;
}

View File

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

View File

@@ -191,7 +191,14 @@
"emailPlaceholder": "ihre@email.de",
"message": "Nachricht",
"messagePlaceholder": "Wie können wir Ihnen helfen?",
"submit": "Nachricht senden"
"submit": "Nachricht senden",
"submitting": "Wird gesendet...",
"successTitle": "Nachricht gesendet!",
"successDesc": "Vielen Dank für Ihre Nachricht. Wir werden uns so schnell wie möglich bei Ihnen melden.",
"sendAnother": "Weitere Nachricht senden",
"errorTitle": "Senden fehlgeschlagen!",
"error": "Etwas ist schief gelaufen. Bitte überprüfen Sie Ihre Eingaben und versuchen Sie es erneut.",
"tryAgain": "Erneut versuchen"
}
},
"Products": {
@@ -386,4 +393,4 @@
"cta": "Zurück zur Sicherheit"
}
}
}
}

View File

@@ -191,7 +191,14 @@
"emailPlaceholder": "your@email.com",
"message": "Message",
"messagePlaceholder": "How can we help you?",
"submit": "Send Message"
"submit": "Send Message",
"submitting": "Sending...",
"successTitle": "Message Sent!",
"successDesc": "Thank you for your message. We will get back to you as soon as possible.",
"sendAnother": "Send another message",
"errorTitle": "Submission Failed!",
"error": "Something went wrong. Please check your input and try again.",
"tryAgain": "Try Again"
}
},
"Products": {
@@ -386,4 +393,4 @@
"cta": "Back to Safety"
}
}
}
}

View File

@@ -1,38 +0,0 @@
import createMiddleware from 'next-intl/middleware';
import { NextResponse } from 'next/server';
import type { NextRequest } from 'next/server';
// Create the internationalization middleware
const intlMiddleware = createMiddleware({
// A list of all locales that are supported
locales: ['en', 'de'],
// Used when no locale matches
defaultLocale: 'en'
});
// Main middleware that logs all requests
export default function middleware(request: NextRequest) {
const startTime = Date.now();
const { method, url, headers } = request;
const userAgent = headers.get('user-agent') || 'unknown';
const referer = headers.get('referer') || 'none';
const ip = headers.get('x-forwarded-for') || headers.get('x-real-ip') || 'unknown';
// Log incoming request
console.log(`Incoming request: method=${method} url=${url}`);
try {
// Apply internationalization middleware
const response = intlMiddleware(request);
return response;
} catch (error) {
console.error(`Request failed: method=${method} url=${url}`, error);
throw error;
}
}
export const config = {
// Match only internationalized pathnames
matcher: ['/((?!api|_next|_vercel|health|.*\\..*).*)', '/', '/(de|en)/:path*']
};

1
mintel-feedback-vendor Symbolic link
View File

@@ -0,0 +1 @@
../at-mintel/packages/next-feedback

3
next-env.d.ts vendored
View File

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

View File

@@ -1,11 +1,16 @@
import createNextIntlPlugin from 'next-intl/plugin';
import { withSentryConfig } from '@sentry/nextjs';
import path from 'path';
import { fileURLToPath } from 'url';
const __dirname = path.dirname(fileURLToPath(import.meta.url));
const withNextIntl = createNextIntlPlugin();
/** @type {import('next').NextConfig} */
const nextConfig = {
output: 'standalone',
outputFileTracingRoot: path.join(__dirname, '..'),
async redirects() {
return [
// Blog redirects
@@ -170,7 +175,7 @@ const nextConfig = {
},
{
source: '/posts/why-the-n2xsf2y-is-the-ideal-cable-for-your-energy-project.html',
destination: '/en/blog/why-the-n2xsf2y-is-the-ideal-cable-for-your-energy-project',
destination: '/de/blog/why-the-n2xsf2y-is-the-ideal-cable-for-your-energy-project',
permanent: true,
},
{
@@ -322,22 +327,9 @@ const nextConfig = {
contentSecurityPolicy: "default-src 'self'; script-src 'none'; sandbox;",
},
async rewrites() {
const umamiUrl = (process.env.NEXT_PUBLIC_UMAMI_SCRIPT_URL || 'https://analytics.infra.mintel.me').replace('/script.js', '');
const glitchtipUrl = process.env.SENTRY_DSN
? new URL(process.env.SENTRY_DSN).origin
: 'https://errors.infra.mintel.me';
const directusUrl = process.env.DIRECTUS_URL || 'https://cms.klz-cables.com';
const directusUrl = process.env.INTERNAL_DIRECTUS_URL || process.env.DIRECTUS_URL || 'https://cms.klz-cables.com';
return [
{
source: '/stats/:path*',
destination: `${umamiUrl}/:path*`,
},
{
source: '/errors/:path*',
destination: `${glitchtipUrl}/:path*`,
},
{
source: '/cms/:path*',
destination: `${directusUrl}/:path*`,

23047
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -1,9 +1,10 @@
{
"dependencies": {
"@directus/sdk": "^18.0.3",
"@mintel/mail": "^1.6.0",
"@react-email/components": "^1.0.6",
"@react-pdf/renderer": "^4.3.2",
"@sentry/nextjs": "^8.55.0",
"@sentry/nextjs": "^10.38.0",
"@swc/helpers": "^0.5.18",
"@types/cheerio": "^0.22.35",
"@types/leaflet": "^1.9.21",
@@ -12,20 +13,21 @@
"clsx": "^2.1.1",
"framer-motion": "^12.27.1",
"gray-matter": "^4.0.3",
"@mintel/next-feedback": "^1.6.0",
"i18next": "^25.7.3",
"jsdom": "^27.4.0",
"leaflet": "^1.9.4",
"lucide-react": "^0.562.0",
"next": "^14.2.35",
"next": "16.1.6",
"next-i18next": "^15.4.3",
"next-intl": "^4.6.1",
"next-intl": "^4.8.2",
"next-mdx-remote": "^5.0.0",
"nodemailer": "^7.0.12",
"pdf-lib": "^1.17.1",
"pino": "^10.3.0",
"pino-pretty": "^13.1.3",
"react": "^18.3.1",
"react-dom": "^18.3.1",
"react": "^19.2.4",
"react-dom": "^19.2.4",
"react-email": "^5.2.5",
"react-leaflet": "^4.2.1",
"resend": "^3.5.0",
@@ -34,7 +36,9 @@
"svg-to-pdfkit": "^0.1.8",
"tailwind-merge": "^3.4.0",
"xlsx": "^0.18.5",
"zod": "^4.3.6"
"zod": "^4.3.6",
"require-in-the-middle": "^8.0.1",
"import-in-the-middle": "^1.11.0"
},
"devDependencies": {
"@commitlint/cli": "^20.4.0",
@@ -49,9 +53,8 @@
"@types/sharp": "^0.31.1",
"@vitest/ui": "^4.0.16",
"autoprefixer": "^10.4.23",
"eslint": "^8.57.1",
"eslint-config-next": "14.2.35",
"eslint-config-prettier": "^10.1.8",
"eslint": "^9.18.0",
"@mintel/eslint-config": "^1.6.0",
"husky": "^9.1.7",
"lint-staged": "^16.2.7",
"postcss": "^8.5.6",
@@ -65,23 +68,32 @@
"name": "klz-cables-nextjs",
"private": true,
"scripts": {
"dev": "docker network create infra 2>/dev/null || true && echo '\\n🚀 Development Environment Starting...\\n\\n📱 App: http://klz.localhost\\n🗄 CMS: http://cms.klz.localhost/admin\\n🚦 Traefik: http://localhost:8080\\n\\n(Press Ctrl+C to stop)\\n' && docker-compose down --remove-orphans && docker-compose up",
"dev": "docker network create infra 2>/dev/null || true && echo '\\n🚀 Development Environment Starting...\\n\\n📱 App: http://klz.localhost\\n🗄 CMS: http://cms.klz.localhost/admin\\n🚦 Traefik: http://localhost:8080\\n\\n(Press Ctrl+C to stop)\\n' && docker-compose down --remove-orphans && docker-compose up klz-app directus directus-db gatekeeper",
"dev:local": "next dev",
"build": "next build",
"start": "next start",
"lint": "next lint",
"lint": "eslint .",
"typecheck": "tsc --noEmit",
"test": "vitest run --passWithNoTests",
"test:og": "vitest run tests/og-image.test.ts",
"bootstrap:cms": "DIRECTUS_URL=http://localhost:8055 npx tsx --env-file=.env scripts/setup-directus-branding.ts",
"cms:branding:local": "DIRECTUS_URL=http://localhost:8055 npx tsx --env-file=.env scripts/setup-directus-branding.ts",
"cms:branding:testing": "DIRECTUS_URL=https://cms.testing.klz-cables.com npx tsx --env-file=.env scripts/setup-directus-branding.ts",
"cms:branding:staging": "DIRECTUS_URL=https://cms.staging.klz-cables.com npx tsx --env-file=.env scripts/setup-directus-branding.ts",
"cms:branding:prod": "DIRECTUS_URL=https://cms.klz-cables.com npx tsx --env-file=.env scripts/setup-directus-branding.ts",
"cms:bootstrap": "npm run cms:branding:local",
"pdf:datasheets": "tsx ./scripts/generate-pdf-datasheets.ts",
"pdf:datasheets:legacy": "tsx ./scripts/generate-pdf-datasheets-pdf-lib.ts",
"directus:push:staging": "./scripts/sync-directus.sh push staging",
"directus:pull:staging": "./scripts/sync-directus.sh pull staging",
"directus:push:testing": "./scripts/sync-directus.sh push testing",
"directus:pull:testing": "./scripts/sync-directus.sh pull testing",
"directus:push:prod": "./scripts/sync-directus.sh push production",
"directus:pull:prod": "./scripts/sync-directus.sh pull production",
"cms:schema:snapshot": "./scripts/cms-snapshot.sh",
"cms:schema:apply": "./scripts/cms-apply.sh local",
"cms:schema:apply:testing": "./scripts/cms-apply.sh testing",
"cms:schema:apply:staging": "./scripts/cms-apply.sh staging",
"cms:schema:apply:prod": "./scripts/cms-apply.sh production",
"cms:pull:testing": "./scripts/sync-directus.sh pull testing",
"cms:pull:staging": "./scripts/sync-directus.sh pull staging",
"cms:pull:prod": "./scripts/sync-directus.sh pull production",
"cms:push:staging:DANGER": "./scripts/sync-directus.sh push staging",
"cms:push:testing:DANGER": "./scripts/sync-directus.sh push testing",
"cms:push:prod:DANGER": "./scripts/sync-directus.sh push production",
"pagespeed:test": "tsx ./scripts/pagespeed-sitemap.ts",
"pagespeed:urls": "tsx -e \"import sitemap from './app/sitemap'; sitemap().then(urls => console.log(urls.map(u => u.url).join('\\n')))\"",
"prepare": "husky"

14009
pnpm-lock.yaml generated Normal file

File diff suppressed because it is too large Load Diff

66
proxy.ts Normal file
View File

@@ -0,0 +1,66 @@
import createMiddleware from 'next-intl/middleware';
import { NextResponse, NextRequest } from 'next/server';
// Create the internationalization middleware
const intlMiddleware = createMiddleware({
// A list of all locales that are supported
locales: ['en', 'de'],
// Used when no locale matches
defaultLocale: 'en',
});
export default function middleware(request: NextRequest) {
const { method, url, headers } = request;
// Build header object for logging
const headerObj: Record<string, string> = {};
headers.forEach((value, key) => {
headerObj[key] = value;
});
// Defensive URL correction for internal container leakage (0.0.0.0, klz-app, localhost)
// This prevents hydration mismatches and host poisoning in generated links/metadata.
const urlObj = new URL(url);
const internalHosts = ['0.0.0.0', 'klz-app', 'localhost', '127.0.0.1'];
let effectiveRequest = request;
if (internalHosts.includes(urlObj.hostname)) {
const proto = headers.get('x-forwarded-proto') || 'https';
// Prioritize x-forwarded-host (passed by Traefik) over the local Host header
const hostHeader =
headers.get('x-forwarded-host') || headers.get('host') || 'testing.klz-cables.com';
const [publicHostname] = hostHeader.split(':');
urlObj.protocol = proto;
// urlObj.hostname = publicHostname; // Don't rewrite hostname yet as it breaks internal fetches in dev
// urlObj.port = ''; // DON'T clear internal port (3000) anymore
effectiveRequest = new NextRequest(urlObj, {
headers: request.headers,
method: request.method,
body: request.body,
});
console.log(
`🛡️ Middleware: Fixed internal URL leak: ${url} -> ${urlObj.toString()} | Proto: ${proto} | Host: ${hostHeader}`,
);
}
try {
// Apply internationalization middleware
const response = intlMiddleware(effectiveRequest);
return response;
} catch (error) {
console.error(
`Request failed: method=${method} url=${url} headers=${JSON.stringify(headerObj)}`,
error,
);
throw error;
}
}
export const config = {
// Match only internationalized pathnames
matcher: ['/((?!api|_next|_vercel|stats|errors|health|.*\\..*).*)', '/', '/(de|en)/:path*'],
};

View File

@@ -1,268 +0,0 @@
# Migrating Analytics from Independent Analytics to Umami
This guide explains how to migrate your analytics data from the Independent Analytics WordPress plugin to Umami.
## What You Have
You have exported your analytics data from Independent Analytics:
- **data/pages(1).csv** - Page-level analytics data with:
- Title, Visitors, Views, View Duration, Bounce Rate, URL, Page Type
- 220 pages with historical data
## What You Need
Before migrating, you need:
1. **Umami instance** running (self-hosted or cloud)
2. **Website ID** from Umami (create a new website in Umami dashboard)
3. **Access credentials** for Umami (API key or database access)
## Migration Options
The migration script provides three output formats:
### Option 1: JSON Import (Recommended for API)
```bash
python3 scripts/migrate-analytics-to-umami.py \
--input data/pages\(1\).csv \
--output data/umami-import.json \
--format json \
--site-id YOUR_UMAMI_SITE_ID
```
**Import via API:**
```bash
curl -X POST \
-H "Content-Type: application/json" \
-H "Authorization: Bearer YOUR_API_KEY" \
-d @data/umami-import.json \
https://your-umami-instance.com/api/import
```
### Option 2: SQL Import (Direct Database)
```bash
python3 scripts/migrate-analytics-to-umami.py \
--input data/pages\(1\).csv \
--output data/umami-import.sql \
--format sql \
--site-id YOUR_UMAMI_SITE_ID
```
**Import via PostgreSQL:**
```bash
psql -U umami -d umami -f data/umami-import.sql
```
### Option 3: API Payload (Manual Import)
```bash
python3 scripts/migrate-analytics-to-umami.py \
--input data/pages\(1\).csv \
--output data/umami-import-api.json \
--format api \
--site-id YOUR_UMAMI_SITE_ID
```
## Step-by-Step Migration Guide
### 1. Prepare Your Umami Instance
**If self-hosting:**
```bash
# Clone Umami
git clone https://github.com/umami-software/umami.git
cd umami
# Install dependencies
npm install
# Set up environment
cp .env.example .env
# Edit .env with your database credentials
# Run migrations
npm run migrate
# Start the server
npm run build
npm run start
```
**If using Umami Cloud:**
1. Sign up at https://umami.is
2. Create a new website
3. Get your Website ID from the dashboard
### 2. Run the Migration Script
Choose one of the migration options above based on your needs.
**Example:**
```bash
# Make the script executable
chmod +x scripts/migrate-analytics-to-umami.py
# Run the migration
python3 scripts/migrate-analytics-to-umami.py \
--input data/pages\(1\).csv \
--output data/umami-import.json \
--format json \
--site-id klz-cables
```
### 3. Import the Data
#### Option A: Using Umami API (Recommended)
1. **Get your API key:**
- Go to Umami dashboard → Settings → API Keys
- Create a new API key
2. **Import the data:**
```bash
curl -X POST \
-H "Content-Type: application/json" \
-H "Authorization: Bearer YOUR_API_KEY" \
-d @data/umami-import.json \
https://your-umami-instance.com/api/import
```
#### Option B: Direct Database Import
1. **Connect to your Umami database:**
```bash
psql -U umami -d umami
```
2. **Import the SQL file:**
```bash
psql -U umami -d umami -f data/umami-import.sql
```
3. **Verify the import:**
```sql
SELECT COUNT(*) FROM website_event WHERE website_id = 'klz-cables';
```
### 4. Verify the Migration
1. **Check Umami dashboard:**
- Log into Umami
- Select your website
- View the analytics dashboard
2. **Verify data:**
- Check page views count
- Verify top pages
- Check visitor counts
## Important Notes
### Data Limitations
The CSV export from Independent Analytics contains **aggregated data**, not raw event data:
- ✅ Page views (total counts)
- ✅ Visitor counts
- ✅ Average view duration
- ❌ Individual user sessions
- ❌ Real-time data
- ❌ Geographic data
- ❌ Referrer data
- ❌ Device/browser data
### What Gets Imported
The migration script creates **simulated historical data**:
- Each page view becomes a separate event
- Timestamps are set to current time (for historical data, you'd need to adjust)
- Duration is preserved from the average view duration
- No session tracking (each view is independent)
### Recommendations
1. **Start fresh with Umami:**
- Let Umami collect new data going forward
- Use the migrated data for historical reference only
2. **Keep the original CSV:**
- Store `data/pages(1).csv` as a backup
- You can re-import if needed
3. **Update your website:**
- Replace Independent Analytics tracking code with Umami tracking code
- Test that Umami is collecting new data
4. **Monitor for a few days:**
- Verify Umami is collecting data correctly
- Compare with any remaining Independent Analytics data
## Troubleshooting
### Issue: "ModuleNotFoundError: No module named 'csv'"
**Solution:** Ensure Python 3 is installed:
```bash
python3 --version
# Should be 3.7 or higher
```
### Issue: "Permission denied" when running script
**Solution:** Make the script executable:
```bash
chmod +x scripts/migrate-analytics-to-umami.py
```
### Issue: API import fails
**Solution:** Check:
1. API key is correct and has import permissions
2. Website ID exists in Umami
3. Umami instance is accessible
4. JSON format is valid
### Issue: SQL import fails
**Solution:** Check:
1. Database credentials in `.env`
2. Database is running
3. Tables exist (run `npm run migrate` first)
4. Permissions to insert into `website_event` table
## Additional Data Migration
If you have other CSV exports from Independent Analytics (referrers, devices, locations), you can:
1. **Export additional data** from Independent Analytics:
- Referrers
- Devices (browsers, OS)
- Geographic data
- Custom events
2. **Create custom migration scripts** for each data type
3. **Contact Umami support** for bulk import assistance
## Support
- **Umami Documentation:** https://umami.is/docs
- **Umami GitHub:** https://github.com/umami-software/umami
- **Independent Analytics:** https://independentanalytics.com/
## Summary
✅ **Completed:**
- Created migration script (`scripts/migrate-analytics-to-umami.py`)
- Generated JSON import file (`data/umami-import.json`)
- Generated SQL import file (`data/umami-import.sql`)
- Created documentation (`scripts/README-migration.md`)
📊 **Data Migrated:**
- 7,634 simulated page view events
- 220 unique pages
- Historical view counts and durations
🎯 **Next Steps:**
1. Choose your import method (API or SQL)
2. Run the migration script
3. Import data into Umami
4. Verify the migration
5. Update your website to use Umami tracking

View File

@@ -1,19 +0,0 @@
import client, { ensureAuthenticated } from '../lib/directus';
import { readCollections, deleteCollection } from '@directus/sdk';
async function cleanup() {
await ensureAuthenticated();
const collections = await (client as any).request(readCollections());
for (const c of collections) {
if (!c.collection.startsWith('directus_')) {
console.log(`Deleting ${c.collection}...`);
try {
await (client as any).request(deleteCollection(c.collection));
} catch (e) {
console.error(`Failed to delete ${c.collection}`);
}
}
}
}
cleanup().catch(console.error);

54
scripts/cms-apply.sh Executable file
View File

@@ -0,0 +1,54 @@
#!/bin/bash
ENV=$1
REMOTE_HOST="root@alpha.mintel.me"
REMOTE_DIR="/home/deploy/sites/klz-cables.com"
if [ -z "$ENV" ]; then
echo "Usage: ./scripts/cms-apply.sh [local|testing|staging|production]"
exit 1
fi
PRJ_ID=$(jq -r .name package.json | sed 's/@mintel\///' | sed 's/\.com$//')
case $ENV in
local)
CONTAINER=$(docker compose ps -q directus)
if [ -z "$CONTAINER" ]; then
echo "❌ Local directus container not found."
exit 1
fi
echo "🚀 Applying schema locally..."
docker exec "$CONTAINER" npx directus schema apply /directus/schema/snapshot.yaml --yes
;;
testing|staging|production)
case $ENV in
testing) PROJECT_NAME="${PRJ_ID}-testing" ;;
staging) PROJECT_NAME="${PRJ_ID}-staging" ;;
production) PROJECT_NAME="${PRJ_ID}-prod" ;;
esac
echo "📤 Uploading snapshot to $ENV..."
scp ./directus/schema/snapshot.yaml "$REMOTE_HOST:$REMOTE_DIR/directus/schema/snapshot.yaml"
echo "🔍 Detecting remote container..."
REMOTE_CONTAINER=$(ssh "$REMOTE_HOST" "cd $REMOTE_DIR && docker compose -p $PROJECT_NAME ps -q directus")
if [ -z "$REMOTE_CONTAINER" ]; then
echo "❌ Remote container for $ENV not found."
exit 1
fi
echo "🚀 Applying schema to $ENV..."
ssh "$REMOTE_HOST" "docker exec $REMOTE_CONTAINER npx directus schema apply /directus/schema/snapshot.yaml --yes"
echo "🔄 Restarting Directus to clear cache..."
ssh "$REMOTE_HOST" "cd $REMOTE_DIR && docker compose -p $PROJECT_NAME restart directus"
;;
*)
echo "❌ Invalid environment."
exit 1
;;
esac
echo "✨ Schema apply complete!"

15
scripts/cms-snapshot.sh Executable file
View File

@@ -0,0 +1,15 @@
#!/bin/bash
# Detect local container
LOCAL_CONTAINER=$(docker compose ps -q directus)
if [ -z "$LOCAL_CONTAINER" ]; then
echo "❌ Local directus container not found. Is it running?"
exit 1
fi
echo "📸 Creating schema snapshot..."
# Note: we save it to the mounted volume path inside the container
docker exec "$LOCAL_CONTAINER" npx directus schema snapshot /directus/schema/snapshot.yaml
echo "✅ Snapshot saved to ./directus/schema/snapshot.yaml"

View File

@@ -1,230 +0,0 @@
#!/usr/bin/env tsx
/**
* Manual Translation Mapping Generator
* Creates translationKey mappings for posts that couldn't be auto-detected
*/
import { readFileSync, writeFileSync } from 'fs';
import { join } from 'path';
interface Post {
id: number;
slug: string;
title: { rendered: string };
date: string;
lang: string;
pll_translation_id?: number;
pll_master_post_id?: number;
}
interface TranslationMapping {
posts: Record<string, string[]>; // translationKey -> [en_id, de_id]
products: Record<string, string[]>;
pages: Record<string, string[]>;
}
interface RawData {
posts: {
en: Post[];
de: Post[];
};
products: {
en: any[];
de: any[];
};
pages: {
en: any[];
de: any[];
};
}
// Simple text similarity function
function calculateSimilarity(text1: string, text2: string): number {
const normalize = (str: string) =>
str.toLowerCase()
.replace(/[^\w\s]/g, '')
.replace(/\s+/g, ' ')
.trim();
const s1 = normalize(text1);
const s2 = normalize(text2);
if (s1 === s2) return 1.0;
// Simple overlap calculation
const words1 = s1.split(' ');
const words2 = s2.split(' ');
const intersection = words1.filter(w => words2.includes(w));
const union = new Set([...words1, ...words2]);
return intersection.length / union.size;
}
// Generate translation key from title
function generateKeyFromTitle(title: string): string {
return title.toLowerCase()
.replace(/[^\w\s-]/g, '')
.replace(/\s+/g, '-')
.replace(/-+/g, '-')
.trim();
}
function findPostTranslations(
postsEn: Post[],
postsDe: Post[]
): TranslationMapping['posts'] {
const mapping: TranslationMapping['posts'] = {};
// First pass: try to match by Polylang metadata
const deById = new Map(postsDe.map(p => [p.id, p]));
const deByTranslationId = new Map(postsDe.map(p => [p.pll_translation_id, p]));
for (const enPost of postsEn) {
// Try by pll_translation_id
if (enPost.pll_translation_id && deByTranslationId.has(enPost.pll_translation_id)) {
const dePost = deByTranslationId.get(enPost.pll_translation_id)!;
const key = `post-${enPost.pll_translation_id}`;
mapping[key] = [enPost.id, dePost.id];
continue;
}
// Try by pll_master_post_id
if (enPost.pll_master_post_id && deById.has(enPost.pll_master_post_id)) {
const dePost = deById.get(enPost.pll_master_post_id)!;
const key = `post-${enPost.pll_master_post_id}`;
mapping[key] = [enPost.id, dePost.id];
continue;
}
}
// Second pass: content-based matching for remaining unmatched posts
const matchedEnIds = new Set(
Object.values(mapping).flat()
);
const unmatchedEn = postsEn.filter(p => !matchedEnIds.includes(p.id));
const unmatchedDe = postsDe.filter(p => !matchedEnIds.includes(p.id));
for (const enPost of unmatchedEn) {
let bestMatch: { post: Post; score: number } | null = null;
for (const dePost of unmatchedDe) {
const titleScore = calculateSimilarity(enPost.title.rendered, dePost.title.rendered);
const slugScore = calculateSimilarity(enPost.slug, dePost.slug);
const dateScore = enPost.date === dePost.date ? 1.0 : 0.0;
// Weighted average
const score = (titleScore * 0.6) + (slugScore * 0.3) + (dateScore * 0.1);
if (score > 0.7 && (!bestMatch || score > bestMatch.score)) {
bestMatch = { post: dePost, score };
}
}
if (bestMatch) {
const key = generateKeyFromTitle(enPost.title.rendered);
mapping[key] = [enPost.id, bestMatch.post.id];
unmatchedDe.splice(unmatchedDe.indexOf(bestMatch.post), 1);
}
}
return mapping;
}
function findProductTranslations(
productsEn: any[],
productsDe: any[]
): TranslationMapping['products'] {
const mapping: TranslationMapping['products'] = {};
// Use SKU as primary key if available
const deBySku = new Map(productsDe.map(p => [p.sku, p]));
for (const enProduct of productsEn) {
if (enProduct.sku && deBySku.has(enProduct.sku)) {
const key = `product-${enProduct.sku}`;
mapping[key] = [enProduct.id, deBySku.get(enProduct.sku)!.id];
}
}
return mapping;
}
function findPageTranslations(
pagesEn: any[],
pagesDe: any[]
): TranslationMapping['pages'] {
const mapping: TranslationMapping['pages'] = {};
// Pages should have better Polylang metadata
const deById = new Map(pagesDe.map(p => [p.id, p]));
const deByTranslationId = new Map(pagesDe.map(p => [p.pll_translation_id, p]));
for (const enPage of pagesEn) {
if (enPage.pll_translation_id && deByTranslationId.has(enPage.pll_translation_id)) {
const dePage = deByTranslationId.get(enPage.pll_translation_id)!;
const key = `page-${enPage.pll_translation_id}`;
mapping[key] = [enPage.id, dePage.id];
}
}
return mapping;
}
function main() {
console.log('🔍 Creating manual translation mapping...\n');
// Read raw data
const rawData: RawData = {
posts: {
en: JSON.parse(readFileSync('data/raw/posts.en.json', 'utf8')),
de: JSON.parse(readFileSync('data/raw/posts.de.json', 'utf8'))
},
products: {
en: JSON.parse(readFileSync('data/raw/products.en.json', 'utf8')),
de: JSON.parse(readFileSync('data/raw/products.de.json', 'utf8'))
},
pages: {
en: JSON.parse(readFileSync('data/raw/pages.en.json', 'utf8')),
de: JSON.parse(readFileSync('data/raw/pages.de.json', 'utf8'))
}
};
console.log('📊 Raw data loaded:');
console.log(` - Posts: ${rawData.posts.en.length} EN, ${rawData.posts.de.length} DE`);
console.log(` - Products: ${rawData.products.en.length} EN, ${rawData.products.de.length} DE`);
console.log(` - Pages: ${rawData.pages.en.length} EN, ${rawData.pages.de.length} DE`);
console.log('');
// Generate mappings
const mapping: TranslationMapping = {
posts: findPostTranslations(rawData.posts.en, rawData.posts.de),
products: findProductTranslations(rawData.products.en, rawData.products.de),
pages: findPageTranslations(rawData.pages.en, rawData.pages.de)
};
// Save mapping
const outputPath = 'data/manual-translation-mapping.json';
writeFileSync(outputPath, JSON.stringify(mapping, null, 2));
console.log('✅ Manual translation mapping created:\n');
console.log(`Posts: ${Object.keys(mapping.posts).length} pairs`);
console.log(`Products: ${Object.keys(mapping.products).length} pairs`);
console.log(`Pages: ${Object.keys(mapping.pages).length} pairs`);
console.log(`\nSaved to: ${outputPath}`);
// Show some examples
if (Object.keys(mapping.posts).length > 0) {
console.log('\n📝 Post mapping examples:');
Object.entries(mapping.posts).slice(0, 3).forEach(([key, ids]) => {
const enPost = rawData.posts.en.find(p => p.id === ids[0]);
const dePost = rawData.posts.de.find(p => p.id === ids[1]);
console.log(` ${key}:`);
console.log(` EN: [${ids[0]}] ${enPost?.title.rendered}`);
console.log(` DE: [${ids[1]}] ${dePost?.title.rendered}`);
});
}
}
main();

View File

@@ -1,76 +0,0 @@
#!/bin/bash
# Deploy analytics data to your Umami instance on alpha.mintel.me
set -e
# Configuration - Umami is on infra.mintel.me
SERVER="root@infra.mintel.me"
REMOTE_PATH="/home/deploy/sites/klz-cables.com"
WEBSITE_ID="59a7db94-0100-4c7e-98ef-99f45b17f9c3"
# Umami API endpoint (assuming it's running on the same server)
UMAMI_API="http://localhost:3000/api/import"
echo "🚀 Deploying analytics data to your Umami instance..."
echo "Server: $SERVER"
echo "Remote path: $REMOTE_PATH"
echo "Website ID: $WEBSITE_ID"
echo "Umami API: $UMAMI_API"
echo ""
# Check if files exist
if [ ! -f "data/umami-import.json" ]; then
echo "❌ Error: data/umami-import.json not found"
echo "Please run the migration script first:"
echo " python3 scripts/migrate-analytics-to-umami.py --input data/pages\(1\).csv --output data/umami-import.json --format json --site-id $WEBSITE_ID"
exit 1
fi
# Test SSH connection
echo "🔍 Testing SSH connection to $SERVER..."
if ! ssh -o ConnectTimeout=5 "$SERVER" "echo 'SSH connection successful'"; then
echo "❌ Error: Cannot connect to $SERVER"
echo "Please check your SSH key and connection"
exit 1
fi
echo "✅ SSH connection successful"
echo ""
# Create directory and copy files to server
echo "📁 Creating remote directory..."
ssh "$SERVER" "mkdir -p $REMOTE_PATH/data"
echo "✅ Remote directory created"
echo "📤 Copying analytics files to server..."
scp data/umami-import.json "$SERVER:$REMOTE_PATH/data/"
scp data/umami-import.sql "$SERVER:$REMOTE_PATH/data/"
echo "✅ Files copied successfully"
echo ""
# Detect Umami container
echo "🔍 Detecting Umami container..."
UMAMI_CONTAINER=$(ssh "$SERVER" "docker ps -q --filter 'name=umami'")
if [ -z "$UMAMI_CONTAINER" ]; then
echo "❌ Error: Could not detect Umami container"
echo "Make sure Umami is running on $SERVER"
exit 1
fi
echo "✅ Umami container detected: $UMAMI_CONTAINER"
echo ""
# Import data via database (most reliable method)
echo "📥 Importing data via database..."
ssh "$SERVER" "
echo 'Importing data into Umami database...'
docker exec -i core-postgres-1 psql -U infra -d umami < $REMOTE_PATH/data/umami-import.sql
echo '✅ Database import completed'
"
echo ""
echo "✅ Migration Complete!"
echo ""
echo "Your analytics data has been imported into Umami."
echo "Website ID: $WEBSITE_ID"
echo ""
echo "Verify in Umami dashboard: https://analytics.infra.mintel.me"
echo "You should see 7,634 historical page view events."

View File

@@ -1,127 +0,0 @@
#!/bin/bash
# Deploy analytics data to Umami server
set -e
# Configuration
SERVER="root@alpha.mintel.me"
REMOTE_PATH="/home/deploy/sites/klz-cables.com"
WEBSITE_ID="59a7db94-0100-4c7e-98ef-99f45b17f9c3"
echo "🚀 Deploying analytics data to Umami server..."
echo "Server: $SERVER"
echo "Remote path: $REMOTE_PATH"
echo "Website ID: $WEBSITE_ID"
echo ""
# Check if files exist
if [ ! -f "data/umami-import.json" ]; then
echo "❌ Error: data/umami-import.json not found"
echo "Please run the migration script first:"
echo " python3 scripts/migrate-analytics-to-umami.py --input data/pages\(1\).csv --output data/umami-import.json --format json --site-id $WEBSITE_ID"
exit 1
fi
if [ ! -f "data/umami-import.sql" ]; then
echo "❌ Error: data/umami-import.sql not found"
echo "Please run the migration script first:"
echo " python3 scripts/migrate-analytics-to-umami.py --input data/pages\(1\).csv --output data/umami-import.sql --format sql --site-id $WEBSITE_ID"
exit 1
fi
# Check if SSH connection works
echo "🔍 Testing SSH connection..."
if ! ssh -o ConnectTimeout=5 "$SERVER" "echo 'SSH connection successful'"; then
echo "❌ Error: Cannot connect to $SERVER"
echo "Please check your SSH key and connection"
exit 1
fi
echo "✅ SSH connection successful"
echo ""
# Create remote directory if it doesn't exist
echo "📁 Creating remote directory..."
ssh "$SERVER" "mkdir -p $REMOTE_PATH/data"
echo "✅ Remote directory created"
echo ""
# Copy files to server
echo "📤 Copying files to server..."
scp data/umami-import.json "$SERVER:$REMOTE_PATH/data/"
scp data/umami-import.sql "$SERVER:$REMOTE_PATH/data/"
echo "✅ Files copied successfully"
echo ""
# Option 1: Import via API (if Umami API is accessible)
echo "📋 Import Options:"
echo ""
echo "Option 1: Import via API (Recommended)"
echo "--------------------------------------"
echo "1. SSH into your server:"
echo " ssh $SERVER"
echo ""
echo "2. Navigate to the directory:"
echo " cd $REMOTE_PATH"
echo ""
echo "3. Get your Umami API key:"
echo " - Log into Umami dashboard"
echo " - Go to Settings → API Keys"
echo " - Create a new API key"
echo ""
echo "4. Import the data:"
echo " curl -X POST \\"
echo " -H \"Content-Type: application/json\" \\"
echo " -H \"Authorization: Bearer YOUR_API_KEY\" \\"
echo " -d @data/umami-import.json \\"
echo " http://localhost:3000/api/import"
echo ""
echo " Or if Umami is on a different port/domain:"
echo " curl -X POST \\"
echo " -H \"Content-Type: application/json\" \\"
echo " -H \"Authorization: Bearer YOUR_API_KEY\" \\"
echo " -d @data/umami-import.json \\"
echo " https://your-umami-domain.com/api/import"
echo ""
# Option 2: Import via Database
echo "Option 2: Import via Database"
echo "------------------------------"
echo "1. SSH into your server:"
echo " ssh $SERVER"
echo ""
echo "2. Navigate to the directory:"
echo " cd $REMOTE_PATH"
echo ""
echo "3. Import the SQL file:"
echo " psql -U umami -d umami -f data/umami-import.sql"
echo ""
echo " If you need to specify host/port:"
echo " PGPASSWORD=your_password psql -h localhost -U umami -d umami -f data/umami-import.sql"
echo ""
# Option 3: Manual import via Umami dashboard
echo "Option 3: Manual Import via Umami Dashboard"
echo "--------------------------------------------"
echo "1. Log into Umami dashboard"
echo "2. Go to Settings → Import"
echo "3. Upload data/umami-import.json"
echo "4. Select your website (ID: $WEBSITE_ID)"
echo "5. Click Import"
echo ""
echo "📊 File Information:"
echo "-------------------"
echo "JSON file: $(ls -lh data/umami-import.json | awk '{print $5}')"
echo "SQL file: $(ls -lh data/umami-import.sql | awk '{print $5}')"
echo ""
echo "✅ Deployment complete!"
echo ""
echo "Next steps:"
echo "1. Choose one of the import methods above"
echo "2. Import the data into Umami"
echo "3. Verify the data in Umami dashboard"
echo "4. Update your website to use Umami tracking code"
echo ""
echo "For detailed instructions, see: scripts/README-migration.md"

View File

@@ -1,32 +0,0 @@
const fs = require('fs');
const path = require('path');
const { execSync } = require('child_process');
const blogDir = path.join(process.cwd(), 'data', 'blog', 'en');
const outputDir = path.join(process.cwd(), 'reference', 'klz-cables-clone', 'posts');
if (!fs.existsSync(outputDir)) {
fs.mkdirSync(outputDir, { recursive: true });
}
const files = fs.readdirSync(blogDir);
files.forEach(file => {
if (!file.endsWith('.mdx')) return;
const slug = file.replace('.mdx', '');
const url = `https://klz-cables.com/${slug}/`;
const outputPath = path.join(outputDir, `${slug}.html`);
if (fs.existsSync(outputPath)) {
console.log(`Skipping ${slug}, already exists.`);
return;
}
console.log(`Fetching ${slug}...`);
try {
execSync(`curl -L -s "${url}" -o "${outputPath}"`);
} catch (e) {
console.error(`Failed to fetch ${slug}: ${e.message}`);
}
});

View File

@@ -1,136 +0,0 @@
const fs = require('fs');
const path = require('path');
const cheerio = require('cheerio');
const API_URL = 'https://klz-cables.com/wp-json/wp/v2/posts?per_page=100&_embed';
async function fetchPosts() {
console.log('Fetching posts...');
const response = await fetch(API_URL);
if (!response.ok) {
throw new Error(`Failed to fetch posts: ${response.statusText}`);
}
const posts = await response.json();
console.log(`Fetched ${posts.length} posts.`);
return posts;
}
function cleanContent(content) {
let cleaned = content;
// Decode HTML entities first to make regex easier
cleaned = cleaned.replace(/&#8221;/g, '"').replace(/&#8220;/g, '"').replace(/&#8217;/g, "'").replace(/&/g, '&').replace(/&#8243;/g, '"');
// Remove vc_row and vc_column wrappers
cleaned = cleaned.replace(/\[\/?vc_row.*?\]/g, '');
cleaned = cleaned.replace(/\[\/?vc_column.*?\]/g, '');
// Remove vc_column_text wrapper but keep content
cleaned = cleaned.replace(/\[vc_column_text.*?\]/g, '');
cleaned = cleaned.replace(/\[\/vc_column_text\]/g, '');
// Convert split_line_heading to h2
cleaned = cleaned.replace(/\[split_line_heading[^\]]*text_content="([^"]+)"[^\]]*\](?:\[\/split_line_heading\])?/g, '<h2>$1</h2>');
// Remove other shortcodes
cleaned = cleaned.replace(/\[image_with_animation.*?\]/g, '');
cleaned = cleaned.replace(/\[divider.*?\]/g, '');
cleaned = cleaned.replace(/\[nectar_global_section.*?\]/g, '');
// Use Cheerio for HTML manipulation
const $ = cheerio.load(cleaned, { xmlMode: false, decodeEntities: false });
// Convert VisualLinkPreview
$('.vlp-link-container').each((i, el) => {
const $el = $(el);
const url = $el.find('a.vlp-link').attr('href');
const title = $el.find('.vlp-link-title').text().trim() || $el.find('a.vlp-link').attr('title');
const image = $el.find('.vlp-link-image img').attr('src');
const summary = $el.find('.vlp-link-summary').text().trim();
if (url && title) {
// We use a placeholder to avoid Cheerio messing up the React component syntax
const component = `__VISUAL_LINK_PREVIEW_START__ url="${url}" title="${title}" image="${image || ''}" summary="${summary || ''}" __VISUAL_LINK_PREVIEW_END__`;
$el.replaceWith(component);
}
});
// Remove data attributes
$('*').each((i, el) => {
const attribs = el.attribs;
for (const name in attribs) {
if (name.startsWith('data-')) {
$(el).removeAttr(name);
}
}
});
// Unwrap divs (remove div tags but keep content)
$('div').each((i, el) => {
$(el).replaceWith($(el).html());
});
// Remove empty paragraphs
$('p').each((i, el) => {
if ($(el).text().trim() === '' && $(el).children().length === 0) {
$(el).remove();
}
});
let output = $('body').html() || '';
// Restore VisualLinkPreview
output = output.replace(/__VISUAL_LINK_PREVIEW_START__/g, '<VisualLinkPreview').replace(/__VISUAL_LINK_PREVIEW_END__/g, '/>');
return output.trim();
}
function generateMdx(post) {
const title = post.title.rendered.replace(/&#8221;/g, '"').replace(/&#8220;/g, '"').replace(/&#8217;/g, "'").replace(/&/g, '&');
const date = post.date;
const slug = post.slug;
const lang = post.lang || 'en'; // Default to en if not specified
let featuredImage = '';
if (post._embedded && post._embedded['wp:featuredmedia'] && post._embedded['wp:featuredmedia'][0]) {
featuredImage = post._embedded['wp:featuredmedia'][0].source_url;
}
const content = cleanContent(post.content.rendered);
return `---
title: "${title}"
date: '${date}'
featuredImage: ${featuredImage}
locale: ${lang}
---
${content}
`;
}
async function main() {
try {
const posts = await fetchPosts();
for (const post of posts) {
const lang = post.lang || 'en';
const slug = post.slug;
const mdxContent = generateMdx(post);
const dir = path.join('data/blog', lang);
if (!fs.existsSync(dir)) {
fs.mkdirSync(dir, { recursive: true });
}
const filePath = path.join(dir, `${slug}.mdx`);
fs.writeFileSync(filePath, mdxContent);
console.log(`Saved ${filePath}`);
}
console.log('Done.');
} catch (error) {
console.error('Error:', error);
}
}
main();

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