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 # Application
NODE_ENV=production NODE_ENV=production
NEXT_PUBLIC_BASE_URL=https://klz-cables.com 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 NEXT_PUBLIC_UMAMI_WEBSITE_ID=59a7db94-0100-4c7e-98ef-99f45b17f9c3
SENTRY_DSN=https://c10957d0182245b1a2a806ac2d34a197@errors.infra.mintel.me/1 SENTRY_DSN=https://c10957d0182245b1a2a806ac2d34a197@errors.infra.mintel.me/1
LOG_LEVEL=info LOG_LEVEL=info
@@ -28,4 +28,9 @@ DIRECTUS_ADMIN_EMAIL=marc@mintel.me
DIRECTUS_ADMIN_PASSWORD=Tim300493. DIRECTUS_ADMIN_PASSWORD=Tim300493.
DIRECTUS_DB_NAME=directus DIRECTUS_DB_NAME=directus
DIRECTUS_DB_USER=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 NODE_ENV=development
NEXT_PUBLIC_BASE_URL=http://localhost:3000 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) # Analytics (Umami)
# ──────────────────────────────────────────────────────────────────────────── # ────────────────────────────────────────────────────────────────────────────
# Optional: Leave empty to disable analytics # Optional: Leave empty to disable analytics
NEXT_PUBLIC_UMAMI_WEBSITE_ID= 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) # Error Tracking (GlitchTip/Sentry)
@@ -52,6 +57,11 @@ IMAGE_TAG=latest
TRAEFIK_HOST=klz-cables.com TRAEFIK_HOST=klz-cables.com
ENV_FILE=.env ENV_FILE=.env
# ────────────────────────────────────────────────────────────────────────────
# Varnish Configuration
# ────────────────────────────────────────────────────────────────────────────
VARNISH_CACHE_SIZE=256M
# ============================================================================ # ============================================================================
# IMPORTANT NOTES # IMPORTANT NOTES
# ============================================================================ # ============================================================================

View File

@@ -13,7 +13,7 @@ NEXT_PUBLIC_BASE_URL=https://klz-cables.com
# Analytics (Umami) # Analytics (Umami)
NEXT_PUBLIC_UMAMI_WEBSITE_ID= 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) # Error Tracking (GlitchTip/Sentry)
SENTRY_DSN= SENTRY_DSN=

View File

