Compare commits

...

50 Commits

Author SHA1 Message Date
698141f70b fix: eslint and tests
All checks were successful
Build & Deploy KLZ Cables / 🔍 Prepare Environment (push) Successful in 7s
Build & Deploy KLZ Cables / 🧪 Quality Assurance (push) Successful in 1m31s
Build & Deploy KLZ Cables / 🏗️ Build App (push) Successful in 3m53s
Build & Deploy KLZ Cables / 🚀 Deploy (push) Successful in 31s
Build & Deploy KLZ Cables / ⚡ PageSpeed (push) Successful in 5m24s
Build & Deploy KLZ Cables / 🔔 Notifications (push) Successful in 2s
2026-02-06 22:46:53 +01:00
e179e8162c refactor: Standardize Umami analytics environment variables to non-public names with fallbacks to NEXT_PUBLIC_ prefixed versions.
Some checks failed
Build & Deploy KLZ Cables / 🔍 Prepare Environment (push) Successful in 7s
Build & Deploy KLZ Cables / 🧪 Quality Assurance (push) Failing after 1m31s
Build & Deploy KLZ Cables / 🏗️ Build App (push) Successful in 3m51s
Build & Deploy KLZ Cables / 🚀 Deploy (push) Has been skipped
Build & Deploy KLZ Cables / ⚡ PageSpeed (push) Has been skipped
Build & Deploy KLZ Cables / 🔔 Notifications (push) Successful in 2s
2026-02-06 22:35:49 +01:00
259d712105 fix: Update Gitea workflow to use environment-specific mail and Directus secrets, and refactor Directus branding script for improved CSS management and button styling.
Some checks failed
Build & Deploy KLZ Cables / 🔍 Prepare Environment (push) Successful in 8s
Build & Deploy KLZ Cables / 🏗️ Build App (push) Has been cancelled
Build & Deploy KLZ Cables / 🚀 Deploy (push) Has been cancelled
Build & Deploy KLZ Cables / ⚡ PageSpeed (push) Has been cancelled
Build & Deploy KLZ Cables / 🔔 Notifications (push) Has been cancelled
Build & Deploy KLZ Cables / 🧪 Quality Assurance (push) Has been cancelled
2026-02-06 21:43:53 +01:00
0178e828d6 chore: ensure traefik network labels are applied
All checks were successful
Build & Deploy KLZ Cables / 🔍 Prepare Environment (push) Successful in 8s
Build & Deploy KLZ Cables / 🧪 Quality Assurance (push) Has been skipped
Build & Deploy KLZ Cables / 🏗️ Build App (push) Has been skipped
Build & Deploy KLZ Cables / 🚀 Deploy (push) Has been skipped
Build & Deploy KLZ Cables / ⚡ PageSpeed (push) Has been skipped
Build & Deploy KLZ Cables / 🔔 Notifications (push) Successful in 1s
2026-02-06 19:19:40 +01:00
e3f7344daf chore: traefik labels
Some checks failed
Build & Deploy KLZ Cables / 🔍 Prepare Environment (push) Successful in 10s
Build & Deploy KLZ Cables / 🏗️ Build App (push) Successful in 26s
Build & Deploy KLZ Cables / 🧪 Quality Assurance (push) Failing after 1m34s
Build & Deploy KLZ Cables / 🚀 Deploy (push) Has been skipped
Build & Deploy KLZ Cables / ⚡ PageSpeed (push) Has been skipped
Build & Deploy KLZ Cables / 🔔 Notifications (push) Successful in 2s
2026-02-06 18:36:34 +01:00
21a7b0ade2 feat: Implement replyTo for contact form emails and refine success/error message layout. 2026-02-06 18:24:58 +01:00
d027fbeac2 chore: Standardize error message extraction in contact form actions and mailer, and add Directus restart to the Directus sync script.
All checks were successful
Build & Deploy KLZ Cables / 🔍 Prepare Environment (push) Successful in 14s
Build & Deploy KLZ Cables / 🧪 Quality Assurance (push) Has been skipped
Build & Deploy KLZ Cables / 🏗️ Build App (push) Has been skipped
Build & Deploy KLZ Cables / 🚀 Deploy (push) Has been skipped
Build & Deploy KLZ Cables / ⚡ PageSpeed (push) Has been skipped
Build & Deploy KLZ Cables / 🔔 Notifications (push) Successful in 2s
2026-02-06 17:55:43 +01:00
8a751998eb feat: Add COOKIE_DOMAIN to .env and improve project name fallback logic in sync-directus script
Some checks failed
Build & Deploy KLZ Cables / 🔍 Prepare Environment (push) Successful in 11s
Build & Deploy KLZ Cables / 🧪 Quality Assurance (push) Successful in 1m40s
Build & Deploy KLZ Cables / 🏗️ Build App (push) Failing after 17m34s
Build & Deploy KLZ Cables / 🚀 Deploy (push) Has been cancelled
Build & Deploy KLZ Cables / ⚡ PageSpeed (push) Has been cancelled
Build & Deploy KLZ Cables / 🔔 Notifications (push) Has been cancelled
2026-02-06 17:37:53 +01:00
48c3e1d013 chore: rename scripts 2026-02-06 17:19:57 +01:00
3df4b44b8d fix: center success and error messages within the RequestQuoteForm component.
All checks were successful
Build & Deploy KLZ Cables / 🔍 Prepare Environment (push) Successful in 7s
Build & Deploy KLZ Cables / 🧪 Quality Assurance (push) Successful in 1m31s
Build & Deploy KLZ Cables / 🏗️ Build App (push) Successful in 3m53s
Build & Deploy KLZ Cables / 🚀 Deploy (push) Successful in 34s
Build & Deploy KLZ Cables / ⚡ PageSpeed (push) Successful in 9m46s
Build & Deploy KLZ Cables / 🔔 Notifications (push) Successful in 2s
2026-02-06 17:08:39 +01:00
07e0f237b9 feat: Introduce metadata-only retrieval functions for posts, products, and pages to optimize sitemap generation.
Some checks failed
Build & Deploy KLZ Cables / 🔍 Prepare Environment (push) Successful in 35s
Build & Deploy KLZ Cables / 🧪 Quality Assurance (push) Successful in 1m30s
Build & Deploy KLZ Cables / 🏗️ Build App (push) Successful in 4m39s
Build & Deploy KLZ Cables / 🚀 Deploy (push) Successful in 46s
Build & Deploy KLZ Cables / 🔔 Notifications (push) Has been cancelled
Build & Deploy KLZ Cables / ⚡ PageSpeed (push) Has been cancelled
2026-02-06 17:01:25 +01:00
57a3944301 feat: Update gatekeeper image to latest, add new environment variables, and allow gatekeeper's own paths to prevent redirect loops.
Some checks failed
Build & Deploy KLZ Cables / 🔍 Prepare Environment (push) Successful in 9s
Build & Deploy KLZ Cables / 🏗️ Build App (push) Successful in 18s
Build & Deploy KLZ Cables / 🧪 Quality Assurance (push) Successful in 1m45s
Build & Deploy KLZ Cables / 🚀 Deploy (push) Successful in 27s
Build & Deploy KLZ Cables / ⚡ PageSpeed (push) Failing after 4m9s
Build & Deploy KLZ Cables / 🔔 Notifications (push) Successful in 1s
2026-02-06 15:26:21 +01:00
5fe0a8d83e chore: Hardcode 'compress' Traefik middleware for Directus, removing the dynamic AUTH_MIDDLEWARE variable.
All checks were successful
Build & Deploy KLZ Cables / 🔍 Prepare Environment (push) Successful in 7s
Build & Deploy KLZ Cables / 🧪 Quality Assurance (push) Has been skipped
Build & Deploy KLZ Cables / 🏗️ Build App (push) Has been skipped
Build & Deploy KLZ Cables / 🏗️ Build Gatekeeper (push) Has been skipped
Build & Deploy KLZ Cables / 🚀 Deploy (push) Has been skipped
Build & Deploy KLZ Cables / ⚡ PageSpeed (push) Has been skipped
Build & Deploy KLZ Cables / 🔔 Notifications (push) Successful in 2s
2026-02-06 14:31:39 +01:00
8062d33f35 fix: Validate server-only environment variables exclusively on the server to prevent browser validation errors.
Some checks failed
Build & Deploy KLZ Cables / 🔍 Prepare Environment (push) Successful in 12s
Build & Deploy KLZ Cables / 🧪 Quality Assurance (push) Successful in 1m39s
Build & Deploy KLZ Cables / 🏗️ Build Gatekeeper (push) Successful in 29s
Build & Deploy KLZ Cables / 🏗️ Build App (push) Successful in 3m56s
Build & Deploy KLZ Cables / 🚀 Deploy (push) Successful in 47s
Build & Deploy KLZ Cables / 🔔 Notifications (push) Has been cancelled
Build & Deploy KLZ Cables / ⚡ PageSpeed (push) Has been cancelled
2026-02-06 14:19:29 +01:00
ebe67afd73 feat: Broaden middleware's internal URL correction to include hosts like klz-app and localhost, and update Varnish's health check URL to /health.
All checks were successful
Build & Deploy KLZ Cables / 🔍 Prepare Environment (push) Successful in 7s
Build & Deploy KLZ Cables / 🧪 Quality Assurance (push) Successful in 1m31s
Build & Deploy KLZ Cables / 🏗️ Build Gatekeeper (push) Successful in 23s
Build & Deploy KLZ Cables / 🏗️ Build App (push) Successful in 5m7s
Build & Deploy KLZ Cables / 🚀 Deploy (push) Successful in 50s
Build & Deploy KLZ Cables / ⚡ PageSpeed (push) Successful in 9m7s
Build & Deploy KLZ Cables / 🔔 Notifications (push) Successful in 2s
2026-02-06 13:23:26 +01:00
b74f6b6f9e fix(middleware): strip port 3000 from reconstructed URL to prevent hydration mismatch
All checks were successful
Build & Deploy KLZ Cables / 🔍 Prepare Environment (push) Successful in 7s
Build & Deploy KLZ Cables / 🧪 Quality Assurance (push) Successful in 1m30s
Build & Deploy KLZ Cables / 🏗️ Build Gatekeeper (push) Successful in 21s
Build & Deploy KLZ Cables / 🏗️ Build App (push) Successful in 4m38s
Build & Deploy KLZ Cables / 🚀 Deploy (push) Successful in 50s
Build & Deploy KLZ Cables / ⚡ PageSpeed (push) Successful in 7m12s
Build & Deploy KLZ Cables / 🔔 Notifications (push) Successful in 2s
2026-02-06 12:35:33 +01:00
24eea9a2fe fix(middleware): resolve 0.0.0.0 hydration issues and add header logging
Some checks failed
Build & Deploy KLZ Cables / 🔍 Prepare Environment (push) Successful in 7s
Build & Deploy KLZ Cables / 🧪 Quality Assurance (push) Successful in 1m29s
Build & Deploy KLZ Cables / 🏗️ Build Gatekeeper (push) Successful in 20s
Build & Deploy KLZ Cables / 🏗️ Build App (push) Successful in 4m32s
Build & Deploy KLZ Cables / 🚀 Deploy (push) Successful in 48s
Build & Deploy KLZ Cables / 🔔 Notifications (push) Has been cancelled
Build & Deploy KLZ Cables / ⚡ PageSpeed (push) Has been cancelled
2026-02-06 12:22:32 +01:00
c70288bba7 feat: Enhance Directus URL resolution for internal and proxy paths, and adjust Traefik host variable interpolation.
Some checks failed
Build & Deploy KLZ Cables / 🔍 Prepare Environment (push) Successful in 39s
Build & Deploy KLZ Cables / 🧪 Quality Assurance (push) Successful in 1m33s
Build & Deploy KLZ Cables / 🏗️ Build Gatekeeper (push) Successful in 22s
Build & Deploy KLZ Cables / 🏗️ Build App (push) Successful in 4m47s
Build & Deploy KLZ Cables / 🚀 Deploy (push) Successful in 48s
Build & Deploy KLZ Cables / ⚡ PageSpeed (push) Failing after 3m50s
Build & Deploy KLZ Cables / 🔔 Notifications (push) Successful in 2s
2026-02-06 11:29:10 +01:00
d438dbdc9d feat: Add Varnish backend health check to deploy workflow and set APP_VERSION in Varnish service.
All checks were successful
Build & Deploy KLZ Cables / 🔍 Prepare Environment (push) Successful in 10s
Build & Deploy KLZ Cables / 🧪 Quality Assurance (push) Successful in 1m36s
Build & Deploy KLZ Cables / 🏗️ Build Gatekeeper (push) Successful in 21s
Build & Deploy KLZ Cables / 🏗️ Build App (push) Successful in 4m41s
Build & Deploy KLZ Cables / 🚀 Deploy (push) Successful in 45s
Build & Deploy KLZ Cables / ⚡ PageSpeed (push) Successful in 4m18s
Build & Deploy KLZ Cables / 🔔 Notifications (push) Successful in 1s
2026-02-06 00:21:39 +01:00
e0c4aaf298 feat: Add PROJECT_NAME and COOKIE_DOMAIN environment variables to the deploy workflow.
Some checks failed
Build & Deploy KLZ Cables / 🔍 Prepare Environment (push) Successful in 7s
Build & Deploy KLZ Cables / 🧪 Quality Assurance (push) Successful in 1m32s
Build & Deploy KLZ Cables / 🏗️ Build Gatekeeper (push) Successful in 24s
Build & Deploy KLZ Cables / 🏗️ Build App (push) Successful in 4m33s
Build & Deploy KLZ Cables / 🚀 Deploy (push) Successful in 44s
Build & Deploy KLZ Cables / ⚡ PageSpeed (push) Failing after 3m56s
Build & Deploy KLZ Cables / 🔔 Notifications (push) Successful in 2s
2026-02-05 23:58:03 +01:00
f44487eeac feat: Add configurable cookie domain to gatekeeper and enhance Varnish backend configuration with health probes and increased timeouts.
Some checks failed
Build & Deploy KLZ Cables / 🔍 Prepare Environment (push) Successful in 11s
Build & Deploy KLZ Cables / 🧪 Quality Assurance (push) Successful in 1m41s
Build & Deploy KLZ Cables / 🏗️ Build Gatekeeper (push) Successful in 28s
Build & Deploy KLZ Cables / 🏗️ Build App (push) Successful in 3m56s
Build & Deploy KLZ Cables / 🚀 Deploy (push) Successful in 46s
Build & Deploy KLZ Cables / ⚡ PageSpeed (push) Failing after 3m7s
Build & Deploy KLZ Cables / 🔔 Notifications (push) Successful in 3s
2026-02-05 23:29:34 +01:00
a82b95a28f feat: Adjust backend timeouts, add /cms to cache bypass, and include X-Backend-IP in responses.
Some checks failed
Build & Deploy KLZ Cables / 🔍 Prepare Environment (push) Successful in 9s
Build & Deploy KLZ Cables / 🧪 Quality Assurance (push) Successful in 1m33s
Build & Deploy KLZ Cables / 🏗️ Build Gatekeeper (push) Successful in 20s
Build & Deploy KLZ Cables / 🏗️ Build App (push) Successful in 4m29s
Build & Deploy KLZ Cables / 🚀 Deploy (push) Successful in 45s
Build & Deploy KLZ Cables / ⚡ PageSpeed (push) Failing after 2m7s
Build & Deploy KLZ Cables / 🔔 Notifications (push) Successful in 2s
2026-02-05 22:41:26 +01:00
ab688a3dab refactor: rename app service to klz-app and establish a new internal Docker network for service communication.
Some checks failed
Build & Deploy KLZ Cables / 🔍 Prepare Environment (push) Successful in 34s
Build & Deploy KLZ Cables / 🧪 Quality Assurance (push) Successful in 1m31s
Build & Deploy KLZ Cables / 🏗️ Build Gatekeeper (push) Successful in 20s
Build & Deploy KLZ Cables / 🏗️ Build App (push) Successful in 9m37s
Build & Deploy KLZ Cables / 🚀 Deploy (push) Successful in 1m41s
Build & Deploy KLZ Cables / ⚡ PageSpeed (push) Failing after 3m6s
Build & Deploy KLZ Cables / 🔔 Notifications (push) Successful in 2s
2026-02-05 21:54:46 +01:00
a0ce37708e fix(ci): fix shell compatibility and scp order in deployment workflow
Some checks failed
Build & Deploy KLZ Cables / 🔍 Prepare Environment (push) Successful in 6s
Build & Deploy KLZ Cables / 🧪 Quality Assurance (push) Successful in 1m29s
Build & Deploy KLZ Cables / 🏗️ Build Gatekeeper (push) Successful in 23s
Build & Deploy KLZ Cables / 🏗️ Build App (push) Successful in 4m34s
Build & Deploy KLZ Cables / 🚀 Deploy (push) Successful in 44s
Build & Deploy KLZ Cables / ⚡ PageSpeed (push) Failing after 2m21s
Build & Deploy KLZ Cables / 🔔 Notifications (push) Successful in 2s
2026-02-05 19:25:53 +01:00
0379d1f05d fix: restore critical build/deploy fixes and standardize destructive UI state
Some checks failed
Build & Deploy KLZ Cables / 🔍 Prepare Environment (push) Successful in 9s
Build & Deploy KLZ Cables / 🧪 Quality Assurance (push) Successful in 1m35s
Build & Deploy KLZ Cables / 🏗️ Build Gatekeeper (push) Successful in 21s
Build & Deploy KLZ Cables / 🏗️ Build App (push) Successful in 4m38s
Build & Deploy KLZ Cables / 🚀 Deploy (push) Failing after 12s
Build & Deploy KLZ Cables / ⚡ PageSpeed (push) Has been skipped
Build & Deploy KLZ Cables / 🔔 Notifications (push) Successful in 2s
2026-02-05 14:16:28 +01:00
50347d049d style: add danger button variant and fix contact form error state
Some checks failed
Build & Deploy KLZ Cables / 🔍 Prepare Environment (push) Successful in 6s
Build & Deploy KLZ Cables / 🧪 Quality Assurance (push) Successful in 2m3s
Build & Deploy KLZ Cables / 🏗️ Build Gatekeeper (push) Successful in 33s
Build & Deploy KLZ Cables / 🏗️ Build App (push) Successful in 3m59s
Build & Deploy KLZ Cables / 🚀 Deploy (push) Failing after 9s
Build & Deploy KLZ Cables / ⚡ PageSpeed (push) Has been skipped
Build & Deploy KLZ Cables / 🔔 Notifications (push) Successful in 3s
2026-02-05 14:08:06 +01:00
9678181927 fix: copy varnish config and ensure server directories in deployment
Some checks failed
Build & Deploy KLZ Cables / 🔍 Prepare Environment (push) Successful in 11s
Build & Deploy KLZ Cables / 🧪 Quality Assurance (push) Successful in 1m45s
Build & Deploy KLZ Cables / 🏗️ Build Gatekeeper (push) Successful in 33s
Build & Deploy KLZ Cables / 🏗️ Build App (push) Successful in 3m54s
Build & Deploy KLZ Cables / 🚀 Deploy (push) Failing after 9s
Build & Deploy KLZ Cables / ⚡ PageSpeed (push) Has been skipped
Build & Deploy KLZ Cables / 🔔 Notifications (push) Successful in 1s
2026-02-05 14:03:36 +01:00
3ffaafefe5 build: use --legacy-peer-deps in CI workflow for peer dependency resolution
Some checks failed
Build & Deploy KLZ Cables / 🔍 Prepare Environment (push) Successful in 7s
Build & Deploy KLZ Cables / 🧪 Quality Assurance (push) Successful in 1m29s
Build & Deploy KLZ Cables / 🏗️ Build Gatekeeper (push) Successful in 21s
Build & Deploy KLZ Cables / 🏗️ Build App (push) Successful in 4m37s
Build & Deploy KLZ Cables / 🚀 Deploy (push) Failing after 36s
Build & Deploy KLZ Cables / ⚡ PageSpeed (push) Has been skipped
Build & Deploy KLZ Cables / 🔔 Notifications (push) Successful in 2s
2026-02-05 13:40:21 +01:00
e5bf8c861c build: fix peer dependency conflict in Dockerfile
Some checks failed
Build & Deploy KLZ Cables / 🔍 Prepare Environment (push) Successful in 10s
Build & Deploy KLZ Cables / 🧪 Quality Assurance (push) Failing after 13s
Build & Deploy KLZ Cables / 🏗️ Build Gatekeeper (push) Successful in 21s
Build & Deploy KLZ Cables / 🏗️ Build App (push) Successful in 11m56s
Build & Deploy KLZ Cables / 🚀 Deploy (push) Has been skipped
Build & Deploy KLZ Cables / ⚡ PageSpeed (push) Has been skipped
Build & Deploy KLZ Cables / 🔔 Notifications (push) Successful in 2s
2026-02-05 13:24:28 +01:00
651e14d665 build: update @mintel/mail to 1.2.3
Some checks failed
Build & Deploy KLZ Cables / 🔍 Prepare Environment (push) Successful in 7s
Build & Deploy KLZ Cables / 🧪 Quality Assurance (push) Failing after 12s
Build & Deploy KLZ Cables / 🏗️ Build Gatekeeper (push) Successful in 20s
Build & Deploy KLZ Cables / 🏗️ Build App (push) Failing after 35s
Build & Deploy KLZ Cables / 🚀 Deploy (push) Has been skipped
Build & Deploy KLZ Cables / ⚡ PageSpeed (push) Has been skipped
Build & Deploy KLZ Cables / 🔔 Notifications (push) Successful in 2s
2026-02-05 13:12:54 +01:00
580cd6789c feat: Allow skipping MAIL_HOST environment variable validation during build processes using SKIP_RUNTIME_ENV_VALIDATION.
Some checks failed
Build & Deploy KLZ Cables / 🔍 Prepare Environment (push) Successful in 8s
Build & Deploy KLZ Cables / 🧪 Quality Assurance (push) Successful in 1m35s
Build & Deploy KLZ Cables / 🏗️ Build Gatekeeper (push) Successful in 20s
Build & Deploy KLZ Cables / 🏗️ Build App (push) Failing after 5m36s
Build & Deploy KLZ Cables / 🚀 Deploy (push) Has been skipped
Build & Deploy KLZ Cables / ⚡ PageSpeed (push) Has been skipped
Build & Deploy KLZ Cables / 🔔 Notifications (push) Successful in 2s
2026-02-05 12:48:56 +01:00
db4cf354ff feat: Add conditional MAIL_HOST validation, lazy-load mailer, and update Gitea workflow to use vars for mail and Sentry environment variables.
Some checks failed
Build & Deploy KLZ Cables / 🔍 Prepare Environment (push) Successful in 14s
Build & Deploy KLZ Cables / 🧪 Quality Assurance (push) Successful in 1m44s
Build & Deploy KLZ Cables / 🏗️ Build App (push) Failing after 1m54s
Build & Deploy KLZ Cables / 🏗️ Build Gatekeeper (push) Successful in 31s
Build & Deploy KLZ Cables / 🚀 Deploy (push) Has been skipped
Build & Deploy KLZ Cables / ⚡ PageSpeed (push) Has been skipped
Build & Deploy KLZ Cables / 🔔 Notifications (push) Successful in 2s
2026-02-05 12:39:51 +01:00
e8957e0672 feat: Add Varnish caching service and configuration, adjusting Traefik routing and implementing a rate limit middleware.
Some checks failed
Build & Deploy KLZ Cables / 🔍 Prepare Environment (push) Successful in 7s
Build & Deploy KLZ Cables / 🧪 Quality Assurance (push) Successful in 1m34s
Build & Deploy KLZ Cables / 🏗️ Build Gatekeeper (push) Successful in 21s
Build & Deploy KLZ Cables / 🏗️ Build App (push) Failing after 4m59s
Build & Deploy KLZ Cables / 🚀 Deploy (push) Has been skipped
Build & Deploy KLZ Cables / ⚡ PageSpeed (push) Has been skipped
Build & Deploy KLZ Cables / 🔔 Notifications (push) Successful in 2s
2026-02-05 01:54:17 +01:00
7ef0bca9f6 feat: Implement dual email sending for contact form submissions, including a customer confirmation and internal notification, by refactoring email rendering to accept pre-rendered HTML. 2026-02-05 01:54:01 +01:00
198944649a feat: Add new 1x1200/35 product configuration to data files and datasheets, including scripts to manage its technical and ampacity values.
All checks were successful
Build & Deploy KLZ Cables / 🔍 Prepare Environment (push) Successful in 7s
Build & Deploy KLZ Cables / 🧪 Quality Assurance (push) Successful in 1m37s
Build & Deploy KLZ Cables / 🏗️ Build Gatekeeper (push) Successful in 21s
Build & Deploy KLZ Cables / 🏗️ Build App (push) Successful in 4m7s
Build & Deploy KLZ Cables / 🚀 Deploy (push) Successful in 44s
Build & Deploy KLZ Cables / ⚡ PageSpeed (push) Successful in 1m18s
Build & Deploy KLZ Cables / 🔔 Notifications (push) Successful in 2s
2026-02-04 17:52:21 +01:00
6aa741ab0a refactor: remove npm cache restoration steps and update Gotify failure notification conditions
All checks were successful
Build & Deploy KLZ Cables / 🔍 Prepare Environment (push) Successful in 8s
Build & Deploy KLZ Cables / 🧪 Quality Assurance (push) Successful in 1m43s
Build & Deploy KLZ Cables / 🏗️ Build Gatekeeper (push) Successful in 22s
Build & Deploy KLZ Cables / 🏗️ Build App (push) Successful in 4m21s
Build & Deploy KLZ Cables / 🚀 Deploy (push) Successful in 44s
Build & Deploy KLZ Cables / ⚡ PageSpeed (push) Successful in 6m6s
Build & Deploy KLZ Cables / 🔔 Notifications (push) Successful in 2s
2026-02-02 18:43:21 +01:00
f69952a5da refactor: Enable pino-pretty transport exclusively for development environments.
All checks were successful
Build & Deploy KLZ Cables / 🔍 Prepare Environment (push) Successful in 7s
Build & Deploy KLZ Cables / 🏗️ Build App (push) Successful in 4m6s
Build & Deploy KLZ Cables / 🏗️ Build Gatekeeper (push) Successful in 22s
Build & Deploy KLZ Cables / 🧪 Quality Assurance (push) Successful in 11m52s
Build & Deploy KLZ Cables / 🚀 Deploy (push) Successful in 42s
Build & Deploy KLZ Cables / ⚡ PageSpeed (push) Successful in 4m40s
Build & Deploy KLZ Cables / 🔔 Notifications (push) Successful in 1s
2026-02-02 17:43:54 +01:00
81af9bf3dd feat: Add Gotify notification service and integrate it with error reporting and contact form actions, making error reporting methods asynchronous.
Some checks failed
Build & Deploy KLZ Cables / 🔍 Prepare Environment (push) Successful in 7s
Build & Deploy KLZ Cables / 🏗️ Build App (push) Successful in 4m3s
Build & Deploy KLZ Cables / 🏗️ Build Gatekeeper (push) Successful in 20s
Build & Deploy KLZ Cables / 🚀 Deploy (push) Has been cancelled
Build & Deploy KLZ Cables / ⚡ PageSpeed (push) Has been cancelled
Build & Deploy KLZ Cables / 🔔 Notifications (push) Has been cancelled
Build & Deploy KLZ Cables / 🧪 Quality Assurance (push) Has been cancelled
2026-02-02 17:38:15 +01:00
f1b617e967 feat: add contact form submission status translations 2026-02-02 17:34:18 +01:00
d6be9beebf fix: directus
Some checks failed
Build & Deploy KLZ Cables / 🔍 Prepare Environment (push) Successful in 8s
Build & Deploy KLZ Cables / 🏗️ Build App (push) Successful in 6m36s
Build & Deploy KLZ Cables / 🏗️ Build Gatekeeper (push) Successful in 23s
Build & Deploy KLZ Cables / 🧪 Quality Assurance (push) Successful in 11m59s
Build & Deploy KLZ Cables / 🚀 Deploy (push) Successful in 43s
Build & Deploy KLZ Cables / ⚡ PageSpeed (push) Failing after 7m22s
Build & Deploy KLZ Cables / 🔔 Notifications (push) Successful in 2s
2026-02-02 16:46:26 +01:00
0a797260e3 feat: Introduce NEXT_PUBLIC_TARGET build argument and abstract server-side error reporting to a dedicated service.
Some checks failed
Build & Deploy KLZ Cables / 🔍 Prepare Environment (push) Successful in 7s
Build & Deploy KLZ Cables / 🏗️ Build App (push) Successful in 4m2s
Build & Deploy KLZ Cables / 🏗️ Build Gatekeeper (push) Successful in 21s
Build & Deploy KLZ Cables / 🧪 Quality Assurance (push) Successful in 11m56s
Build & Deploy KLZ Cables / 🚀 Deploy (push) Successful in 43s
Build & Deploy KLZ Cables / ⚡ PageSpeed (push) Failing after 2m7s
Build & Deploy KLZ Cables / 🔔 Notifications (push) Successful in 1s
2026-02-02 15:27:04 +01:00
2a4cc76292 feat: Disable Lighthouse CI report uploading and generate a local summary table of PageSpeed scores instead. 2026-02-02 15:20:33 +01:00
f87eb27f41 ci: tighten docker maintenance window and fix yaml syntax
Some checks failed
Build & Deploy KLZ Cables / 🔍 Prepare Environment (push) Successful in 19s
Build & Deploy KLZ Cables / 🧪 Quality Assurance (push) Successful in 1m37s
Build & Deploy KLZ Cables / 🏗️ Build Gatekeeper (push) Successful in 22s
Build & Deploy KLZ Cables / 🏗️ Build App (push) Successful in 4m23s
Build & Deploy KLZ Cables / 🚀 Deploy (push) Successful in 43s
Build & Deploy KLZ Cables / ⚡ PageSpeed (push) Failing after 2m23s
Build & Deploy KLZ Cables / 🔔 Notifications (push) Successful in 2s
2026-02-02 14:57:14 +01:00
acd86099e5 ci: add proactive docker cleanup and tighten prune filters
Some checks failed
Build & Deploy KLZ Cables / 🧪 Quality Assurance (push) Has been cancelled
Build & Deploy KLZ Cables / 🏗️ Build App (push) Has been cancelled
Build & Deploy KLZ Cables / 🏗️ Build Gatekeeper (push) Has been cancelled
Build & Deploy KLZ Cables / 🚀 Deploy (push) Has been cancelled
Build & Deploy KLZ Cables / ⚡ PageSpeed (push) Has been cancelled
Build & Deploy KLZ Cables / 🔔 Notifications (push) Has been cancelled
Build & Deploy KLZ Cables / 🔍 Prepare Environment (push) Has been cancelled
2026-02-02 14:49:15 +01:00
5ab9791c72 ci: use shallow clones to resolve 'No space left on device' errors
Some checks failed
Build & Deploy KLZ Cables / 🔍 Prepare Environment (push) Successful in 7s
Build & Deploy KLZ Cables / 🏗️ Build App (push) Failing after 10s
Build & Deploy KLZ Cables / 🏗️ Build Gatekeeper (push) Failing after 9s
Build & Deploy KLZ Cables / 🚀 Deploy (push) Has been cancelled
Build & Deploy KLZ Cables / ⚡ PageSpeed (push) Has been cancelled
Build & Deploy KLZ Cables / 🔔 Notifications (push) Has been cancelled
Build & Deploy KLZ Cables / 🧪 Quality Assurance (push) Has been cancelled
2026-02-02 14:45:15 +01:00
8152ccd5df ci: standardize runner containers and harden script output logic
Some checks failed
Build & Deploy KLZ Cables / 🔍 Prepare Environment (push) Failing after 57s
Build & Deploy KLZ Cables / 🧪 Quality Assurance (push) Has been skipped
Build & Deploy KLZ Cables / 🏗️ Build App (push) Has been skipped
Build & Deploy KLZ Cables / 🏗️ Build Gatekeeper (push) Has been skipped
Build & Deploy KLZ Cables / 🚀 Deploy (push) Has been skipped
Build & Deploy KLZ Cables / ⚡ PageSpeed (push) Has been skipped
Build & Deploy KLZ Cables / 🔔 Notifications (push) Successful in 2s
2026-02-02 14:35:49 +01:00
8eeb571c2d feat: Add deployment target configuration for environment-specific settings, Sentry integration, and CMS notice logic.
Some checks failed
Build & Deploy KLZ Cables / 🔍 Prepare Environment (push) Failing after 56s
Build & Deploy KLZ Cables / 🧪 Quality Assurance (push) Has been skipped
Build & Deploy KLZ Cables / 🏗️ Build App (push) Has been skipped
Build & Deploy KLZ Cables / 🏗️ Build Gatekeeper (push) Has been skipped
Build & Deploy KLZ Cables / 🚀 Deploy (push) Has been skipped
Build & Deploy KLZ Cables / ⚡ PageSpeed (push) Has been skipped
Build & Deploy KLZ Cables / 🔔 Notifications (push) Successful in 2s
2026-02-02 14:31:03 +01:00
b1854d5255 ci: optimize builds with parallelism, change detection, and registry caching
Some checks failed
Build & Deploy KLZ Cables / 🔍 Prepare Environment (push) Successful in 22s
Build & Deploy KLZ Cables / 🧪 Quality Assurance (push) Successful in 1m32s
Build & Deploy KLZ Cables / 🏗️ Build App (push) Failing after 5m24s
Build & Deploy KLZ Cables / 🏗️ Build Gatekeeper (push) Failing after 12s
Build & Deploy KLZ Cables / 🚀 Deploy (push) Has been skipped
Build & Deploy KLZ Cables / ⚡ PageSpeed (push) Has been skipped
Build & Deploy KLZ Cables / 🔔 Notifications (push) Successful in 1s
2026-02-02 14:18:01 +01:00
7f4f970a38 feat: Configure deploy workflow concurrency to dynamically group by environment and enable in-progress cancellation. 2026-02-02 14:17:04 +01:00
e5908c757c ci: ensure docker cli is available in build job
Some checks failed
Build & Deploy KLZ Cables / 🔍 Prepare Environment (push) Successful in 27s
Build & Deploy KLZ Cables / 🧪 Quality Assurance (push) Successful in 1m32s
Build & Deploy KLZ Cables / 🏗️ Build & Push (push) Successful in 5m7s
Build & Deploy KLZ Cables / 🚀 Deploy (push) Successful in 43s
Build & Deploy KLZ Cables / ⚡ PageSpeed (push) Failing after 7m52s
Build & Deploy KLZ Cables / 🔔 Notifications (push) Successful in 2s
2026-02-02 14:11:09 +01:00
65 changed files with 2559 additions and 2586 deletions