@@ -14,8 +14,8 @@ on:
default: 'false' default: 'false'
concurrency: concurrency:
group: ${{ github.workflow }} group: ${{ github.workflow }}-${{ (github.ref_type == 'tag' && !contains(github.ref_name, '-')) && 'prod' || (github.ref_type == 'tag' && 'staging' || 'testing') }}
cancel-in-progress: false cancel-in-progress: true
jobs: jobs:
# ────────────────────────────────────────────────────────────────────────────── # ──────────────────────────────────────────────────────────────────────────────
@@ -38,11 +38,21 @@ jobs:
gotify_priority: ${{ steps.determine.outputs.gotify_priority }} gotify_priority: ${{ steps.determine.outputs.gotify_priority }}
short_sha: ${{ steps.determine.outputs.short_sha }} short_sha: ${{ steps.determine.outputs.short_sha }}
commit_msg: ${{ steps.determine.outputs.commit_msg }} commit_msg: ${{ steps.determine.outputs.commit_msg }}
container:
image: catthehacker/ubuntu:act-latest
steps: 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 - name: Checkout repository
uses: actions/checkout@v4 uses: actions/checkout@v4
with: with:
fetch-depth: 0 fetch-depth: 2
- name: 🔍 Environment & Version ermitteln - name: 🔍 Environment & Version ermitteln
id: determine id: determine
@@ -62,10 +72,10 @@ jobs:
TARGET="testing" TARGET="testing"
IMAGE_TAG="main-${SHORT_SHA}" IMAGE_TAG="main-${SHORT_SHA}"
ENV_FILE=".env.testing" 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" NEXT_PUBLIC_BASE_URL="https://testing.klz-cables.com"
DIRECTUS_URL="https://cms.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" PROJECT_NAME="klz-cables-testing"
IS_PROD="false" IS_PROD="false"
GOTIFY_TITLE="🧪 Testing-Deploy" GOTIFY_TITLE="🧪 Testing-Deploy"
@@ -76,10 +86,10 @@ jobs:
TARGET="production" TARGET="production"
IMAGE_TAG="$TAG" IMAGE_TAG="$TAG"
ENV_FILE=".env.prod" 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" NEXT_PUBLIC_BASE_URL="https://klz-cables.com"
DIRECTUS_URL="https://cms.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" PROJECT_NAME="klz-cables-prod"
IS_PROD="true" IS_PROD="true"
GOTIFY_TITLE="🚀 Production-Release" GOTIFY_TITLE="🚀 Production-Release"
@@ -88,10 +98,10 @@ jobs:
TARGET="staging" TARGET="staging"
IMAGE_TAG="$TAG" IMAGE_TAG="$TAG"
ENV_FILE=".env.staging" 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" NEXT_PUBLIC_BASE_URL="https://staging.klz-cables.com"
DIRECTUS_URL="https://cms.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" PROJECT_NAME="klz-cables-staging"
IS_PROD="false" IS_PROD="false"
GOTIFY_TITLE="🧪 Staging-Deploy (Pre-Release)" GOTIFY_TITLE="🧪 Staging-Deploy (Pre-Release)"
@@ -105,19 +115,21 @@ jobs:
TARGET="skip" TARGET="skip"
fi fi
echo "target=$TARGET" >> $GITHUB_OUTPUT {
echo "image_tag=$IMAGE_TAG" >> $GITHUB_OUTPUT echo "target=$TARGET"
echo "env_file=$ENV_FILE" >> $GITHUB_OUTPUT echo "image_tag=$IMAGE_TAG"
echo "traefik_host=$TRAEFIK_HOST" >> $GITHUB_OUTPUT echo "env_file=$ENV_FILE"
echo "next_public_base_url=$NEXT_PUBLIC_BASE_URL" >> $GITHUB_OUTPUT echo "traefik_host=$TRAEFIK_HOST"
echo "directus_url=$DIRECTUS_URL" >> $GITHUB_OUTPUT echo "next_public_base_url=$NEXT_PUBLIC_BASE_URL"
echo "directus_host=$DIRECTUS_HOST" >> $GITHUB_OUTPUT echo "directus_url=$DIRECTUS_URL"
echo "project_name=$PROJECT_NAME" >> $GITHUB_OUTPUT echo "directus_host=$DIRECTUS_HOST"
echo "is_prod=$IS_PROD" >> $GITHUB_OUTPUT echo "project_name=$PROJECT_NAME"
echo "gotify_title=$GOTIFY_TITLE" >> $GITHUB_OUTPUT echo "is_prod=$IS_PROD"
echo "gotify_priority=$GOTIFY_PRIORITY" >> $GITHUB_OUTPUT echo "gotify_title=$GOTIFY_TITLE"
echo "short_sha=$SHORT_SHA" >> $GITHUB_OUTPUT echo "gotify_priority=$GOTIFY_PRIORITY"
echo "commit_msg=$COMMIT_MSG" >> $GITHUB_OUTPUT echo "short_sha=$SHORT_SHA"
echo "commit_msg=$COMMIT_MSG"
} >> "$GITHUB_OUTPUT"
# ────────────────────────────────────────────────────────────────────────────── # ──────────────────────────────────────────────────────────────────────────────
# JOB 2: Quality Assurance (Lint & Test) # JOB 2: Quality Assurance (Lint & Test)
@@ -127,26 +139,21 @@ jobs:
needs: prepare needs: prepare
if: needs.prepare.outputs.target != 'skip' if: needs.prepare.outputs.target != 'skip'
runs-on: docker runs-on: docker
container:
image: catthehacker/ubuntu:act-latest
steps: steps:
- name: Checkout repository - name: Checkout repository
uses: actions/checkout@v4 uses: actions/checkout@v4
with:
fetch-depth: 1
- name: Setup Node.js - name: Setup Node.js
uses: actions/setup-node@v4 uses: actions/setup-node@v4
with: with:
node-version: 20 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 - name: Install dependencies
run: npm ci run: npm ci --legacy-peer-deps
- name: 🧪 Run Checks in Parallel - name: 🧪 Run Checks in Parallel
if: github.event.inputs.skip_long_checks != 'true' if: github.event.inputs.skip_long_checks != 'true'
@@ -166,60 +173,58 @@ jobs:
# ────────────────────────────────────────────────────────────────────────────── # ──────────────────────────────────────────────────────────────────────────────
# JOB 3: Build & Push Docker Image # JOB 3: Build & Push Docker Image
# ────────────────────────────────────────────────────────────────────────────── # ──────────────────────────────────────────────────────────────────────────────
build: build-app:
name: 🏗️ Build & Push name: 🏗️ Build App
needs: prepare needs: prepare
if: ${{ needs.prepare.outputs.target != 'skip' }} if: ${{ needs.prepare.outputs.target != 'skip' }}
runs-on: docker runs-on: docker
container:
image: catthehacker/ubuntu:act-latest
steps: steps:
- name: Checkout repository - name: Checkout repository
uses: actions/checkout@v4 uses: actions/checkout@v4
with:
fetch-depth: 1
- name: 🐳 Set up Docker Buildx
uses: docker/setup-buildx-action@v3
- name: 🔐 Registry Login - name: 🔐 Registry Login
run: | run: |
echo "${{ secrets.REGISTRY_PASS }}" | docker login registry.infra.mintel.me -u "${{ secrets.REGISTRY_USER }}" --password-stdin 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: env:
IMAGE_TAG: ${{ needs.prepare.outputs.image_tag }} IMAGE_TAG: ${{ needs.prepare.outputs.image_tag }}
TARGET: ${{ needs.prepare.outputs.target }} TARGET: ${{ needs.prepare.outputs.target }}
NEXT_PUBLIC_BASE_URL: ${{ needs.prepare.outputs.next_public_base_url }} 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_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 }} DIRECTUS_URL: ${{ needs.prepare.outputs.directus_url }}
run: | run: |
echo "🏗️ Building → $TARGET / $IMAGE_TAG"
docker buildx build \ docker buildx build \
--pull \ --pull \
--platform linux/arm64 \ --platform linux/arm64 \
--build-arg NEXT_PUBLIC_BASE_URL="$NEXT_PUBLIC_BASE_URL" \ --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_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" \ --build-arg DIRECTUS_URL="$DIRECTUS_URL" \
-t registry.infra.mintel.me/mintel/klz-cables.com:$IMAGE_TAG \ -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-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 \ --cache-to type=registry,ref=registry.infra.mintel.me/mintel/klz-cables.com:buildcache,mode=max \
--push . --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 # JOB 4: Deploy via SSH
# ────────────────────────────────────────────────────────────────────────────── # ──────────────────────────────────────────────────────────────────────────────
deploy: deploy:
name: 🚀 Deploy name: 🚀 Deploy
needs: [prepare, build, qa] needs: [prepare, build-app, qa]
if: ${{ needs.prepare.outputs.target != 'skip' }} if: ${{ needs.prepare.outputs.target != 'skip' }}
runs-on: docker runs-on: docker
container:
image: catthehacker/ubuntu:act-latest
env: env:
TARGET: ${{ needs.prepare.outputs.target }} TARGET: ${{ needs.prepare.outputs.target }}
IMAGE_TAG: ${{ needs.prepare.outputs.image_tag }} IMAGE_TAG: ${{ needs.prepare.outputs.image_tag }}
@@ -227,31 +232,34 @@ jobs:
TRAEFIK_HOST: ${{ needs.prepare.outputs.traefik_host }} TRAEFIK_HOST: ${{ needs.prepare.outputs.traefik_host }}
NEXT_PUBLIC_BASE_URL: ${{ needs.prepare.outputs.next_public_base_url }} 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_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) }}
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) }} 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 }} 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 }} 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 }} 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 }} 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 }} 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 }} 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_URL: ${{ needs.prepare.outputs.directus_url }}
DIRECTUS_HOST: ${{ needs.prepare.outputs.directus_host }} DIRECTUS_HOST: ${{ needs.prepare.outputs.directus_host }}
PROJECT_NAME: ${{ needs.prepare.outputs.project_name }} PROJECT_NAME: ${{ needs.prepare.outputs.project_name }}
DIRECTUS_KEY: ${{ secrets.DIRECTUS_KEY }} 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 }} 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 }} 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 }} 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_NAME: ${{ secrets.DIRECTUS_DB_NAME || 'directus' }}
DIRECTUS_DB_USER: ${{ secrets.DIRECTUS_DB_USER || 'directus' }} DIRECTUS_DB_USER: ${{ secrets.DIRECTUS_DB_USER || 'directus' }}
DIRECTUS_DB_PASSWORD: ${{ secrets.DIRECTUS_DB_PASSWORD }} 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 }} 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' }} GATEKEEPER_PASSWORD: ${{ secrets.GATEKEEPER_PASSWORD || 'klz2026' }}
steps: steps:
- name: Checkout repository - name: Checkout repository
uses: actions/checkout@v4 uses: actions/checkout@v4
with:
fetch-depth: 1
- name: 🚀 Deploy to ${{ env.TARGET }} - name: 🚀 Deploy to ${{ env.TARGET }}
shell: bash
run: | run: |
echo "Deploying $TARGET → $IMAGE_TAG" echo "Deploying $TARGET → $IMAGE_TAG"
@@ -264,9 +272,11 @@ jobs:
# Generated by CI - $TARGET - $(date -u) # Generated by CI - $TARGET - $(date -u)
NODE_ENV=production NODE_ENV=production
NEXT_PUBLIC_BASE_URL=$NEXT_PUBLIC_BASE_URL 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_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 SENTRY_DSN=$SENTRY_DSN
LOG_LEVEL=$( [[ "$TARGET" == "testing" || "$TARGET" == "development" ]] && echo "debug" || echo "info" )
MAIL_HOST=$MAIL_HOST MAIL_HOST=$MAIL_HOST
MAIL_PORT=$MAIL_PORT MAIL_PORT=$MAIL_PORT
MAIL_USERNAME=$MAIL_USERNAME MAIL_USERNAME=$MAIL_USERNAME
@@ -288,26 +298,45 @@ jobs:
INTERNAL_DIRECTUS_URL=http://directus:8055 INTERNAL_DIRECTUS_URL=http://directus:8055
GATEKEEPER_PASSWORD=$GATEKEEPER_PASSWORD GATEKEEPER_PASSWORD=$GATEKEEPER_PASSWORD
TARGET=$TARGET
SENTRY_ENVIRONMENT=$TARGET
IMAGE_TAG=$IMAGE_TAG IMAGE_TAG=$IMAGE_TAG
TRAEFIK_HOST=$TRAEFIK_HOST TRAEFIK_HOST=$TRAEFIK_HOST
ENV_FILE=$ENV_FILE ENV_FILE=$ENV_FILE
AUTH_MIDDLEWARE=$( [[ "$TARGET" == "production" ]] && echo "compress" || echo "${PROJECT_NAME}-auth,compress" ) 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 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 /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 -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' 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 set -e
cd /home/deploy/sites/klz-cables.com cd /home/deploy/sites/klz-cables.com
chmod 600 "$ENV_FILE" chmod 600 "$ENV_FILE"
chown deploy:deploy "$ENV_FILE" chown deploy:deploy "$ENV_FILE"
echo "${{ secrets.REGISTRY_PASS }}" | docker login registry.infra.mintel.me -u "${{ secrets.REGISTRY_USER }}" --password-stdin echo "${{ secrets.REGISTRY_PASS }}" | docker login registry.infra.mintel.me -u "${{ secrets.REGISTRY_USER }}" --password-stdin
echo "→ Pulling image: $IMAGE_TAG" echo "→ Pulling image: $IMAGE_TAG"
docker compose -p "$PROJECT_NAME" --env-file "$ENV_FILE" pull docker compose -p "$PROJECT_NAME" --env-file "$ENV_FILE" pull
echo "→ Starting containers..." echo "→ Starting containers..."
docker compose -p "$PROJECT_NAME" --env-file "$ENV_FILE" up -d --remove-orphans 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..." echo "→ Waiting 15s for warmup..."
sleep 15 sleep 15
echo "→ Container status:" echo "→ Container status:"
@@ -317,6 +346,15 @@ jobs:
docker compose -p "$PROJECT_NAME" --env-file "$ENV_FILE" logs --tail=150 docker compose -p "$PROJECT_NAME" --env-file "$ENV_FILE" logs --tail=150
exit 1 exit 1
fi 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 # JOB 5: PageSpeed Test
@@ -330,20 +368,23 @@ jobs:
needs.deploy.result == 'success' && needs.deploy.result == 'success' &&
github.event.inputs.skip_long_checks != 'true' github.event.inputs.skip_long_checks != 'true'
runs-on: docker runs-on: docker
outputs: container:
report_url: ${{ steps.save.outputs.report_url }} image: catthehacker/ubuntu:act-latest
# outputs:
# report_url: ${{ steps.save.outputs.report_url }}
steps: steps:
- name: Checkout repository - name: Checkout repository
uses: actions/checkout@v4 uses: actions/checkout@v4
with:
fetch-depth: 1
- name: Setup Node.js - name: Setup Node.js
uses: actions/setup-node@v4 uses: actions/setup-node@v4
with: with:
node-version: 20 node-version: 20
cache: 'npm'
- name: Install dependencies - name: Install dependencies
run: npm ci run: npm ci --legacy-peer-deps
- name: 🔍 Install Chromium (Native & ARM64) - name: 🔍 Install Chromium (Native & ARM64)
run: | run: |
@@ -412,24 +453,18 @@ jobs:
CHROME_PATH: /usr/bin/chromium CHROME_PATH: /usr/bin/chromium
run: npm run pagespeed:test 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 # JOB 6: Notifications
# ────────────────────────────────────────────────────────────────────────────── # ──────────────────────────────────────────────────────────────────────────────
notifications: notifications:
name: 🔔 Notifications name: 🔔 Notifications
needs: [prepare, qa, build, deploy, pagespeed] needs: [prepare, qa, build-app, deploy, pagespeed]
if: always() if: always()
runs-on: docker runs-on: docker
container:
image: catthehacker/ubuntu:act-latest
steps: steps:
- name: 📊 Deployment Summary - name: 📊 Deployment Summary
run: | run: |
@@ -446,18 +481,18 @@ jobs:
- name: 🔔 Gotify - Success - name: 🔔 Gotify - Success
if: needs.deploy.result == 'success' if: needs.deploy.result == 'success'
run: | 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 }}" \ curl -s -k -X POST "${{ secrets.GOTIFY_URL }}/message?token=${{ secrets.GOTIFY_TOKEN }}" \
-F "title=${{ needs.prepare.outputs.gotify_title }}" \ -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 -F "priority=4" || true
- name: 🔔 Gotify - Failure - 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: | run: |
curl -s -k -X POST "${{ secrets.GOTIFY_URL }}/message?token=${{ secrets.GOTIFY_TOKEN }}" \ curl -s -k -X POST "${{ secrets.GOTIFY_URL }}/message?token=${{ secrets.GOTIFY_TOKEN }}" \
-F "title=❌ Deployment FEHLGESCHLAGEN ${{ needs.prepare.outputs.target || 'unknown' }}" \ -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 # Install dependencies based on the preferred package manager
COPY package.json package-lock.json* ./ 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 # Rebuild the source code only when needed
@@ -25,17 +25,22 @@ ENV NEXT_TELEMETRY_DISABLED=1
# Build-time environment variables for Next.js # Build-time environment variables for Next.js
# These are baked into the client bundle during build # These are baked into the client bundle during build
ARG NEXT_PUBLIC_BASE_URL ARG NEXT_PUBLIC_BASE_URL
ARG NEXT_PUBLIC_BASE_URL
ARG NEXT_PUBLIC_UMAMI_WEBSITE_ID ARG NEXT_PUBLIC_UMAMI_WEBSITE_ID
ARG UMAMI_API_ENDPOINT
ARG UMAMI_SCRIPT_URL
ARG NEXT_PUBLIC_UMAMI_SCRIPT_URL ARG NEXT_PUBLIC_UMAMI_SCRIPT_URL
ARG NEXT_PUBLIC_TARGET
ARG DIRECTUS_URL ARG DIRECTUS_URL
ENV NEXT_PUBLIC_BASE_URL=$NEXT_PUBLIC_BASE_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_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 ENV DIRECTUS_URL=$DIRECTUS_URL
# Validate environment variables during build # 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 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 ## 🚀 Quick Start
### Prerequisites ### Prerequisites
- Node.js 18+
- Node.js 18+
- npm or yarn - npm or yarn
### Installation ### Installation
```bash
````bash
# Install dependencies # Install dependencies
npm install --legacy-peer-deps npm install --legacy-peer-deps
@@ -42,11 +44,12 @@ npm run cms:logs
# Stop the CMS # Stop the CMS
npm run cms:stop npm run cms:stop
``` ````
Once running, you can access the Strapi admin panel at `http://localhost:1337/admin`. Once running, you can access the Strapi admin panel at `http://localhost:1337/admin`.
### 🔄 Data & Migration ### 🔄 Data & Migration
To sync data or migrate existing content: To sync data or migrate existing content:
```bash ```bash
@@ -61,6 +64,7 @@ npm run cms:migrate
``` ```
### Environment Variables ### Environment Variables
```bash ```bash
# .env # .env
SITE_URL=https://klz-cables.com SITE_URL=https://klz-cables.com
@@ -69,8 +73,8 @@ TURNSTILE_SITE_KEY=your_turnstile_key
TURNSTILE_SECRET_KEY=your_turnstile_secret TURNSTILE_SECRET_KEY=your_turnstile_secret
# Umami # Umami
NEXT_PUBLIC_UMAMI_WEBSITE_ID=your_umami_website_id UMAMI_WEBSITE_ID=your_umami_website_id
NEXT_PUBLIC_UMAMI_SCRIPT_URL=https://analytics.infra.mintel.me/script.js UMAMI_API_ENDPOINT=https://analytics.infra.mintel.me
# GlitchTip (Sentry compatible) # GlitchTip (Sentry compatible)
SENTRY_DSN=https://PUBLIC_KEY@errors.infra.mintel.me/PROJECT_ID 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 ## 📊 Project Overview
### Migration Statistics ### Migration Statistics
- **Content Exported**: 141 items - **Content Exported**: 141 items
- 18 pages (9 EN + 9 DE) - 18 pages (9 EN + 9 DE)
- 59 posts (29 EN + 30 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 - **Translation Pairs**: 16
### Performance Benefits ### Performance Benefits
- **Before**: Dynamic WordPress with database queries - **Before**: Dynamic WordPress with database queries
- **After**: Static HTML with CDN delivery - **After**: Static HTML with CDN delivery
- **Load Time**: <100ms (vs 500ms+) - **Load Time**: <100ms (vs 500ms+)
@@ -99,6 +105,7 @@ NEXT_PUBLIC_SENTRY_DSN=https://PUBLIC_KEY@errors.infra.mintel.me/PROJECT_ID
## 🏗️ Architecture ## 🏗️ Architecture
### Tech Stack ### Tech Stack
- **Framework**: Next.js 14 (App Router) - **Framework**: Next.js 14 (App Router)
- **Language**: TypeScript - **Language**: TypeScript
- **Styling**: SCSS - **Styling**: SCSS
@@ -109,6 +116,7 @@ NEXT_PUBLIC_SENTRY_DSN=https://PUBLIC_KEY@errors.infra.mintel.me/PROJECT_ID
- **CAPTCHA**: Cloudflare Turnstile - **CAPTCHA**: Cloudflare Turnstile
### Project Structure ### Project Structure
``` ```
app/ app/
├── layout.tsx # Root layout ├── layout.tsx # Root layout
@@ -163,6 +171,7 @@ scripts/
## 🎯 Features ## 🎯 Features
### ✅ Implemented ### ✅ Implemented
- **Multi-language**: EN/DE with `/de/` prefix routing - **Multi-language**: EN/DE with `/de/` prefix routing
- **Contact Forms**: Resend integration with validation - **Contact Forms**: Resend integration with validation
- **GDPR Compliance**: Cookie consent banner - **GDPR Compliance**: Cookie consent banner
@@ -175,12 +184,14 @@ scripts/
- **Asset Management**: WordPress → local path mapping - **Asset Management**: WordPress → local path mapping
### 🔄 In Progress ### 🔄 In Progress
- Analytics integration (consent-based) - Analytics integration (consent-based)
- Turnstile CAPTCHA - Turnstile CAPTCHA
- Build testing - Build testing
- Deployment configuration - Deployment configuration
### 📝 Remaining ### 📝 Remaining
- Performance optimization - Performance optimization
- Final QA testing - Final QA testing
- Documentation updates - Documentation updates
@@ -188,6 +199,7 @@ scripts/
## 📝 Content Management ## 📝 Content Management
### Data Export ### Data Export
```bash ```bash
# Export from WordPress # Export from WordPress
npm run data:export npm run data:export
@@ -203,6 +215,7 @@ npm run data:improve-mapping
``` ```
### Adding New Content ### Adding New Content
1. Export new content from WordPress 1. Export new content from WordPress
2. Process the data 2. Process the data
3. Rebuild the site 3. Rebuild the site
@@ -210,17 +223,20 @@ npm run data:improve-mapping
## 🎨 Design System ## 🎨 Design System
### Colors ### Colors
- Primary: `#0066cc` (KLZ Blue) - Primary: `#0066cc` (KLZ Blue)
- Secondary: `#00a896` (Teal) - Secondary: `#00a896` (Teal)
- Text: `#1a1a1a` - Text: `#1a1a1a`
- Background: `#f8f9fa` - Background: `#f8f9fa`
### Typography ### Typography
- Font: Inter - Font: Inter
- Base: 16px - Base: 16px
- Scale: 1.25 (Major Third) - Scale: 1.25 (Major Third)
### Layout ### Layout
- Max width: 1200px - Max width: 1200px
- Responsive grid - Responsive grid
- Mobile-first - Mobile-first
@@ -228,6 +244,7 @@ npm run data:improve-mapping
## 🔧 API Endpoints ## 🔧 API Endpoints
### Contact Form ### Contact Form
``` ```
POST /api/contact POST /api/contact
{ {
@@ -239,11 +256,13 @@ POST /api/contact
``` ```
### Sitemap ### Sitemap
``` ```
GET /sitemap.xml GET /sitemap.xml
``` ```
### Robots ### Robots
``` ```
GET /robots.txt 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` **Workflow**: `.gitea/workflows/deploy.yml`
**Branch Deployments**: **Branch Deployments**:
- `main` branch: Deploys to production using `.env.prod` - `main` branch: Deploys to production using `.env.prod`
- `staging` branch: Deploys to staging using `.env.staging` - `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. 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): **Required Secrets** (configure in Gitea repository settings):
- `REGISTRY_USER` - Docker registry username - `REGISTRY_USER` - Docker registry username
- `REGISTRY_PASS` - Docker registry password - `REGISTRY_PASS` - Docker registry password
- `ALPHA_SSH_KEY` - SSH private key for deployment - `ALPHA_SSH_KEY` - SSH private key for deployment
- `NEXT_PUBLIC_BASE_URL` - Application base URL (e.g., `https://klz-cables.com`) - `NEXT_PUBLIC_BASE_URL` - Application base URL (e.g., `https://klz-cables.com`)
- `NEXT_PUBLIC_UMAMI_WEBSITE_ID` - Analytics ID - `UMAMI_WEBSITE_ID` - Analytics ID
- `NEXT_PUBLIC_UMAMI_SCRIPT_URL` - Analytics script URL - `UMAMI_API_ENDPOINT` - Analytics API endpoint (formerly NEXT_PUBLIC_UMAMI_SCRIPT_URL)
- `SENTRY_DSN` - Error tracking DSN - `SENTRY_DSN` - Error tracking DSN
- `MAIL_HOST`, `MAIL_PORT`, `MAIL_USERNAME`, `MAIL_PASSWORD`, `MAIL_FROM`, `MAIL_RECIPIENTS` - Email configuration - `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: Or use the convenience script:
```bash ```bash
bash scripts/deploy-webhook.sh bash scripts/deploy-webhook.sh
``` ```
@@ -304,11 +326,13 @@ Client → Traefik (TLS) → Next.js App
``` ```
**Domains**: **Domains**:
- `klz-cables.com` - Production - `klz-cables.com` - Production
- `www.klz-cables.com` - Production (www) - `www.klz-cables.com` - Production (www)
- `staging.klz-cables.com` - Staging - `staging.klz-cables.com` - Staging
**Services**: **Services**:
- `app`: Next.js application (port 3000) - `app`: Next.js application (port 3000)
- `traefik`: Reverse proxy (external) - `traefik`: Reverse proxy (external)
@@ -317,25 +341,30 @@ For detailed deployment documentation, see [`docs/DEPLOYMENT.md`](docs/DEPLOYMEN
## 📈 Performance ## 📈 Performance
### Build Time ### Build Time
- **Target**: < 2 minutes - **Target**: < 2 minutes
- **Current**: ~1-2 minutes - **Current**: ~1-2 minutes
### Page Load ### Page Load
- **Target**: < 100ms - **Target**: < 100ms
- **Current**: Static HTML from CDN - **Current**: Static HTML from CDN
### Bundle Size ### Bundle Size
- **Target**: < 100KB gzipped - **Target**: < 100KB gzipped
- **Current**: Optimized with code splitting - **Current**: Optimized with code splitting
## 🔒 Security ## 🔒 Security
### Environment Variables ### Environment Variables
- Never commit `.env` file - Never commit `.env` file
- Rotate keys regularly - Rotate keys regularly
- Use secrets in deployment platform - Use secrets in deployment platform
### Form Security ### Form Security
- Email validation - Email validation
- Rate limiting (recommended) - Rate limiting (recommended)
- Turnstile CAPTCHA (pending) - Turnstile CAPTCHA (pending)
@@ -343,6 +372,7 @@ For detailed deployment documentation, see [`docs/DEPLOYMENT.md`](docs/DEPLOYMEN
## 🎓 WordPress Specifics ## 🎓 WordPress Specifics
### WPBakery Shortcodes Removed ### WPBakery Shortcodes Removed
- `[vc_row]`, `[vc_column]`, `[vc_column_text]` - `[vc_row]`, `[vc_column]`, `[vc_column_text]`
- `[nectar_*]` (Salient theme) - `[nectar_*]` (Salient theme)
- `[image_with_animation]` - `[image_with_animation]`
@@ -350,13 +380,16 @@ For detailed deployment documentation, see [`docs/DEPLOYMENT.md`](docs/DEPLOYMEN
- `[divider]` - `[divider]`
### HTML Sanitization ### HTML Sanitization
- Removes inline event handlers - Removes inline event handlers
- Strips scripts - Strips scripts
- Normalizes classes - Normalizes classes
- Preserves structure - Preserves structure
### Asset Mapping ### Asset Mapping
WordPress URLs → Local paths: WordPress URLs → Local paths:
``` ```
https://klz-cables.com/wp-content/uploads/... → /media/... https://klz-cables.com/wp-content/uploads/... → /media/...
``` ```
@@ -364,11 +397,13 @@ https://klz-cables.com/wp-content/uploads/... → /media/...
## 📚 Documentation ## 📚 Documentation
### Internal ### Internal
- `PROJECT_STRUCTURE.md` - Detailed structure - `PROJECT_STRUCTURE.md` - Detailed structure
- `IMPLEMENTATION_SUMMARY.md` - Progress tracking - `IMPLEMENTATION_SUMMARY.md` - Progress tracking
- `FINAL_SUMMARY.md` - Complete overview - `FINAL_SUMMARY.md` - Complete overview
### External ### External
- [Next.js Docs](https://nextjs.org/docs) - [Next.js Docs](https://nextjs.org/docs)
- [WordPress REST API](https://developer.wordpress.org/rest-api/) - [WordPress REST API](https://developer.wordpress.org/rest-api/)
- [Resend Docs](https://resend.com/docs) - [Resend Docs](https://resend.com/docs)
@@ -379,17 +414,20 @@ https://klz-cables.com/wp-content/uploads/... → /media/...
### Common Issues ### Common Issues
**TypeScript Errors** **TypeScript Errors**
- The TypeScript errors shown in the editor are expected - The TypeScript errors shown in the editor are expected
- They occur because modules reference each other - They occur because modules reference each other
- The build process resolves these correctly - The build process resolves these correctly
- Run `npm run build` to verify - Run `npm run build` to verify
**Build Failures** **Build Failures**
- Check environment variables - Check environment variables
- Verify data files exist - Verify data files exist
- Clear `.next` cache: `rm -rf .next` - Clear `.next` cache: `rm -rf .next`
**Missing Modules** **Missing Modules**
- Run `npm install --legacy-peer-deps` - Run `npm install --legacy-peer-deps`
- Check `package.json` dependencies - Check `package.json` dependencies
@@ -404,11 +442,12 @@ https://klz-cables.com/wp-content/uploads/... → /media/...
**i18n**: Multi-language support **i18n**: Multi-language support
**SEO**: Metadata and sitemaps **SEO**: Metadata and sitemaps
**Compatibility**: WPBakery content handled **Compatibility**: WPBakery content handled
**Media**: All images downloaded **Media**: All images downloaded
## 📞 Support ## 📞 Support
For issues or questions: For issues or questions:
1. Check the documentation 1. Check the documentation
2. Review the troubleshooting section 2. Review the troubleshooting section
3. Check environment variables 3. Check environment variables

View File

@@ -8,6 +8,7 @@ import { NextIntlClientProvider } from 'next-intl';
import { getMessages } from 'next-intl/server'; import { getMessages } from 'next-intl/server';
import '../../styles/globals.css'; import '../../styles/globals.css';
import { SITE_URL } from '@/lib/schema'; import { SITE_URL } from '@/lib/schema';
import { config } from '@/lib/config';
export const metadata: Metadata = { export const metadata: Metadata = {
metadataBase: new URL(SITE_URL), metadataBase: new URL(SITE_URL),
@@ -51,7 +52,7 @@ export default async function LocaleLayout({
<CMSConnectivityNotice /> <CMSConnectivityNotice />
{/* Sends pageviews for client-side navigations */} {/* Sends pageviews for client-side navigations */}
<AnalyticsProvider /> <AnalyticsProvider websiteId={config.analytics.umami.websiteId} />
</NextIntlClientProvider> </NextIntlClientProvider>
</body> </body>
</html> </html>

View File

@@ -1,72 +1,131 @@
"use server"; 'use server';
import client, { ensureAuthenticated } from "@/lib/directus"; import client, { ensureAuthenticated } from '@/lib/directus';
import { createItem } from "@directus/sdk"; import { createItem } from '@directus/sdk';
import { sendEmail } from "@/lib/mail/mailer"; import { sendEmail } from '@/lib/mail/mailer';
import ContactEmail from "@/components/emails/ContactEmail"; import { render, ContactFormNotification, ConfirmationMessage } from '@mintel/mail';
import React from "react"; import React from 'react';
import { getServerAppServices } from "@/lib/services/create-services.server"; import { getServerAppServices } from '@/lib/services/create-services.server';
export async function sendContactFormAction(formData: FormData) { export async function sendContactFormAction(formData: FormData) {
const services = getServerAppServices(); const services = getServerAppServices();
const logger = services.logger.child({ action: 'sendContactFormAction' }); const logger = services.logger.child({ action: 'sendContactFormAction' });
const name = formData.get("name") as string; const name = formData.get('name') as string;
const email = formData.get("email") as string; const email = formData.get('email') as string;
const message = formData.get("message") as string; const message = formData.get('message') as string;
const productName = formData.get("productName") as string | null; const productName = formData.get('productName') as string | null;
if (!name || !email || !message) { if (!name || !email || !message) {
logger.warn('Missing required fields in contact form', { name: !!name, email: !!email, message: !!message }); logger.warn('Missing required fields in contact form', {
return { success: false, error: "Missing required fields" }; name: !!name,
email: !!email,
message: !!message,
});
return { success: false, error: 'Missing required fields' };
} }
// 1. Save to Directus // 1. Save to Directus
try { try {
await ensureAuthenticated(); await ensureAuthenticated();
if (productName) { if (productName) {
await client.request(createItem('product_requests', { await client.request(
product_name: productName, createItem('product_requests', {
email, product_name: productName,
message email,
})); message,
}),
);
logger.info('Product request stored in Directus'); logger.info('Product request stored in Directus');
} else { } else {
await client.request(createItem('contact_submissions', { await client.request(
name, createItem('contact_submissions', {
email, name,
message email,
})); message,
}),
);
logger.info('Contact submission stored in Directus'); logger.info('Contact submission stored in Directus');
} }
} catch (error) { } catch (error) {
logger.error('Failed to store submission in Directus', { 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 // 2. Send Emails
logger.info('Sending contact form email', { email, productName }); logger.info('Sending branded emails', { email, productName });
const subject = productName const notificationSubject = productName
? `Product Inquiry: ${productName}` ? `Product Inquiry: ${productName}`
: "New Contact Form Submission"; : 'New Contact Form Submission';
const confirmationSubject = 'Thank you for your inquiry';
const result = await sendEmail({ try {
subject, // 2a. Send notification to Mintel/Client
template: React.createElement(ContactEmail, { const notificationHtml = await render(
name, React.createElement(ContactFormNotification, {
email, name,
message, email,
productName: productName || undefined, message,
subject, productName: productName || undefined,
}), }),
}); );
if (result.success) { const notificationResult = await sendEmail({
logger.info('Contact form email sent successfully', { messageId: result.messageId }); replyTo: email,
} else { subject: notificationSubject,
logger.error('Failed to send contact form email', { error: result.error }); html: notificationHtml,
services.errors.captureException(result.error, { action: 'sendContactFormAction', email }); });
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 { config } from '@/lib/config';
import { MetadataRoute } from 'next'; import { MetadataRoute } from 'next';
import { getAllProducts } from '@/lib/mdx'; import { getAllProductsMetadata } from '@/lib/mdx';
import { getAllPosts } from '@/lib/blog'; import { getAllPostsMetadata } from '@/lib/blog';
import { getAllPages } from '@/lib/pages'; import { getAllPagesMetadata } from '@/lib/pages';
export const revalidate = 3600; // Revalidate every hour
export default async function sitemap(): Promise<MetadataRoute.Sitemap> { export default async function sitemap(): Promise<MetadataRoute.Sitemap> {
const baseUrl = config.baseUrl || 'https://klz-cables.com'; const baseUrl = config.baseUrl || 'https://klz-cables.com';
@@ -34,11 +36,10 @@ export default async function sitemap(): Promise<MetadataRoute.Sitemap> {
} }
// Products // Products
const products = await getAllProducts(locale); const productsMetadata = await getAllProductsMetadata(locale);
for (const product of products) { for (const product of productsMetadata) {
// We need to find the category for the product to build the URL if (!product.frontmatter || !product.slug) continue;
// In this project, products are under /products/[category]/[slug]
// The category is in product.frontmatter.categories
const category = const category =
product.frontmatter.categories[0]?.toLowerCase().replace(/\s+/g, '-') || 'other'; product.frontmatter.categories[0]?.toLowerCase().replace(/\s+/g, '-') || 'other';
sitemapEntries.push({ sitemapEntries.push({
@@ -50,8 +51,10 @@ export default async function sitemap(): Promise<MetadataRoute.Sitemap> {
} }
// Blog posts // Blog posts
const posts = await getAllPosts(locale); const postsMetadata = await getAllPostsMetadata(locale);
for (const post of posts) { for (const post of postsMetadata) {
if (!post.frontmatter || !post.slug) continue;
sitemapEntries.push({ sitemapEntries.push({
url: `${baseUrl}/${locale}/blog/${post.slug}`, url: `${baseUrl}/${locale}/blog/${post.slug}`,
lastModified: new Date(post.frontmatter.date), lastModified: new Date(post.frontmatter.date),
@@ -61,8 +64,10 @@ export default async function sitemap(): Promise<MetadataRoute.Sitemap> {
} }
// Static pages // Static pages
const pages = await getAllPages(locale); const pagesMetadata = await getAllPagesMetadata(locale);
for (const page of pages) { for (const page of pagesMetadata) {
if (!page.slug) continue;
sitemapEntries.push({ sitemapEntries.push({
url: `${baseUrl}/${locale}/${page.slug}`, url: `${baseUrl}/${locale}/${page.slug}`,
lastModified: new Date(), lastModified: new Date(),

View File

@@ -2,6 +2,7 @@
import React, { useEffect, useState } from 'react'; import React, { useEffect, useState } from 'react';
import { AlertCircle, RefreshCw, Database } from 'lucide-react'; import { AlertCircle, RefreshCw, Database } from 'lucide-react';
import { config } from '../lib/config';
export default function CMSConnectivityNotice() { export default function CMSConnectivityNotice() {
const [status, setStatus] = useState<'checking' | 'ok' | 'error'>('checking'); 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 // Only show if we've detected an issue AND we are in a context where we want to see it
const checkCMS = async () => { const checkCMS = async () => {
const isDebug = new URLSearchParams(window.location.search).has('cms_debug'); const isDebug = new URLSearchParams(window.location.search).has('cms_debug');
const isLocal = const isLocal = config.isDevelopment;
window.location.hostname === 'localhost' || window.location.hostname.includes('127.0.0.1'); const isTesting = config.isTesting;
const isStaging =
window.location.hostname.includes('staging') ||
window.location.hostname.includes('testing');
// Only proceed with check if it's developer context // Only proceed with check if it's developer context (Local or Testing)
if (!isLocal && !isStaging && !isDebug) return; // Staging and Production should NEVER see this unless forced with ?cms_debug
if (!isLocal && !isTesting && !isDebug) return;
try { try {
const response = await fetch('/api/health/cms'); const response = await fetch('/api/health/cms');

View File

@@ -17,10 +17,10 @@ export default function ContactForm() {
const formData = new FormData(e.currentTarget); const formData = new FormData(e.currentTarget);
const email = formData.get('email') as string; const email = formData.get('email') as string;
try { try {
const result = await sendContactFormAction(formData); const result = await sendContactFormAction(formData);
if (result.success) { if (result?.success) {
trackEvent('contact_form_submission', { trackEvent('contact_form_submission', {
form_type: 'general', form_type: 'general',
email, email,
@@ -41,7 +41,12 @@ export default function ContactForm() {
return ( return (
<Card className="p-6 md:p-12 rounded-2xl md:rounded-[40px] border-none shadow-2xl text-center"> <Card className="p-6 md:p-12 rounded-2xl md:rounded-[40px] border-none shadow-2xl text-center">
<div className="w-20 h-20 bg-accent rounded-full flex items-center justify-center mx-auto mb-6 shadow-lg shadow-accent/20"> <div className="w-20 h-20 bg-accent rounded-full flex items-center justify-center mx-auto mb-6 shadow-lg shadow-accent/20">
<svg 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" /> <path strokeLinecap="round" strokeLinejoin="round" strokeWidth={3} d="M5 13l4 4L19 7" />
</svg> </svg>
</div> </div>
@@ -49,7 +54,8 @@ export default function ContactForm() {
{t('form.successTitle') || 'Message Sent!'} {t('form.successTitle') || 'Message Sent!'}
</Heading> </Heading>
<p className="text-text-secondary text-lg mb-8"> <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> </p>
<Button onClick={() => setStatus('idle')} variant="saturated"> <Button onClick={() => setStatus('idle')} variant="saturated">
{t('form.sendAnother') || 'Send another message'} {t('form.sendAnother') || 'Send another message'}
@@ -62,7 +68,13 @@ export default function ContactForm() {
return ( return (
<Card className="p-6 md:p-12 rounded-2xl md:rounded-[40px] border-destructive/20 shadow-2xl text-center bg-destructive/5 animate-slide-up"> <Card className="p-6 md:p-12 rounded-2xl md:rounded-[40px] border-destructive/20 shadow-2xl text-center bg-destructive/5 animate-slide-up">
<div className="w-20 h-20 bg-destructive rounded-full flex items-center justify-center mx-auto mb-6 shadow-lg shadow-destructive/20"> <div className="w-20 h-20 bg-destructive rounded-full flex items-center justify-center mx-auto mb-6 shadow-lg shadow-destructive/20">
<svg 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" /> <circle cx="12" cy="12" r="10" />
<line x1="15" y1="9" x2="9" y2="15" strokeLinecap="round" strokeLinejoin="round" /> <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" /> <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"> <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.'} {t('form.error') || 'Something went wrong. Please check your input and try again.'}
</p> </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'} {t('form.tryAgain') || 'Try Again'}
</Button> </Button>
</Card> </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"> <form onSubmit={handleSubmit} className="grid grid-cols-1 md:grid-cols-2 gap-4 md:gap-8">
<div className="space-y-1 md:space-y-2"> <div className="space-y-1 md:space-y-2">
<Label htmlFor="name">{t('form.name')}</Label> <Label htmlFor="name">{t('form.name')}</Label>
<Input <Input
type="text" type="text"
id="name" id="name"
name="name" name="name"
autoComplete="name" autoComplete="name"
enterKeyHint="next" enterKeyHint="next"
@@ -101,9 +118,9 @@ export default function ContactForm() {
</div> </div>
<div className="space-y-1 md:space-y-2"> <div className="space-y-1 md:space-y-2">
<Label htmlFor="email">{t('form.email')}</Label> <Label htmlFor="email">{t('form.email')}</Label>
<Input <Input
type="email" type="email"
id="email" id="email"
name="email" name="email"
autoComplete="email" autoComplete="email"
inputMode="email" inputMode="email"
@@ -114,32 +131,50 @@ export default function ContactForm() {
</div> </div>
<div className="md:col-span-2 space-y-1 md:space-y-2"> <div className="md:col-span-2 space-y-1 md:space-y-2">
<Label htmlFor="message">{t('form.message')}</Label> <Label htmlFor="message">{t('form.message')}</Label>
<Textarea <Textarea
id="message" id="message"
name="message" name="message"
rows={4} rows={4}
enterKeyHint="send" enterKeyHint="send"
placeholder={t('form.messagePlaceholder')} placeholder={t('form.messagePlaceholder')}
required required
/> />
</div> </div>
<div className="md:col-span-2 pt-2 md:pt-4"> <div className="md:col-span-2 pt-2 md:pt-4">
<Button <Button
type="submit" type="submit"
variant="saturated" variant="saturated"
size="lg" size="lg"
disabled={status === 'submitting'} 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" 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' ? ( {status === 'submitting' ? (
<span className="flex items-center gap-2"> <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"> <svg
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4"></circle> className="animate-spin h-5 w-5 text-white"
<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> 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> </svg>
{t('form.submitting') || 'Sending...'} {t('form.submitting') || 'Sending...'}
</span> </span>
) : t('form.submit')} ) : (
t('form.submit')
)}
</Button> </Button>
</div> </div>
</form> </form>

View File

@@ -49,14 +49,28 @@ export default function RequestQuoteForm({ productName }: RequestQuoteFormProps)
if (status === 'success') { if (status === 'success') {
return ( return (
<div className="bg-accent/5 border border-accent/20 text-primary-dark p-4 rounded-xl text-center animate-fade-in !mt-0"> <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="w-10 h-10 bg-accent rounded-full flex items-center justify-center mx-auto mb-3 shadow-lg shadow-accent/20"> <div className="flex justify-center mb-3">
<svg className="w-5 h-5 text-primary-dark" fill="none" stroke="currentColor" viewBox="0 0 24 24"> <div className="w-10 h-10 bg-accent rounded-full flex items-center justify-center shadow-lg shadow-accent/20">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={3} d="M5 13l4 4L19 7" /> <svg
</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> </div>
<h3 className="text-base font-extrabold mb-1 tracking-tight !mt-0">{t('successTitle')}</h3> <h3 className="text-base font-extrabold mb-1 tracking-tight !mt-0 text-center w-full">
<p className="text-text-secondary text-xs leading-tight mb-4 !mt-0"> {t('successTitle')}
</h3>
<p className="text-text-secondary text-xs leading-tight mb-4 !mt-0 text-center w-full">
{t('successDesc', { productName })} {t('successDesc', { productName })}
</p> </p>
<button <button
@@ -73,26 +87,36 @@ export default function RequestQuoteForm({ productName }: RequestQuoteFormProps)
if (status === 'error') { if (status === 'error') {
return ( return (
<div className="bg-destructive/5 border border-destructive/20 text-destructive p-4 rounded-xl text-center animate-fade-in !mt-0"> <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="w-10 h-10 bg-destructive rounded-full flex items-center justify-center mx-auto mb-3 shadow-lg shadow-destructive/20"> <div className="flex justify-center mb-3">
<svg className="w-5 h-5 text-destructive-foreground" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth="3"> <div className="w-10 h-10 bg-destructive rounded-full flex items-center justify-center shadow-lg shadow-destructive/20">
<circle cx="12" cy="12" r="10" /> <svg
<line x1="15" y1="9" x2="9" y2="15" /> className="w-5 h-5 text-destructive-foreground"
<line x1="9" y1="9" x2="15" y2="15" /> fill="none"
</svg> 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> </div>
<h3 className="text-base font-extrabold mb-1 tracking-tight !mt-0 text-destructive">{t('errorTitle') || 'Submission Failed'}</h3> <h3 className="text-base font-extrabold mb-1 tracking-tight !mt-0 text-destructive text-center w-full">
<p className="text-destructive/80 text-xs leading-tight mb-4 !mt-0"> {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.'} {t('errorDesc') || 'Something went wrong. Please try again.'}
</p> </p>
<button <Button
onClick={() => setStatus('idle')} 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'}
{t('tryAgain') || 'Try Again'} </Button>
</span>
</button>
</div> </div>
); );
} }
@@ -133,22 +157,48 @@ export default function RequestQuoteForm({ productName }: RequestQuoteFormProps)
> >
{status === 'submitting' ? ( {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"> <svg
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4"></circle> className="animate-spin h-3 w-3 text-white"
<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> 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> </svg>
<span className="text-xs">{t('submitting')}</span> <span className="text-xs">{t('submitting')}</span>
</> </>
) : ( ) : (
<> <>
<span className="text-xs">{t('submit')}</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"> <svg
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M17 8l4 4m0 0l-4 4m4-4H3" /> 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> </svg>
</> </>
)} )}
</Button> </Button>
<p className="text-[7px] text-center text-text-secondary/40 uppercase tracking-[0.15em] font-medium px-2 !mt-1 !mb-0"> <p className="text-[7px] text-center text-text-secondary/40 uppercase tracking-[0.15em] font-medium px-2 !mt-1 !mb-0">
{t('privacyNote')} {t('privacyNote')}
</p> </p>

View File

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

View File

@@ -3,24 +3,43 @@ import Link from 'next/link';
import { cn } from './utils'; import { cn } from './utils';
export interface ButtonProps extends React.ButtonHTMLAttributes<HTMLButtonElement> { 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'; size?: 'sm' | 'md' | 'lg' | 'xl';
href?: string; href?: string;
className?: string; className?: string;
children?: React.ReactNode; children?: React.ReactNode;
} }
export function Button({ className, variant = 'primary', size = 'md', href, ...props }: ButtonProps) { export function Button({
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'; 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 = { const variants = {
primary: 'bg-primary text-white shadow-md hover:shadow-primary/30 hover:shadow-2xl', 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', 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', 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', 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', 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 = { const sizes = {
@@ -40,20 +59,25 @@ export function Button({ className, variant = 'primary', size = 'md', href, ...p
outline: 'bg-primary', outline: 'bg-primary',
ghost: 'bg-primary-light/10', ghost: 'bg-primary-light/10',
white: 'bg-primary-light', white: 'bg-primary-light',
destructive: 'bg-destructive/90',
}; };
const content = ( const content = (
<> <>
<span className={cn( <span
"relative z-10 flex items-center justify-center gap-2 transition-colors duration-500", className={cn(
variant === 'white' ? "group-hover/btn:text-primary-dark" : "" 'relative z-10 flex items-center justify-center gap-2 transition-colors duration-500',
)}> variant === 'white' ? 'group-hover/btn:text-primary-dark' : '',
)}
>
{props.children} {props.children}
</span> </span>
<span className={cn( <span
"absolute inset-0 translate-y-[101%] group-hover/btn:translate-y-0 transition-transform duration-500 ease-out", className={cn(
overlayColors[variant] '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", "55",
"4195" "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", "60",
"4634" "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", "65",
"5093" "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", "55",
"4195" "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", "60",
"4634" "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", "65",
"5093" "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 NODE_ENV: development
# Docker Internal Communication # Docker Internal Communication
DIRECTUS_URL: http://directus:8055 DIRECTUS_URL: http://directus:8055
ports:
- "3000:3000"
labels: labels:
- "traefik.enable=true" - "traefik.enable=true"
- "traefik.http.routers.klz-app-local.rule=Host(`klz.localhost`)" # Clear all production-related TLS/Middleware settings for the main routers
- "traefik.http.routers.klz-app-local.entrypoints=web" - "traefik.http.routers.klz-cables.entrypoints=web"
- "traefik.http.routers.klz-app-local.service=klz-cables" - "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: directus:
labels: labels:
- "traefik.enable=true" - "traefik.enable=true"
- "traefik.http.routers.klz-directus-local.rule=Host(`cms.klz.localhost`)" - "traefik.http.routers.klz-cables-directus.entrypoints=web"
- "traefik.http.routers.klz-directus-local.entrypoints=web" - "traefik.http.routers.klz-cables-directus.rule=Host(`cms.klz.localhost`)"
- "traefik.http.routers.klz-directus-local.service=klz-directus" - "traefik.http.routers.klz-cables-directus.tls=false"
- "traefik.http.routers.klz-cables-directus.middlewares="
ports: ports:
- "8055:8055" - "8055:8055"
environment: environment:

View File

@@ -1,61 +1,87 @@
services: services:
app: klz-app:
image: registry.infra.mintel.me/mintel/klz-cables.com:${IMAGE_TAG:-latest} image: registry.infra.mintel.me/mintel/klz-cables.com:${IMAGE_TAG:-latest}
restart: always restart: always
networks: networks:
- infra - default
env_file: env_file:
- ${ENV_FILE:-.env} - ${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: labels:
- "traefik.enable=true" - "traefik.enable=true"
# HTTP ⇒ HTTPS redirect # 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.entrypoints=web"
- "traefik.http.routers.${PROJECT_NAME:-klz-cables}-web.middlewares=redirect-https" - "traefik.http.routers.${PROJECT_NAME:-klz-cables}-web.middlewares=redirect-https"
# HTTPS router # 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}.entrypoints=websecure"
- "traefik.http.routers.${PROJECT_NAME:-klz-cables}.tls.certresolver=le" - "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}.tls=true"
- "traefik.http.routers.${PROJECT_NAME:-klz-cables}.service=${PROJECT_NAME:-klz-cables}" - "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" - "traefik.http.services.${PROJECT_NAME:-klz-cables}.loadbalancer.server.scheme=http"
# Forwarded Headers # 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-Proto=https"
- "traefik.http.middlewares.${PROJECT_NAME:-klz-cables}-forward.headers.customrequestheaders.X-Forwarded-Ssl=on" - "traefik.http.middlewares.${PROJECT_NAME:-klz-cables}-forward.headers.customrequestheaders.X-Forwarded-Ssl=on"
# Middlewares # 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) # 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.entrypoints=websecure"
- "traefik.http.routers.${PROJECT_NAME:-klz-cables}-gatekeeper.tls.certresolver=le" - "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.tls=true"
- "traefik.http.routers.${PROJECT_NAME:-klz-cables}-gatekeeper.service=${PROJECT_NAME:-klz-cables}-gatekeeper" - "traefik.http.routers.${PROJECT_NAME:-klz-cables}-gatekeeper.service=${PROJECT_NAME:-klz-cables}-gatekeeper"
# Middleware Definitions # 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.trustForwardHeader=true"
- "traefik.http.middlewares.${PROJECT_NAME:-klz-cables}-auth.forwardauth.authResponseHeaders=X-Auth-User" - "traefik.http.middlewares.${PROJECT_NAME:-klz-cables}-auth.forwardauth.authResponseHeaders=X-Auth-User"
gatekeeper: 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 container_name: ${PROJECT_NAME:-klz-cables}-gatekeeper
restart: always restart: always
networks: networks:
- default
- infra - infra
env_file: env_file:
- ${ENV_FILE:-.env} - ${ENV_FILE:-.env}
environment: environment:
PORT: 3000 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: labels:
- "traefik.enable=true" - "traefik.enable=true"
- "traefik.http.services.${PROJECT_NAME:-klz-cables}-gatekeeper.loadbalancer.server.port=3000" - "traefik.http.services.${PROJECT_NAME:-klz-cables}-gatekeeper.loadbalancer.server.port=3000"
- "traefik.docker.network=infra"
directus: directus:
image: directus/directus:11 image: directus/directus:11
restart: always restart: always
networks: networks:
- default
- infra - infra
env_file: env_file:
- ${ENV_FILE:-.env} - ${ENV_FILE:-.env}
@@ -75,23 +101,25 @@ services:
# Error Tracking # Error Tracking
SENTRY_DSN: ${SENTRY_DSN} SENTRY_DSN: ${SENTRY_DSN}
SENTRY_ENVIRONMENT: ${TARGET:-development} SENTRY_ENVIRONMENT: ${TARGET:-development}
LOGGER_LEVEL: ${LOG_LEVEL:-info}
volumes: volumes:
- ./directus/uploads:/directus/uploads - ./directus/uploads:/directus/uploads
- ./directus/extensions:/directus/extensions - ./directus/extensions:/directus/extensions
labels: labels:
- "traefik.enable=true" - "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.entrypoints=websecure"
- "traefik.http.routers.${PROJECT_NAME:-klz-cables}-directus.tls.certresolver=le" - "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.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.http.services.${PROJECT_NAME:-klz-cables}-directus.loadbalancer.server.port=8055"
- "traefik.docker.network=infra"
directus-db: directus-db:
image: postgres:15-alpine image: postgres:15-alpine
restart: always restart: always
networks: networks:
- infra - default
env_file: env_file:
- ${ENV_FILE:-.env} - ${ENV_FILE:-.env}
environment: environment:
@@ -102,6 +130,8 @@ services:
- directus-db-data:/var/lib/postgresql/data - directus-db-data:/var/lib/postgresql/data
networks: networks:
default:
name: ${PROJECT_NAME:-klz-cables}-internal
infra: infra:
external: true external: true

View File

@@ -42,15 +42,15 @@ The application uses a clean, robust, **fully automated** environment variable s
## Environment Variables ## 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: These are embedded into the JavaScript bundle during build and are visible to the client:
| Variable | Required | Description | | Variable | Required | Description |
|----------|----------|-------------| | ---------------------- | -------- | ------------------------------------------------------------ |
| `NEXT_PUBLIC_BASE_URL` | ✅ Yes | Base URL of the application (e.g., `https://klz-cables.com`) | | `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 | | `UMAMI_WEBSITE_ID` | ❌ No | Umami analytics website ID (passed as prop) |
| `NEXT_PUBLIC_UMAMI_SCRIPT_URL` | ❌ No | Umami analytics script URL (default: `https://analytics.infra.mintel.me/script.js`) | | `UMAMI_API_ENDPOINT` | ❌ No | Backend-only Umami analytics API target (internal) |
**Important**: These must be provided as `--build-arg` when building the Docker image. **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: These are loaded from the `.env` file at runtime and are only available on the server:
| Variable | Required | Description | | Variable | Required | Description |
|----------|----------|-------------| | -------------------------- | -------- | ------------------------------------------------------ |
| `NODE_ENV` | ✅ Yes | Environment mode (`production`, `development`, `test`) | | `NODE_ENV` | ✅ Yes | Environment mode (`production`, `development`, `test`) |
| `SENTRY_DSN` | ❌ No | GlitchTip/Sentry error tracking DSN | | `SENTRY_DSN` | ❌ No | GlitchTip/Sentry error tracking DSN |
| `MAIL_HOST` | ❌ No | SMTP server hostname | | `MAIL_HOST` | ❌ No | SMTP server hostname |
| `MAIL_PORT` | ❌ No | SMTP server port (default: `587`) | | `MAIL_PORT` | ❌ No | SMTP server port (default: `587`) |
| `MAIL_USERNAME` | ❌ No | SMTP authentication username | | `MAIL_USERNAME` | ❌ No | SMTP authentication username |
| `MAIL_PASSWORD` | ❌ No | SMTP authentication password | | `MAIL_PASSWORD` | ❌ No | SMTP authentication password |
| `MAIL_FROM` | ❌ No | Email sender address | | `MAIL_FROM` | ❌ No | Email sender address |
| `MAIL_RECIPIENTS` | ❌ No | Comma-separated list of recipient emails | | `MAIL_RECIPIENTS` | ❌ No | Comma-separated list of recipient emails |
| `REDIS_URL` | ❌ No | Redis connection URL (e.g., `redis://redis:6379/2`) | | `REDIS_URL` | ❌ No | Redis connection URL (e.g., `redis://redis:6379/2`) |
| `REDIS_KEY_PREFIX` | ❌ No | Redis key prefix (default: `klz:`) | | `REDIS_KEY_PREFIX` | ❌ No | Redis key prefix (default: `klz:`) |
| `STRAPI_DATABASE_NAME` | ✅ Yes | Strapi database name | | `STRAPI_DATABASE_NAME` | ✅ Yes | Strapi database name |
| `STRAPI_DATABASE_USERNAME` | ✅ Yes | Strapi database username | | `STRAPI_DATABASE_USERNAME` | ✅ Yes | Strapi database username |
| `STRAPI_DATABASE_PASSWORD` | ✅ Yes | Strapi database password | | `STRAPI_DATABASE_PASSWORD` | ✅ Yes | Strapi database password |
| `STRAPI_URL` | ✅ Yes | URL of the Strapi CMS | | `STRAPI_URL` | ✅ Yes | URL of the Strapi CMS |
| `APP_KEYS` | ✅ Yes | Strapi application keys (comma-separated) | | `APP_KEYS` | ✅ Yes | Strapi application keys (comma-separated) |
| `API_TOKEN_SALT` | ✅ Yes | Strapi API token salt | | `API_TOKEN_SALT` | ✅ Yes | Strapi API token salt |
| `ADMIN_JWT_SECRET` | ✅ Yes | Strapi admin JWT secret | | `ADMIN_JWT_SECRET` | ✅ Yes | Strapi admin JWT secret |
| `TRANSFER_TOKEN_SALT` | ✅ Yes | Strapi transfer token salt | | `TRANSFER_TOKEN_SALT` | ✅ Yes | Strapi transfer token salt |
| `JWT_SECRET` | ✅ Yes | Strapi JWT secret | | `JWT_SECRET` | ✅ Yes | Strapi JWT secret |
## Local Development ## Local Development
### Setup ### Setup
1. Copy the example environment file: 1. Copy the example environment file:
```bash ```bash
cp .env.example .env cp .env.example .env
``` ```
2. Edit `.env` and fill in your local configuration: 2. Edit `.env` and fill in your local configuration:
```bash ```bash
NODE_ENV=development NODE_ENV=development
NEXT_PUBLIC_BASE_URL=http://localhost:3000 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: 3. Install dependencies:
```bash ```bash
npm install 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 # Build with build-time arguments
docker build \ docker build \
--build-arg NEXT_PUBLIC_BASE_URL=http://localhost:3000 \ --build-arg NEXT_PUBLIC_BASE_URL=http://localhost:3000 \
--build-arg NEXT_PUBLIC_UMAMI_WEBSITE_ID=your-id \ --build-arg UMAMI_WEBSITE_ID=your-id \
--build-arg NEXT_PUBLIC_UMAMI_SCRIPT_URL=https://analytics.infra.mintel.me/script.js \ --build-arg UMAMI_API_ENDPOINT=https://analytics.infra.mintel.me \
-t klz-cables:local . -t klz-cables:local .
# Run with runtime environment file # Run with runtime environment file
@@ -138,8 +141,8 @@ docker run --env-file .env -p 3000:3000 klz-cables:local
**Build-Time Variables:** **Build-Time Variables:**
- `NEXT_PUBLIC_BASE_URL` - Production URL (e.g., `https://klz-cables.com`) - `NEXT_PUBLIC_BASE_URL` - Production URL (e.g., `https://klz-cables.com`)
- `NEXT_PUBLIC_UMAMI_WEBSITE_ID` - Umami analytics ID - `UMAMI_WEBSITE_ID` - Umami analytics ID
- `NEXT_PUBLIC_UMAMI_SCRIPT_URL` - Umami script URL - `UMAMI_API_ENDPOINT` - Umami API endpoint
**Runtime Variables:** **Runtime Variables:**
- `SENTRY_DSN` - Error tracking DSN - `SENTRY_DSN` - Error tracking DSN
@@ -209,11 +212,12 @@ docker-compose logs -f app
**Problem**: Build fails with "Environment validation failed" **Problem**: Build fails with "Environment validation failed"
**Solution**: Ensure all required `NEXT_PUBLIC_*` variables are provided as build arguments: **Solution**: Ensure all required `NEXT_PUBLIC_*` variables are provided as build arguments:
```bash ```bash
docker build \ docker build \
--build-arg NEXT_PUBLIC_BASE_URL=https://klz-cables.com \ --build-arg NEXT_PUBLIC_BASE_URL=https://klz-cables.com \
--build-arg NEXT_PUBLIC_UMAMI_WEBSITE_ID=your-id \ --build-arg UMAMI_WEBSITE_ID=your-id \
--build-arg NEXT_PUBLIC_UMAMI_SCRIPT_URL=https://analytics.infra.mintel.me/script.js \ --build-arg UMAMI_API_ENDPOINT=https://analytics.infra.mintel.me \
-t klz-cables . -t klz-cables .
``` ```
@@ -222,6 +226,7 @@ docker build \
**Problem**: Container starts but application crashes **Problem**: Container starts but application crashes
**Solution**: Check that the `.env` file exists and contains all required runtime variables: **Solution**: Check that the `.env` file exists and contains all required runtime variables:
```bash ```bash
# On the server # On the server
cat /home/deploy/sites/klz-cables.com/.env cat /home/deploy/sites/klz-cables.com/.env
@@ -235,9 +240,11 @@ docker-compose logs app
**Problem**: Features not working (email, analytics, etc.) **Problem**: Features not working (email, analytics, etc.)
**Solution**: **Solution**:
1. Check that the secret is configured in Gitea 1. Check that the secret is configured in Gitea
2. Verify the workflow includes it in the `.env` generation (see `.gitea/workflows/deploy.yml`) 2. Verify the workflow includes it in the `.env` generation (see `.gitea/workflows/deploy.yml`)
3. Redeploy to regenerate the `.env` file: 3. Redeploy to regenerate the `.env` file:
```bash ```bash
git commit --allow-empty -m "Trigger redeploy" git commit --allow-empty -m "Trigger redeploy"
git push origin main git push origin main
@@ -255,6 +262,7 @@ docker-compose logs app
**Problem**: `docker-compose up` fails with "env file not found" **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: **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 1. Check the workflow logs for errors in the "📝 Preparing environment configuration" step
2. Manually trigger a deployment by pushing to main 2. Manually trigger a deployment by pushing to main
3. If still missing, check server permissions and disk space 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 **Problem**: Container can't connect to Traefik
**Solution**: Verify the `infra` network exists: **Solution**: Verify the `infra` network exists:
```bash ```bash
docker network ls | grep infra docker network ls | grep infra
docker network inspect 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) ### Before (Fragile & Overkill)
**Problems:** **Problems:**
- Environment variables passed individually via SSH (12+ vars) - Environment variables passed individually via SSH (12+ vars)
- Duplicate definitions in Dockerfile, docker-compose.yml, and deploy.yml - 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 - No single source of truth
- Difficult to maintain and error-prone - Difficult to maintain and error-prone
```yaml ```yaml
# Old deploy.yml - FRAGILE! # Old deploy.yml - FRAGILE!
ssh root@alpha.mintel.me \ ssh root@alpha.mintel.me \
"MAIL_FROM='${{ secrets.MAIL_FROM }}' \ "MAIL_FROM='${{ secrets.MAIL_FROM }}' \
MAIL_HOST='${{ secrets.MAIL_HOST }}' \ MAIL_HOST='${{ secrets.MAIL_HOST }}' \
MAIL_PASSWORD='${{ secrets.MAIL_PASSWORD }}' \ MAIL_PASSWORD='${{ secrets.MAIL_PASSWORD }}' \
MAIL_PORT='${{ secrets.MAIL_PORT }}' \ MAIL_PORT='${{ secrets.MAIL_PORT }}' \
MAIL_RECIPIENTS='${{ secrets.MAIL_RECIPIENTS }}' \ MAIL_RECIPIENTS='${{ secrets.MAIL_RECIPIENTS }}' \
MAIL_USERNAME='${{ secrets.MAIL_USERNAME }}' \ MAIL_USERNAME='${{ secrets.MAIL_USERNAME }}' \
NEXT_PUBLIC_BASE_URL='${{ secrets.NEXT_PUBLIC_BASE_URL }}' \ NEXT_PUBLIC_BASE_URL='${{ secrets.NEXT_PUBLIC_BASE_URL }}' \
... (12+ variables) \ ... (12+ variables) \
/home/deploy/deploy.sh" /home/deploy/deploy.sh"
``` ```
### After (Clean & Robust) ### After (Clean & Robust)
**Benefits:** **Benefits:**
- Single `.env` file on server contains all runtime variables - Single `.env` file on server contains all runtime variables
- Only `NEXT_PUBLIC_*` variables passed as build args (3 vars) - Only `NEXT_PUBLIC_*` variables passed as build args (3 vars)
- Clear separation: build-time vs runtime - Clear separation: build-time vs runtime
@@ -46,6 +48,7 @@ ssh root@alpha.mintel.me "/home/deploy/deploy.sh"
### Step 1: Update Gitea Secrets ### Step 1: Update Gitea Secrets
**Remove these secrets** (no longer needed in CI/CD): **Remove these secrets** (no longer needed in CI/CD):
-`MAIL_FROM` -`MAIL_FROM`
-`MAIL_HOST` -`MAIL_HOST`
-`MAIL_PASSWORD` -`MAIL_PASSWORD`
@@ -58,9 +61,11 @@ ssh root@alpha.mintel.me "/home/deploy/deploy.sh"
-`SENTRY_DSN` (from build args) -`SENTRY_DSN` (from build args)
**Keep these secrets** (still needed for build): **Keep these secrets** (still needed for build):
-`NEXT_PUBLIC_BASE_URL` -`NEXT_PUBLIC_BASE_URL`
-`NEXT_PUBLIC_UMAMI_WEBSITE_ID` -`NEXT_PUBLIC_BASE_URL`
-`NEXT_PUBLIC_UMAMI_SCRIPT_URL` -`UMAMI_WEBSITE_ID`
-`UMAMI_API_ENDPOINT`
-`REGISTRY_USER` -`REGISTRY_USER`
-`REGISTRY_PASS` -`REGISTRY_PASS`
-`ALPHA_SSH_KEY` -`ALPHA_SSH_KEY`
@@ -81,8 +86,8 @@ NODE_ENV=production
NEXT_PUBLIC_BASE_URL=https://klz-cables.com NEXT_PUBLIC_BASE_URL=https://klz-cables.com
# Analytics # Analytics
NEXT_PUBLIC_UMAMI_WEBSITE_ID=your-actual-id UMAMI_WEBSITE_ID=your-actual-id
NEXT_PUBLIC_UMAMI_SCRIPT_URL=https://analytics.infra.mintel.me/script.js UMAMI_API_ENDPOINT=https://analytics.infra.mintel.me
# Error Tracking # Error Tracking
SENTRY_DSN=your-actual-dsn SENTRY_DSN=your-actual-dsn
@@ -168,6 +173,7 @@ git push origin main
``` ```
The CI/CD workflow will: The CI/CD workflow will:
1. Build with only `NEXT_PUBLIC_*` build args 1. Build with only `NEXT_PUBLIC_*` build args
2. Push to registry 2. Push to registry
3. SSH to server and run deploy.sh 3. SSH to server and run deploy.sh
@@ -197,21 +203,22 @@ curl -I https://klz-cables.com
## Comparison Table ## Comparison Table
| Aspect | Before | After | | Aspect | Before | After |
|--------|--------|-------| | ----------------- | ------------------------------- | ---------------------------- |
| **Gitea Secrets** | 15+ secrets | 8 secrets | | **Gitea Secrets** | 15+ secrets | 8 secrets |
| **Build Args** | 4 vars (including runtime-only) | 3 vars (NEXT_PUBLIC_* only) | | **Build Args** | 4 vars (including runtime-only) | 3 vars (NEXT*PUBLIC*\* only) |
| **Runtime Vars** | Passed via SSH command | Loaded from .env file | | **Runtime Vars** | Passed via SSH command | Loaded from .env file |
| **Maintenance** | Update in 3 places | Update in 1 place | | **Maintenance** | Update in 3 places | Update in 1 place |
| **Security** | Secrets in CI logs | Secrets only on server | | **Security** | Secrets in CI logs | Secrets only on server |
| **Clarity** | Confusing duplication | Clear separation | | **Clarity** | Confusing duplication | Clear separation |
| **Robustness** | Fragile SSH command | Robust file-based config | | **Robustness** | Fragile SSH command | Robust file-based config |
## Rollback Plan ## Rollback Plan
If you need to rollback to the old system: If you need to rollback to the old system:
1. Revert the changes in git: 1. Revert the changes in git:
```bash ```bash
git revert HEAD git revert HEAD
git push origin main 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?** **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 ```bash
nano /home/deploy/sites/klz-cables.com/.env nano /home/deploy/sites/klz-cables.com/.env
docker-compose down && docker-compose up -d 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?** **Q: Where should I store the .env file backup?**
A: Keep a secure backup outside the server: A: Keep a secure backup outside the server:
```bash ```bash
# Download from server # Download from server
scp root@alpha.mintel.me:/home/deploy/sites/klz-cables.com/.env \ 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?** **Q: What if I accidentally commit .env to git?**
A: A:
1. Remove it immediately: `git rm .env && git commit -m "Remove .env"` 1. Remove it immediately: `git rm .env && git commit -m "Remove .env"`
2. Rotate all credentials in the file 2. Rotate all credentials in the file
3. Update the `.gitignore` to ensure it doesn't happen again (already done) 3. Update the `.gitignore` to ensure it doesn't happen again (already done)
@@ -267,6 +277,7 @@ If you encounter issues during migration:
## Summary ## Summary
The new system is: The new system is:
-**Simpler**: One .env file instead of scattered variables -**Simpler**: One .env file instead of scattered variables
-**Cleaner**: Clear separation of build vs runtime -**Cleaner**: Clear separation of build vs runtime
-**Robust**: File-based config instead of fragile SSH commands -**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[]> { export async function getAllPosts(locale: string): Promise<PostMdx[]> {
const postsDir = path.join(process.cwd(), 'data', 'blog', locale); const postsDir = path.join(process.cwd(), 'data', 'blog', locale);
if (!fs.existsSync(postsDir)) return []; if (!fs.existsSync(postsDir)) return [];
const files = fs.readdirSync(postsDir); const files = fs.readdirSync(postsDir);
const posts = files const posts = files
.filter(file => file.endsWith('.mdx')) .filter((file) => file.endsWith('.mdx'))
.map(file => { .map((file) => {
const filePath = path.join(postsDir, file); const filePath = path.join(postsDir, file);
const fileContent = fs.readFileSync(filePath, 'utf8'); const fileContent = fs.readFileSync(filePath, 'utf8');
const { data, content } = matter(fileContent); const { data, content } = matter(fileContent);
@@ -55,14 +55,42 @@ export async function getAllPosts(locale: string): Promise<PostMdx[]> {
content, 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; 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 posts = await getAllPosts(locale);
const currentIndex = posts.findIndex(post => post.slug === slug); const currentIndex = posts.findIndex((post) => post.slug === slug);
if (currentIndex === -1) { if (currentIndex === -1) {
return { prev: null, next: null }; return { prev: null, next: null };

View File

@@ -13,20 +13,22 @@ let memoizedConfig: ReturnType<typeof createConfig> | undefined;
function createConfig() { function createConfig() {
const env = envSchema.parse(getRawEnv()); const env = envSchema.parse(getRawEnv());
const target = env.NEXT_PUBLIC_TARGET || env.TARGET;
return { return {
env: env.NODE_ENV, env: env.NODE_ENV,
isProduction: env.NODE_ENV === 'production', target,
isDevelopment: env.NODE_ENV === 'development', isProduction: target === 'production' || !target,
isTest: env.NODE_ENV === 'test', isStaging: target === 'staging',
isTesting: target === 'testing',
isDevelopment: target === 'development',
baseUrl: env.NEXT_PUBLIC_BASE_URL, baseUrl: env.NEXT_PUBLIC_BASE_URL,
analytics: { analytics: {
umami: { umami: {
websiteId: env.NEXT_PUBLIC_UMAMI_WEBSITE_ID, websiteId: env.NEXT_PUBLIC_UMAMI_WEBSITE_ID,
scriptUrl: env.NEXT_PUBLIC_UMAMI_SCRIPT_URL, apiEndpoint: env.UMAMI_API_ENDPOINT,
// The proxied path used in the frontend
proxyPath: '/stats/script.js',
enabled: Boolean(env.NEXT_PUBLIC_UMAMI_WEBSITE_ID), enabled: Boolean(env.NEXT_PUBLIC_UMAMI_WEBSITE_ID),
}, },
}, },
@@ -65,6 +67,13 @@ function createConfig() {
internalUrl: env.INTERNAL_DIRECTUS_URL, internalUrl: env.INTERNAL_DIRECTUS_URL,
proxyPath: '/cms', proxyPath: '/cms',
}, },
notifications: {
gotify: {
url: env.GOTIFY_URL,
token: env.GOTIFY_TOKEN,
enabled: Boolean(env.GOTIFY_URL && env.GOTIFY_TOKEN),
},
},
} as const; } as const;
} }
@@ -87,15 +96,21 @@ export const config = {
get env() { get env() {
return getConfig().env; return getConfig().env;
}, },
get target() {
return getConfig().target;
},
get isProduction() { get isProduction() {
return getConfig().isProduction; return getConfig().isProduction;
}, },
get isStaging() {
return getConfig().isStaging;
},
get isTesting() {
return getConfig().isTesting;
},
get isDevelopment() { get isDevelopment() {
return getConfig().isDevelopment; return getConfig().isDevelopment;
}, },
get isTest() {
return getConfig().isTest;
},
get baseUrl() { get baseUrl() {
return getConfig().baseUrl; return getConfig().baseUrl;
}, },
@@ -117,6 +132,9 @@ export const config = {
get directus() { get directus() {
return getConfig().directus; return getConfig().directus;
}, },
get notifications() {
return getConfig().notifications;
},
}; };
/** /**
@@ -132,7 +150,7 @@ export function getMaskedConfig() {
analytics: { analytics: {
umami: { umami: {
websiteId: mask(c.analytics.umami.websiteId), websiteId: mask(c.analytics.umami.websiteId),
scriptUrl: c.analytics.umami.scriptUrl, apiEndpoint: c.analytics.umami.apiEndpoint,
enabled: c.analytics.umami.enabled, enabled: c.analytics.umami.enabled,
}, },
}, },
@@ -161,5 +179,12 @@ export function getMaskedConfig() {
password: mask(c.directus.password), password: mask(c.directus.password),
token: mask(c.directus.token), 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 { createDirectus, rest, authentication, readItems, readCollections } from '@directus/sdk';
import { config } from './config'; import { config } from './config';
import { getServerAppServices } from './services/create-services.server';
const { url, adminEmail, password, token, proxyPath, internalUrl } = config.directus; const { url, adminEmail, password, token, proxyPath, internalUrl } = config.directus;
// Use internal URL if on server to bypass Gatekeeper/Auth // 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()); 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() { export async function ensureAuthenticated() {
if (token) { if (token) {
client.setToken(token); client.setToken(token);
@@ -17,6 +39,9 @@ export async function ensureAuthenticated() {
try { try {
await client.login(adminEmail, password); await client.login(adminEmail, password);
} catch (e) { } catch (e) {
if (typeof window === 'undefined') {
getServerAppServices().errors.captureException(e, { part: 'directus_auth' });
}
console.error('Failed to authenticate with Directus:', e); 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)); return items.map((item) => mapDirectusProduct(item, locale));
} catch (error) { } catch (error) {
if (typeof window === 'undefined') {
getServerAppServices().errors.captureException(error, { part: 'directus_get_products' });
}
console.error('Error fetching products:', error); console.error('Error fetching products:', error);
return []; return [];
} }
@@ -86,6 +114,12 @@ export async function getProductBySlug(slug: string, locale: string = 'de') {
if (!items || items.length === 0) return null; if (!items || items.length === 0) return null;
return mapDirectusProduct(items[0], locale); return mapDirectusProduct(items[0], locale);
} catch (error) { } catch (error) {
if (typeof window === 'undefined') {
getServerAppServices().errors.captureException(error, {
part: 'directus_get_product_by_slug',
slug,
});
}
console.error(`Error fetching product ${slug}:`, error); console.error(`Error fetching product ${slug}:`, error);
return null; return null;
} }
@@ -98,20 +132,27 @@ export async function checkHealth() {
await ensureAuthenticated(); await ensureAuthenticated();
await client.request(readCollections()); await client.request(readCollections());
} catch (e: any) { } catch (e: any) {
if (typeof window === 'undefined') {
getServerAppServices().errors.captureException(e, { part: 'directus_health_auth' });
}
console.error('Directus authentication failed during health check:', e); console.error('Directus authentication failed during health check:', e);
return { return {
status: 'error', status: 'error',
message: message: shouldShowDevErrors
'Authentication failed. Check your DIRECTUS_ADMIN_EMAIL and DIRECTUS_ADMIN_PASSWORD.', ? 'Authentication failed. Check your DIRECTUS_ADMIN_EMAIL and DIRECTUS_ADMIN_PASSWORD.'
: 'CMS is currently unavailable due to an internal authentication error.',
code: 'AUTH_FAILED', 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 { try {
await client.request(readItems('products', { limit: 1 })); await client.request(readItems('contact_submissions', { limit: 1 }));
} catch (e: any) { } catch (e: any) {
if (typeof window === 'undefined') {
getServerAppServices().errors.captureException(e, { part: 'directus_health_schema' });
}
if ( if (
e.message?.includes('does not exist') || e.message?.includes('does not exist') ||
e.code === 'INVALID_PAYLOAD' || e.code === 'INVALID_PAYLOAD' ||
@@ -119,23 +160,30 @@ export async function checkHealth() {
) { ) {
return { return {
status: 'error', 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', code: 'SCHEMA_MISSING',
}; };
} }
return { return {
status: 'error', 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', code: 'SCHEMA_ERROR',
}; };
} }
return { status: 'ok', message: 'Directus is reachable and responding.' }; return { status: 'ok', message: 'Directus is reachable and responding.' };
} catch (error: any) { } 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); console.error('Directus health check failed with unexpected error:', error);
return { return {
status: 'error', status: 'error',
message: error.message || 'An unexpected error occurred while connecting to the CMS.', message: formatError(error),
code: error.code || 'UNKNOWN', 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. * Environment variable schema.
*/ */
export const envSchema = z.object({ export const envSchema = z
NODE_ENV: z.enum(['development', 'production', 'test']).default('development'), .object({
NEXT_PUBLIC_BASE_URL: z.preprocess(preprocessEmptyString, z.string().url()), 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 // Analytics
NEXT_PUBLIC_UMAMI_WEBSITE_ID: z.preprocess(preprocessEmptyString, z.string().optional()), NEXT_PUBLIC_UMAMI_WEBSITE_ID: z.preprocess(preprocessEmptyString, z.string().optional()),
NEXT_PUBLIC_UMAMI_SCRIPT_URL: z.preprocess( UMAMI_API_ENDPOINT: z.preprocess(
preprocessEmptyString, preprocessEmptyString,
z.string().url().default('https://analytics.infra.mintel.me/script.js'), z.string().url().default('https://analytics.infra.mintel.me'),
), ),
// Error Tracking // Error Tracking
SENTRY_DSN: z.preprocess(preprocessEmptyString, z.string().optional()), SENTRY_DSN: z.preprocess(preprocessEmptyString, z.string().optional()),
// Logging // Logging
LOG_LEVEL: z.enum(['debug', 'info', 'warn', 'error']).default('info'), LOG_LEVEL: z.enum(['debug', 'info', 'warn', 'error']).default('info'),
// Mail // Mail
MAIL_HOST: z.preprocess(preprocessEmptyString, z.string().optional()), MAIL_HOST: z.preprocess(preprocessEmptyString, z.string().optional()),
MAIL_PORT: z.preprocess(preprocessEmptyString, z.coerce.number().default(587)), MAIL_PORT: z.preprocess(preprocessEmptyString, z.coerce.number().default(587)),
MAIL_USERNAME: z.preprocess(preprocessEmptyString, z.string().optional()), MAIL_USERNAME: z.preprocess(preprocessEmptyString, z.string().optional()),
MAIL_PASSWORD: z.preprocess(preprocessEmptyString, z.string().optional()), MAIL_PASSWORD: z.preprocess(preprocessEmptyString, z.string().optional()),
MAIL_FROM: z.preprocess(preprocessEmptyString, z.string().optional()), MAIL_FROM: z.preprocess(preprocessEmptyString, z.string().optional()),
MAIL_RECIPIENTS: z.preprocess( MAIL_RECIPIENTS: z.preprocess(
(val) => (typeof val === 'string' ? val.split(',').filter(Boolean) : val), (val) => (typeof val === 'string' ? val.split(',').filter(Boolean) : val),
z.array(z.string()).default([]), z.array(z.string()).default([]),
), ),
// Directus // Directus
DIRECTUS_URL: z.preprocess( DIRECTUS_URL: z.preprocess(
preprocessEmptyString, preprocessEmptyString,
z.string().url().default('http://localhost:8055'), z.string().url().default('http://localhost:8055'),
), ),
DIRECTUS_ADMIN_EMAIL: z.preprocess(preprocessEmptyString, z.string().optional()), DIRECTUS_ADMIN_EMAIL: z.preprocess(preprocessEmptyString, z.string().optional()),
DIRECTUS_ADMIN_PASSWORD: z.preprocess(preprocessEmptyString, z.string().optional()), DIRECTUS_ADMIN_PASSWORD: z.preprocess(preprocessEmptyString, z.string().optional()),
DIRECTUS_API_TOKEN: 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()), 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>; export type Env = z.infer<typeof envSchema>;
@@ -57,8 +81,13 @@ export function getRawEnv() {
return { return {
NODE_ENV: process.env.NODE_ENV, NODE_ENV: process.env.NODE_ENV,
NEXT_PUBLIC_BASE_URL: process.env.NEXT_PUBLIC_BASE_URL, NEXT_PUBLIC_BASE_URL: process.env.NEXT_PUBLIC_BASE_URL,
NEXT_PUBLIC_UMAMI_WEBSITE_ID: process.env.NEXT_PUBLIC_UMAMI_WEBSITE_ID, NEXT_PUBLIC_TARGET: process.env.NEXT_PUBLIC_TARGET,
NEXT_PUBLIC_UMAMI_SCRIPT_URL: process.env.NEXT_PUBLIC_UMAMI_SCRIPT_URL, 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, SENTRY_DSN: process.env.SENTRY_DSN,
LOG_LEVEL: process.env.LOG_LEVEL, LOG_LEVEL: process.env.LOG_LEVEL,
MAIL_HOST: process.env.MAIL_HOST, MAIL_HOST: process.env.MAIL_HOST,
@@ -72,5 +101,8 @@ export function getRawEnv() {
DIRECTUS_ADMIN_PASSWORD: process.env.DIRECTUS_ADMIN_PASSWORD, DIRECTUS_ADMIN_PASSWORD: process.env.DIRECTUS_ADMIN_PASSWORD,
DIRECTUS_API_TOKEN: process.env.DIRECTUS_API_TOKEN, DIRECTUS_API_TOKEN: process.env.DIRECTUS_API_TOKEN,
INTERNAL_DIRECTUS_URL: process.env.INTERNAL_DIRECTUS_URL, 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 nodemailer from 'nodemailer';
import { render } from "@react-email/components"; import { getServerAppServices } from '@/lib/services/create-services.server';
import { ReactElement } from "react"; import { config } from '../config';
import { getServerAppServices } from "@/lib/services/create-services.server"; import { ReactElement } from 'react';
import { config } from "../config";
const transporter = nodemailer.createTransport({ let transporterInstance: nodemailer.Transporter | null = null;
host: config.mail.host,
port: config.mail.port, function getTransporter() {
secure: config.mail.port === 465, if (transporterInstance) return transporterInstance;
auth: {
user: config.mail.user, if (!config.mail.host) {
pass: config.mail.pass, 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 { interface SendEmailOptions {
to?: string | string[]; to?: string | string[];
replyTo?: string;
subject: string; subject: string;
template: ReactElement; html: string;
} }
export async function sendEmail({ to, subject, template }: SendEmailOptions) { export async function sendEmail({ to, replyTo, subject, html }: SendEmailOptions) {
const html = await render(template);
const recipients = to || config.mail.recipients; const recipients = to || config.mail.recipients;
const mailOptions = { const mailOptions = {
from: config.mail.from, from: config.mail.from,
to: recipients, to: recipients,
replyTo,
subject, subject,
html, html,
}; };
@@ -35,11 +46,12 @@ export async function sendEmail({ to, subject, template }: SendEmailOptions) {
const logger = getServerAppServices().logger.child({ component: 'mailer' }); const logger = getServerAppServices().logger.child({ component: 'mailer' });
try { try {
const info = await transporter.sendMail(mailOptions); const info = await getTransporter().sendMail(mailOptions);
logger.info("Email sent successfully", { messageId: info.messageId, subject, recipients }); logger.info('Email sent successfully', { messageId: info.messageId, subject, recipients });
return { success: true, messageId: info.messageId }; return { success: true, messageId: info.messageId };
} catch (error) { } catch (error) {
logger.error("Error sending email", { error, subject, recipients }); const errorMsg = error instanceof Error ? error.message : String(error);
return { success: false, 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; 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> { export async function getProductBySlug(slug: string, locale: string): Promise<ProductMdx | null> {
// Map translated slug to file slug // Map translated slug to file slug
const fileSlug = await mapSlugToFileSlug(slug, locale); const fileSlug = await mapSlugToFileSlug(slug, locale);
const productsDir = path.join(process.cwd(), 'data', 'products', locale); const productsDir = path.join(process.cwd(), 'data', 'products', locale);
// Try exact slug first // Try exact slug first
let filePath = path.join(productsDir, `${fileSlug}.mdx`); 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)) { if (!fs.existsSync(enFilePath)) {
enFilePath = path.join(enProductsDir, `${fileSlug}-2.mdx`); enFilePath = path.join(enProductsDir, `${fileSlug}-2.mdx`);
} }
if (fs.existsSync(enFilePath)) { if (fs.existsSync(enFilePath)) {
const fileContent = fs.readFileSync(enFilePath, 'utf8'); const fileContent = fs.readFileSync(enFilePath, 'utf8');
const { data, content } = matter(fileContent); const { data, content } = matter(fileContent);
@@ -49,7 +99,7 @@ export async function getProductBySlug(slug: string, locale: string): Promise<Pr
slug: fileSlug, slug: fileSlug,
frontmatter: { frontmatter: {
...data, ...data,
isFallback: true isFallback: true,
} as ProductFrontmatter & { isFallback?: boolean }, } as ProductFrontmatter & { isFallback?: boolean },
content, content,
}; };
@@ -67,7 +117,12 @@ export async function getProductBySlug(slug: string, locale: string): Promise<Pr
} }
// Filter out products without images // 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; return null;
} }
@@ -77,9 +132,9 @@ export async function getProductBySlug(slug: string, locale: string): Promise<Pr
export async function getAllProductSlugs(locale: string): Promise<string[]> { export async function getAllProductSlugs(locale: string): Promise<string[]> {
const productsDir = path.join(process.cwd(), 'data', 'products', locale); const productsDir = path.join(process.cwd(), 'data', 'products', locale);
if (!fs.existsSync(productsDir)) return []; if (!fs.existsSync(productsDir)) return [];
const files = fs.readdirSync(productsDir); 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[]> { 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])); 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); 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[]> { export async function getAllPages(locale: string): Promise<PageMdx[]> {
const pagesDir = path.join(process.cwd(), 'data', 'pages', locale); const pagesDir = path.join(process.cwd(), 'data', 'pages', locale);
if (!fs.existsSync(pagesDir)) return []; if (!fs.existsSync(pagesDir)) return [];
const files = fs.readdirSync(pagesDir); const files = fs.readdirSync(pagesDir);
const pages = await Promise.all( const pages = await Promise.all(
files files
.filter(file => file.endsWith('.mdx')) .filter((file) => file.endsWith('.mdx'))
.map(file => { .map((file) => {
const fileSlug = file.replace(/\.mdx$/, ''); const fileSlug = file.replace(/\.mdx$/, '');
const filePath = path.join(pagesDir, file); const filePath = path.join(pagesDir, file);
const fileContent = { content: fs.readFileSync(filePath, 'utf8') }; const fileContent = fs.readFileSync(filePath, 'utf8');
const { data, content } = matter(fileContent.content); const { data, content } = matter(fileContent);
return { return {
slug: fileSlug, slug: fileSlug,
frontmatter: data as PageFrontmatter, frontmatter: data as PageFrontmatter,
content, content,
}; };
}) }),
); );
return pages.filter((p): p is PageMdx => p !== null); 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'; import type { AnalyticsEventProperties, AnalyticsService } from './analytics-service';
import { config } from '../../config';
/**
* 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;
};
/** /**
* Configuration options for UmamiAnalyticsService. * 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. * This version implements the Umami tracking protocol directly via fetch,
* It provides type-safe event tracking and pageview tracking. * eliminating the need to load an external script.js file.
* *
* @example * In the browser, it gathers standard metadata (screen, language, referrer)
* ```typescript * and sends it to the proxied '/stats/api/send' endpoint.
* // 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,
* });
* ```
*/ */
export class UmamiAnalyticsService implements AnalyticsService { 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. * Internal method to send the payload to Umami API.
* */
* This method checks if analytics are enabled and if we're in a browser environment private async sendPayload(type: 'event', data: Record<string, any>) {
* before attempting to track the event. if (!this.options.enabled || !this.websiteId) return;
*
* @param eventName - The name of the event to track try {
* @param props - Optional event properties const payload = {
* website: this.websiteId,
* @example hostname: typeof window !== 'undefined' ? window.location.hostname : 'server',
* ```typescript screen:
* service.track('product_add_to_cart', { typeof window !== 'undefined'
* product_id: '123', ? `${window.screen.width}x${window.screen.height}`
* product_name: 'Cable', : undefined,
* price: 99.99, language: typeof window !== 'undefined' ? navigator.language : undefined,
* quantity: 1, 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) { track(eventName: string, props?: AnalyticsEventProperties) {
if (!this.options.enabled) return; this.sendPayload('event', {
name: eventName,
// Server-side tracking via proxy data: props,
if (typeof window === 'undefined') { url:
const { getServerAppServices } = require('../create-services.server'); typeof window !== 'undefined'
const { config } = require('../../config'); ? window.location.pathname + window.location.search
const websiteId = config.analytics.umami.websiteId; : undefined,
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);
} }
/** /**
* Track a pageview. * 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) { trackPageview(url?: string) {
if (!this.options.enabled) return; this.sendPayload('event', {
url:
// Server-side tracking via proxy url ||
if (typeof window === 'undefined') { (typeof window !== 'undefined'
const { getServerAppServices } = require('../create-services.server'); ? window.location.pathname + window.location.search
const { config } = require('../../config'); : undefined),
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);
} }
} }

View File

@@ -2,6 +2,7 @@ import type { AnalyticsService } from './analytics/analytics-service';
import type { CacheService } from './cache/cache-service'; import type { CacheService } from './cache/cache-service';
import type { ErrorReportingService } from './errors/error-reporting-service'; import type { ErrorReportingService } from './errors/error-reporting-service';
import type { LoggerService } from './logging/logger-service'; import type { LoggerService } from './logging/logger-service';
import type { NotificationService } from './notifications/notification-service';
// Simple constructor-based DI container. // Simple constructor-based DI container.
export class AppServices { export class AppServices {
@@ -9,6 +10,7 @@ export class AppServices {
public readonly analytics: AnalyticsService, public readonly analytics: AnalyticsService,
public readonly errors: ErrorReportingService, public readonly errors: ErrorReportingService,
public readonly cache: CacheService, 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 { MemoryCacheService } from './cache/memory-cache-service';
import { GlitchtipErrorReportingService } from './errors/glitchtip-error-reporting-service'; import { GlitchtipErrorReportingService } from './errors/glitchtip-error-reporting-service';
import { NoopErrorReportingService } from './errors/noop-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 { PinoLoggerService } from './logging/pino-logger-service';
import { config, getMaskedConfig } from '../config'; import { config, getMaskedConfig } from '../config';
@@ -13,7 +17,7 @@ export function getServerAppServices(): AppServices {
// Create logger first to log initialization // Create logger first to log initialization
const logger = new PinoLoggerService('server'); const logger = new PinoLoggerService('server');
logger.info('Initializing server application services', { logger.info('Initializing server application services', {
environment: getMaskedConfig(), environment: getMaskedConfig(),
timestamp: new Date().toISOString(), timestamp: new Date().toISOString(),
@@ -23,6 +27,7 @@ export function getServerAppServices(): AppServices {
umamiEnabled: config.analytics.umami.enabled, umamiEnabled: config.analytics.umami.enabled,
sentryEnabled: config.errors.glitchtip.enabled, sentryEnabled: config.errors.glitchtip.enabled,
mailEnabled: Boolean(config.mail.host && config.mail.user), mailEnabled: Boolean(config.mail.host && config.mail.user),
gotifyEnabled: config.notifications.gotify.enabled,
}); });
const analytics = config.analytics.umami.enabled const analytics = config.analytics.umami.enabled
@@ -35,12 +40,28 @@ export function getServerAppServices(): AppServices {
logger.info('Noop analytics service initialized (analytics disabled)'); 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 const errors = config.errors.glitchtip.enabled
? new GlitchtipErrorReportingService({ enabled: true }) ? new GlitchtipErrorReportingService({ enabled: true }, notifications)
: new NoopErrorReportingService(); : new NoopErrorReportingService();
if (config.errors.glitchtip.enabled) { 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 { } else {
logger.info('Noop error reporting service initialized (error reporting disabled)'); logger.info('Noop error reporting service initialized (error reporting disabled)');
} }
@@ -53,10 +74,9 @@ export function getServerAppServices(): AppServices {
level: config.logging.level, 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'); logger.info('All application services initialized successfully');
return singleton; return singleton;
} }

View File

@@ -5,6 +5,7 @@ import { GlitchtipErrorReportingService } from './errors/glitchtip-error-reporti
import { NoopErrorReportingService } from './errors/noop-error-reporting-service'; import { NoopErrorReportingService } from './errors/noop-error-reporting-service';
import { NoopLoggerService } from './logging/noop-logger-service'; import { NoopLoggerService } from './logging/noop-logger-service';
import { PinoLoggerService } from './logging/pino-logger-service'; import { PinoLoggerService } from './logging/pino-logger-service';
import { NoopNotificationService } from './notifications/gotify-notification-service';
import { config, getMaskedConfig } from '../config'; import { config, getMaskedConfig } from '../config';
/** /**
@@ -27,7 +28,7 @@ let singleton: AppServices | undefined;
* - Cache service (in-memory) * - Cache service (in-memory)
* *
* The services are configured based on environment variables: * 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 * - `NEXT_PUBLIC_SENTRY_DSN` - Enables client-side error reporting
* - `SENTRY_DSN` - Enables server-side error reporting * - `SENTRY_DSN` - Enables server-side error reporting
* *
@@ -71,9 +72,7 @@ export function getAppServices(): AppServices {
// Create logger first to log initialization // Create logger first to log initialization
const logger = const logger =
typeof window === 'undefined' typeof window === 'undefined' ? new PinoLoggerService('server') : new NoopLoggerService();
? new PinoLoggerService('server')
: new NoopLoggerService();
// Log initialization // Log initialization
if (typeof window === 'undefined') { if (typeof window === 'undefined') {
@@ -121,7 +120,9 @@ export function getAppServices(): AppServices {
: new NoopErrorReportingService(); : new NoopErrorReportingService();
if (sentryEnabled) { 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 { } else {
logger.info('Noop error reporting service initialized (error reporting disabled)'); logger.info('Noop error reporting service initialized (error reporting disabled)');
} }
@@ -138,9 +139,10 @@ export function getAppServices(): AppServices {
}); });
// Create and cache the singleton // 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'); logger.info('All application services initialized successfully');
return singleton; return singleton;
} }

View File

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

View File

@@ -4,6 +4,7 @@ import type {
ErrorReportingService, ErrorReportingService,
ErrorReportingUser, ErrorReportingUser,
} from './error-reporting-service'; } from './error-reporting-service';
import type { NotificationService } from '../notifications/notification-service';
type SentryLike = typeof Sentry; type SentryLike = typeof Sentry;
@@ -15,12 +16,29 @@ export type GlitchtipErrorReportingServiceOptions = {
export class GlitchtipErrorReportingService implements ErrorReportingService { export class GlitchtipErrorReportingService implements ErrorReportingService {
constructor( constructor(
private readonly options: GlitchtipErrorReportingServiceOptions, 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; 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') { 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 { export class NoopErrorReportingService implements ErrorReportingService {
captureException(_error: unknown, _context?: Record<string, unknown>) { async captureException(_error: unknown, _context?: Record<string, unknown>) {
return undefined; return undefined;
} }
captureMessage(_message: string, _level?: ErrorReportingLevel) { async captureMessage(_message: string, _level?: ErrorReportingLevel) {
return undefined; return undefined;
} }

View File

@@ -12,20 +12,19 @@ export class PinoLoggerService implements LoggerService {
// In Next.js, especially in the Edge runtime or during instrumentation, // In Next.js, especially in the Edge runtime or during instrumentation,
// pino transports (which use worker threads) can cause issues. // pino transports (which use worker threads) can cause issues.
// We disable transport in production and during instrumentation. // 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({ this.logger = pino({
name: name || 'app', name: name || 'app',
level: config.logging.level, level: config.logging.level,
transport: transport: useTransport
useTransport ? {
? { target: 'pino-pretty',
target: 'pino-pretty', options: {
options: { colorize: true,
colorize: true, },
}, }
} : undefined,
: 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 = { module.exports = {
ci: { ci: {
collect: { collect: {
numberOfRuns: 1, numberOfRuns: 1,
settings: { settings: {
preset: 'desktop', preset: 'desktop',
onlyCategories: ['performance', 'accessibility', 'best-practices', 'seo'], 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',
},
}, },
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", "emailPlaceholder": "ihre@email.de",
"message": "Nachricht", "message": "Nachricht",
"messagePlaceholder": "Wie können wir Ihnen helfen?", "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": { "Products": {

View File

@@ -191,7 +191,14 @@
"emailPlaceholder": "your@email.com", "emailPlaceholder": "your@email.com",
"message": "Message", "message": "Message",
"messagePlaceholder": "How can we help you?", "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": { "Products": {

View File

@@ -1,6 +1,5 @@
import createMiddleware from 'next-intl/middleware'; import createMiddleware from 'next-intl/middleware';
import { NextResponse } from 'next/server'; import { NextResponse, NextRequest } from 'next/server';
import type { NextRequest } from 'next/server';
// Create the internationalization middleware // Create the internationalization middleware
const intlMiddleware = createMiddleware({ const intlMiddleware = createMiddleware({
@@ -8,31 +7,60 @@ const intlMiddleware = createMiddleware({
locales: ['en', 'de'], locales: ['en', 'de'],
// Used when no locale matches // Used when no locale matches
defaultLocale: 'en' defaultLocale: 'en',
}); });
// Main middleware that logs all requests
export default function middleware(request: NextRequest) { export default function middleware(request: NextRequest) {
const startTime = Date.now();
const { method, url, headers } = request; 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 // Build header object for logging
console.log(`Incoming request: method=${method} url=${url}`); 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 { try {
// Apply internationalization middleware // Apply internationalization middleware
const response = intlMiddleware(request); const response = intlMiddleware(effectiveRequest);
return response; return response;
} catch (error) { } 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; throw error;
} }
} }
export const config = { export const config = {
// Match only internationalized pathnames // 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;", contentSecurityPolicy: "default-src 'self'; script-src 'none'; sandbox;",
}, },
async rewrites() { 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 const glitchtipUrl = process.env.SENTRY_DSN
? new URL(process.env.SENTRY_DSN).origin ? new URL(process.env.SENTRY_DSN).origin
: 'https://errors.infra.mintel.me'; : '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 [ return [
{ {

1020
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -1,6 +1,7 @@
{ {
"dependencies": { "dependencies": {
"@directus/sdk": "^18.0.3", "@directus/sdk": "^18.0.3",
"@mintel/mail": "^1.2.3",
"@react-email/components": "^1.0.6", "@react-email/components": "^1.0.6",
"@react-pdf/renderer": "^4.3.2", "@react-pdf/renderer": "^4.3.2",
"@sentry/nextjs": "^8.55.0", "@sentry/nextjs": "^8.55.0",
@@ -65,7 +66,7 @@
"name": "klz-cables-nextjs", "name": "klz-cables-nextjs",
"private": true, "private": true,
"scripts": { "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", "dev:local": "next dev",
"build": "next build", "build": "next build",
"start": "next start", "start": "next start",
@@ -73,15 +74,15 @@
"typecheck": "tsc --noEmit", "typecheck": "tsc --noEmit",
"test": "vitest run --passWithNoTests", "test": "vitest run --passWithNoTests",
"test:og": "vitest run tests/og-image.test.ts", "test:og": "vitest run tests/og-image.test.ts",
"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": "tsx ./scripts/generate-pdf-datasheets.ts",
"pdf:datasheets:legacy": "tsx ./scripts/generate-pdf-datasheets-pdf-lib.ts", "pdf:datasheets:legacy": "tsx ./scripts/generate-pdf-datasheets-pdf-lib.ts",
"directus:push:staging": "./scripts/sync-directus.sh push staging", "cms:push:staging": "./scripts/sync-directus.sh push staging",
"directus:pull:staging": "./scripts/sync-directus.sh pull staging", "cms:pull:staging": "./scripts/sync-directus.sh pull staging",
"directus:push:testing": "./scripts/sync-directus.sh push testing", "cms:push:testing": "./scripts/sync-directus.sh push testing",
"directus:pull:testing": "./scripts/sync-directus.sh pull testing", "cms:pull:testing": "./scripts/sync-directus.sh pull testing",
"directus:push:prod": "./scripts/sync-directus.sh push production", "cms:push:prod": "./scripts/sync-directus.sh push production",
"directus:pull:prod": "./scripts/sync-directus.sh pull production", "cms:pull:prod": "./scripts/sync-directus.sh pull production",
"pagespeed:test": "tsx ./scripts/pagespeed-sitemap.ts", "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')))\"", "pagespeed:urls": "tsx -e \"import sitemap from './app/sitemap'; sitemap().then(urls => console.log(urls.map(u => u.url).join('\\n')))\"",
"prepare": "husky" "prepare": "husky"

View File

@@ -79,33 +79,67 @@ async function main() {
const chromePath = process.env.CHROME_PATH || process.env.PUPPETEER_EXECUTABLE_PATH; const chromePath = process.env.CHROME_PATH || process.env.PUPPETEER_EXECUTABLE_PATH;
const chromePathArg = chromePath ? `--collect.chromePath="${chromePath}"` : ''; 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 // 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...`); console.log(`💻 Executing LHCI...`);
try { try {
const output = execSync(lhciCommand, { execSync(lhciCommand, {
encoding: 'utf8', 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 // Calculate Average
const reportMatch = output.match( const avg = {
/Sent to (https:\/\/storage\.googleapis\.com\/lighthouse-infrastructure\.appspot\.com\/reports\/[^\s]+)/, Perf: Math.round(
); summaryTable.reduce((acc: any, curr: any) => acc + curr.Perf, 0) / summaryTable.length,
if (reportMatch && reportMatch[1]) { ),
const reportUrl = reportMatch[1]; Acc: Math.round(
console.log(`\n📊 Report URL: ${reportUrl}`); summaryTable.reduce((acc: any, curr: any) => acc + curr.Acc, 0) / summaryTable.length,
fs.writeFileSync('pagespeed-report-url.txt', reportUrl); ),
} BP: Math.round(
} catch (err: any) { summaryTable.reduce((acc: any, curr: any) => acc + curr.BP, 0) / summaryTable.length,
console.error('❌ LHCI execution failed.'); ),
if (err.stdout) console.log(err.stdout); SEO: Math.round(
if (err.stderr) console.error(err.stderr); summaryTable.reduce((acc: any, curr: any) => acc + curr.SEO, 0) / summaryTable.length,
throw err; ),
};
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!`); console.log(`\n✨ PageSpeed tests completed successfully!`);

View File

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

View File

@@ -36,6 +36,8 @@ case $ENV in
;; ;;
production) production)
PROJECT_NAME="klz-cables-prod" 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" ENV_FILE=".env.prod"
;; ;;
*) *)
@@ -58,6 +60,7 @@ if [ "$ACTION" == "push" ]; then
# 1. DB Dump # 1. DB Dump
echo "📦 Dumping local database..." 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 docker exec "$LOCAL_DB_CONTAINER" pg_dump -U "$DB_USER" --clean --if-exists --no-owner --no-privileges "$DB_NAME" > dump.sql
# 2. Upload Dump # 2. Upload Dump
@@ -67,10 +70,21 @@ if [ "$ACTION" == "push" ]; then
# 3. Restore on Remote # 3. Restore on Remote
echo "🔄 Restoring dump on $ENV..." echo "🔄 Restoring dump on $ENV..."
REMOTE_DB_CONTAINER=$(ssh "$REMOTE_HOST" "cd $REMOTE_DIR && docker compose -p $PROJECT_NAME ps -q directus-db") 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 if [ -z "$REMOTE_DB_CONTAINER" ]; then
echo "❌ Remote $ENV-db container not found!" echo "❌ Remote $ENV-db container not found!"
exit 1 exit 1
fi 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" ssh "$REMOTE_HOST" "docker exec -i $REMOTE_DB_CONTAINER psql -U $DB_USER $DB_NAME < $REMOTE_DIR/dump.sql"
# 4. Sync Uploads # 4. Sync Uploads
@@ -83,6 +97,10 @@ if [ "$ACTION" == "push" ]; then
rm dump.sql rm dump.sql
ssh "$REMOTE_HOST" "rm $REMOTE_DIR/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!" echo "✨ Push to $ENV complete!"
elif [ "$ACTION" == "pull" ]; then elif [ "$ACTION" == "pull" ]; then
@@ -91,6 +109,11 @@ elif [ "$ACTION" == "pull" ]; then
# 1. DB Dump on Remote # 1. DB Dump on Remote
echo "📦 Dumping remote database ($ENV)..." echo "📦 Dumping remote database ($ENV)..."
REMOTE_DB_CONTAINER=$(ssh "$REMOTE_HOST" "cd $REMOTE_DIR && docker compose -p $PROJECT_NAME ps -q directus-db") 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 if [ -z "$REMOTE_DB_CONTAINER" ]; then
echo "❌ Remote $ENV-db container not found!" echo "❌ Remote $ENV-db container not found!"
exit 1 exit 1
@@ -101,8 +124,11 @@ elif [ "$ACTION" == "pull" ]; then
echo "📥 Downloading dump..." echo "📥 Downloading dump..."
scp "$REMOTE_HOST:$REMOTE_DIR/dump.sql" dump.sql scp "$REMOTE_HOST:$REMOTE_DIR/dump.sql" dump.sql
# 3. Restore Locally # Wipe local DB clean before restore to avoid constraint errors
echo "🔄 Restoring dump locally..." 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 docker exec -i "$LOCAL_DB_CONTAINER" psql -U "$DB_USER" "$DB_NAME" < dump.sql
# 4. Sync Uploads # 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 { @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-heading: 'Inter', system-ui, sans-serif;
--font-body: 'Inter', system-ui, sans-serif; --font-body: 'Inter', system-ui, sans-serif;
@@ -30,43 +32,82 @@
--color-success: #10b981; --color-success: #10b981;
--color-warning: #f59e0b; --color-warning: #f59e0b;
--color-danger: #ef4444; --color-danger: #ef4444;
--color-destructive: #ef4444;
--color-destructive-foreground: #ffffff;
--color-info: #3b82f6; --color-info: #3b82f6;
--animate-fade-in: fade-in 0.5s ease-out; --animate-fade-in: fade-in 0.5s ease-out;
--animate-slide-up: slide-up 0.6s ease-out; --animate-slide-up: slide-up 0.6s ease-out;
--animate-slow-zoom: slow-zoom 20s linear infinite; --animate-slow-zoom: slow-zoom 20s linear infinite;
--animate-reveal: reveal 0.8s cubic-bezier(0.16, 1, 0.3, 1) forwards; --animate-reveal: reveal 0.8s cubic-bezier(0.16, 1, 0.3, 1) forwards;
--animate-slight-fade-in-from-bottom: slight-fade-in-from-bottom 0.8s cubic-bezier(0.16, 1, 0.3, 1) forwards; --animate-slight-fade-in-from-bottom: slight-fade-in-from-bottom 0.8s
cubic-bezier(0.16, 1, 0.3, 1) forwards;
--animate-gradient-x: gradient-x 15s ease infinite; --animate-gradient-x: gradient-x 15s ease infinite;
@keyframes gradient-x { @keyframes gradient-x {
0%, 100% { background-position: 0% 50%; } 0%,
50% { background-position: 100% 50%; } 100% {
background-position: 0% 50%;
}
50% {
background-position: 100% 50%;
}
} }
@keyframes fade-in { @keyframes fade-in {
from { opacity: 0; } from {
to { opacity: 1; } opacity: 0;
}
to {
opacity: 1;
}
} }
@keyframes slide-up { @keyframes slide-up {
from { opacity: 0; transform: translateY(20px); } from {
to { opacity: 1; transform: translateY(0); } opacity: 0;
transform: translateY(20px);
}
to {
opacity: 1;
transform: translateY(0);
}
} }
@keyframes slow-zoom { @keyframes slow-zoom {
from { transform: scale(1); } from {
to { transform: scale(1.1); } transform: scale(1);
}
to {
transform: scale(1.1);
}
} }
@keyframes reveal { @keyframes reveal {
from { opacity: 0; transform: translateY(20px); filter: blur(8px); } from {
to { opacity: 1; transform: translateY(0); filter: blur(0); } opacity: 0;
transform: translateY(20px);
filter: blur(8px);
}
to {
opacity: 1;
transform: translateY(0);
filter: blur(0);
}
} }
@keyframes slight-fade-in-from-bottom { @keyframes slight-fade-in-from-bottom {
from { opacity: 0; transform: translateY(10px); filter: blur(4px); } from {
to { opacity: 1; transform: translateY(0); filter: blur(0); } opacity: 0;
transform: translateY(10px);
filter: blur(4px);
}
to {
opacity: 1;
transform: translateY(0);
filter: blur(0);
}
} }
} }
@layer base { @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; @apply text-white/90 hover:text-white transition-colors;
} }
body { body {
@@ -76,63 +117,81 @@
-moz-osx-font-smoothing: grayscale; -moz-osx-font-smoothing: grayscale;
line-height: 1.7; line-height: 1.7;
} }
h1, h2, h3, h4, h5, h6 { h1,
h2,
h3,
h4,
h5,
h6 {
@apply font-heading font-bold tracking-tight; @apply font-heading font-bold tracking-tight;
line-height: 1.2; line-height: 1.2;
} }
/* Enhanced Mobile-first typography hierarchy with fluid sizing */ /* Enhanced Mobile-first typography hierarchy with fluid sizing */
h1 { @apply text-3xl md:text-5xl lg:text-6xl leading-[1.1]; } h1 {
h2 { @apply text-2xl md:text-4xl lg:text-5xl leading-[1.2]; } @apply text-3xl md:text-5xl lg:text-6xl leading-[1.1];
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]; } h2 {
h5 { @apply text-base md:text-lg leading-[1.5]; } @apply text-2xl md:text-4xl lg:text-5xl leading-[1.2];
h6 { @apply text-sm md:text-base leading-[1.6]; } }
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 */ /* Paragraph and text styles */
p { p {
@apply mb-4 leading-relaxed; @apply mb-4 leading-relaxed;
} }
/* Link styles */ /* Link styles */
a { a {
@apply no-underline transition-all duration-200; @apply no-underline transition-all duration-200;
} }
/* List styles */ /* List styles */
ul, ol { ul,
ol {
@apply my-4 ml-6; @apply my-4 ml-6;
} }
li { li {
@apply mb-2 leading-relaxed; @apply mb-2 leading-relaxed;
} }
/* Small text */ /* Small text */
small { small {
@apply text-sm md:text-base; @apply text-sm md:text-base;
} }
/* Strong and emphasis */ /* Strong and emphasis */
strong { strong {
@apply font-bold; @apply font-bold;
} }
em { em {
@apply italic; @apply italic;
} }
/* Blockquote */ /* Blockquote */
blockquote { blockquote {
@apply border-l-4 pl-6 my-6 italic; @apply border-l-4 pl-6 my-6 italic;
} }
/* Code */ /* Code */
code { code {
@apply px-2 py-1 rounded font-mono text-sm; @apply px-2 py-1 rounded font-mono text-sm;
} }
/* Horizontal rule */ /* Horizontal rule */
hr { hr {
@apply my-8; @apply my-8;
@@ -177,7 +236,7 @@
opacity 0.6s ease-out, opacity 0.6s ease-out,
transform 0.8s cubic-bezier(0.16, 1, 0.3, 1), transform 0.8s cubic-bezier(0.16, 1, 0.3, 1),
filter 0.8s cubic-bezier(0.16, 1, 0.3, 1); filter 0.8s cubic-bezier(0.16, 1, 0.3, 1);
&.is-visible { &.is-visible {
opacity: 1; opacity: 1;
transform: translateY(0); 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";
}
}