9
.env
View File

@@ -1,7 +1,7 @@
# Application
NODE_ENV=production
NEXT_PUBLIC_BASE_URL=https://klz-cables.com
UMAMI_SCRIPT_URL=https://analytics.infra.mintel.me/script.js
UMAMI_API_ENDPOINT=https://analytics.infra.mintel.me
NEXT_PUBLIC_UMAMI_WEBSITE_ID=59a7db94-0100-4c7e-98ef-99f45b17f9c3
SENTRY_DSN=https://c10957d0182245b1a2a806ac2d34a197@errors.infra.mintel.me/1
LOG_LEVEL=info
@@ -28,4 +28,9 @@ DIRECTUS_ADMIN_EMAIL=marc@mintel.me
DIRECTUS_ADMIN_PASSWORD=Tim300493.
DIRECTUS_DB_NAME=directus
DIRECTUS_DB_USER=directus
DIRECTUS_DB_PASSWORD=directus
# Local Development
PROJECT_NAME=klz-cables
TRAEFIK_HOST=klz.localhost
DIRECTUS_HOST=cms.klz.localhost
GATEKEEPER_PASSWORD=klz2026
COOKIE_DOMAIN=localhost

View File

@@ -10,13 +10,18 @@
# ────────────────────────────────────────────────────────────────────────────
NODE_ENV=development
NEXT_PUBLIC_BASE_URL=http://localhost:3000
# TARGET is used to differentiate between environments (testing, staging, production)
# NEXT_PUBLIC_TARGET makes this information available to the frontend
NEXT_PUBLIC_TARGET=development
# TARGET is used server-side
TARGET=development
# ────────────────────────────────────────────────────────────────────────────
# 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_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

@@ -13,7 +13,7 @@ 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_API_ENDPOINT=https://analytics.infra.mintel.me
# Error Tracking (GlitchTip/Sentry)
SENTRY_DSN=

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:
# ──────────────────────────────────────────────────────────────────────────────
@@ -38,11 +38,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 +72,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 +86,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 +98,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 +115,21 @@ 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
{
echo "target=$TARGET"
echo "image_tag=$IMAGE_TAG"
echo "env_file=$ENV_FILE"
echo "traefik_host=$TRAEFIK_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 +139,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,60 +173,58 @@ 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
container:
image: catthehacker/ubuntu:act-latest
steps:
- name: Checkout repository
uses: actions/checkout@v4
with:
fetch-depth: 1
- name: 🐳 Set up Docker Buildx
uses: docker/setup-buildx-action@v3
- name: 🔐 Registry Login
run: |
echo "${{ secrets.REGISTRY_PASS }}" | docker login registry.infra.mintel.me -u "${{ secrets.REGISTRY_USER }}" --password-stdin
- name: 🏗️ 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) }}
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) }}
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 UMAMI_API_ENDPOINT="$UMAMI_API_ENDPOINT" \
--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 }}
@@ -227,31 +232,34 @@ jobs:
TRAEFIK_HOST: ${{ needs.prepare.outputs.traefik_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_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 || (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 || 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"
@@ -264,9 +272,11 @@ jobs:
# Generated by CI - $TARGET - $(date -u)
NODE_ENV=production
NEXT_PUBLIC_BASE_URL=$NEXT_PUBLIC_BASE_URL
NEXT_PUBLIC_TARGET=$TARGET
NEXT_PUBLIC_UMAMI_WEBSITE_ID=$NEXT_PUBLIC_UMAMI_WEBSITE_ID
NEXT_PUBLIC_UMAMI_SCRIPT_URL=$NEXT_PUBLIC_UMAMI_SCRIPT_URL
UMAMI_API_ENDPOINT=$UMAMI_API_ENDPOINT
SENTRY_DSN=$SENTRY_DSN
LOG_LEVEL=$( [[ "$TARGET" == "testing" || "$TARGET" == "development" ]] && echo "debug" || echo "info" )
MAIL_HOST=$MAIL_HOST
MAIL_PORT=$MAIL_PORT
MAIL_USERNAME=$MAIL_USERNAME
@@ -288,26 +298,45 @@ jobs:
INTERNAL_DIRECTUS_URL=http://directus:8055
GATEKEEPER_PASSWORD=$GATEKEEPER_PASSWORD
TARGET=$TARGET
SENTRY_ENVIRONMENT=$TARGET
IMAGE_TAG=$IMAGE_TAG
TRAEFIK_HOST=$TRAEFIK_HOST
ENV_FILE=$ENV_FILE
AUTH_MIDDLEWARE=$( [[ "$TARGET" == "production" ]] && echo "compress" || echo "${PROJECT_NAME}-auth,compress" )
PROJECT_NAME=$PROJECT_NAME
COOKIE_DOMAIN=.$(echo $NEXT_PUBLIC_BASE_URL | sed 's|https://||')
EOF
# 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
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 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:"
@@ -317,6 +346,15 @@ jobs:
docker compose -p "$PROJECT_NAME" --env-file "$ENV_FILE" logs --tail=150
exit 1
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
@@ -330,20 +368,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: |
@@ -412,24 +453,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: |
@@ -446,18 +481,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' }}" \

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,22 @@ 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_BASE_URL
ARG NEXT_PUBLIC_UMAMI_WEBSITE_ID
ARG UMAMI_API_ENDPOINT
ARG UMAMI_SCRIPT_URL
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 UMAMI_API_ENDPOINT=${UMAMI_API_ENDPOINT:-${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

@@ -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

@@ -8,6 +8,7 @@ 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),
@@ -51,7 +52,7 @@ export default async function LocaleLayout({
<CMSConnectivityNotice />
{/* Sends pageviews for client-side navigations */}
<AnalyticsProvider />
<AnalyticsProvider websiteId={config.analytics.umami.websiteId} />
</NextIntlClientProvider>
</body>
</html>

View File

@@ -1,72 +1,131 @@
"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;
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,
});
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;
}

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

@@ -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>

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,7 +3,6 @@
import { useEffect } from 'react';
import { usePathname, useSearchParams } from 'next/navigation';
import { getAppServices } from '@/lib/services/create-services';
import Script from 'next/script';
/**
* AnalyticsProvider Component
@@ -11,49 +10,35 @@ import Script from 'next/script';
* Automatically tracks pageviews on client-side route changes.
* This component should be placed inside your layout to handle navigation events.
*
* @param {Object} props - Component props
* @param {string} [props.websiteId] - The Umami website ID (passed from server config)
*
* @example
* ```tsx
* // In your layout.tsx
* <NextIntlClientProvider messages={messages} locale={locale}>
* <UmamiScript />
* <Header />
* <main>{children}</main>
* <Footer />
* <AnalyticsProvider />
* </NextIntlClientProvider>
* const { websiteId } = config.analytics.umami;
* <AnalyticsProvider websiteId={websiteId} />
* ```
*/
export default function AnalyticsProvider() {
export default function AnalyticsProvider({ websiteId }: { websiteId?: string }) {
const pathname = usePathname();
const searchParams = useSearchParams();
useEffect(() => {
if (!pathname) return;
const services = getAppServices();
const url = `${pathname}${searchParams?.size ? `?${searchParams.toString()}` : ''}`;
// Track pageview with the full URL
services.analytics.trackPageview(url);
if (process.env.NODE_ENV === 'development') {
console.log('[Umami] Tracked pageview:', url);
}
}, [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

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

4
cookies.txt Normal file
View File

@@ -0,0 +1,4 @@
# Netscape HTTP Cookie File
# https://curl.se/docs/http-cookies.html
# This file was generated by libcurl! Edit at your own risk.

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

@@ -9,18 +9,27 @@ services:
NODE_ENV: development
# Docker Internal Communication
DIRECTUS_URL: http://directus:8055
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"
# Clear all production-related TLS/Middleware settings for the main routers
- "traefik.http.routers.klz-cables.entrypoints=web"
- "traefik.http.routers.klz-cables.rule=Host(`klz.localhost`)"
- "traefik.http.routers.klz-cables.tls=false"
- "traefik.http.routers.klz-cables.middlewares="
- "traefik.http.routers.klz-cables-web.entrypoints=web"
- "traefik.http.routers.klz-cables-web.rule=Host(`klz.localhost`)"
- "traefik.http.routers.klz-cables-web.middlewares="
directus:
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.entrypoints=web"
- "traefik.http.routers.klz-cables-directus.rule=Host(`cms.klz.localhost`)"
- "traefik.http.routers.klz-cables-directus.tls=false"
- "traefik.http.routers.klz-cables-directus.middlewares="
ports:
- "8055:8055"
environment:

View File

@@ -1,61 +1,87 @@
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=Host(`${TRAEFIK_HOST}`) && !PathPrefix(`/.well-known/acme-challenge/`)"
- "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})"
- "traefik.http.routers.${PROJECT_NAME:-klz-cables}.rule=Host(`${TRAEFIK_HOST}`)"
- "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.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.http.routers.${PROJECT_NAME:-klz-cables}.middlewares=${PROJECT_NAME:-klz-cables}-ratelimit,${PROJECT_NAME:-klz-cables}-forward,${AUTH_MIDDLEWARE:-compress}"
- "traefik.docker.network=infra"
# 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}`)"
- "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"
# 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:latest
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}
GATEKEEPER_PASSWORD: ${GATEKEEPER_PASSWORD:-klz2026}
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 +101,25 @@ 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
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 +130,8 @@ services:
- directus-db-data:/var/lib/postgresql/data
networks:
default:
name: ${PROJECT_NAME:-klz-cables}-internal
infra:
external: true

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

@@ -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

@@ -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,20 +13,22 @@ 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',
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',
apiEndpoint: env.UMAMI_API_ENDPOINT,
enabled: Boolean(env.NEXT_PUBLIC_UMAMI_WEBSITE_ID),
},
},
@@ -65,6 +67,13 @@ function createConfig() {
internalUrl: env.INTERNAL_DIRECTUS_URL,
proxyPath: '/cms',
},
notifications: {
gotify: {
url: env.GOTIFY_URL,
token: env.GOTIFY_TOKEN,
enabled: Boolean(env.GOTIFY_URL && env.GOTIFY_TOKEN),
},
},
} as const;
}
@@ -87,15 +96,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 +132,9 @@ export const config = {
get directus() {
return getConfig().directus;
},
get notifications() {
return getConfig().notifications;
},
};
/**
@@ -132,7 +150,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 +179,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,13 +1,35 @@
import { createDirectus, rest, authentication, 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;
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.';
}
export async function ensureAuthenticated() {
if (token) {
client.setToken(token);
@@ -17,6 +39,9 @@ export async function ensureAuthenticated() {
try {
await client.login(adminEmail, password);
} catch (e) {
if (typeof window === 'undefined') {
getServerAppServices().errors.captureException(e, { part: 'directus_auth' });
}
console.error('Failed to authenticate with Directus:', e);
}
}
@@ -61,6 +86,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 +114,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 +132,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 +160,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,68 @@ 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
NEXT_PUBLIC_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()),
})
.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 +81,13 @@ 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,
NEXT_PUBLIC_UMAMI_WEBSITE_ID:
process.env.NEXT_PUBLIC_UMAMI_WEBSITE_ID || process.env.UMAMI_WEBSITE_ID,
UMAMI_API_ENDPOINT:
process.env.UMAMI_API_ENDPOINT ||
process.env.UMAMI_SCRIPT_URL ||
process.env.NEXT_PUBLIC_UMAMI_SCRIPT_URL,
SENTRY_DSN: process.env.SENTRY_DSN,
LOG_LEVEL: process.env.LOG_LEVEL,
MAIL_HOST: process.env.MAIL_HOST,
@@ -72,5 +101,8 @@ 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,
};
}

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,5 @@
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';
/**
* Configuration options for UmamiAnalyticsService.
@@ -20,133 +11,90 @@ 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.
*/
export class UmamiAnalyticsService implements AnalyticsService {
constructor(private readonly options: UmamiAnalyticsServiceOptions) {}
private websiteId?: string;
private endpoint: string;
constructor(private readonly options: UmamiAnalyticsServiceOptions) {
this.websiteId = config.analytics.umami.websiteId;
// On server, use the full internal URL; on client, use the proxied path
this.endpoint = typeof window === 'undefined' ? config.analytics.umami.apiEndpoint : '/stats';
}
/**
* 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,
* });
* ```
* Internal method to send the payload to Umami API.
*/
private async sendPayload(type: 'event', data: Record<string, any>) {
if (!this.options.enabled || !this.websiteId) return;
try {
const payload = {
website: this.websiteId,
hostname: typeof window !== 'undefined' ? window.location.hostname : 'server',
screen:
typeof window !== 'undefined'
? `${window.screen.width}x${window.screen.height}`
: undefined,
language: typeof window !== 'undefined' ? navigator.language : undefined,
referrer: typeof window !== 'undefined' ? document.referrer : undefined,
...data,
};
const response = await fetch(`${this.endpoint}/api/send`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'User-Agent': typeof window === 'undefined' ? 'KLZ-Server' : navigator.userAgent,
},
body: JSON.stringify({ type, payload }),
// Use keepalive for page navigation events to ensure they complete
keepalive: true,
} as any);
if (!response.ok && process.env.NODE_ENV === 'development') {
const errorText = await response.text();
console.warn(`[Umami] API responded with ${response.status}: ${errorText}`);
}
} catch (error) {
if (process.env.NODE_ENV === 'development') {
console.error('[Umami] Failed to send analytics:', error);
}
}
}
/**
* Track a custom event.
*/
track(eventName: string, props?: AnalyticsEventProperties) {
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 });
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 });
});
return;
}
const umami = (window as unknown as { umami?: UmamiGlobal }).umami;
umami?.track?.(eventName, props);
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,6 +27,7 @@ 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
@@ -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 }, 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

@@ -5,6 +5,7 @@ import { GlitchtipErrorReportingService } from './errors/glitchtip-error-reporti
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,7 +28,7 @@ let singleton: AppServices | undefined;
* - Cache service (in-memory)
*
* The services are configured based on environment variables:
* - `NEXT_PUBLIC_UMAMI_WEBSITE_ID` - Enables Umami analytics
* - `UMAMI_WEBSITE_ID` - Enables Umami analytics
* - `NEXT_PUBLIC_SENTRY_DSN` - Enables client-side error reporting
* - `SENTRY_DSN` - Enables server-side error reporting
*
@@ -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') {
@@ -121,7 +120,9 @@ export function getAppServices(): AppServices {
: 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,10 @@ export function getAppServices(): AppServices {
});
// Create and cache the singleton
singleton = new AppServices(analytics, errors, cache, logger);
const notifications = new NoopNotificationService();
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,7 @@ import type {
ErrorReportingService,
ErrorReportingUser,
} from './error-reporting-service';
import type { NotificationService } from '../notifications/notification-service';
type SentryLike = typeof Sentry;
@@ -15,12 +16,29 @@ export type GlitchtipErrorReportingServiceOptions = {
export class GlitchtipErrorReportingService implements ErrorReportingService {
constructor(
private readonly options: GlitchtipErrorReportingServiceOptions,
private readonly sentry: SentryLike = Sentry
private readonly notifications?: NotificationService,
private readonly sentry: SentryLike = Sentry,
) {}
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": {

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": {

View File

@@ -1,6 +1,5 @@
import createMiddleware from 'next-intl/middleware';
import { NextResponse } from 'next/server';
import type { NextRequest } from 'next/server';
import { NextResponse, NextRequest } from 'next/server';
// Create the internationalization middleware
const intlMiddleware = createMiddleware({
@@ -8,31 +7,60 @@ const intlMiddleware = createMiddleware({
locales: ['en', 'de'],
// Used when no locale matches
defaultLocale: 'en'
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}`);
// 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;
urlObj.port = ''; // Explicitly clear internal port (3000)
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(request);
const response = intlMiddleware(effectiveRequest);
return response;
} catch (error) {
console.error(`Request failed: method=${method} url=${url}`, 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|health|.*\\..*).*)', '/', '/(de|en)/:path*']
matcher: ['/((?!api|_next|_vercel|health|.*\\..*).*)', '/', '/(de|en)/:path*'],
};

View File

@@ -322,12 +322,12 @@ 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 umamiUrl = (process.env.UMAMI_API_ENDPOINT || process.env.UMAMI_SCRIPT_URL || process.env.NEXT_PUBLIC_UMAMI_SCRIPT_URL || 'https://analytics.infra.mintel.me');
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 [
{

1020
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -1,6 +1,7 @@
{
"dependencies": {
"@directus/sdk": "^18.0.3",
"@mintel/mail": "^1.2.3",
"@react-email/components": "^1.0.6",
"@react-pdf/renderer": "^4.3.2",
"@sentry/nextjs": "^8.55.0",
@@ -65,7 +66,7 @@
"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",
"dev:local": "next dev",
"build": "next build",
"start": "next start",
@@ -73,15 +74,15 @@
"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:bootstrap": "DIRECTUS_URL=http://localhost:8055 npx tsx --env-file=.env scripts/setup-directus-branding.ts",
"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:push:staging": "./scripts/sync-directus.sh push staging",
"cms:pull:staging": "./scripts/sync-directus.sh pull staging",
"cms:push:testing": "./scripts/sync-directus.sh push testing",
"cms:pull:testing": "./scripts/sync-directus.sh pull testing",
"cms:push:prod": "./scripts/sync-directus.sh push production",
"cms:pull:prod": "./scripts/sync-directus.sh pull 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"

View File

@@ -79,33 +79,67 @@ async function main() {
const chromePath = process.env.CHROME_PATH || process.env.PUPPETEER_EXECUTABLE_PATH;
const chromePathArg = chromePath ? `--collect.chromePath="${chromePath}"` : '';
// Clean up old reports
if (fs.existsSync('.lighthouseci')) {
fs.rmSync('.lighthouseci', { recursive: true, force: true });
}
// Using a more robust way to execute and capture output
const lhciCommand = `npx lhci collect ${urlArgs} ${chromePathArg} --collect.settings.chromeFlags='--no-sandbox --disable-setuid-sandbox' --collect.settings.extraHeaders='${extraHeaders}' && npx lhci assert && npx lhci upload`;
// We remove 'npx lhci upload' to keep everything local and avoid Google-hosted reports
const lhciCommand = `npx lhci collect ${urlArgs} ${chromePathArg} --collect.settings.chromeFlags='--no-sandbox --disable-setuid-sandbox' --collect.settings.extraHeaders='${extraHeaders}' && npx lhci assert`;
console.log(`💻 Executing LHCI...`);
try {
const output = execSync(lhciCommand, {
execSync(lhciCommand, {
encoding: 'utf8',
stdio: ['inherit', 'pipe', 'inherit'], // Pipe stdout so we can parse it
stdio: 'inherit',
});
} catch (err: any) {
console.warn('⚠️ LHCI assertion finished with warnings or errors.');
// We continue to show the table even if assertions failed
}
// 3. Summarize Results (Local & Independent)
const manifestPath = path.join(process.cwd(), '.lighthouseci', 'manifest.json');
if (fs.existsSync(manifestPath)) {
const manifest = JSON.parse(fs.readFileSync(manifestPath, 'utf8'));
console.log(`\n📊 PageSpeed Summary (FOSS - Local Report):\n`);
const summaryTable = manifest.map((entry: any) => {
const s = entry.summary;
return {
URL: entry.url.replace(targetUrl, ''),
Perf: Math.round(s.performance * 100),
Acc: Math.round(s.accessibility * 100),
BP: Math.round(s['best-practices'] * 100),
SEO: Math.round(s.seo * 100),
};
});
console.log(output);
console.table(summaryTable);
// Extract report URL from LHCI output
const reportMatch = output.match(
/Sent to (https:\/\/storage\.googleapis\.com\/lighthouse-infrastructure\.appspot\.com\/reports\/[^\s]+)/,
);
if (reportMatch && reportMatch[1]) {
const reportUrl = reportMatch[1];
console.log(`\n📊 Report URL: ${reportUrl}`);
fs.writeFileSync('pagespeed-report-url.txt', reportUrl);
}
} catch (err: any) {
console.error('❌ LHCI execution failed.');
if (err.stdout) console.log(err.stdout);
if (err.stderr) console.error(err.stderr);
throw err;
// Calculate Average
const avg = {
Perf: Math.round(
summaryTable.reduce((acc: any, curr: any) => acc + curr.Perf, 0) / summaryTable.length,
),
Acc: Math.round(
summaryTable.reduce((acc: any, curr: any) => acc + curr.Acc, 0) / summaryTable.length,
),
BP: Math.round(
summaryTable.reduce((acc: any, curr: any) => acc + curr.BP, 0) / summaryTable.length,
),
SEO: Math.round(
summaryTable.reduce((acc: any, curr: any) => acc + curr.SEO, 0) / summaryTable.length,
),
};
console.log(`\n📈 Average Scores:`);
console.log(` Performance: ${avg.Perf > 90 ? '✅' : '⚠️'} ${avg.Perf}`);
console.log(` Accessibility: ${avg.Acc > 90 ? '✅' : '⚠️'} ${avg.Acc}`);
console.log(` Best Practices: ${avg.BP > 90 ? '✅' : '⚠️'} ${avg.BP}`);
console.log(` SEO: ${avg.SEO > 90 ? '✅' : '⚠️'} ${avg.SEO}`);
}
console.log(`\n✨ PageSpeed tests completed successfully!`);

View File

@@ -9,49 +9,65 @@ const __filename = fileURLToPath(import.meta.url);
const __dirname = path.dirname(__filename);
async function setupBranding() {
console.log('🎨 Refining Directus Branding for Premium Website Look...');
console.log('🎨 Refining Directus Branding for Premium Website Look...');
// 1. Authenticate
await ensureAuthenticated();
// 1. Authenticate
await ensureAuthenticated();
try {
// 2. Upload Assets (MIME FIXED)
console.log('📤 Re-uploading assets for clean IDs...');
try {
// 2. Upload Assets (MIME FIXED)
console.log('📤 Re-uploading assets for clean IDs...');
const getMimeType = (filePath: string) => {
const ext = path.extname(filePath).toLowerCase();
switch (ext) {
case '.svg': return 'image/svg+xml';
case '.png': return 'image/png';
case '.jpg':
case '.jpeg': return 'image/jpeg';
case '.ico': return 'image/x-icon';
default: return 'application/octet-stream';
}
};
const getMimeType = (filePath: string) => {
const ext = path.extname(filePath).toLowerCase();
switch (ext) {
case '.svg':
return 'image/svg+xml';
case '.png':
return 'image/png';
case '.jpg':
case '.jpeg':
return 'image/jpeg';
case '.ico':
return 'image/x-icon';
default:
return 'application/octet-stream';
}
};
const uploadAsset = async (filePath: string, title: string) => {
if (!fs.existsSync(filePath)) {
console.warn(`⚠️ File not found: ${filePath}`);
return null;
}
const mimeType = getMimeType(filePath);
const form = new FormData();
const fileBuffer = fs.readFileSync(filePath);
const blob = new Blob([fileBuffer], { type: mimeType });
form.append('file', blob, path.basename(filePath));
form.append('title', title);
const res = await client.request(uploadFiles(form));
return res.id;
};
const uploadAsset = async (filePath: string, title: string) => {
if (!fs.existsSync(filePath)) {
console.warn(`⚠️ File not found: ${filePath}`);
return null;
}
const mimeType = getMimeType(filePath);
const form = new FormData();
const fileBuffer = fs.readFileSync(filePath);
const blob = new Blob([fileBuffer], { type: mimeType });
form.append('file', blob, path.basename(filePath));
form.append('title', title);
const res = await client.request(uploadFiles(form));
return res.id;
};
const logoWhiteId = await uploadAsset(path.resolve(__dirname, '../public/logo-white.svg'), 'Logo White');
const logoBlueId = await uploadAsset(path.resolve(__dirname, '../public/logo-blue.svg'), 'Logo Blue');
const faviconId = await uploadAsset(path.resolve(__dirname, '../public/favicon.ico'), 'Favicon');
const logoWhiteId = await uploadAsset(
path.resolve(__dirname, '../public/logo-white.svg'),
'Logo White',
);
const logoBlueId = await uploadAsset(
path.resolve(__dirname, '../public/logo-blue.svg'),
'Logo Blue',
);
const faviconId = await uploadAsset(
path.resolve(__dirname, '../public/favicon.ico'),
'Favicon',
);
// Smoother Background SVG
const bgSvgPath = path.resolve(__dirname, '../public/login-bg.svg');
fs.writeFileSync(bgSvgPath, `<svg width="1920" height="1080" viewBox="0 0 1920 1080" fill="none" xmlns="http://www.w3.org/2000/svg">
// Smoother Background SVG
const bgSvgPath = path.resolve(__dirname, '../public/login-bg.svg');
fs.writeFileSync(
bgSvgPath,
`<svg width="1920" height="1080" viewBox="0 0 1920 1080" fill="none" xmlns="http://www.w3.org/2000/svg">
<rect width="1920" height="1080" fill="#001a4d"/>
<ellipse cx="960" cy="540" rx="800" ry="600" fill="url(#paint0_radial_premium)"/>
<defs>
@@ -60,122 +76,125 @@ async function setupBranding() {
<stop offset="1" stop-color="#001a4d" stop-opacity="0"/>
</radialGradient>
</defs>
</svg>`);
const backgroundId = await uploadAsset(bgSvgPath, 'Login Bg');
if (fs.existsSync(bgSvgPath)) fs.unlinkSync(bgSvgPath);
</svg>`,
);
const backgroundId = await uploadAsset(bgSvgPath, 'Login Bg');
if (fs.existsSync(bgSvgPath)) fs.unlinkSync(bgSvgPath);
// 3. Update Settings with "Premium Web" Theme
console.log('⚙️ Updating Directus settings...');
// 3. Update Settings with "Premium Web" Theme
console.log('⚙️ Updating Directus settings...');
const COLOR_PRIMARY = '#001a4d'; // Deep Blue
const COLOR_ACCENT = '#82ed20'; // Sustainability Green
const COLOR_SECONDARY = '#003d82';
const COLOR_PRIMARY = '#001a4d'; // Deep Blue
const COLOR_ACCENT = '#82ed20'; // Sustainability Green
const COLOR_SECONDARY = '#003d82';
const cssInjection = `
<style>
@import url('https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&display=swap');
/* Global Login Styles */
body, .v-app {
font-family: 'Inter', sans-serif !important;
-webkit-font-smoothing: antialiased;
}
/* Glassmorphism Effect for Login Card */
.public-view .v-card {
background: rgba(255, 255, 255, 0.95) !important;
backdrop-filter: blur(20px);
border: 1px solid rgba(255, 255, 255, 0.3) !important;
border-radius: 32px !important;
box-shadow: 0 50px 100px -20px rgba(0, 0, 0, 0.4) !important;
padding: 40px !important;
}
const customCss = `
@import url('https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&display=swap');
/* Global Login Styles */
body, .v-app {
font-family: 'Inter', sans-serif !important;
-webkit-font-smoothing: antialiased;
}
/* Glassmorphism Effect for Login Card */
.public-view .v-card {
background: rgba(255, 255, 255, 0.95) !important;
backdrop-filter: blur(20px);
border: 1px solid rgba(255, 255, 255, 0.3) !important;
border-radius: 32px !important;
box-shadow: 0 50px 100px -20px rgba(0, 0, 0, 0.4) !important;
padding: 40px !important;
}
.public-view .v-button {
border-radius: full !important;
height: 56px !important;
font-weight: 600 !important;
letter-spacing: -0.01em !important;
transition: all 0.4s cubic-bezier(0.16, 1, 0.3, 1) !important;
}
.public-view .v-button {
border-radius: 9999px !important;
height: 56px !important;
font-weight: 600 !important;
letter-spacing: -0.01em !important;
transition: all 0.4s cubic-bezier(0.16, 1, 0.3, 1) !important;
}
.public-view .v-button:hover {
transform: translateY(-2px);
box-shadow: 0 15px 30px rgba(130, 237, 32, 0.2) !important;
}
.public-view .v-button:hover {
transform: translateY(-2px);
box-shadow: 0 15px 30px rgba(130, 237, 32, 0.2) !important;
}
.public-view .v-input {
--v-input-border-radius: 12px !important;
--v-input-background-color: #f8f9fa !important;
}
</style>
.public-view .v-input {
--v-input-border-radius: 12px !important;
--v-input-background-color: #f8f9fa !important;
}
`;
const publicNote = `
<div style="font-family: 'Inter', sans-serif; text-align: center; margin-top: 24px;">
<p style="color: rgba(255,255,255,0.7); font-size: 14px; margin-bottom: 4px; font-weight: 500;">KLZ INFRASTRUCTURE ENGINE</p>
<h1 style="color: #ffffff; font-size: 18px; font-weight: 700; margin: 0;">Sustainable Energy. <span style="color: #82ed20;">Industrial Reliability.</span></h1>
</div>
`;
await client.request(updateSettings({
project_name: 'KLZ Cables',
project_url: 'https://klz-cables.com',
project_color: COLOR_ACCENT,
project_descriptor: 'Sustainable Energy Infrastructure',
project_owner: 'KLZ Cables',
await client.request(
updateSettings({
project_name: 'KLZ Cables',
project_url: 'https://klz-cables.com',
project_color: COLOR_ACCENT,
project_descriptor: 'Sustainable Energy Infrastructure',
// FIXED: Use WHITE logo for the Blue Sidebar
project_logo: logoWhiteId as any,
// FIXED: Use WHITE logo for the Blue Sidebar
project_logo: logoWhiteId as any,
public_foreground: logoWhiteId as any,
public_background: backgroundId as any,
public_note: cssInjection,
public_favicon: faviconId as any,
public_foreground: logoWhiteId as any,
public_background: backgroundId as any,
public_note: publicNote,
public_favicon: faviconId as any,
custom_css: customCss,
// DEEP PREMIUM THEME
theme_light_overrides: {
// Brands
"primary": COLOR_ACCENT, // Buttons/Actions are GREEN like the website
"secondary": COLOR_SECONDARY,
// DEEP PREMIUM THEME
theme_light_overrides: {
// Brands
primary: COLOR_ACCENT, // Buttons/Actions are GREEN like the website
secondary: COLOR_SECONDARY,
// Content Area
"background": "#f1f3f7",
"backgroundNormal": "#ffffff",
"backgroundAccent": "#eef2ff",
// Content Area
background: '#f1f3f7',
backgroundNormal: '#ffffff',
backgroundAccent: '#eef2ff',
// Sidebar Branding
"navigationBackground": COLOR_PRIMARY,
"navigationForeground": "#ffffff",
"navigationBackgroundHover": "rgba(255,255,255,0.05)",
"navigationForegroundHover": "#ffffff",
"navigationBackgroundActive": "rgba(130, 237, 32, 0.15)", // Subtle Green highlight
"navigationForegroundActive": COLOR_ACCENT, // Active item is GREEN
// Sidebar Branding
navigationBackground: COLOR_PRIMARY,
navigationForeground: '#ffffff',
navigationBackgroundHover: 'rgba(255,255,255,0.05)',
navigationForegroundHover: '#ffffff',
navigationBackgroundActive: 'rgba(130, 237, 32, 0.15)', // Subtle Green highlight
navigationForegroundActive: COLOR_ACCENT, // Active item is GREEN
// Module Bar (Thin far left)
"moduleBarBackground": "#000d26",
"moduleBarForeground": "#ffffff",
"moduleBarForegroundActive": COLOR_ACCENT,
// Module Bar (Thin far left)
moduleBarBackground: '#000d26',
moduleBarForeground: '#ffffff',
moduleBarForegroundActive: COLOR_ACCENT,
// UI Standards
"borderRadius": "16px", // Larger radius for modern feel
"borderWidth": "1px",
"borderColor": "#e2e8f0",
"formFieldHeight": "48px" // Touch-target height
} as any,
// UI Standards
borderRadius: '16px', // Larger radius for modern feel
borderWidth: '1px',
borderColor: '#e2e8f0',
formFieldHeight: '48px', // Touch-target height
} as any,
theme_dark_overrides: {
"primary": COLOR_ACCENT,
"background": "#0a0a0a",
"navigationBackground": "#000000",
"moduleBarBackground": COLOR_PRIMARY,
"borderRadius": "16px",
"formFieldHeight": "48px"
} as any
}));
theme_dark_overrides: {
primary: COLOR_ACCENT,
background: '#0a0a0a',
navigationBackground: '#000000',
moduleBarBackground: COLOR_PRIMARY,
borderRadius: '16px',
formFieldHeight: '48px',
} as any,
}),
);
console.log('✨ Premium Theme applied successfully!');
} catch (error: any) {
console.error('❌ Error:', JSON.stringify(error, null, 2));
}
console.log('✨ Premium Theme applied successfully!');
} catch (error: any) {
console.error('❌ Error:', JSON.stringify(error, null, 2));
}
}
setupBranding();

View File

@@ -36,6 +36,8 @@ case $ENV in
;;
production)
PROJECT_NAME="klz-cables-prod"
# Fallback to older project name if prod-specific one isn't found later in the script
OLD_PROJECT_NAME="klz-cablescom"
ENV_FILE=".env.prod"
;;
*)
@@ -58,6 +60,7 @@ if [ "$ACTION" == "push" ]; then
# 1. DB Dump
echo "📦 Dumping local database..."
# Note: we use --no-owner --no-privileges to ensure restore works on remote with different user setup
docker exec "$LOCAL_DB_CONTAINER" pg_dump -U "$DB_USER" --clean --if-exists --no-owner --no-privileges "$DB_NAME" > dump.sql
# 2. Upload Dump
@@ -67,10 +70,21 @@ if [ "$ACTION" == "push" ]; then
# 3. Restore on Remote
echo "🔄 Restoring dump on $ENV..."
REMOTE_DB_CONTAINER=$(ssh "$REMOTE_HOST" "cd $REMOTE_DIR && docker compose -p $PROJECT_NAME ps -q directus-db")
if [ -z "$REMOTE_DB_CONTAINER" ] && [ -n "$OLD_PROJECT_NAME" ]; then
echo "⚠️ $PROJECT_NAME not found, trying fallback $OLD_PROJECT_NAME..."
REMOTE_DB_CONTAINER=$(ssh "$REMOTE_HOST" "cd $REMOTE_DIR && docker compose -p $OLD_PROJECT_NAME ps -q directus-db")
fi
if [ -z "$REMOTE_DB_CONTAINER" ]; then
echo "❌ Remote $ENV-db container not found!"
exit 1
fi
# Wipe remote DB clean before restore to avoid constraint errors
echo "🧹 Wiping remote database schema..."
ssh "$REMOTE_HOST" "docker exec $REMOTE_DB_CONTAINER psql -U $DB_USER $DB_NAME -c 'DROP SCHEMA public CASCADE; CREATE SCHEMA public;'"
echo "⚡ Restoring database..."
ssh "$REMOTE_HOST" "docker exec -i $REMOTE_DB_CONTAINER psql -U $DB_USER $DB_NAME < $REMOTE_DIR/dump.sql"
# 4. Sync Uploads
@@ -83,6 +97,10 @@ if [ "$ACTION" == "push" ]; then
rm dump.sql
ssh "$REMOTE_HOST" "rm $REMOTE_DIR/dump.sql"
# 5. Restart Directus to trigger migrations and refresh schema cache
echo "🔄 Restarting remote Directus to apply migrations..."
ssh "$REMOTE_HOST" "cd $REMOTE_DIR && docker compose -p $PROJECT_NAME restart directus"
echo "✨ Push to $ENV complete!"
elif [ "$ACTION" == "pull" ]; then
@@ -91,6 +109,11 @@ elif [ "$ACTION" == "pull" ]; then
# 1. DB Dump on Remote
echo "📦 Dumping remote database ($ENV)..."
REMOTE_DB_CONTAINER=$(ssh "$REMOTE_HOST" "cd $REMOTE_DIR && docker compose -p $PROJECT_NAME ps -q directus-db")
if [ -z "$REMOTE_DB_CONTAINER" ] && [ -n "$OLD_PROJECT_NAME" ]; then
echo "⚠️ $PROJECT_NAME not found, trying fallback $OLD_PROJECT_NAME..."
REMOTE_DB_CONTAINER=$(ssh "$REMOTE_HOST" "cd $REMOTE_DIR && docker compose -p $OLD_PROJECT_NAME ps -q directus-db")
fi
if [ -z "$REMOTE_DB_CONTAINER" ]; then
echo "❌ Remote $ENV-db container not found!"
exit 1
@@ -101,8 +124,11 @@ elif [ "$ACTION" == "pull" ]; then
echo "📥 Downloading dump..."
scp "$REMOTE_HOST:$REMOTE_DIR/dump.sql" dump.sql
# 3. Restore Locally
echo "🔄 Restoring dump locally..."
# Wipe local DB clean before restore to avoid constraint errors
echo "🧹 Wiping local database schema..."
docker exec "$LOCAL_DB_CONTAINER" psql -U "$DB_USER" "$DB_NAME" -c 'DROP SCHEMA public CASCADE; CREATE SCHEMA public;'
echo "⚡ Restoring database locally..."
docker exec -i "$LOCAL_DB_CONTAINER" psql -U "$DB_USER" "$DB_NAME" < dump.sql
# 4. Sync Uploads

View File

@@ -0,0 +1,59 @@
import openpyxl
def update_excel_ampacity(file_path, headers_row_idx, ampacity_cols_identifiers, target_cross_section="1x1200/35"):
print(f"Updating {file_path}...")
wb = openpyxl.load_workbook(file_path)
ws = wb.active
# openpyxl is 1-indexed for rows and columns
headers = [cell.value for cell in ws[headers_row_idx]]
# Identify column indices for ampacity (0-indexed locally for easier row access)
col_indices = []
for identifier in ampacity_cols_identifiers:
if isinstance(identifier, int):
col_indices.append(identifier)
else:
try:
# list.index returns 0-indexed position
col_indices.append(headers.index(identifier))
except ValueError:
print(f"Warning: Could not find column '{identifier}' in {file_path}")
# Find row index for "Number of cores and cross-section" or use index 8
cs_col_idx = 8
try:
cs_col_idx = headers.index("Number of cores and cross-section")
except ValueError:
pass
rows_updated = 0
# ws.iter_rows returns 1-indexed rows
for row in ws.iter_rows(min_row=headers_row_idx + 1):
# row is a tuple of cells, so row[cs_col_idx] is 0-indexed access to the tuple
if str(row[cs_col_idx].value).strip() == target_cross_section:
for col_idx in col_indices:
row[col_idx].value = "On Request"
rows_updated += 1
wb.save(file_path)
print(f"Updated {rows_updated} rows in {file_path}")
# File 1: medium-voltage-KM.xlsx
update_excel_ampacity(
'data/excel/medium-voltage-KM.xlsx',
1, # Headers are in first row (1-indexed)
[
'Current ratings in air, trefoil*',
'Current ratings in air, flat*',
'Current ratings in ground, trefoil*',
'Current ratings in ground, flat*'
]
)
# File 2: medium-voltage-KM 170126.xlsx
update_excel_ampacity(
'data/excel/medium-voltage-KM 170126.xlsx',
1, # Indices 39 and 41 were from a 0-indexed JSON representation
[39, 41]
)

87
scripts/update_excel.py Normal file
View File

@@ -0,0 +1,87 @@
import openpyxl
excel_path = 'data/excel/medium-voltage-KM.xlsx'
wb = openpyxl.load_workbook(excel_path)
ws = wb.active
# Technical data for 1x1200RM/35
new_rows_data = [
{
"Rated voltage": "6/10",
"Test voltage": 21,
"Nominal insulation thickness": 3.4,
"Diameter over insulation (approx.)": 48.5,
"Minimum sheath thickness": 2.1,
"Outer diameter (approx.)": 59,
"Bending radius (min.)": 885,
"Weight (approx.)": 4800,
"Capacitance (approx.)": 0.95,
"Inductance, trefoil (approx.)": 0.24,
"Inductance in air, flat (approx.) 1": 0.40,
"Inductance in ground, flat (approx.) 1": 0.42,
},
{
"Rated voltage": "12/20",
"Test voltage": 42,
"Nominal insulation thickness": 5.5,
"Diameter over insulation (approx.)": 52.3,
"Minimum sheath thickness": 2.1,
"Outer diameter (approx.)": 66,
"Bending radius (min.)": 990,
"Weight (approx.)": 5200,
"Capacitance (approx.)": 1.05,
"Inductance, trefoil (approx.)": 0.23,
"Inductance in air, flat (approx.) 1": 0.43,
"Inductance in ground, flat (approx.) 1": 0.45,
},
{
"Rated voltage": "18/30",
"Test voltage": 63,
"Nominal insulation thickness": 8.0,
"Diameter over insulation (approx.)": 57.5,
"Minimum sheath thickness": 2.4,
"Outer diameter (approx.)": 71,
"Bending radius (min.)": 1065,
"Weight (approx.)": 5900,
"Capacitance (approx.)": 1.15,
"Inductance, trefoil (approx.)": 0.22,
"Inductance in air, flat (approx.) 1": 0.45,
"Inductance in ground, flat (approx.) 1": 0.47,
}
]
# Find a template row for NA2XS(F)2Y
template_row = None
headers = [cell.value for cell in ws[1]]
for row in ws.iter_rows(min_row=3, values_only=True):
if row[0] == 'NA2XS(F)2Y':
template_row = list(row)
break
if not template_row:
print("Error: Could not find template row for NA2XS(F)2Y")
exit(1)
# Function to update template with new values
def create_row(template, updates, headers):
new_row = template[:]
# Change "Number of cores and cross-section"
cs_idx = headers.index("Number of cores and cross-section")
new_row[cs_idx] = "1x1200/35"
# Apply specific updates
for key, value in updates.items():
if key in headers:
idx = headers.index(key)
new_row[idx] = value
return new_row
# Append new rows
for data in new_rows_data:
new_row_values = create_row(template_row, data, headers)
ws.append(new_row_values)
print(f"Added row for {data['Rated voltage']} kV")
wb.save(excel_path)
print("Excel file updated successfully.")

120
scripts/update_excel_v2.py Normal file
View File

@@ -0,0 +1,120 @@
import openpyxl
excel_path = 'data/excel/medium-voltage-KM 170126.xlsx'
wb = openpyxl.load_workbook(excel_path)
ws = wb.active
# Technical data for 1x1200RM/35
# Indices based on Row 2 (Units) and Row 1
# Index 0: Part Number
# Index 8: Querschnitt
# Index 9: Rated voltage
# Index 10: Test voltage
# Index 23: LD mm
# Index 24: ID mm
# Index 25: DI mm
# Index 26: MWD mm
# Index 27: AD mm
# Index 28: BR
# Index 29: G kg
# Index 30: RI Ohm
# Index 31: Cap
# Index 32: Inductance trefoil
# Index 35: BK
# Index 39: SBL 30
# Index 41: SBE 20
new_rows_data = [
{
"voltage": "6/10",
"test_v": 21,
"ld": 41.5,
"id": 3.4,
"di": 48.5,
"mwd": 2.1,
"ad": 59,
"br": 885,
"g": 4800,
"ri": 0.0247,
"cap": 0.95,
"ind": 0.24,
"bk": 113,
"sbl": 1300,
"sbe": 933
},
{
"voltage": "12/20",
"test_v": 42,
"ld": 41.5,
"id": 5.5,
"di": 52.3,
"mwd": 2.1,
"ad": 66,
"br": 990,
"g": 5200,
"ri": 0.0247,
"cap": 1.05,
"ind": 0.23,
"bk": 113,
"sbl": 1200,
"sbe": 900
},
{
"voltage": "18/30",
"test_v": 63,
"ld": 41.5,
"id": 8.0,
"di": 57.5,
"mwd": 2.4,
"ad": 71,
"br": 1065,
"g": 5900,
"ri": 0.0247,
"cap": 1.15,
"ind": 0.22,
"bk": 113,
"sbl": 1300,
"sbe": 950
}
]
# Find a template row for NA2XS(F)2Y
template_row = None
for row in ws.iter_rows(min_row=3, values_only=True):
if row[0] == 'NA2XS(F)2Y' and row[9] == '6/10':
template_row = list(row)
break
if not template_row:
print("Error: Could not find template row for NA2XS(F)2Y")
exit(1)
# Function to update template with new values
def create_row(template, data):
new_row = template[:]
new_row[8] = "1x1200/35"
new_row[9] = data["voltage"]
new_row[10] = data["test_v"]
new_row[23] = data["ld"]
new_row[24] = data["id"]
new_row[25] = data["di"]
new_row[26] = data["mwd"]
new_row[27] = data["ad"]
new_row[28] = data["br"]
new_row[29] = data["g"]
new_row[30] = data["ri"]
new_row[31] = data["cap"]
new_row[32] = data["ind"]
new_row[35] = data["bk"]
new_row[39] = data["sbl"]
new_row[41] = data["sbe"]
return new_row
# Append new rows
for data in new_rows_data:
new_row_values = create_row(template_row, data)
ws.append(new_row_values)
print(f"Added row for {data['voltage']} kV")
wb.save(excel_path)
print("Excel file updated successfully.")

View File

@@ -1,7 +1,9 @@
@import "tailwindcss";
@import 'tailwindcss';
@theme {
--font-sans: 'Inter', system-ui, -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif;
--font-sans:
'Inter', system-ui, -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue',
Arial, sans-serif;
--font-heading: 'Inter', system-ui, sans-serif;
--font-body: 'Inter', system-ui, sans-serif;
@@ -30,43 +32,82 @@
--color-success: #10b981;
--color-warning: #f59e0b;
--color-danger: #ef4444;
--color-destructive: #ef4444;
--color-destructive-foreground: #ffffff;
--color-info: #3b82f6;
--animate-fade-in: fade-in 0.5s ease-out;
--animate-slide-up: slide-up 0.6s ease-out;
--animate-slow-zoom: slow-zoom 20s linear infinite;
--animate-reveal: reveal 0.8s cubic-bezier(0.16, 1, 0.3, 1) forwards;
--animate-slight-fade-in-from-bottom: slight-fade-in-from-bottom 0.8s cubic-bezier(0.16, 1, 0.3, 1) forwards;
--animate-slight-fade-in-from-bottom: slight-fade-in-from-bottom 0.8s
cubic-bezier(0.16, 1, 0.3, 1) forwards;
--animate-gradient-x: gradient-x 15s ease infinite;
@keyframes gradient-x {
0%, 100% { background-position: 0% 50%; }
50% { background-position: 100% 50%; }
0%,
100% {
background-position: 0% 50%;
}
50% {
background-position: 100% 50%;
}
}
@keyframes fade-in {
from { opacity: 0; }
to { opacity: 1; }
from {
opacity: 0;
}
to {
opacity: 1;
}
}
@keyframes slide-up {
from { opacity: 0; transform: translateY(20px); }
to { opacity: 1; transform: translateY(0); }
from {
opacity: 0;
transform: translateY(20px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
@keyframes slow-zoom {
from { transform: scale(1); }
to { transform: scale(1.1); }
from {
transform: scale(1);
}
to {
transform: scale(1.1);
}
}
@keyframes reveal {
from { opacity: 0; transform: translateY(20px); filter: blur(8px); }
to { opacity: 1; transform: translateY(0); filter: blur(0); }
from {
opacity: 0;
transform: translateY(20px);
filter: blur(8px);
}
to {
opacity: 1;
transform: translateY(0);
filter: blur(0);
}
}
@keyframes slight-fade-in-from-bottom {
from { opacity: 0; transform: translateY(10px); filter: blur(4px); }
to { opacity: 1; transform: translateY(0); filter: blur(0); }
from {
opacity: 0;
transform: translateY(10px);
filter: blur(4px);
}
to {
opacity: 1;
transform: translateY(0);
filter: blur(0);
}
}
}
@layer base {
.bg-primary a, .bg-primary-dark a {
.bg-primary a,
.bg-primary-dark a {
@apply text-white/90 hover:text-white transition-colors;
}
body {
@@ -76,63 +117,81 @@
-moz-osx-font-smoothing: grayscale;
line-height: 1.7;
}
h1, h2, h3, h4, h5, h6 {
h1,
h2,
h3,
h4,
h5,
h6 {
@apply font-heading font-bold tracking-tight;
line-height: 1.2;
}
/* Enhanced Mobile-first typography hierarchy with fluid sizing */
h1 { @apply text-3xl md:text-5xl lg:text-6xl leading-[1.1]; }
h2 { @apply text-2xl md:text-4xl lg:text-5xl leading-[1.2]; }
h3 { @apply text-xl md:text-2xl lg:text-3xl leading-[1.3]; }
h4 { @apply text-lg md:text-xl lg:text-2xl leading-[1.4]; }
h5 { @apply text-base md:text-lg leading-[1.5]; }
h6 { @apply text-sm md:text-base leading-[1.6]; }
h1 {
@apply text-3xl md:text-5xl lg:text-6xl leading-[1.1];
}
h2 {
@apply text-2xl md:text-4xl lg:text-5xl leading-[1.2];
}
h3 {
@apply text-xl md:text-2xl lg:text-3xl leading-[1.3];
}
h4 {
@apply text-lg md:text-xl lg:text-2xl leading-[1.4];
}
h5 {
@apply text-base md:text-lg leading-[1.5];
}
h6 {
@apply text-sm md:text-base leading-[1.6];
}
/* Paragraph and text styles */
p {
@apply mb-4 leading-relaxed;
}
/* Link styles */
a {
@apply no-underline transition-all duration-200;
}
/* List styles */
ul, ol {
ul,
ol {
@apply my-4 ml-6;
}
li {
@apply mb-2 leading-relaxed;
}
/* Small text */
small {
@apply text-sm md:text-base;
}
/* Strong and emphasis */
strong {
@apply font-bold;
}
em {
@apply italic;
}
/* Blockquote */
blockquote {
@apply border-l-4 pl-6 my-6 italic;
}
/* Code */
code {
@apply px-2 py-1 rounded font-mono text-sm;
}
/* Horizontal rule */
hr {
@apply my-8;
@@ -177,7 +236,7 @@
opacity 0.6s ease-out,
transform 0.8s cubic-bezier(0.16, 1, 0.3, 1),
filter 0.8s cubic-bezier(0.16, 1, 0.3, 1);
&.is-visible {
opacity: 1;
transform: translateY(0);

File diff suppressed because one or more lines are too long

102
varnish/default.vcl Normal file
View File

@@ -0,0 +1,102 @@
vcl 4.1;
import std;
probe default_probe {
.url = "/health";
.timeout = 2s;
.interval = 5s;
.window = 5;
.threshold = 3;
}
backend default {
.host = "klz-app";
.port = "3000";
.connect_timeout = 10s;
.first_byte_timeout = 300s;
.between_bytes_timeout = 10s;
.probe = default_probe;
}
acl purge {
"localhost";
"127.0.0.1";
}
sub vcl_recv {
# Only allow PURGE from the ACL
if (req.method == "PURGE") {
if (!client.ip ~ purge) {
return (synth(405, "Not allowed."));
}
return (purge);
}
# Only cache GET and HEAD requests
if (req.method != "GET" && req.method != "HEAD") {
return (pass);
}
# Bypass cache for Directus and CMS proxy
if (req.url ~ "^/directus" || req.url ~ "^/admin" || req.url ~ "^/cms") {
return (pass);
}
# Bypass cache for Next.js preview mode / health checks
if (req.url ~ "^/api/preview" || req.url ~ "^/health") {
return (pass);
}
# Remove all cookies for static files to improve cache hits
if (req.url ~ "\.(png|gif|jpg|jpeg|svg|ico|webp|js|css|woff|woff2|otf|ttf)$") {
unset req.http.Cookie;
}
# Normalize Cookies: Remove tracking cookies that don't affect page content
# This keeps cookies like NEXT_LOCALE or AUTH cookies if needed, but strips others
if (req.http.Cookie) {
# Strip Google Analytics cookies
set req.http.Cookie = regsuball(req.http.Cookie, "(^|;\s*)(__utm.|_ga.|_gid.|_gat)(=[^;]*)?", "");
# Strip empty cookies
set req.http.Cookie = regsub(req.http.Cookie, "^;\s*", "");
if (req.http.Cookie ~ "^\s*$") {
unset req.http.Cookie;
}
}
return (hash);
}
sub vcl_backend_response {
# Cache static assets for a long time
if (bereq.url ~ "\.(png|gif|jpg|jpeg|svg|ico|webp|js|css|woff|woff2|otf|ttf)$") {
set beresp.ttl = 1w;
}
# Respect Cache-Control from Next.js
# If the response should not be cached, Next.js will usually send Cache-Control: no-cache, no-store, etc.
if (beresp.http.Cache-Control ~ "private" ||
beresp.http.Cache-Control ~ "no-cache" ||
beresp.http.Cache-Control ~ "no-store") {
set beresp.uncacheable = true;
return (deliver);
}
# Set a default TTL if none is provided by the backend
if (beresp.ttl <= 0s) {
set beresp.ttl = 120s;
}
return (deliver);
}
sub vcl_deliver {
# Add a debug header to show if it was a hit or miss
if (obj.hits > 0) {
set resp.http.X-Cache = "HIT";
set resp.http.X-Cache-Hits = obj.hits;
} else {
set resp.http.X-Cache = "MISS";
}
}