78 Commits

Author SHA1 Message Date
16916654c0 feat: add database backup script and npm command
Some checks failed
Build & Deploy / 🔍 Prepare (push) Successful in 4s
Build & Deploy / 🧪 QA (push) Failing after 1m17s
Build & Deploy / 🚀 Deploy (push) Has been skipped
Build & Deploy / 🏗️ Build (push) Failing after 3m31s
Build & Deploy / 🧪 Post-Deploy Verification (push) Has been skipped
Build & Deploy / 🔔 Notify (push) Successful in 1s
2026-03-11 12:36:34 +01:00
6daf5c66a8 refactor: remove all legacy directus environment variables and standardize on postgres
Some checks failed
Build & Deploy / 🔍 Prepare (push) Successful in 4s
Build & Deploy / 🧪 QA (push) Successful in 5m14s
Build & Deploy / 🏗️ Build (push) Successful in 7m26s
Build & Deploy / 🚀 Deploy (push) Failing after 32s
Build & Deploy / 🧪 Post-Deploy Verification (push) Has been skipped
Build & Deploy / 🔔 Notify (push) Successful in 1s
2026-03-11 12:22:57 +01:00
95b594d8cd feat: link migrations to payload config for production bundling and simplify dockerfile
All checks were successful
Build & Deploy / 🔍 Prepare (push) Successful in 5s
Build & Deploy / 🧪 QA (push) Successful in 5m22s
Build & Deploy / 🏗️ Build (push) Successful in 7m24s
Build & Deploy / 🚀 Deploy (push) Successful in 11s
Build & Deploy / 🧪 Post-Deploy Verification (push) Successful in 3m39s
Build & Deploy / 🔔 Notify (push) Successful in 1s
2026-03-11 11:48:55 +01:00
cd6651cc43 fix(migrations): use robust ESM import extensions in Dockerfile for v1.1.9
Some checks failed
Build & Deploy / 🔍 Prepare (push) Successful in 6s
Build & Deploy / 🧪 QA (push) Successful in 5m34s
Build & Deploy / 🏗️ Build (push) Successful in 8m28s
Build & Deploy / 🚀 Deploy (push) Failing after 36s
Build & Deploy / 🧪 Post-Deploy Verification (push) Has been skipped
Build & Deploy / 🔔 Notify (push) Successful in 3s
Nightly QA / call-qa-workflow (push) Failing after 37s
2026-03-11 00:55:05 +01:00
855390a27b fix(deploy): use dedicated dist-migrations dir and fix sed path in Dockerfile
Some checks failed
Build & Deploy / 🔍 Prepare (push) Successful in 4s
Build & Deploy / 🧪 QA (push) Successful in 4m28s
Build & Deploy / 🏗️ Build (push) Successful in 2m55s
Build & Deploy / 🚀 Deploy (push) Failing after 31s
Build & Deploy / 🧪 Post-Deploy Verification (push) Has been skipped
Build & Deploy / 🔔 Notify (push) Successful in 1s
2026-03-11 00:46:10 +01:00
ea8bd46973 fix(deploy): add .js extensions to transpiled migration imports for ESM compatibility
Some checks failed
Build & Deploy / 🔍 Prepare (push) Successful in 7s
Build & Deploy / 🧪 QA (push) Successful in 5m30s
Build & Deploy / 🚀 Deploy (push) Has been skipped
Build & Deploy / 🏗️ Build (push) Failing after 3m20s
Build & Deploy / 🧪 Post-Deploy Verification (push) Has been skipped
Build & Deploy / 🔔 Notify (push) Successful in 2s
2026-03-11 00:38:23 +01:00
ff269b1f84 fix(deploy): transpile migrations to JS for production compatibility
Some checks failed
Build & Deploy / 🔍 Prepare (push) Successful in 11s
Build & Deploy / 🧪 QA (push) Successful in 5m3s
Build & Deploy / 🏗️ Build (push) Successful in 7m14s
Build & Deploy / 🚀 Deploy (push) Failing after 32s
Build & Deploy / 🧪 Post-Deploy Verification (push) Has been skipped
Build & Deploy / 🔔 Notify (push) Successful in 2s
2026-03-11 00:26:46 +01:00
afa586c833 fix(deploy): bundle migrations into Next.js standalone output
Some checks failed
Build & Deploy / 🔍 Prepare (push) Successful in 5s
Build & Deploy / 🧪 QA (push) Failing after 1m12s
Build & Deploy / 🏗️ Build (push) Failing after 3m58s
Build & Deploy / 🚀 Deploy (push) Has been skipped
Build & Deploy / 🧪 Post-Deploy Verification (push) Has been skipped
Build & Deploy / 🔔 Notify (push) Successful in 4s
2026-03-11 00:24:08 +01:00
9b55c42f35 fix(deploy): move migrations and include them in Docker image
Some checks failed
Build & Deploy / 🔍 Prepare (push) Successful in 4s
Build & Deploy / 🧪 QA (push) Successful in 5m13s
Build & Deploy / 🏗️ Build (push) Successful in 7m46s
Build & Deploy / 🚀 Deploy (push) Failing after 51s
Build & Deploy / 🧪 Post-Deploy Verification (push) Has been skipped
Build & Deploy / 🔔 Notify (push) Successful in 4s
2026-03-11 00:11:52 +01:00
554f958ba2 chore(deploy): fix registry URLs in docker-compose.yaml
All checks were successful
Build & Deploy / 🔍 Prepare (push) Successful in 4s
Build & Deploy / 🧪 QA (push) Successful in 5m27s
Build & Deploy / 🏗️ Build (push) Successful in 8m22s
Build & Deploy / 🚀 Deploy (push) Successful in 22s
Build & Deploy / 🧪 Post-Deploy Verification (push) Successful in 3m42s
Build & Deploy / 🔔 Notify (push) Successful in 2s
2026-03-10 23:28:50 +01:00
c9f174e828 fix(middleware): exclude /admin from next-intl rewrites
Some checks failed
Build & Deploy / 🔍 Prepare (push) Successful in 4s
Build & Deploy / 🧪 QA (push) Successful in 2m22s
Build & Deploy / 🏗️ Build (push) Successful in 4m37s
Build & Deploy / 🚀 Deploy (push) Failing after 7s
Build & Deploy / 🧪 Post-Deploy Verification (push) Has been skipped
Build & Deploy / 🔔 Notify (push) Successful in 2s
2026-03-10 18:04:36 +01:00
8dce4890c4 chore(ci): migrate docker registry publishers to git.infra.mintel.me 2026-03-03 12:25:30 +01:00
963e572291 fix(lint): remove explicit any from check-http error handler
Some checks failed
Build & Deploy / 🔍 Prepare (push) Successful in 4s
Build & Deploy / 🧪 QA (push) Successful in 1m58s
Build & Deploy / 🏗️ Build (push) Successful in 4m27s
Build & Deploy / 🚀 Deploy (push) Successful in 11s
Build & Deploy / 🧪 Post-Deploy Verification (push) Successful in 3m16s
Build & Deploy / 🔔 Notify (push) Successful in 1s
Nightly QA / call-qa-workflow (push) Failing after 34s
2026-02-28 23:07:27 +01:00
9887324469 fix(ci): point CMS deep health check to correct api/health endpoint and improve check-http error formatting
Some checks failed
Build & Deploy / 🔍 Prepare (push) Successful in 4s
Build & Deploy / 🧪 QA (push) Failing after 31s
Build & Deploy / 🏗️ Build (push) Successful in 2m6s
Build & Deploy / 🚀 Deploy (push) Has been skipped
Build & Deploy / 🧪 Post-Deploy Verification (push) Has been skipped
Build & Deploy / 🔔 Notify (push) Successful in 1s
2026-02-28 22:56:41 +01:00
78da0fdea9 chore(deps): update pnpm-lock.yaml after local install
Some checks failed
Build & Deploy / 🔍 Prepare (push) Successful in 5s
Build & Deploy / 🧪 QA (push) Successful in 4m20s
Build & Deploy / 🏗️ Build (push) Successful in 6m48s
Build & Deploy / 🚀 Deploy (push) Successful in 11s
Build & Deploy / 🧪 Post-Deploy Verification (push) Failing after 19m17s
Build & Deploy / 🔔 Notify (push) Successful in 1s
Nightly QA / call-qa-workflow (push) Failing after 26s
2026-02-28 00:29:49 +01:00
91db336c0e fix(deploy): add missing compress middleware declaration to fix 404 traefik router drops
Some checks failed
Build & Deploy / 🔍 Prepare (push) Successful in 6s
Build & Deploy / 🧪 QA (push) Successful in 1m54s
Build & Deploy / 🏗️ Build (push) Successful in 2m42s
Build & Deploy / 🚀 Deploy (push) Successful in 18s
Build & Deploy / 🔔 Notify (push) Has been cancelled
Build & Deploy / 🧪 Post-Deploy Verification (push) Has been cancelled
2026-02-28 00:23:22 +01:00
cfbff88e45 fix(deploy): rename traefik routers dynamically by PROJECT_NAME to prevent testing environment from overwriting production proxy rules
Some checks failed
Build & Deploy / 🔍 Prepare (push) Successful in 4s
Build & Deploy / 🧪 QA (push) Successful in 1m52s
Build & Deploy / 🏗️ Build (push) Successful in 2m40s
Build & Deploy / 🚀 Deploy (push) Successful in 13s
Build & Deploy / 🔔 Notify (push) Has been cancelled
Build & Deploy / 🧪 Post-Deploy Verification (push) Has been cancelled
2026-02-28 00:19:04 +01:00
90b41d2a15 fix(deploy): remove literal quotes from TRAEFIK_HOST in env generation to fix invalid traefik router syntax
Some checks failed
Build & Deploy / 🔍 Prepare (push) Successful in 4s
Build & Deploy / 🧪 QA (push) Successful in 1m50s
Build & Deploy / 🏗️ Build (push) Successful in 2m40s
Build & Deploy / 🚀 Deploy (push) Successful in 12s
Build & Deploy / 🧪 Post-Deploy Verification (push) Failing after 2m26s
Build & Deploy / 🔔 Notify (push) Successful in 1s
2026-02-28 00:07:53 +01:00
3f45293c2e fix(deploy): rewrite traefik routers and add public route for sitemap to bypass gatekeeper
Some checks failed
Build & Deploy / 🔍 Prepare (push) Successful in 4s
Build & Deploy / 🧪 QA (push) Successful in 1m53s
Build & Deploy / 🏗️ Build (push) Successful in 2m41s
Build & Deploy / 🚀 Deploy (push) Successful in 12s
Build & Deploy / 🧪 Post-Deploy Verification (push) Failing after 2m48s
Build & Deploy / 🔔 Notify (push) Successful in 1s
2026-02-27 23:55:52 +01:00
7e957d6fb4 fix(deploy): add retry loop to payload migration curl to handle slow postgres startups
Some checks failed
Build & Deploy / 🔍 Prepare (push) Successful in 4s
Build & Deploy / 🧪 QA (push) Successful in 2m19s
Build & Deploy / 🏗️ Build (push) Successful in 4m40s
Build & Deploy / 🚀 Deploy (push) Successful in 12s
Build & Deploy / 🧪 Post-Deploy Verification (push) Failing after 2m29s
Build & Deploy / 🔔 Notify (push) Successful in 1s
2026-02-27 23:37:47 +01:00
4334d31445 test: disable happy-dom iframe and resource loading to silence AbortError logs
Some checks failed
Build & Deploy / 🔍 Prepare (push) Successful in 5s
Build & Deploy / 🧪 QA (push) Successful in 2m22s
Build & Deploy / 🏗️ Build (push) Successful in 4m48s
Build & Deploy / 🚀 Deploy (push) Failing after 11s
Build & Deploy / 🧪 Post-Deploy Verification (push) Has been skipped
Build & Deploy / 🔔 Notify (push) Successful in 1s
2026-02-27 22:55:50 +01:00
1559037029 fix(ci): resolve eslint type errors in qa smoke test scripts
All checks were successful
Build & Deploy / 🔍 Prepare (push) Successful in 3s
Build & Deploy / 🧪 QA (push) Successful in 2m1s
Build & Deploy / 🏗️ Build (push) Successful in 2m38s
Build & Deploy / 🚀 Deploy (push) Successful in 12s
Build & Deploy / 🧪 Post-Deploy Verification (push) Successful in 3m23s
Build & Deploy / 🔔 Notify (push) Successful in 1s
2026-02-27 21:09:07 +01:00
b7438f2718 ci: add missing core smoke test scripts and dependencies
Some checks failed
Build & Deploy / 🔍 Prepare (push) Successful in 22s
Build & Deploy / 🧪 QA (push) Failing after 1m5s
Build & Deploy / 🏗️ Build (push) Successful in 4m47s
Build & Deploy / 🚀 Deploy (push) Has been skipped
Build & Deploy / 🧪 Post-Deploy Verification (push) Has been skipped
Build & Deploy / 🔔 Notify (push) Successful in 1s
2026-02-27 19:24:53 +01:00
a090373825 fix(pipeline): add default network to app container so it can reach postgres db
Some checks failed
Build & Deploy / 🔍 Prepare (push) Successful in 4s
Build & Deploy / 🧪 QA (push) Successful in 1m53s
Build & Deploy / 🏗️ Build (push) Successful in 2m54s
Build & Deploy / 🚀 Deploy (push) Successful in 18s
Build & Deploy / 🧪 Post-Deploy Verification (push) Failing after 2m52s
Build & Deploy / 🔔 Notify (push) Successful in 2s
2026-02-27 19:13:18 +01:00
3c3d019924 fix(pipeline): correct bash string escaping and env fallbacks for migration script
Some checks failed
Build & Deploy / 🔍 Prepare (push) Successful in 4s
Build & Deploy / 🧪 QA (push) Successful in 3m18s
Build & Deploy / 🏗️ Build (push) Successful in 4m14s
Build & Deploy / 🚀 Deploy (push) Failing after 16s
Build & Deploy / 🧪 Post-Deploy Verification (push) Has been skipped
Build & Deploy / 🔔 Notify (push) Successful in 6s
2026-02-27 19:05:10 +01:00
c6d20119c7 fix(pipeline): provide fallback PAYLOAD_SECRET so migration curl can authenticate
Some checks failed
Build & Deploy / 🔍 Prepare (push) Successful in 4s
Build & Deploy / 🧪 QA (push) Successful in 2m25s
Build & Deploy / 🏗️ Build (push) Successful in 3m30s
Build & Deploy / 🚀 Deploy (push) Failing after 16s
Build & Deploy / 🧪 Post-Deploy Verification (push) Has been skipped
Build & Deploy / 🔔 Notify (push) Successful in 2s
2026-02-27 18:52:43 +01:00
04a19742da fix(pipeline): resolve typescript error inside payload migration endpoint
Some checks failed
Build & Deploy / 🔍 Prepare (push) Successful in 4s
Build & Deploy / 🧪 QA (push) Successful in 3m36s
Build & Deploy / 🏗️ Build (push) Successful in 4m46s
Build & Deploy / 🚀 Deploy (push) Failing after 18s
Build & Deploy / 🧪 Post-Deploy Verification (push) Has been skipped
Build & Deploy / 🔔 Notify (push) Successful in 12s
2026-02-27 18:40:45 +01:00
39b96a51db fix(pipeline): resolve eslint type error in payload migration endpoint
Some checks failed
Build & Deploy / 🔍 Prepare (push) Successful in 14s
Build & Deploy / 🧪 QA (push) Failing after 43s
Build & Deploy / 🏗️ Build (push) Failing after 1m48s
Build & Deploy / 🚀 Deploy (push) Has been skipped
Build & Deploy / 🧪 Post-Deploy Verification (push) Has been skipped
Build & Deploy / 🔔 Notify (push) Successful in 2s
2026-02-27 18:27:02 +01:00
d27e1f91ad ci: add nightly QA pipeline utilizing reusable templates
Some checks failed
Build & Deploy / 🔍 Prepare (push) Successful in 6s
Build & Deploy / 🧪 QA (push) Failing after 57s
Build & Deploy / 🏗️ Build (push) Failing after 2m52s
Build & Deploy / 🚀 Deploy (push) Has been skipped
Build & Deploy / 🧪 Post-Deploy Verification (push) Has been skipped
Build & Deploy / 🔔 Notify (push) Successful in 2s
2026-02-27 18:15:47 +01:00
18cd576ee9 ci: modernize deploy pipeline to use reusable at-mintel core-smoke-tests template
Some checks failed
Build & Deploy / 🔍 Prepare (push) Successful in 4s
Build & Deploy / 🧪 QA (push) Failing after 31s
Build & Deploy / 🚀 Deploy (push) Has been cancelled
Build & Deploy / 🧪 Post-Deploy Verification (push) Has been cancelled
Build & Deploy / 🔔 Notify (push) Has been cancelled
Build & Deploy / 🏗️ Build (push) Has been cancelled
2026-02-27 18:14:35 +01:00
3d2f240cf6 feat(cms): robust api based migration endpoint for prod
Some checks failed
Build & Deploy / 🔍 Prepare (push) Successful in 4s
Build & Deploy / 🧪 QA (push) Failing after 31s
Build & Deploy / 🏗️ Build (push) Failing after 2m1s
Build & Deploy / 🚀 Deploy (push) Has been skipped
Build & Deploy / 🩺 Health Check (push) Has been skipped
Build & Deploy / 🔔 Notify (push) Successful in 1s
2026-02-27 15:18:10 +01:00
6260b40b91 fix: remove tsx from payload migrate command
Some checks failed
Build & Deploy / 🔍 Prepare (push) Successful in 4s
Build & Deploy / 🧪 QA (push) Successful in 1m57s
Build & Deploy / 🏗️ Build (push) Successful in 2m47s
Build & Deploy / 🚀 Deploy (push) Failing after 18s
Build & Deploy / 🩺 Health Check (push) Has been skipped
Build & Deploy / 🔔 Notify (push) Successful in 1s
2026-02-27 14:23:34 +01:00
109c8389f3 test: add api integration tests for contact form
Some checks failed
Build & Deploy / 🔍 Prepare (push) Successful in 4s
Build & Deploy / 🧪 QA (push) Successful in 2m28s
Build & Deploy / 🏗️ Build (push) Successful in 4m45s
Build & Deploy / 🚀 Deploy (push) Failing after 13s
Build & Deploy / 🩺 Health Check (push) Has been skipped
Build & Deploy / 🔔 Notify (push) Successful in 1s
2026-02-27 13:34:23 +01:00
55cb073a6d feat(cms): migrate from directus to payloadcms
Some checks failed
Build & Deploy / 🔍 Prepare (push) Successful in 4s
Build & Deploy / 🧪 QA (push) Successful in 2m55s
Build & Deploy / 🏗️ Build (push) Successful in 11m40s
Build & Deploy / 🚀 Deploy (push) Failing after 8s
Build & Deploy / 🩺 Health Check (push) Has been skipped
Build & Deploy / 🔔 Notify (push) Successful in 1s
2026-02-27 12:56:35 +01:00
fb87fd52f7 fix(traefik): parameterize cms auth middleware
Some checks failed
Build & Deploy / 🔍 Prepare (push) Successful in 8s
Build & Deploy / 🧪 QA (push) Successful in 3m13s
Build & Deploy / 🏗️ Build (push) Successful in 10m7s
Build & Deploy / 🚀 Deploy (push) Failing after 7s
Build & Deploy / 🩺 Health Check (push) Has been skipped
Build & Deploy / 🔔 Notify (push) Successful in 2s
2026-02-27 11:36:09 +01:00
da9b2fb9cf fix(gatekeeper): use testing tag instead of v1.8.21
Some checks failed
Build & Deploy / 🔍 Prepare (push) Successful in 10s
Build & Deploy / 🚀 Deploy (push) Has been cancelled
Build & Deploy / 🩺 Health Check (push) Has been cancelled
Build & Deploy / 🔔 Notify (push) Has been cancelled
Build & Deploy / 🧪 QA (push) Has been cancelled
Build & Deploy / 🏗️ Build (push) Has been cancelled
2026-02-27 11:34:31 +01:00
5032700c2c fix(gatekeeper): upgrade gatekeeper tag to v1.8.21
Some checks failed
Build & Deploy / 🔍 Prepare (push) Successful in 6s
Build & Deploy / 🚀 Deploy (push) Has been cancelled
Build & Deploy / 🩺 Health Check (push) Has been cancelled
Build & Deploy / 🔔 Notify (push) Has been cancelled
Build & Deploy / 🏗️ Build (push) Has been cancelled
Build & Deploy / 🧪 QA (push) Has been cancelled
2026-02-27 11:33:46 +01:00
d44838254c fix(ci): add pnpm store prune to fix integrity errors
Some checks failed
Build & Deploy / 🔍 Prepare (push) Successful in 8s
Build & Deploy / 🧪 QA (push) Successful in 2m50s
Build & Deploy / 🚀 Deploy (push) Has been cancelled
Build & Deploy / 🩺 Health Check (push) Has been cancelled
Build & Deploy / 🔔 Notify (push) Has been cancelled
Build & Deploy / 🏗️ Build (push) Has been cancelled
2026-02-27 11:28:50 +01:00
1742604a7a chore(workspace): add gitea repository url to all packages
Some checks failed
Build & Deploy / 🔍 Prepare (push) Successful in 5s
Build & Deploy / 🚀 Deploy (push) Has been cancelled
Build & Deploy / 🩺 Health Check (push) Has been cancelled
Build & Deploy / 🧪 QA (push) Has been cancelled
Build & Deploy / 🔔 Notify (push) Has been cancelled
Build & Deploy / 🏗️ Build (push) Has been cancelled
2026-02-27 11:27:24 +01:00
ca59f32b99 fix(ci): update to v1.8.21 for x86 base images
Some checks failed
Build & Deploy / 🔍 Prepare (push) Successful in 4s
Build & Deploy / 🧪 QA (push) Failing after 9s
Build & Deploy / 🏗️ Build (push) Failing after 42s
Build & Deploy / 🚀 Deploy (push) Has been skipped
Build & Deploy / 🩺 Health Check (push) Has been skipped
Build & Deploy / 🔔 Notify (push) Successful in 1s
2026-02-26 19:45:34 +01:00
0e98659506 fix(infra): prevent redirect loop by excluding gatekeeper path from app router
All checks were successful
Build & Deploy / 🔍 Prepare (push) Successful in 4s
Build & Deploy / 🧪 QA (push) Successful in 1m38s
Build & Deploy / 🏗️ Build (push) Successful in 4m27s
Build & Deploy / 🚀 Deploy (push) Successful in 11s
Build & Deploy / 🩺 Health Check (push) Successful in 19s
Build & Deploy / 🔔 Notify (push) Successful in 2s
2026-02-12 17:58:25 +01:00
744e1da716 fix(infra): resolve identity shadowing by standardizing internal hostnames and isolated networks
Some checks failed
Build & Deploy / 🔍 Prepare (push) Successful in 4s
Build & Deploy / 🏗️ Build (push) Successful in 1m42s
Build & Deploy / 🧪 QA (push) Successful in 2m14s
Build & Deploy / 🚀 Deploy (push) Successful in 17s
Build & Deploy / 🩺 Health Check (push) Failing after 13s
Build & Deploy / 🔔 Notify (push) Successful in 2s
2026-02-12 17:53:37 +01:00
f2e38f9c29 fix(infra): harden health checks and fix directus security warnings
Some checks failed
Build & Deploy / 🔍 Prepare (push) Successful in 4s
Build & Deploy / 🧪 QA (push) Successful in 3m27s
Build & Deploy / 🏗️ Build (push) Successful in 3m41s
Build & Deploy / 🚀 Deploy (push) Successful in 15s
Build & Deploy / 🩺 Health Check (push) Failing after 12s
Build & Deploy / 🔔 Notify (push) Successful in 2s
2026-02-12 17:19:12 +01:00
b85312c433 fix(infra): full KLZ parity for gatekeeper (dynamic COOKIE_DOMAIN + X-Forwarded-Host)
Some checks failed
Build & Deploy / 🔍 Prepare (push) Successful in 4s
Build & Deploy / 🏗️ Build (push) Successful in 1m44s
Build & Deploy / 🧪 QA (push) Successful in 2m32s
Build & Deploy / 🚀 Deploy (push) Successful in 11s
Build & Deploy / 🩺 Health Check (push) Failing after 12s
Build & Deploy / 🔔 Notify (push) Successful in 1s
2026-02-12 17:10:44 +01:00
081ebec567 fix(infra): forward Cookie header to gatekeeper for session verification
Some checks failed
Build & Deploy / 🔍 Prepare (push) Successful in 7s
Build & Deploy / 🏗️ Build (push) Successful in 1m43s
Build & Deploy / 🧪 QA (push) Successful in 2m55s
Build & Deploy / 🚀 Deploy (push) Successful in 12s
Build & Deploy / 🩺 Health Check (push) Failing after 12s
Build & Deploy / 🔔 Notify (push) Successful in 4s
2026-02-12 17:05:03 +01:00
d9dece37e5 fix(infra): remove redundant gatekeeper environment defaults to allow clean inheritance
All checks were successful
Build & Deploy / 🔍 Prepare (push) Successful in 11s
Build & Deploy / 🏗️ Build (push) Successful in 1m44s
Build & Deploy / 🧪 QA (push) Successful in 2m51s
Build & Deploy / 🚀 Deploy (push) Successful in 17s
Build & Deploy / 🩺 Health Check (push) Successful in 2s
Build & Deploy / 🔔 Notify (push) Successful in 2s
2026-02-12 16:59:01 +01:00
9495772d1a fix(ci): upgrade upstream verification logic to klz-2026 standard
All checks were successful
Build & Deploy / 🔍 Prepare (push) Successful in 4s
Build & Deploy / 🏗️ Build (push) Successful in 1m40s
Build & Deploy / 🧪 QA (push) Successful in 2m5s
Build & Deploy / 🚀 Deploy (push) Successful in 17s
Build & Deploy / 🩺 Health Check (push) Successful in 2s
Build & Deploy / 🔔 Notify (push) Successful in 2s
2026-02-12 15:44:22 +01:00
248a0dc1f0 fix(ci): fix typo in directus router rule Host
Some checks failed
Build & Deploy / 🔍 Prepare (push) Failing after 24s
Build & Deploy / 🧪 QA (push) Has been skipped
Build & Deploy / 🏗️ Build (push) Has been skipped
Build & Deploy / 🚀 Deploy (push) Has been skipped
Build & Deploy / 🩺 Health Check (push) Has been skipped
Build & Deploy / 🔔 Notify (push) Successful in 2s
2026-02-12 15:11:27 +01:00
eecc1b6108 fix(ci): fix YAML syntax in docker-compose.yaml
Some checks failed
Build & Deploy / 🔍 Prepare (push) Successful in 4s
Build & Deploy / 🏗️ Build (push) Successful in 1m40s
Build & Deploy / 🧪 QA (push) Successful in 2m29s
Build & Deploy / 🚀 Deploy (push) Successful in 14s
Build & Deploy / 🩺 Health Check (push) Failing after 11s
Build & Deploy / 🔔 Notify (push) Successful in 2s
2026-02-12 15:06:27 +01:00
e0b38e617d fix(ci): consolidate middleware definitions to avoid missing middleware error
Some checks failed
Build & Deploy / 🔍 Prepare (push) Successful in 4s
Build & Deploy / 🧪 QA (push) Successful in 1m35s
Build & Deploy / 🏗️ Build (push) Successful in 2m28s
Build & Deploy / 🚀 Deploy (push) Failing after 6s
Build & Deploy / 🩺 Health Check (push) Has been skipped
Build & Deploy / 🔔 Notify (push) Successful in 2s
2026-02-12 14:57:36 +01:00
8a9339f00f fix(ci): set high priority for Traefik routers to avoid 404
Some checks failed
Build & Deploy / 🔍 Prepare (push) Successful in 4s
Build & Deploy / 🧪 QA (push) Successful in 1m37s
Build & Deploy / 🏗️ Build (push) Successful in 2m29s
Build & Deploy / 🚀 Deploy (push) Successful in 11s
Build & Deploy / 🩺 Health Check (push) Failing after 12s
Build & Deploy / 🔔 Notify (push) Successful in 2s
2026-02-12 14:52:10 +01:00
f23fa4e2c8 fix(ci): fix Traefik rule syntax and add Host header to ForwardAuth
Some checks failed
Build & Deploy / 🔍 Prepare (push) Successful in 10s
Build & Deploy / 🏗️ Build (push) Successful in 1m43s
Build & Deploy / 🧪 QA (push) Successful in 2m29s
Build & Deploy / 🚀 Deploy (push) Successful in 10s
Build & Deploy / 🩺 Health Check (push) Failing after 29s
Build & Deploy / 🔔 Notify (push) Successful in 2s
2026-02-12 14:47:50 +01:00
e177693aae fix(ci): standardize project name to mb-grid-solutions to avoid conflicts
Some checks failed
Build & Deploy / 🔍 Prepare (push) Successful in 4s
Build & Deploy / 🧪 QA (push) Successful in 1m38s
Build & Deploy / 🏗️ Build (push) Successful in 2m54s
Build & Deploy / 🚀 Deploy (push) Successful in 16s
Build & Deploy / 🩺 Health Check (push) Failing after 12s
Build & Deploy / 🔔 Notify (push) Successful in 2s
2026-02-12 13:54:37 +01:00
39920bf432 chore(ci): trigger deployment
Some checks failed
Build & Deploy / 🔍 Prepare (push) Successful in 4s
Build & Deploy / 🏗️ Build (push) Successful in 1m41s
Build & Deploy / 🧪 QA (push) Successful in 2m8s
Build & Deploy / 🚀 Deploy (push) Successful in 11s
Build & Deploy / 🩺 Health Check (push) Failing after 11s
Build & Deploy / 🔔 Notify (push) Successful in 1s
2026-02-12 00:52:29 +01:00
04d3dac627 fix(ci): pass missing TRAEFIK_HOST to deployment env generation
Some checks failed
Build & Deploy / 🔍 Prepare (push) Successful in 4s
Build & Deploy / 🏗️ Build (push) Successful in 1m41s
Build & Deploy / 🚀 Deploy (push) Has been cancelled
Build & Deploy / 🩺 Health Check (push) Has been cancelled
Build & Deploy / 🔔 Notify (push) Has been cancelled
Build & Deploy / 🧪 QA (push) Has been cancelled
2026-02-12 00:50:39 +01:00
bc0a6627c0 fix(gatekeeper): upgrade to v1.7.12
Some checks failed
Build & Deploy / 🔍 Prepare (push) Successful in 4s
Build & Deploy / 🧪 QA (push) Successful in 3m24s
Build & Deploy / 🏗️ Build (push) Successful in 4m44s
Build & Deploy / 🚀 Deploy (push) Successful in 14s
Build & Deploy / 🩺 Health Check (push) Failing after 12s
Build & Deploy / 🔔 Notify (push) Successful in 2s
2026-02-11 22:49:37 +01:00
8030e45920 feat(pipeline): add smart dependency waiting for upstream releases
Some checks failed
Build & Deploy / 🔍 Prepare (push) Successful in 5s
Build & Deploy / 🧪 QA (push) Has been cancelled
Build & Deploy / 🏗️ Build (push) Has been cancelled
Build & Deploy / 🚀 Deploy (push) Has been cancelled
Build & Deploy / 🩺 Health Check (push) Has been cancelled
Build & Deploy / 🔔 Notify (push) Has been cancelled
2026-02-11 22:42:26 +01:00
fbc7b9bba0 fix(gatekeeper): upgrade to v1.7.11
Some checks failed
Build & Deploy / 🔍 Prepare (push) Successful in 5s
Build & Deploy / 🧪 QA (push) Has been cancelled
Build & Deploy / 🏗️ Build (push) Has been cancelled
Build & Deploy / 🚀 Deploy (push) Has been cancelled
Build & Deploy / 🩺 Health Check (push) Has been cancelled
Build & Deploy / 🔔 Notify (push) Has been cancelled
2026-02-11 22:35:43 +01:00
05a90df512 fix(gatekeeper): standardize auth headers and path-based routing
Some checks failed
Build & Deploy / 🔍 Prepare (push) Successful in 6s
Build & Deploy / 🧪 QA (push) Successful in 1m37s
Build & Deploy / 🏗️ Build (push) Successful in 1m40s
Build & Deploy / 🚀 Deploy (push) Successful in 9s
Build & Deploy / 🩺 Health Check (push) Failing after 11s
Build & Deploy / 🔔 Notify (push) Successful in 2s
2026-02-11 22:04:28 +01:00
817ee05710 test: restore and fix tests broken by lazy-loading
Some checks failed
Build & Deploy / 🔍 Prepare (push) Successful in 4s
Build & Deploy / 🏗️ Build (push) Successful in 1m41s
Build & Deploy / 🧪 QA (push) Successful in 2m1s
Build & Deploy / 🚀 Deploy (push) Failing after 7s
Build & Deploy / 🩺 Health Check (push) Has been skipped
Build & Deploy / 🔔 Notify (push) Successful in 2s
2026-02-11 19:49:52 +01:00
5d01c2e963 fix: remove useless tests
Some checks failed
Build & Deploy / 🔍 Prepare (push) Successful in 4s
Build & Deploy / 🧪 QA (push) Failing after 43s
Build & Deploy / 🏗️ Build (push) Successful in 2m11s
Build & Deploy / 🚀 Deploy (push) Has been skipped
Build & Deploy / 🩺 Health Check (push) Has been skipped
Build & Deploy / 🔔 Notify (push) Successful in 2s
2026-02-11 19:45:06 +01:00
1e32b8fbea fix(lint): remove unused imports in HomeContent.tsx
Some checks failed
Build & Deploy / 🔍 Prepare (push) Successful in 7s
Build & Deploy / 🧪 QA (push) Failing after 49s
Build & Deploy / 🏗️ Build (push) Successful in 2m22s
Build & Deploy / 🚀 Deploy (push) Has been skipped
Build & Deploy / 🩺 Health Check (push) Has been skipped
Build & Deploy / 🔔 Notify (push) Successful in 1s
2026-02-11 19:32:35 +01:00
1919d8bc2a perf: implement multi-phase performance optimizations for PageSpeed 90+
Some checks failed
Build & Deploy / 🔍 Prepare (push) Successful in 5s
Build & Deploy / 🧪 QA (push) Failing after 34s
Build & Deploy / 🏗️ Build (push) Successful in 1m41s
Build & Deploy / 🚀 Deploy (push) Has been skipped
Build & Deploy / 🩺 Health Check (push) Has been skipped
Build & Deploy / 🔔 Notify (push) Successful in 2s
- Resized and compressed oversized logo (204KB -> 21KB)
- Optimized large media images (hs-kabel.png, contact-hero.jpg)
- Implemented dynamic lazy-loading for home page sections
- Tuned Sentry traces sample rate (1.0 -> 0.1)
- Refined font loading and fixed redundant analytics tracking
2026-02-11 19:16:39 +01:00
67d47e3ec7 fix(deploy): pin gatekeeper version and add protocol normalization
Some checks failed
Build & Deploy / 🔍 Prepare (push) Successful in 5s
Build & Deploy / 🏗️ Build (push) Successful in 1m41s
Build & Deploy / 🧪 QA (push) Successful in 2m7s
Build & Deploy / 🚀 Deploy (push) Failing after 13s
Build & Deploy / 🩺 Health Check (push) Has been skipped
Build & Deploy / 🔔 Notify (push) Successful in 2s
2026-02-11 15:29:30 +01:00
2f8d015823 fix(deploy): use correct docker-compose extension (.yaml)
Some checks failed
Build & Deploy / 🔍 Prepare (push) Successful in 4s
Build & Deploy / 🧪 QA (push) Successful in 1m38s
Build & Deploy / 🏗️ Build (push) Successful in 2m8s
Build & Deploy / 🚀 Deploy (push) Failing after 7s
Build & Deploy / 🩺 Health Check (push) Has been skipped
Build & Deploy / 🔔 Notify (push) Successful in 2s
2026-02-11 15:21:18 +01:00
e18bd0b6f3 chore: fix docker build failure, resolve zod conflict and stabilize test suite
Some checks failed
Build & Deploy / 🔍 Prepare (push) Successful in 4s
Build & Deploy / 🧪 QA (push) Successful in 1m36s
Build & Deploy / 🏗️ Build (push) Successful in 3m58s
Build & Deploy / 🚀 Deploy (push) Failing after 9s
Build & Deploy / 🩺 Health Check (push) Has been skipped
Build & Deploy / 🔔 Notify (push) Successful in 2s
2026-02-11 15:08:02 +01:00
2ca79ee23a fix: resolve redundant success messages and next/server resolution errors in tests
Some checks failed
Build & Deploy / 🔍 Prepare (push) Successful in 4s
Build & Deploy / 🧪 QA (push) Successful in 2m2s
Build & Deploy / 🏗️ Build (push) Failing after 25s
Build & Deploy / 🚀 Deploy (push) Has been skipped
Build & Deploy / 🩺 Health Check (push) Has been skipped
Build & Deploy / 🔔 Notify (push) Successful in 2s
2026-02-11 14:42:05 +01:00
e28c3c0f96 chore: standardize project environment and CI/CD maintenance
Some checks failed
Build & Deploy / 🔍 Prepare (push) Successful in 5s
Build & Deploy / 🧪 QA (push) Failing after 58s
Build & Deploy / 🏗️ Build (push) Failing after 2m13s
Build & Deploy / 🚀 Deploy (push) Has been skipped
Build & Deploy / 🩺 Health Check (push) Has been skipped
Build & Deploy / 🔔 Notify (push) Successful in 2s
2026-02-11 12:23:06 +01:00
8f3f56a12c fix: harmonize Zod versions to v3.24.1, restore build, and update tests
Some checks failed
Build & Deploy / 🔍 Prepare (push) Successful in 6s
Build & Deploy / 🧪 QA (push) Failing after 31s
Build & Deploy / 🏗️ Build (push) Successful in 6m51s
Build & Deploy / 🚀 Deploy (push) Has been skipped
Build & Deploy / 🩺 Health Check (push) Has been skipped
Build & Deploy / 🔔 Notify (push) Successful in 2s
2026-02-11 11:58:29 +01:00
8d547c559e chore: standardize
Some checks failed
Build & Deploy / 🔍 Prepare (push) Successful in 4s
Build & Deploy / 🧪 QA (push) Failing after 42s
Build & Deploy / 🏗️ Build (push) Failing after 2m14s
Build & Deploy / 🚀 Deploy (push) Has been skipped
Build & Deploy / 🩺 Health Check (push) Has been skipped
Build & Deploy / 🔔 Notify (push) Successful in 2s
2026-02-11 11:05:37 +01:00
8ff4503270 refactor: standardize env and directus logic using enhanced @mintel/next-utils
Some checks failed
Build & Deploy / 🔍 Prepare (push) Successful in 4s
Build & Deploy / 🧪 QA (push) Failing after 1m30s
Build & Deploy / 🏗️ Build (push) Failing after 1m51s
Build & Deploy / 🚀 Deploy (push) Has been skipped
Build & Deploy / 🔔 Notify (push) Successful in 3s
2026-02-10 23:47:18 +01:00
ad08c6c1f3 ci: simplify QA checks to avoid potential hangs
Some checks failed
Build & Deploy / 🔍 Prepare (push) Successful in 4s
Build & Deploy / 🧪 QA (push) Successful in 1m31s
Build & Deploy / 🏗️ Build (push) Successful in 2m49s
Build & Deploy / 🚀 Deploy (push) Failing after 6s
Build & Deploy / 🔔 Notify (push) Successful in 2s
2026-02-10 22:33:24 +01:00
1f188c84b4 ci: fix workflow syntax (EOF indentation)
Some checks failed
Build & Deploy / 🔍 Prepare (push) Successful in 8s
Build & Deploy / 🧪 QA (push) Successful in 1m48s
Build & Deploy / 🏗️ Build (push) Successful in 3m29s
Build & Deploy / 🚀 Deploy (push) Has been cancelled
Build & Deploy / 🔔 Notify (push) Has been cancelled
2026-02-10 22:25:21 +01:00
e50cdade6c ci: complete pipeline standardization 2026-02-10 22:20:06 +01:00
17bbb2f0e0 ci: fix SSH variable expansion in deployment 2026-02-10 22:17:49 +01:00
ffb73e4b06 ci: restore missing Directus and Mail secrets in deployment
Some checks failed
Build & Deploy / 🔍 Prepare (push) Successful in 4s
Build & Deploy / 🧪 QA (push) Successful in 1m24s
Build & Deploy / 🏗️ Build (push) Successful in 2m46s
Build & Deploy / 🚀 Deploy (push) Failing after 5s
Build & Deploy / 🔔 Notify (push) Successful in 1s
2026-02-10 22:10:51 +01:00
71b30ba8c5 fix: sentry issues
All checks were successful
Build & Deploy / 🔍 Prepare Environment (push) Successful in 5s
Build & Deploy / 🧪 QA (push) Successful in 1m23s
Build & Deploy / 🏗️ Build (push) Successful in 2m55s
Build & Deploy / 🚀 Deploy (push) Successful in 18s
Build & Deploy / 🔔 Notifications (push) Successful in 1s
2026-02-10 13:49:22 +01:00
e9ea253021 fix: build and lint
All checks were successful
Build & Deploy / 🔍 Prepare Environment (push) Successful in 5s
Build & Deploy / 🧪 QA (push) Successful in 1m22s
Build & Deploy / 🏗️ Build (push) Successful in 1m54s
Build & Deploy / 🚀 Deploy (push) Successful in 16s
Build & Deploy / 🔔 Notifications (push) Successful in 1s
2026-02-10 00:31:51 +01:00
71 changed files with 10415 additions and 2751 deletions

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

@@ -0,0 +1,33 @@
name: CI - Quality Assurance
on:
pull_request:
jobs:
qa:
name: 🧪 QA
runs-on: docker
container:
image: catthehacker/ubuntu:act-latest
steps:
- name: Checkout repository
uses: actions/checkout@v4
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: 20
- name: Setup pnpm
uses: pnpm/action-setup@v3
with:
version: 10
- name: 🔐 Registry Auth
run: |
echo "@mintel:registry=https://${{ vars.REGISTRY_HOST || 'npm.infra.mintel.me' }}" > .npmrc
echo "//${{ vars.REGISTRY_HOST || 'npm.infra.mintel.me' }}/:_authToken=${{ secrets.REGISTRY_PASS }}" >> .npmrc
- name: Install dependencies
run: pnpm install --frozen-lockfile
- name: 🧪 Parallel Checks
run: |
pnpm lint &
pnpm build &
wait

View File

@@ -7,138 +7,137 @@ on:
tags:
- 'v*'
workflow_dispatch:
inputs:
skip_checks:
description: 'Skip tests? (true/false)'
required: false
default: 'false'
concurrency:
group: ${{ github.workflow }}
cancel-in-progress: false
group: ${{ github.workflow }}-${{ (github.ref_type == 'tag' && !contains(github.ref_name, '-')) && 'prod' || (github.ref_name == 'main' && 'testing' || github.ref_name) }}
cancel-in-progress: true
jobs:
# ──────────────────────────────────────────────────────────────────────────────
# JOB 1: Prepare Environment
# ──────────────────────────────────────────────────────────────────────────────
prepare:
name: 🔍 Prepare Environment
name: 🔍 Prepare
runs-on: docker
container:
image: catthehacker/ubuntu:act-latest
outputs:
target: ${{ steps.determine.outputs.target }}
image_tag: ${{ steps.determine.outputs.image_tag }}
env_file: ${{ steps.determine.outputs.env_file }}
traefik_host: ${{ steps.determine.outputs.traefik_host }}
next_public_base_url: ${{ steps.determine.outputs.next_public_base_url }}
directus_url: ${{ steps.determine.outputs.directus_url }}
directus_host: ${{ steps.determine.outputs.directus_host }}
gatekeeper_host: ${{ steps.determine.outputs.gatekeeper_host }}
traefik_rule: ${{ steps.determine.outputs.traefik_rule }}
gatekeeper_rule: ${{ steps.determine.outputs.gatekeeper_rule }}
traefik_middlewares: ${{ steps.determine.outputs.traefik_middlewares }}
next_public_url: ${{ steps.determine.outputs.next_public_url }}
project_name: ${{ steps.determine.outputs.project_name }}
short_sha: ${{ steps.determine.outputs.short_sha }}
container:
image: catthehacker/ubuntu:act-latest
steps:
- name: 🔍 Debug Info
- name: 🧹 Maintenance (High Density Cleanup)
shell: bash
run: |
echo "ref_name: ${{ github.ref_name }}"
echo "ref_type: ${{ github.ref_type }}"
echo "tag: ${{ github.ref_name }}"
- name: 🧹 Maintenance (Runner Cleanup)
continue-on-error: true
shell: bash
run: |
docker image prune -f || true
docker builder prune -f --filter "until=24h" || true
echo "Purging old build layers and dangling images..."
docker image prune -f
docker builder prune -f --filter "until=6h"
- name: Checkout repository
uses: actions/checkout@v4
with:
fetch-depth: 2
- name: 🔍 Determine Environment
- name: 🔍 Environment ermitteln
id: determine
shell: bash
run: |
REF="${{ github.ref }}"
REF_NAME="${{ github.ref_name }}"
REF_TYPE="${{ github.ref_type }}"
REF="${{ github.ref_name }}"
SHORT_SHA=$(echo "${{ github.sha }}" | cut -c1-7)
DOMAIN_BASE="mb-grid-solutions.com"
PRJ_ID="mb-grid-solutions"
DOMAIN="mb-grid-solutions.com"
PRJ="mb-grid-solutions"
echo "Detecting environment for ref: $REF ($REF_NAME, type: $REF_TYPE)"
# Fallback for REF_TYPE if missing
if [[ -z "$REF_TYPE" ]]; then
if [[ "$REF" == refs/tags/* ]]; then
REF_TYPE="tag"
elif [[ "$REF" == refs/heads/* ]]; then
REF_TYPE="branch"
fi
fi
if [[ "$REF_TYPE" == "branch" && "$REF_NAME" == "main" ]]; then
if [[ "${{ github.ref_type }}" == "branch" && "$REF" == "main" ]]; then
TARGET="testing"
IMAGE_TAG="testing-${SHORT_SHA}"
IMAGE_TAG="main-${SHORT_SHA}"
ENV_FILE=".env.testing"
TRAEFIK_HOST="testing.${DOMAIN_BASE}"
GATEKEEPER_HOST="gatekeeper.testing.${DOMAIN_BASE}"
NEXT_PUBLIC_BASE_URL="https://testing.${DOMAIN_BASE}"
DIRECTUS_URL="https://cms.testing.${DOMAIN_BASE}"
DIRECTUS_HOST="cms.testing.${DOMAIN_BASE}"
elif [[ "$REF_TYPE" == "tag" ]]; then
if [[ "$REF_NAME" =~ ^v[0-9]+\.[0-9]+(\.[0-9]+)?$ ]]; then
TRAEFIK_HOST="testing.${DOMAIN}"
elif [[ "${{ github.ref_type }}" == "tag" ]]; then
if [[ "$REF" =~ ^v[0-9]+\.[0-9]+(\.[0-9]+)?$ ]]; then
TARGET="production"
IMAGE_TAG="$REF_NAME"
IMAGE_TAG="$REF"
ENV_FILE=".env.prod"
TRAEFIK_HOST="${DOMAIN_BASE}" # Primary domain
GATEKEEPER_HOST="gatekeeper.${DOMAIN_BASE}"
NEXT_PUBLIC_BASE_URL="https://${DOMAIN_BASE}"
DIRECTUS_URL="https://cms.${DOMAIN_BASE}"
DIRECTUS_HOST="cms.${DOMAIN_BASE}"
elif [[ "$REF_NAME" =~ -rc || "$REF_NAME" =~ -beta || "$REF_NAME" =~ -alpha ]]; then
TARGET="staging"
IMAGE_TAG="$REF_NAME"
ENV_FILE=".env.staging"
TRAEFIK_HOST="staging.${DOMAIN_BASE}"
GATEKEEPER_HOST="gatekeeper.staging.${DOMAIN_BASE}"
NEXT_PUBLIC_BASE_URL="https://staging.${DOMAIN_BASE}"
DIRECTUS_URL="https://cms.staging.${DOMAIN_BASE}"
DIRECTUS_HOST="cms.staging.${DOMAIN_BASE}"
TRAEFIK_HOST="${DOMAIN}, www.${DOMAIN}"
else
TARGET="skip"
echo "Tag $REF_NAME did not match any environment pattern."
TARGET="staging"
IMAGE_TAG="$REF"
ENV_FILE=".env.staging"
TRAEFIK_HOST="staging.${DOMAIN}"
fi
else
TARGET="skip"
echo "Ref type $REF_TYPE is not handled for deployment."
fi
# Determine Rules based on target (if not skipped)
if [[ "$TARGET" != "skip" ]]; then
if [[ "$TARGET" == "production" ]]; then
TRAEFIK_RULE="Host(\`${DOMAIN_BASE}\`) || Host(\`www.${DOMAIN_BASE}\`)"
GATEKEEPER_RULE="(Host(\`${DOMAIN_BASE}\`) || Host(\`www.${DOMAIN_BASE}\`)) && PathPrefix(\`/gatekeeper\`) || Host(\`gatekeeper.${DOMAIN_BASE}\`)"
TRAEFIK_MIDDLEWARES="compress"
# Standardize Traefik Rule
if [[ "$TRAEFIK_HOST" == *","* ]]; then
TRAEFIK_RULE=$(echo "$TRAEFIK_HOST" | sed 's/,/ /g' | awk '{for(i=1;i<=NF;i++) printf "Host(\`%s\`)%s", $i, (i==NF?"":" || ")}')
PRIMARY_HOST=$(echo "$TRAEFIK_HOST" | cut -d',' -f1 | sed 's/ //g')
else
TRAEFIK_RULE="Host(\`${TRAEFIK_HOST}\`)"
GATEKEEPER_RULE="(Host(\`${TRAEFIK_HOST}\`) && PathPrefix(\`/gatekeeper\`)) || Host(\`gatekeeper.${TRAEFIK_HOST}\`)"
TRAEFIK_MIDDLEWARES="${PRJ_ID}-${TARGET}-auth"
TRAEFIK_RULE="Host(\`$TRAEFIK_HOST\`)"
PRIMARY_HOST="$TRAEFIK_HOST"
fi
{
echo "target=$TARGET"
echo "image_tag=$IMAGE_TAG"
echo "env_file=$ENV_FILE"
echo "traefik_host=$PRIMARY_HOST"
echo "traefik_rule=$TRAEFIK_RULE"
echo "next_public_url=https://$PRIMARY_HOST"
echo "project_name=$PRJ-$TARGET"
echo "short_sha=$SHORT_SHA"
} >> "$GITHUB_OUTPUT"
# ⏳ Wait for Upstream Packages/Images if Tagged
if [[ "${{ github.ref_type }}" == "tag" ]]; then
echo "🔎 Checking for @mintel dependencies in package.json..."
# Extract any @mintel/ version (they should be synced in monorepo)
UPSTREAM_VERSION=$(grep -o '"@mintel/.*": "[^"]*"' package.json | grep -v "next-utils" | cut -d'"' -f4 | sed 's/\^//; s/\~//' | sort -V | tail -1)
TAG_TO_WAIT="v$UPSTREAM_VERSION"
if [[ -n "$UPSTREAM_VERSION" && "$UPSTREAM_VERSION" != "workspace:"* ]]; then
# 1. Discovery (Works without token for public repositories)
UPSTREAM_SHA=$(git ls-remote --tags https://git.infra.mintel.me/mmintel/at-mintel.git "$TAG_TO_WAIT" | grep "$TAG_TO_WAIT" | tail -n1 | awk '{print $1}')
if [[ -z "$UPSTREAM_SHA" ]]; then
echo "❌ Error: Tag $TAG_TO_WAIT not found in mmintel/at-mintel."
exit 1
fi
echo "✅ Tag verified: Found upstream SHA $UPSTREAM_SHA for $TAG_TO_WAIT"
# 2. Status Check (Requires PAT for cross-repo API access)
POLL_TOKEN="${{ secrets.GITEA_PAT || secrets.MINTEL_PRIVATE_TOKEN }}"
if [[ -n "$POLL_TOKEN" ]]; then
echo "⏳ POLL_TOKEN found. Checking upstream build status..."
curl -s -H "Authorization: token ${{ secrets.GITHUB_TOKEN }}" \
"https://git.infra.mintel.me/mmintel/at-mintel/raw/branch/main/packages/infra/scripts/wait-for-upstream.sh" > wait-for-upstream.sh
chmod +x wait-for-upstream.sh
GITEA_TOKEN="$POLL_TOKEN" ./wait-for-upstream.sh "mmintel/at-mintel" "$TAG_TO_WAIT"
else
echo " No PAT secret found. Skipping build status wait (Actions API is restricted)."
echo " If this build fails, ensure that mmintel/at-mintel $TAG_TO_WAIT has finished its Docker build."
fi
fi
fi
else
echo "target=skip" >> "$GITHUB_OUTPUT"
fi
echo "Target determined: $TARGET"
echo "Image tag: $IMAGE_TAG"
echo "target=$TARGET" >> "$GITHUB_OUTPUT"
echo "image_tag=$IMAGE_TAG" >> "$GITHUB_OUTPUT"
echo "env_file=$ENV_FILE" >> "$GITHUB_OUTPUT"
echo "traefik_host=$TRAEFIK_HOST" >> "$GITHUB_OUTPUT"
echo "traefik_rule=$TRAEFIK_RULE" >> "$GITHUB_OUTPUT"
echo "gatekeeper_rule=$GATEKEEPER_RULE" >> "$GITHUB_OUTPUT"
echo "traefik_middlewares=$TRAEFIK_MIDDLEWARES" >> "$GITHUB_OUTPUT"
echo "gatekeeper_host=$GATEKEEPER_HOST" >> "$GITHUB_OUTPUT"
echo "next_public_base_url=$NEXT_PUBLIC_BASE_URL" >> "$GITHUB_OUTPUT"
echo "directus_url=$DIRECTUS_URL" >> "$GITHUB_OUTPUT"
echo "directus_host=$DIRECTUS_HOST" >> "$GITHUB_OUTPUT"
echo "project_name=$PRJ_ID-$TARGET" >> "$GITHUB_OUTPUT"
# ──────────────────────────────────────────────────────────────────────────────
# JOB 2: QA (Lint, Build Test)
# ──────────────────────────────────────────────────────────────────────────────
qa:
name: 🧪 QA
needs: prepare
@@ -153,25 +152,31 @@ jobs:
uses: actions/setup-node@v4
with:
node-version: 20
- name: Install dependencies
shell: bash
- name: Setup pnpm
uses: pnpm/action-setup@v3
with:
version: 10
- name: 🔐 Registry Auth
run: |
corepack enable
pnpm install --frozen-lockfile
env:
NPM_TOKEN: ${{ secrets.NPM_TOKEN }}
- name: 🧪 Lint
shell: bash
run: pnpm lint
env:
NPM_TOKEN: ${{ secrets.NPM_TOKEN }}
echo "@mintel:registry=https://${{ vars.REGISTRY_HOST || 'npm.infra.mintel.me' }}" > .npmrc
echo "//${{ vars.REGISTRY_HOST || 'npm.infra.mintel.me' }}/:_authToken=${{ secrets.REGISTRY_PASS }}" >> .npmrc
- name: Install dependencies
run: |
pnpm store prune
pnpm install --no-frozen-lockfile
- name: 🧪 QA Checks
if: github.event.inputs.skip_checks != 'true'
run: |
pnpm lint
pnpm exec tsc --noEmit
pnpm test run
- name: 🏗️ Build Test
shell: bash
if: github.event.inputs.skip_checks != 'true'
run: pnpm build
env:
NPM_TOKEN: ${{ secrets.NPM_TOKEN }}
NEXT_PUBLIC_BASE_URL: https://dummy.test
# ──────────────────────────────────────────────────────────────────────────────
# JOB 3: Build & Push
# ──────────────────────────────────────────────────────────────────────────────
build:
name: 🏗️ Build
needs: prepare
@@ -182,134 +187,317 @@ jobs:
steps:
- name: Checkout repository
uses: actions/checkout@v4
- name: 🐳 Set up Docker Buildx
uses: docker/setup-buildx-action@v3
- name: 🔐 Registry Login
run: echo "${{ secrets.REGISTRY_PASS }}" | docker login registry.infra.mintel.me -u "${{ secrets.REGISTRY_USER }}" --password-stdin
- name: 🏗️ Build and Push
shell: bash
run: |
docker buildx build \
--pull \
--platform linux/arm64 \
--build-arg NPM_TOKEN=${{ secrets.NPM_TOKEN }} \
--build-arg NEXT_PUBLIC_BASE_URL=${{ needs.prepare.outputs.next_public_base_url }} \
--build-arg NEXT_PUBLIC_TARGET=${{ needs.prepare.outputs.target }} \
--build-arg DIRECTUS_URL=${{ needs.prepare.outputs.directus_url }} \
--build-arg UMAMI_API_ENDPOINT=${{ secrets.UMAMI_API_ENDPOINT || secrets.NEXT_PUBLIC_UMAMI_SCRIPT_URL || vars.UMAMI_API_ENDPOINT || 'https://analytics.infra.mintel.me' }} \
--build-arg NEXT_PUBLIC_UMAMI_WEBSITE_ID=${{ secrets.UMAMI_WEBSITE_ID || secrets.NEXT_PUBLIC_UMAMI_WEBSITE_ID || vars.UMAMI_WEBSITE_ID || vars.NEXT_PUBLIC_UMAMI_WEBSITE_ID }} \
-t registry.infra.mintel.me/mintel/mb-grid-solutions:${{ needs.prepare.outputs.image_tag }} \
--push .
uses: docker/build-push-action@v5
with:
context: .
push: true
platforms: linux/amd64
build-args: |
NEXT_PUBLIC_BASE_URL=${{ needs.prepare.outputs.next_public_url }}
NEXT_PUBLIC_TARGET=${{ needs.prepare.outputs.target }}
NPM_TOKEN=${{ secrets.REGISTRY_PASS }}
tags: registry.infra.mintel.me/mintel/mb-grid-solutions:${{ needs.prepare.outputs.image_tag }}
cache-from: type=registry,ref=registry.infra.mintel.me/mintel/mb-grid-solutions:buildcache
cache-to: type=registry,ref=registry.infra.mintel.me/mintel/mb-grid-solutions:buildcache,mode=max
secrets: |
"NPM_TOKEN=${{ secrets.REGISTRY_PASS }}"
# ──────────────────────────────────────────────────────────────────────────────
# JOB 4: Deploy
# ──────────────────────────────────────────────────────────────────────────────
deploy:
name: 🚀 Deploy
needs: [prepare, build, qa]
if: needs.prepare.outputs.target != 'skip'
runs-on: docker
container:
image: catthehacker/ubuntu:act-latest
env:
TARGET: ${{ needs.prepare.outputs.target }}
IMAGE_TAG: ${{ needs.prepare.outputs.image_tag }}
PROJECT_NAME: ${{ needs.prepare.outputs.project_name }}
NEXT_PUBLIC_BASE_URL: ${{ needs.prepare.outputs.next_public_url }}
# Secrets mapping (Database & CMS)
PAYLOAD_SECRET: ${{ (needs.prepare.outputs.target == 'testing' && secrets.TESTING_PAYLOAD_SECRET) || (needs.prepare.outputs.target == 'staging' && secrets.STAGING_PAYLOAD_SECRET) || secrets.PAYLOAD_SECRET || secrets.DIRECTUS_SECRET || vars.PAYLOAD_SECRET || 'you-need-to-set-a-payload-secret' }}
DATABASE_URI: ${{ (needs.prepare.outputs.target == 'testing' && secrets.TESTING_DATABASE_URI) || (needs.prepare.outputs.target == 'staging' && secrets.STAGING_DATABASE_URI) || secrets.DATABASE_URI || vars.DATABASE_URI }}
POSTGRES_DB: ${{ secrets.POSTGRES_DB || vars.POSTGRES_DB || 'payload' }}
POSTGRES_USER: ${{ secrets.POSTGRES_USER || vars.POSTGRES_USER || 'postgres' }}
POSTGRES_PASSWORD: ${{ (needs.prepare.outputs.target == 'testing' && secrets.TESTING_POSTGRES_PASSWORD) || (needs.prepare.outputs.target == 'staging' && secrets.STAGING_POSTGRES_PASSWORD) || secrets.POSTGRES_PASSWORD || vars.POSTGRES_PASSWORD || 'postgres' }}
# Secrets mapping (Mail)
MAIL_HOST: ${{ secrets.SMTP_HOST || vars.SMTP_HOST }}
MAIL_PORT: ${{ secrets.SMTP_PORT || vars.SMTP_PORT || '587' }}
MAIL_USERNAME: ${{ secrets.SMTP_USER || vars.SMTP_USER }}
MAIL_PASSWORD: ${{ secrets.SMTP_PASS || vars.SMTP_PASS }}
MAIL_FROM: ${{ secrets.SMTP_FROM || vars.SMTP_FROM }}
MAIL_RECIPIENTS: ${{ secrets.CONTACT_RECIPIENT || vars.CONTACT_RECIPIENT }}
# Authentication
GATEKEEPER_PASSWORD: ${{ secrets.GATEKEEPER_PASSWORD || vars.GATEKEEPER_PASSWORD }}
AUTH_COOKIE_NAME: ${{ secrets.AUTH_COOKIE_NAME || vars.AUTH_COOKIE_NAME || 'mintel_gatekeeper_session' }}
COOKIE_DOMAIN: ${{ secrets.COOKIE_DOMAIN || vars.COOKIE_DOMAIN || '.mb-grid-solutions.com' }}
# Monitoring & Services
SENTRY_DSN: ${{ secrets.SENTRY_DSN || vars.SENTRY_DSN }}
UMAMI_WEBSITE_ID: ${{ secrets.UMAMI_WEBSITE_ID || secrets.NEXT_PUBLIC_UMAMI_WEBSITE_ID || vars.UMAMI_WEBSITE_ID || vars.NEXT_PUBLIC_UMAMI_WEBSITE_ID }}
UMAMI_API_ENDPOINT: ${{ secrets.UMAMI_API_ENDPOINT || secrets.NEXT_PUBLIC_UMAMI_SCRIPT_URL || vars.UMAMI_API_ENDPOINT || 'https://analytics.infra.mintel.me' }}
PROJECT_COLOR: ${{ secrets.PROJECT_COLOR || vars.PROJECT_COLOR || '#82ed20' }}
steps:
- name: Checkout repository
uses: actions/checkout@v4
- name: 📝 Generate Environment
shell: bash
env:
TRAEFIK_HOST: ${{ needs.prepare.outputs.traefik_host }}
ENV_FILE: ${{ needs.prepare.outputs.env_file }}
run: |
# Middleware & Auth Logic
LOG_LEVEL=$( [[ "$TARGET" == "testing" || "$TARGET" == "development" ]] && echo "debug" || echo "info" )
STD_MW="${PROJECT_NAME}-forward,compress"
if [[ "$TARGET" == "production" ]]; then
AUTH_MIDDLEWARE="$STD_MW"
COMPOSE_PROFILES=""
else
# Order: Forward (Proto) -> Auth -> Compression
AUTH_MIDDLEWARE="${PROJECT_NAME}-forward,${PROJECT_NAME}-auth,compress"
COMPOSE_PROFILES="gatekeeper"
fi
# Gatekeeper Origin
COOKIE_DOMAIN=.$(echo $NEXT_PUBLIC_BASE_URL | sed 's|https://||')
GATEKEEPER_ORIGIN="$NEXT_PUBLIC_BASE_URL/gatekeeper"
# Generate Environment File
cat > .env.deploy << EOF
# Generated by CI - $TARGET
IMAGE_TAG=$IMAGE_TAG
NEXT_PUBLIC_BASE_URL=$NEXT_PUBLIC_BASE_URL
GATEKEEPER_ORIGIN=$GATEKEEPER_ORIGIN
SENTRY_DSN=$SENTRY_DSN
PROJECT_COLOR=$PROJECT_COLOR
LOG_LEVEL=$LOG_LEVEL
# Database & Payload
DATABASE_URI=\${DATABASE_URI:-postgresql://$POSTGRES_USER:$POSTGRES_PASSWORD@mb-grid-db:5432/$POSTGRES_DB}
PAYLOAD_SECRET=${PAYLOAD_SECRET:-you-need-to-set-a-payload-secret}
POSTGRES_DB=$POSTGRES_DB
POSTGRES_USER=$POSTGRES_USER
POSTGRES_PASSWORD=$POSTGRES_PASSWORD
# Mail
MAIL_HOST=$MAIL_HOST
MAIL_PORT=$MAIL_PORT
MAIL_USERNAME=$MAIL_USERNAME
MAIL_PASSWORD=$MAIL_PASSWORD
MAIL_FROM=$MAIL_FROM
MAIL_RECIPIENTS=$MAIL_RECIPIENTS
# Authentication
GATEKEEPER_PASSWORD=$GATEKEEPER_PASSWORD
AUTH_COOKIE_NAME=$AUTH_COOKIE_NAME
COOKIE_DOMAIN=$COOKIE_DOMAIN
# Analytics
UMAMI_WEBSITE_ID=$UMAMI_WEBSITE_ID
NEXT_PUBLIC_UMAMI_WEBSITE_ID=$UMAMI_WEBSITE_ID
UMAMI_API_ENDPOINT=$UMAMI_API_ENDPOINT
TARGET=$TARGET
SENTRY_ENVIRONMENT=$TARGET
PROJECT_NAME=$PROJECT_NAME
ENV_FILE=$ENV_FILE
TRAEFIK_HOST=$TRAEFIK_HOST
COMPOSE_PROFILES=$COMPOSE_PROFILES
TRAEFIK_MIDDLEWARES=$AUTH_MIDDLEWARE
EOF
- name: 🚀 SSH Deploy
shell: bash
env:
ENV_FILE: ${{ needs.prepare.outputs.env_file }}
run: |
mkdir -p ~/.ssh
echo "${{ secrets.ALPHA_SSH_KEY }}" > ~/.ssh/id_ed25519
chmod 600 ~/.ssh/id_ed25519
ssh-keyscan -H alpha.mintel.me >> ~/.ssh/known_hosts 2>/dev/null
# Transfer and Restart
SITE_DIR="/home/deploy/sites/mb-grid-solutions.com"
ssh root@alpha.mintel.me "mkdir -p $SITE_DIR"
scp .env.deploy root@alpha.mintel.me:$SITE_DIR/$ENV_FILE
scp docker-compose.yaml root@alpha.mintel.me:$SITE_DIR/docker-compose.yaml
ssh root@alpha.mintel.me "cd $SITE_DIR && echo '${{ secrets.REGISTRY_PASS }}' | docker login registry.infra.mintel.me -u '${{ secrets.REGISTRY_USER }}' --password-stdin"
ssh root@alpha.mintel.me "cd $SITE_DIR && docker compose -p '${{ needs.prepare.outputs.project_name }}' --env-file '$ENV_FILE' pull"
ssh root@alpha.mintel.me "cd $SITE_DIR && docker compose -p '${{ needs.prepare.outputs.project_name }}' --env-file '$ENV_FILE' up -d --remove-orphans"
# Apply Payload Migrations using the target app container's programmatic endpoint
ssh root@alpha.mintel.me "cd $SITE_DIR && echo '→ Waiting for DB and Running Payload Migrations...' && \
for i in {1..5}; do \
echo \"Attempt \$i...\"; \
docker compose -p '${{ needs.prepare.outputs.project_name }}' --env-file '$ENV_FILE' exec -T mb-grid-app sh -c 'curl -s -f -X POST -H \"Authorization: Bearer \$PAYLOAD_SECRET\" http://localhost:3000/api/payload/migrate \
|| { echo \"HTTP error or DB not ready.\"; exit 1; }' && { echo '✅ Migrations successful!'; break; } \
|| { if [ \$i -eq 5 ]; then echo '❌ Migration failed after 5 attempts!'; exit 1; else echo '⏳ Retrying in 5s...'; sleep 5; fi; }; \
done"
ssh root@alpha.mintel.me "docker system prune -f --filter 'until=24h'"
- name: 🧹 Post-Deploy Cleanup (Runner)
if: always()
run: docker builder prune -f --filter "until=1h"
# ──────────────────────────────────────────────────────────────────────────────
# JOB 5: Post-Deploy Verification (Smoke Tests + Quality Gates)
# ──────────────────────────────────────────────────────────────────────────────
post_deploy_checks:
name: 🧪 Post-Deploy Verification
needs: [prepare, deploy]
if: needs.deploy.result == 'success' && needs.prepare.outputs.target != 'branch'
runs-on: docker
container:
image: catthehacker/ubuntu:act-latest
steps:
- name: Checkout repository
uses: actions/checkout@v4
- name: Setup pnpm
uses: pnpm/action-setup@v3
with:
fetch-depth: 1
- name: 🚀 Deploy via SSH
shell: bash
version: 10
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: 20
- name: 🔐 Registry Auth
run: |
echo "Deploying to alpha.mintel.me"
mkdir -p ~/.ssh
echo "${{ secrets.ALPHA_SSH_KEY }}" > ~/.ssh/id_ed25519
chmod 600 ~/.ssh/id_ed25519
ssh-keyscan -H alpha.mintel.me >> ~/.ssh/known_hosts 2>/dev/null
echo "@mintel:registry=https://${{ vars.REGISTRY_HOST || 'npm.infra.mintel.me' }}" > .npmrc
echo "//${{ vars.REGISTRY_HOST || 'npm.infra.mintel.me' }}/:_authToken=${{ secrets.REGISTRY_PASS }}" >> .npmrc
- name: Install dependencies
id: deps
run: |
pnpm store prune
pnpm install --no-frozen-lockfile
- name: 📦 Cache APT Packages
uses: actions/cache@v4
with:
path: /var/cache/apt/archives
key: apt-cache-${{ runner.os }}-${{ runner.arch }}-chromium
# Generate Environment File
cat > .env.deploy << 'EOF'
ENV_FILE=${{ needs.prepare.outputs.env_file }}
IMAGE_TAG=${{ needs.prepare.outputs.image_tag }}
TRAEFIK_HOST=${{ needs.prepare.outputs.traefik_host }}
TRAEFIK_RULE=${{ needs.prepare.outputs.traefik_rule }}
TRAEFIK_MIDDLEWARES=${{ needs.prepare.outputs.traefik_middlewares }}
GATEKEEPER_RULE=${{ needs.prepare.outputs.gatekeeper_rule }}
GATEKEEPER_HOST=${{ needs.prepare.outputs.gatekeeper_host }}
PROJECT_NAME=${{ needs.prepare.outputs.project_name }}
NEXT_PUBLIC_BASE_URL=${{ needs.prepare.outputs.next_public_base_url }}
- name: 💾 Cache Chromium
id: cache-chromium
uses: actions/cache@v4
with:
path: /usr/bin/chromium
key: ${{ runner.os }}-chromium-native-${{ hashFiles('package.json') }}
# Directus
DIRECTUS_URL=${{ needs.prepare.outputs.directus_url }}
DIRECTUS_HOST=${{ needs.prepare.outputs.directus_host }}
INTERNAL_DIRECTUS_URL=http://directus:8055
DIRECTUS_API_TOKEN=${{ secrets.DIRECTUS_API_TOKEN || vars.DIRECTUS_API_TOKEN }}
DIRECTUS_ADMIN_EMAIL=${{ secrets.DIRECTUS_ADMIN_EMAIL || vars.DIRECTUS_ADMIN_EMAIL || 'admin@mintel.me' }}
DIRECTUS_ADMIN_PASSWORD=${{ secrets.DIRECTUS_ADMIN_PASSWORD || vars.DIRECTUS_ADMIN_PASSWORD }}
DIRECTUS_DB_NAME=${{ secrets.DIRECTUS_DB_NAME || vars.DIRECTUS_DB_NAME || 'directus' }}
DIRECTUS_DB_USER=${{ secrets.DIRECTUS_DB_USER || vars.DIRECTUS_DB_USER || 'directus' }}
DIRECTUS_DB_PASSWORD=${{ secrets.DIRECTUS_DB_PASSWORD || vars.DIRECTUS_DB_PASSWORD }}
DIRECTUS_KEY=${{ secrets.DIRECTUS_KEY || vars.DIRECTUS_KEY }}
DIRECTUS_SECRET=${{ secrets.DIRECTUS_SECRET || vars.DIRECTUS_SECRET }}
# Mail
MAIL_HOST=${{ secrets.SMTP_HOST || vars.SMTP_HOST }}
MAIL_PORT=${{ secrets.SMTP_PORT || vars.SMTP_PORT || '587' }}
MAIL_USERNAME=${{ secrets.SMTP_USER || vars.SMTP_USER }}
MAIL_PASSWORD=${{ secrets.SMTP_PASS || vars.SMTP_PASS }}
MAIL_FROM=${{ secrets.SMTP_FROM || vars.SMTP_FROM }}
MAIL_RECIPIENTS=${{ secrets.CONTACT_RECIPIENT || vars.CONTACT_RECIPIENT }}
- name: 🔍 Install Chromium (Native & ARM64)
if: steps.cache-chromium.outputs.cache-hit != 'true'
run: |
rm -f /etc/apt/apt.conf.d/docker-clean
apt-get update
apt-get install -y gnupg wget ca-certificates
OS_ID=$(. /etc/os-release && echo $ID)
CODENAME=$(. /etc/os-release && echo $VERSION_CODENAME)
if [ "$OS_ID" = "debian" ]; then
apt-get install -y chromium
else
mkdir -p /etc/apt/keyrings
KEY_ID="82BB6851C64F6880"
wget -qO- "https://keyserver.ubuntu.com/pks/lookup?op=get&search=0x$KEY_ID" | gpg --dearmor > /etc/apt/keyrings/xtradeb.gpg
echo "deb [signed-by=/etc/apt/keyrings/xtradeb.gpg] http://ppa.launchpad.net/xtradeb/apps/ubuntu $CODENAME main" > /etc/apt/sources.list.d/xtradeb-ppa.list
printf "Package: *\nPin: release o=LP-PPA-xtradeb-apps\nPin-Priority: 1001\n" > /etc/apt/preferences.d/xtradeb
apt-get update
apt-get install -y --allow-downgrades chromium
fi
[ -f /usr/bin/chromium ] && ln -sf /usr/bin/chromium /usr/bin/google-chrome
[ -f /usr/bin/chromium ] && ln -sf /usr/bin/chromium /usr/bin/chromium-browser
# Authentication
GATEKEEPER_PASSWORD=${{ secrets.GATEKEEPER_PASSWORD || vars.GATEKEEPER_PASSWORD }}
AUTH_COOKIE_NAME=${{ secrets.AUTH_COOKIE_NAME || vars.AUTH_COOKIE_NAME || 'mintel_gatekeeper_session' }}
COOKIE_DOMAIN=${{ secrets.COOKIE_DOMAIN || vars.COOKIE_DOMAIN || '.mb-grid-solutions.com' }}
# ── Critical Smoke Tests (MUST pass) ──────────────────────────────────
- name: 🏥 CMS Deep Health Check
env:
DEPLOY_URL: ${{ needs.prepare.outputs.next_public_url }}
GK_PASS: ${{ secrets.GATEKEEPER_PASSWORD || vars.GATEKEEPER_PASSWORD }}
run: |
echo "Waiting 10s for app to fully start..."
sleep 10
echo "Checking basic health..."
curl -sf "$DEPLOY_URL/api/health" || { echo "❌ Basic health check failed"; exit 1; }
echo "✅ Basic health OK"
# External Services
SENTRY_DSN=${{ secrets.SENTRY_DSN || vars.SENTRY_DSN }}
GOTIFY_URL=${{ secrets.GOTIFY_URL || vars.GOTIFY_URL }}
GOTIFY_TOKEN=${{ secrets.GOTIFY_TOKEN || vars.GOTIFY_TOKEN }}
UMAMI_WEBSITE_ID=${{ secrets.UMAMI_WEBSITE_ID || secrets.NEXT_PUBLIC_UMAMI_WEBSITE_ID || vars.UMAMI_WEBSITE_ID || vars.NEXT_PUBLIC_UMAMI_WEBSITE_ID }}
NEXT_PUBLIC_UMAMI_WEBSITE_ID=${{ secrets.UMAMI_WEBSITE_ID || secrets.NEXT_PUBLIC_UMAMI_WEBSITE_ID || vars.UMAMI_WEBSITE_ID || vars.NEXT_PUBLIC_UMAMI_WEBSITE_ID }}
UMAMI_API_ENDPOINT=${{ secrets.UMAMI_API_ENDPOINT || secrets.NEXT_PUBLIC_UMAMI_SCRIPT_URL || vars.UMAMI_API_ENDPOINT || 'https://analytics.infra.mintel.me' }}
# Project
PROJECT_COLOR=${{ secrets.PROJECT_COLOR || vars.PROJECT_COLOR || '#82ed20' }}
EOF
- name: 🌐 Core Smoke Tests (HTTP, API, Locale)
if: always() && steps.deps.outcome == 'success'
uses: https://git.infra.mintel.me/mmintel/at-mintel/.gitea/actions/core-smoke-tests@main
with:
TARGET_URL: ${{ needs.prepare.outputs.next_public_url }}
GATEKEEPER_PASSWORD: ${{ secrets.GATEKEEPER_PASSWORD || vars.GATEKEEPER_PASSWORD }}
UMAMI_API_ENDPOINT: ${{ secrets.UMAMI_API_ENDPOINT || secrets.NEXT_PUBLIC_UMAMI_SCRIPT_URL || vars.UMAMI_API_ENDPOINT || 'https://analytics.infra.mintel.me' }}
SENTRY_DSN: ${{ secrets.SENTRY_DSN || vars.SENTRY_DSN }}
APP_DIR="/home/deploy/sites/mb-grid-solutions.com"
ssh -o StrictHostKeyChecking=accept-new root@alpha.mintel.me "mkdir -p $APP_DIR"
scp -o StrictHostKeyChecking=accept-new .env.deploy root@alpha.mintel.me:$APP_DIR/${{ needs.prepare.outputs.env_file }}
scp -o StrictHostKeyChecking=accept-new docker-compose.yaml root@alpha.mintel.me:$APP_DIR/docker-compose.yaml
ssh -o StrictHostKeyChecking=accept-new root@alpha.mintel.me bash << 'EOF'
set -e
APP_DIR="/home/deploy/sites/mb-grid-solutions.com"
cd $APP_DIR
echo "${{ secrets.REGISTRY_PASS }}" | docker login registry.infra.mintel.me -u "${{ secrets.REGISTRY_USER }}" --password-stdin
docker compose -p "${{ needs.prepare.outputs.project_name }}" --env-file ${{ needs.prepare.outputs.env_file }} pull
docker compose -p "${{ needs.prepare.outputs.project_name }}" --env-file ${{ needs.prepare.outputs.env_file }} up -d --wait --remove-orphans
docker system prune -f --filter "until=24h"
EOF
- name: 📝 E2E Form Submission Test
if: always() && steps.deps.outcome == 'success'
env:
NEXT_PUBLIC_BASE_URL: ${{ needs.prepare.outputs.next_public_url }}
GATEKEEPER_PASSWORD: ${{ secrets.GATEKEEPER_PASSWORD || vars.GATEKEEPER_PASSWORD }}
PUPPETEER_EXECUTABLE_PATH: /usr/bin/chromium
run: pnpm test run
# ──────────────────────────────────────────────────────────────────────────────
# JOB 6: Notifications
# ──────────────────────────────────────────────────────────────────────────────
notifications:
name: 🔔 Notifications
needs: [prepare, deploy]
name: 🔔 Notify
needs: [prepare, deploy, post_deploy_checks]
if: always()
runs-on: docker
container:
image: catthehacker/ubuntu:act-latest
steps:
- name: Notify Gotify
- name: 🔔 Gotify
shell: bash
run: |
STATUS="${{ needs.deploy.result }}"
COLOR="info"
[[ "$STATUS" == "success" ]] && PRIORITY=5 || PRIORITY=8
curl -X POST "${{ secrets.GOTIFY_URL }}/message?token=${{ secrets.GOTIFY_TOKEN }}" \
-F "title=mb-grid-solutions Deployment" \
-F "message=Status: $STATUS for ${{ needs.prepare.outputs.target }} (${{ needs.prepare.outputs.image_tag }})" \
-F "priority=$PRIORITY"
DEPLOY="${{ needs.deploy.result }}"
SMOKE="${{ needs.post_deploy_checks.result }}"
PERF="${{ needs.post_deploy_checks.result }}"
TARGET="${{ needs.prepare.outputs.target }}"
VERSION="${{ needs.prepare.outputs.image_tag }}"
URL="${{ needs.prepare.outputs.next_public_url }}"
# Gotify priority scale:
# 1-3 = low (silent/info)
# 4-5 = normal
# 6-7 = high (warning)
# 8-10 = critical (alarm)
if [[ "$DEPLOY" != "success" ]]; then
PRIORITY=10
EMOJI="🚨"
STATUS_LINE="DEPLOY FAILED"
elif [[ "$SMOKE" != "success" ]]; then
PRIORITY=8
EMOJI="⚠️"
STATUS_LINE="Smoke tests failed"
elif [[ "$PERF" != "success" ]]; then
PRIORITY=5
EMOJI="📉"
STATUS_LINE="Performance degraded"
else
PRIORITY=2
EMOJI="✅"
STATUS_LINE="All checks passed"
fi
TITLE="$EMOJI mb-grid-solutions.com $VERSION → $TARGET"
MESSAGE="$STATUS_LINE
Deploy: $DEPLOY | Smoke: $SMOKE | Perf: $PERF
$URL"
curl -s -k -X POST "${{ secrets.GOTIFY_URL }}/message?token=${{ secrets.GOTIFY_TOKEN }}" \
-F "title=$TITLE" \
-F "message=$MESSAGE" \
-F "priority=$PRIORITY" || true

17
.gitea/workflows/qa.yml Normal file
View File

@@ -0,0 +1,17 @@
name: Nightly QA
on:
schedule:
- cron: '0 4 * * *'
workflow_dispatch:
jobs:
call-qa-workflow:
uses: mmintel/at-mintel/.gitea/workflows/quality-assurance-template.yml@main
with:
TARGET_URL: 'https://testing.mb-grid-solutions.com'
PROJECT_NAME: 'mb-grid-solutions'
secrets:
GOTIFY_URL: ${{ secrets.GOTIFY_URL }}
GOTIFY_TOKEN: ${{ secrets.GOTIFY_TOKEN }}
GATEKEEPER_PASSWORD: ${{ secrets.GATEKEEPER_PASSWORD || vars.GATEKEEPER_PASSWORD }}

View File

@@ -1,57 +1,68 @@
# Start from the pre-built Nextjs Base image
FROM registry.infra.mintel.me/mintel/nextjs:latest AS builder
# Stage 1: Builder
FROM node:20-alpine AS builder
WORKDIR /app
# Ensure we are in a clean, standalone environment
RUN rm -rf packages apps pnpm-workspace.yaml 2>/dev/null || true
# Clean the workspace
RUN rm -rf ./*
# Build-time environment variables for Next.js
# Arguments for build-time configuration
ARG NEXT_PUBLIC_BASE_URL
ARG UMAMI_API_ENDPOINT
ARG NEXT_PUBLIC_TARGET
ARG DIRECTUS_URL
ARG UMAMI_API_ENDPOINT
ARG NEXT_PUBLIC_UMAMI_WEBSITE_ID
ARG NPM_TOKEN
# Environment variables for Next.js build
ENV NEXT_PUBLIC_BASE_URL=$NEXT_PUBLIC_BASE_URL
ENV UMAMI_API_ENDPOINT=$UMAMI_API_ENDPOINT
ENV NEXT_PUBLIC_TARGET=$NEXT_PUBLIC_TARGET
ENV DIRECTUS_URL=$DIRECTUS_URL
ENV UMAMI_API_ENDPOINT=$UMAMI_API_ENDPOINT
ENV NEXT_PUBLIC_UMAMI_WEBSITE_ID=$NEXT_PUBLIC_UMAMI_WEBSITE_ID
ENV NPM_TOKEN=$NPM_TOKEN
ENV SENTRY_SUPPRESS_TURBOPACK_WARNING=1
ENV SKIP_RUNTIME_ENV_VALIDATION=true
ENV CI=true
# Enable corepack
RUN corepack enable
# Enable pnpm
RUN corepack enable && corepack prepare pnpm@10.3.0 --activate
# Copy package files
COPY package.json pnpm-lock.yaml* .npmrc ./
# Copy lockfile and manifest for dependency installation caching
COPY pnpm-lock.yaml package.json .npmrc* ./
# Install dependencies
RUN pnpm install --no-frozen-lockfile
# Install dependencies with cache mount
RUN --mount=type=cache,id=pnpm,target=/pnpm/store \
--mount=type=secret,id=NPM_TOKEN \
export NPM_TOKEN=$(cat /run/secrets/NPM_TOKEN 2>/dev/null || echo $NPM_TOKEN) && \
pnpm store prune && \
pnpm install --no-frozen-lockfile
# Copy local files
# Copy source code
COPY . .
# Build the specific application
# Build application
RUN pnpm build
# Production runner image
FROM registry.infra.mintel.me/mintel/runtime:latest AS runner
# Production environment configuration
# Stage 2: Runner
FROM node:20-alpine AS runner
WORKDIR /app
# Install curl for health checks
RUN apk add --no-cache curl
# Create nextjs user and group for security
RUN addgroup --system --gid 1001 nodejs && \
adduser --system --uid 1001 nextjs && \
chown -R nextjs:nodejs /app
ENV HOSTNAME="0.0.0.0"
ENV PORT=3000
ENV NODE_ENV=production
ENV NEXT_TELEMETRY_DISABLED=1
# Copy standalone output and static files
# Create directory as root first, then copy with chown
RUN mkdir -p /app/.next/cache && chown -R nextjs:nodejs /app/.next/cache
COPY --from=builder --chown=nextjs:nodejs /app/public ./public
COPY --from=builder --chown=nextjs:nodejs /app/.next/standalone ./
COPY --from=builder --chown=nextjs:nodejs /app/.next/static ./.next/static
COPY --from=builder --chown=nextjs:nodejs /app/.next/cache ./.next/cache
USER nextjs

View File

@@ -0,0 +1,17 @@
import configPromise from "@payload-config";
import { RootPage } from "@payloadcms/next/views";
import { importMap } from "../importMap";
type Args = {
params: Promise<{
segments: string[];
}>;
searchParams: Promise<{
[key: string]: string | string[];
}>;
};
const Page = ({ params, searchParams }: Args) =>
RootPage({ config: configPromise, importMap, params, searchParams });
export default Page;

View File

@@ -0,0 +1,78 @@
import { RscEntryLexicalCell as RscEntryLexicalCell_44fe37237e0ebf4470c9990d8cb7b07e } from "@payloadcms/richtext-lexical/rsc";
import { RscEntryLexicalField as RscEntryLexicalField_44fe37237e0ebf4470c9990d8cb7b07e } from "@payloadcms/richtext-lexical/rsc";
import { LexicalDiffComponent as LexicalDiffComponent_44fe37237e0ebf4470c9990d8cb7b07e } from "@payloadcms/richtext-lexical/rsc";
import { BlocksFeatureClient as BlocksFeatureClient_e70f5e05f09f93e00b997edb1ef0c864 } from "@payloadcms/richtext-lexical/client";
import { InlineToolbarFeatureClient as InlineToolbarFeatureClient_e70f5e05f09f93e00b997edb1ef0c864 } from "@payloadcms/richtext-lexical/client";
import { HorizontalRuleFeatureClient as HorizontalRuleFeatureClient_e70f5e05f09f93e00b997edb1ef0c864 } from "@payloadcms/richtext-lexical/client";
import { UploadFeatureClient as UploadFeatureClient_e70f5e05f09f93e00b997edb1ef0c864 } from "@payloadcms/richtext-lexical/client";
import { BlockquoteFeatureClient as BlockquoteFeatureClient_e70f5e05f09f93e00b997edb1ef0c864 } from "@payloadcms/richtext-lexical/client";
import { RelationshipFeatureClient as RelationshipFeatureClient_e70f5e05f09f93e00b997edb1ef0c864 } from "@payloadcms/richtext-lexical/client";
import { LinkFeatureClient as LinkFeatureClient_e70f5e05f09f93e00b997edb1ef0c864 } from "@payloadcms/richtext-lexical/client";
import { ChecklistFeatureClient as ChecklistFeatureClient_e70f5e05f09f93e00b997edb1ef0c864 } from "@payloadcms/richtext-lexical/client";
import { OrderedListFeatureClient as OrderedListFeatureClient_e70f5e05f09f93e00b997edb1ef0c864 } from "@payloadcms/richtext-lexical/client";
import { UnorderedListFeatureClient as UnorderedListFeatureClient_e70f5e05f09f93e00b997edb1ef0c864 } from "@payloadcms/richtext-lexical/client";
import { IndentFeatureClient as IndentFeatureClient_e70f5e05f09f93e00b997edb1ef0c864 } from "@payloadcms/richtext-lexical/client";
import { AlignFeatureClient as AlignFeatureClient_e70f5e05f09f93e00b997edb1ef0c864 } from "@payloadcms/richtext-lexical/client";
import { HeadingFeatureClient as HeadingFeatureClient_e70f5e05f09f93e00b997edb1ef0c864 } from "@payloadcms/richtext-lexical/client";
import { ParagraphFeatureClient as ParagraphFeatureClient_e70f5e05f09f93e00b997edb1ef0c864 } from "@payloadcms/richtext-lexical/client";
import { InlineCodeFeatureClient as InlineCodeFeatureClient_e70f5e05f09f93e00b997edb1ef0c864 } from "@payloadcms/richtext-lexical/client";
import { SuperscriptFeatureClient as SuperscriptFeatureClient_e70f5e05f09f93e00b997edb1ef0c864 } from "@payloadcms/richtext-lexical/client";
import { SubscriptFeatureClient as SubscriptFeatureClient_e70f5e05f09f93e00b997edb1ef0c864 } from "@payloadcms/richtext-lexical/client";
import { StrikethroughFeatureClient as StrikethroughFeatureClient_e70f5e05f09f93e00b997edb1ef0c864 } from "@payloadcms/richtext-lexical/client";
import { UnderlineFeatureClient as UnderlineFeatureClient_e70f5e05f09f93e00b997edb1ef0c864 } from "@payloadcms/richtext-lexical/client";
import { BoldFeatureClient as BoldFeatureClient_e70f5e05f09f93e00b997edb1ef0c864 } from "@payloadcms/richtext-lexical/client";
import { ItalicFeatureClient as ItalicFeatureClient_e70f5e05f09f93e00b997edb1ef0c864 } from "@payloadcms/richtext-lexical/client";
import { CollectionCards as CollectionCards_f9c02e79a4aed9a3924487c0cd4cafb1 } from "@payloadcms/next/rsc";
export const importMap = {
"@payloadcms/richtext-lexical/rsc#RscEntryLexicalCell":
RscEntryLexicalCell_44fe37237e0ebf4470c9990d8cb7b07e,
"@payloadcms/richtext-lexical/rsc#RscEntryLexicalField":
RscEntryLexicalField_44fe37237e0ebf4470c9990d8cb7b07e,
"@payloadcms/richtext-lexical/rsc#LexicalDiffComponent":
LexicalDiffComponent_44fe37237e0ebf4470c9990d8cb7b07e,
"@payloadcms/richtext-lexical/client#BlocksFeatureClient":
BlocksFeatureClient_e70f5e05f09f93e00b997edb1ef0c864,
"@payloadcms/richtext-lexical/client#InlineToolbarFeatureClient":
InlineToolbarFeatureClient_e70f5e05f09f93e00b997edb1ef0c864,
"@payloadcms/richtext-lexical/client#HorizontalRuleFeatureClient":
HorizontalRuleFeatureClient_e70f5e05f09f93e00b997edb1ef0c864,
"@payloadcms/richtext-lexical/client#UploadFeatureClient":
UploadFeatureClient_e70f5e05f09f93e00b997edb1ef0c864,
"@payloadcms/richtext-lexical/client#BlockquoteFeatureClient":
BlockquoteFeatureClient_e70f5e05f09f93e00b997edb1ef0c864,
"@payloadcms/richtext-lexical/client#RelationshipFeatureClient":
RelationshipFeatureClient_e70f5e05f09f93e00b997edb1ef0c864,
"@payloadcms/richtext-lexical/client#LinkFeatureClient":
LinkFeatureClient_e70f5e05f09f93e00b997edb1ef0c864,
"@payloadcms/richtext-lexical/client#ChecklistFeatureClient":
ChecklistFeatureClient_e70f5e05f09f93e00b997edb1ef0c864,
"@payloadcms/richtext-lexical/client#OrderedListFeatureClient":
OrderedListFeatureClient_e70f5e05f09f93e00b997edb1ef0c864,
"@payloadcms/richtext-lexical/client#UnorderedListFeatureClient":
UnorderedListFeatureClient_e70f5e05f09f93e00b997edb1ef0c864,
"@payloadcms/richtext-lexical/client#IndentFeatureClient":
IndentFeatureClient_e70f5e05f09f93e00b997edb1ef0c864,
"@payloadcms/richtext-lexical/client#AlignFeatureClient":
AlignFeatureClient_e70f5e05f09f93e00b997edb1ef0c864,
"@payloadcms/richtext-lexical/client#HeadingFeatureClient":
HeadingFeatureClient_e70f5e05f09f93e00b997edb1ef0c864,
"@payloadcms/richtext-lexical/client#ParagraphFeatureClient":
ParagraphFeatureClient_e70f5e05f09f93e00b997edb1ef0c864,
"@payloadcms/richtext-lexical/client#InlineCodeFeatureClient":
InlineCodeFeatureClient_e70f5e05f09f93e00b997edb1ef0c864,
"@payloadcms/richtext-lexical/client#SuperscriptFeatureClient":
SuperscriptFeatureClient_e70f5e05f09f93e00b997edb1ef0c864,
"@payloadcms/richtext-lexical/client#SubscriptFeatureClient":
SubscriptFeatureClient_e70f5e05f09f93e00b997edb1ef0c864,
"@payloadcms/richtext-lexical/client#StrikethroughFeatureClient":
StrikethroughFeatureClient_e70f5e05f09f93e00b997edb1ef0c864,
"@payloadcms/richtext-lexical/client#UnderlineFeatureClient":
UnderlineFeatureClient_e70f5e05f09f93e00b997edb1ef0c864,
"@payloadcms/richtext-lexical/client#BoldFeatureClient":
BoldFeatureClient_e70f5e05f09f93e00b997edb1ef0c864,
"@payloadcms/richtext-lexical/client#ItalicFeatureClient":
ItalicFeatureClient_e70f5e05f09f93e00b997edb1ef0c864,
"@payloadcms/next/rsc#CollectionCards":
CollectionCards_f9c02e79a4aed9a3924487c0cd4cafb1,
};

View File

@@ -0,0 +1 @@
export const importMap = {};

View File

@@ -0,0 +1,14 @@
import config from "@payload-config";
import {
REST_GET,
REST_OPTIONS,
REST_PATCH,
REST_POST,
REST_DELETE,
} from "@payloadcms/next/routes";
export const GET = REST_GET(config);
export const POST = REST_POST(config);
export const DELETE = REST_DELETE(config);
export const PATCH = REST_PATCH(config);
export const OPTIONS = REST_OPTIONS(config);

View File

@@ -0,0 +1 @@
/* Custom SCSS for Payload Admin Panel */

36
app/(payload)/layout.tsx Normal file
View File

@@ -0,0 +1,36 @@
import configPromise from "@payload-config";
import { RootLayout } from "@payloadcms/next/layouts";
import React from "react";
import "@payloadcms/next/css";
import "./custom.scss";
import { handleServerFunctions } from "@payloadcms/next/layouts";
import { importMap } from "./admin/importMap";
type Args = {
children: React.ReactNode;
};
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const serverFunction: any = async function (args: any) {
"use server";
return handleServerFunctions({
...args,
config: configPromise,
importMap,
});
};
const Layout = ({ children }: Args) => {
return (
<RootLayout
config={configPromise}
importMap={importMap}
serverFunction={serverFunction}
>
{children}
</RootLayout>
);
};
export default Layout;

View File

@@ -13,6 +13,7 @@ const inter = Inter({
subsets: ["latin"],
display: "swap",
variable: "--font-inter",
weight: ["400", "700", "800"], // Explicit weights to optimize download
});
export const metadata: Metadata = {
@@ -129,7 +130,7 @@ export default async function RootLayout({
}
// Track server-side (initial load)
serverServices.analytics.trackPageview("/");
// serverServices.analytics.trackPageview("/"); // Removed to avoid double-tracking and incorrect path reporting
return (
<html lang={locale} className={`${inter.variable}`}>

View File

@@ -118,6 +118,8 @@ export default async function Image() {
{/* Title */}
<div
style={{
display: "flex",
flexDirection: "row",
fontSize: "72px",
fontWeight: "900",
color: "#0f172a",
@@ -126,12 +128,19 @@ export default async function Image() {
letterSpacing: "-0.02em",
}}
>
MB Grid <span style={{ color: "#10b981" }}>Solutions</span>
MB Grid{" "}
<span
style={{ color: "#10b981", display: "flex", marginLeft: "16px" }}
>
Solutions
</span>
</div>
{/* Subtitle */}
<div
style={{
display: "flex",
flexDirection: "column",
fontSize: "32px",
fontWeight: "500",
color: "#64748b",
@@ -140,9 +149,8 @@ export default async function Image() {
lineHeight: 1.4,
}}
>
Energiekabelprojekte & Technische Beratung
<br />
bis 110 kV
<span>Energiekabelprojekte & Technische Beratung</span>
<span>bis 110 kV</span>
</div>
</div>

View File

@@ -118,6 +118,8 @@ export default async function Image() {
{/* Title */}
<div
style={{
display: "flex",
flexDirection: "row",
fontSize: "72px",
fontWeight: "900",
color: "#0f172a",
@@ -126,12 +128,19 @@ export default async function Image() {
letterSpacing: "-0.02em",
}}
>
MB Grid <span style={{ color: "#10b981" }}>Solutions</span>
MB Grid{" "}
<span
style={{ color: "#10b981", display: "flex", marginLeft: "16px" }}
>
Solutions
</span>
</div>
{/* Subtitle */}
<div
style={{
display: "flex",
flexDirection: "column",
fontSize: "32px",
fontWeight: "500",
color: "#64748b",
@@ -140,9 +149,8 @@ export default async function Image() {
lineHeight: 1.4,
}}
>
Energiekabelprojekte & Technische Beratung
<br />
bis 110 kV
<span>Energiekabelprojekte & Technische Beratung</span>
<span>bis 110 kV</span>
</div>
</div>

View File

@@ -1,15 +1,19 @@
import { NextResponse } from "next/server";
import * as nodemailer from "nodemailer";
import directus, { ensureAuthenticated } from "@/lib/directus";
import { createItem } from "@directus/sdk";
import { getPayload } from "payload";
import configPromise from "@payload-config";
import { getServerAppServices } from "@/lib/services/create-services.server";
import {
render,
ContactFormNotification,
ConfirmationMessage,
} from "@mintel/mail";
import React from "react";
export async function POST(req: Request) {
const services = getServerAppServices();
const logger = services.logger.child({ action: "contact_submission" });
// Set analytics context from request headers for high-fidelity server-side tracking
// This fulfills the "server-side via nextjs proxy" requirement
if (services.analytics.setServerContext) {
services.analytics.setServerContext({
userAgent: req.headers.get("user-agent") || undefined,
@@ -41,70 +45,87 @@ export async function POST(req: Request) {
if (!message || message.length < 20) {
return NextResponse.json({ error: "message_too_short" }, { status: 400 });
}
if (message.length > 4000) {
return NextResponse.json({ error: "message_too_long" }, { status: 400 });
}
// 1. Directus save
let directusSaved = false;
const payload = await getPayload({ config: configPromise });
// 1. Payload save
let payloadSaved = false;
try {
await ensureAuthenticated();
await directus.request(
createItem("contact_submissions", {
await payload.create({
collection: "form-submissions",
data: {
name,
email,
company: company || "Nicht angegeben",
message,
}),
);
logger.info("Contact submission saved to Directus");
directusSaved = true;
} catch (directusError) {
const errorMessage =
directusError instanceof Error
? directusError.message
: String(directusError);
logger.error("Failed to save to Directus", {
error: errorMessage,
details: directusError,
});
services.errors.captureException(directusError, {
phase: "directus_save",
});
// We still try to send the email even if Directus fails
}
// 2. Email sending
try {
const { config } = await import("@/lib/config");
const transporter = nodemailer.createTransport({
host: config.mail.host,
port: config.mail.port,
secure: config.mail.port === 465,
auth: {
user: config.mail.user,
pass: config.mail.pass,
},
});
logger.info("Contact submission saved to PayloadCMS");
payloadSaved = true;
} catch (payloadError) {
const errorMessage =
payloadError instanceof Error
? payloadError.message
: String(payloadError);
logger.error("Failed to save to Payload", {
error: errorMessage,
details: payloadError,
});
services.errors.captureException(payloadError, { phase: "payload_save" });
}
await transporter.sendMail({
// 2. Email sending via Payload (which uses configured nodemailer)
try {
const { config } = await import("@/lib/config");
const clientName = "MB Grid Solutions";
// 2a. Notification to MB Grid
const notificationHtml = await render(
React.createElement(ContactFormNotification, {
name,
email,
message,
company,
}),
);
await payload.sendEmail({
from: config.mail.from,
to: config.mail.recipients.join(",") || "info@mb-grid-solutions.com",
to:
config.mail.recipients.join(",") ||
process.env.CONTACT_RECIPIENT ||
"info@mb-grid-solutions.com",
replyTo: email,
subject: `Kontaktanfrage von ${name}`,
text: `
Name: ${name}
Firma: ${company || "Nicht angegeben"}
E-Mail: ${email}
Zeitpunkt: ${new Date().toISOString()}
Nachricht:
${message}
`,
html: notificationHtml,
});
logger.info("Email sent successfully");
// 2b. Confirmation to the User
try {
const confirmationHtml = await render(
React.createElement(ConfirmationMessage, {
name,
clientName,
}),
);
await payload.sendEmail({
from: config.mail.from,
to: email,
subject: `Ihre Kontaktanfrage bei ${clientName}`,
html: confirmationHtml,
});
} catch (confirmError) {
logger.warn(
"Failed to send confirmation email, but notification was sent",
{ error: confirmError },
);
}
logger.info("Emails sent successfully");
// Notify success for important leads
await services.notifications.notify({
@@ -116,18 +137,16 @@ ${message}
logger.error("SMTP Error", { error: smtpError });
services.errors.captureException(smtpError, { phase: "smtp_send" });
// If Directus failed AND SMTP failed, then we really have a problem
if (!directusSaved) {
if (!payloadSaved) {
return NextResponse.json(
{ error: "Systemfehler (Speicherung und Versand fehlgeschlagen)" },
{ status: 500 },
);
}
// If Directus was successful, we tell the user "Ok" but we know internally it was a partial failure
await services.notifications.notify({
title: "🚨 SMTP Fehler (Kontaktformular)",
message: `Anfrage von ${name} (${email}) in Directus gespeichert, aber E-Mail-Versand fehlgeschlagen: ${smtpError instanceof Error ? smtpError.message : String(smtpError)}`,
message: `Anfrage von ${name} (${email}) in Payload gespeichert, aber E-Mail-Versand fehlgeschlagen: ${smtpError instanceof Error ? smtpError.message : String(smtpError)}`,
priority: 8,
});
}

View File

@@ -0,0 +1,35 @@
import { NextResponse } from "next/server";
import { getPayload } from "payload";
import configPromise from "@payload-config";
import { getServerAppServices } from "@/lib/services/create-services.server";
export async function POST(req: Request) {
const authHeader = req.headers.get("authorization");
if (authHeader !== `Bearer ${process.env.PAYLOAD_SECRET}`) {
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
}
const { logger } = getServerAppServices();
try {
logger.info("Starting programmatic Payload migrations...");
const payload = await getPayload({ config: configPromise });
await payload.db.migrate();
logger.info("Successfully executed Payload migrations.");
return NextResponse.json({
success: true,
message: "Migrations executed successfully.",
});
} catch (error) {
logger.error("Failed to run migrations remotely", { error });
return NextResponse.json(
{
error:
error instanceof Error ? error.message : "Unknown error occurred",
},
{ status: 500 },
);
}
}

View File

@@ -197,6 +197,7 @@ export default function Contact() {
) : (
<form
onSubmit={handleSubmit}
aria-label={t("form.submit")}
className="space-y-6 relative z-10"
>
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">

View File

@@ -1,21 +1,29 @@
"use client";
import { m } from "framer-motion";
import {
BarChart3,
CheckCircle2,
ChevronRight,
Shield,
Zap,
} from "lucide-react";
import Image from "next/image";
import Link from "next/link";
import { Button } from "./Button";
import { Counter } from "./Counter";
import { Reveal } from "./Reveal";
import { TechBackground } from "./TechBackground";
import { useTranslations } from "next-intl";
import dynamic from "next/dynamic";
const PortfolioSection = dynamic(() =>
import("./sections/PortfolioSection").then((mod) => mod.PortfolioSection),
);
const ExpertiseSection = dynamic(() =>
import("./sections/ExpertiseSection").then((mod) => mod.ExpertiseSection),
);
const TechnicalSpecsSection = dynamic(() =>
import("./sections/TechnicalSpecsSection").then(
(mod) => mod.TechnicalSpecsSection,
),
);
const CTASection = dynamic(() =>
import("./sections/CTASection").then((mod) => mod.CTASection),
);
export default function Home() {
const t = useTranslations("Index");
@@ -74,7 +82,7 @@ export default function Home() {
fill
className="object-cover"
priority
quality={90}
quality={75}
/>
<div className="absolute inset-0 bg-gradient-to-r from-slate-100/80 via-white/90 to-white/40 md:to-transparent" />
<TechBackground />
@@ -127,272 +135,11 @@ export default function Home() {
</div>
</section>
{/* Portfolio Section */}
<section className="bg-slate-950 text-accent relative overflow-hidden">
<TechBackground />
<div className="container-custom relative z-10">
<Counter value={2} className="section-number !text-white/5" />
<Reveal className="flex flex-col md:flex-row md:items-end justify-between gap-8 mb-16">
<div>
<span className="text-accent font-bold uppercase tracking-widest text-sm mb-4 block">
{t("portfolio.tag")}
</span>
<h2 className="text-3xl md:text-5xl font-bold text-white mb-6">
{t("portfolio.title")}
</h2>
<p className="text-slate-400 text-base md:text-xl">
{t("portfolio.description")}
</p>
</div>
<Link
href="/ueber-uns"
className="text-accent font-bold flex items-center gap-2 hover:text-white transition-colors group"
>
{t("portfolio.link")}{" "}
<ChevronRight
className="transition-transform group-hover:translate-x-1"
size={20}
/>
</Link>
</Reveal>
<div className="grid grid-cols-1 md:grid-cols-3 gap-8">
{[
{
icon: <Zap size={32} />,
title: t("portfolio.items.beratung.title"),
desc: t("portfolio.items.beratung.desc"),
},
{
icon: <Shield size={32} />,
title: t("portfolio.items.begleitung.title"),
desc: t("portfolio.items.begleitung.desc"),
},
{
icon: <BarChart3 size={32} />,
title: t("portfolio.items.beschaffung.title"),
desc: t("portfolio.items.beschaffung.desc"),
},
].map((item, i) => (
<Reveal key={i} delay={i * 0.1}>
<div className="bg-white/5 p-8 rounded-2xl border border-white/10 group hover:-translate-y-2 transition-[box-shadow,transform] duration-300 h-full relative overflow-hidden">
<div className="absolute top-0 right-0 w-16 h-16 bg-accent/5 -mr-8 -mt-8 rounded-full group-hover:bg-accent/10 transition-colors" />
<div className="w-16 h-16 rounded-2xl bg-accent/10 text-accent flex items-center justify-center mb-8 group-hover:bg-accent group-hover:text-white transition-colors relative z-10">
{item.icon}
</div>
<h3 className="text-2xl font-bold text-white mb-4 relative z-10">
{item.title}
</h3>
<p className="text-slate-400 leading-relaxed relative z-10">
{item.desc}
</p>
</div>
</Reveal>
))}
</div>
</div>
</section>
{/* Expertise Section */}
<section className="bg-white relative overflow-hidden">
<TechBackground />
<div className="container-custom relative z-10">
<Counter value={3} className="section-number" />
<div className="grid grid-cols-1 lg:grid-cols-2 items-center gap-16 md:gap-24">
<Reveal direction="right">
<div className="relative overflow-hidden rounded-2xl shadow-lg group">
<div className="absolute inset-0 bg-accent/10 opacity-0 group-hover:opacity-100 transition-opacity duration-500 z-10 pointer-events-none" />
<Image
src="/media/cables/hs-kabel.png"
alt="Technische Beratung"
width={800}
height={600}
className="w-full h-[400px] md:h-[500px] object-cover hover:scale-105 transition-transform duration-700"
/>
<div className="tech-corner top-4 left-4 border-t-2 border-l-2 z-20" />
<div className="tech-corner bottom-4 right-4 border-b-2 border-r-2 z-20" />
</div>
</Reveal>
<div>
<Reveal>
<span className="text-accent font-bold uppercase tracking-widest text-sm mb-4 block">
{t("expertise.tag")}
</span>
<h2 className="text-3xl md:text-5xl font-bold text-primary mb-6 md:mb-8">
{t("expertise.title")}
</h2>
<p className="text-slate-600 text-base md:text-xl mb-8 md:mb-12">
{t("expertise.description")}
</p>
</Reveal>
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
{t.raw("expertise.groups").map((item: string, i: number) => (
<Reveal key={i} delay={i * 0.05}>
<div className="flex items-center gap-4 p-4 bg-slate-50 rounded-xl border border-slate-100 hover:border-accent/30 transition-colors group relative overflow-hidden">
<div className="absolute top-0 left-0 w-1 h-full bg-accent/0 group-hover:bg-accent/100 transition-all duration-300" />
<div className="w-8 h-8 rounded-full bg-white flex items-center justify-center shadow-sm group-hover:bg-accent group-hover:text-white transition-colors">
<CheckCircle2 size={16} />
</div>
<span className="text-primary font-semibold">{item}</span>
</div>
</Reveal>
))}
</div>
</div>
</div>
</div>
</section>
{/* Technical Specs Section */}
<section className="relative py-24 md:py-32 text-white overflow-hidden bg-slate-900">
<div className="absolute inset-0 opacity-20">
<Image
src="/media/drums/about-hero.jpg"
alt="Background"
fill
className="object-cover"
/>
<div className="absolute inset-0 bg-gradient-to-b from-slate-900 via-slate-900/80 to-slate-900" />
</div>
<TechBackground />
<div className="container-custom relative z-10">
<Counter value={4} className="section-number !text-white/5" />
{/* Data Stream Effect */}
<div className="absolute -top-10 right-0 w-px h-64 bg-gradient-to-b from-transparent via-accent/50 to-transparent animate-pulse" />
<div className="absolute -bottom-10 left-10 w-px h-64 bg-gradient-to-b from-transparent via-accent/30 to-transparent animate-pulse delay-700" />
<Reveal className="mb-20">
<span className="text-accent font-bold uppercase tracking-widest text-sm mb-4 block">
{t("specs.tag")}
</span>
<h2 className="text-3xl md:text-5xl font-bold text-white mb-6">
{t("specs.title")}
</h2>
</Reveal>
<div className="grid grid-cols-1 md:grid-cols-3 gap-6 md:gap-8">
{[
{
label: t("specs.items.kabel.label"),
value: t("specs.items.kabel.value"),
desc: t("specs.items.kabel.desc"),
},
{
label: t("specs.items.spannung.label"),
value: t("specs.items.spannung.value"),
desc: t("specs.items.spannung.desc"),
},
{
label: t("specs.items.technologie.label"),
value: t("specs.items.technologie.value"),
desc: t("specs.items.technologie.desc"),
},
].map((item, i) => (
<Reveal key={i} delay={i * 0.1}>
<div className="p-10 rounded-3xl bg-white/5 border border-white/10 backdrop-blur-sm hover:bg-white/10 transition-colors h-full relative group overflow-hidden">
<div className="absolute top-0 left-0 w-full h-1 bg-accent/0 group-hover:bg-accent/50 transition-all duration-500" />
<h4 className="text-accent font-bold text-xs uppercase tracking-widest mb-6">
{item.label}
</h4>
<p className="text-2xl font-bold text-white mb-4 leading-tight">
{item.value}
</p>
<p className="text-slate-400 leading-relaxed">{item.desc}</p>
</div>
</Reveal>
))}
</div>
</div>
</section>
{/* CTA Section */}
<section className="bg-white relative overflow-hidden">
<TechBackground />
{/* Decorative Background Elements */}
<div className="absolute top-0 left-0 w-64 h-64 bg-accent/5 rounded-full blur-3xl -translate-x-1/2 -translate-y-1/2" />
<div className="absolute bottom-0 right-0 w-96 h-96 bg-primary/5 rounded-full blur-3xl translate-x-1/2 translate-y-1/2" />
<div className="container-custom relative z-10">
<Counter value={5} className="section-number" />
<Reveal>
<div className="relative rounded-3xl md:rounded-[2.5rem] bg-primary p-8 md:p-24 overflow-hidden group">
{/* Corner Accents */}
<div className="tech-corner top-8 left-8 border-t-2 border-l-2" />
<div className="tech-corner top-8 right-8 border-t-2 border-r-2" />
<div className="tech-corner bottom-8 left-8 border-b-2 border-l-2" />
<div className="tech-corner bottom-8 right-8 border-b-2 border-r-2" />
<div className="absolute top-0 right-0 w-1/2 h-full opacity-10 pointer-events-none">
<svg
viewBox="0 0 400 400"
fill="none"
xmlns="http://www.w3.org/2000/svg"
>
<m.circle
animate={{ r: [400, 410, 400], opacity: [0.1, 0.2, 0.1] }}
transition={{
duration: 5,
repeat: Infinity,
ease: "easeInOut",
}}
cx="400"
cy="0"
r="400"
stroke="white"
strokeWidth="2"
/>
<m.circle
animate={{ r: [300, 310, 300], opacity: [0.1, 0.2, 0.1] }}
transition={{
duration: 4,
repeat: Infinity,
ease: "easeInOut",
delay: 0.5,
}}
cx="400"
cy="0"
r="300"
stroke="white"
strokeWidth="2"
/>
<m.circle
animate={{ r: [200, 210, 200], opacity: [0.1, 0.2, 0.1] }}
transition={{
duration: 3,
repeat: Infinity,
ease: "easeInOut",
delay: 1,
}}
cx="400"
cy="0"
r="200"
stroke="white"
strokeWidth="2"
/>
</svg>
</div>
<div className="relative z-10">
<h2 className="text-3xl md:text-6xl font-bold text-white mb-6 md:mb-8 leading-tight">
{t("cta.title")}
</h2>
<p className="text-slate-300 text-lg md:text-xl mb-8 md:mb-12 leading-relaxed">
{t("cta.subtitle")}
</p>
<Button
href="/kontakt"
variant="accent"
showArrow
className="w-full sm:w-auto !px-10 !py-5 text-lg"
>
{t("cta.button")}
</Button>
</div>
</div>
</Reveal>
</div>
</section>
{/* Dynamic Sections */}
<PortfolioSection />
<ExpertiseSection />
<TechnicalSpecsSection />
<CTASection />
</div>
);
}

View File

@@ -0,0 +1,102 @@
"use client";
import React from "react";
import { m } from "framer-motion";
import { Reveal } from "../Reveal";
import { Counter } from "../Counter";
import { TechBackground } from "../TechBackground";
import { Button } from "../Button";
import { useTranslations } from "next-intl";
export const CTASection = () => {
const t = useTranslations("Index");
return (
<section className="bg-white relative overflow-hidden">
<TechBackground />
{/* Decorative Background Elements */}
<div className="absolute top-0 left-0 w-64 h-64 bg-accent/5 rounded-full blur-3xl -translate-x-1/2 -translate-y-1/2" />
<div className="absolute bottom-0 right-0 w-96 h-96 bg-primary/5 rounded-full blur-3xl translate-x-1/2 translate-y-1/2" />
<div className="container-custom relative z-10">
<Counter value={5} className="section-number" />
<Reveal>
<div className="relative rounded-3xl md:rounded-[2.5rem] bg-primary p-8 md:p-24 overflow-hidden group">
{/* Corner Accents */}
<div className="tech-corner top-8 left-8 border-t-2 border-l-2" />
<div className="tech-corner top-8 right-8 border-t-2 border-r-2" />
<div className="tech-corner bottom-8 left-8 border-b-2 border-l-2" />
<div className="tech-corner bottom-8 right-8 border-b-2 border-r-2" />
<div className="absolute top-0 right-0 w-1/2 h-full opacity-10 pointer-events-none">
<svg
viewBox="0 0 400 400"
fill="none"
xmlns="http://www.w3.org/2000/svg"
>
<m.circle
animate={{ r: [400, 410, 400], opacity: [0.1, 0.2, 0.1] }}
transition={{
duration: 5,
repeat: Infinity,
ease: "easeInOut",
}}
cx="400"
cy="0"
r="400"
stroke="white"
strokeWidth="2"
/>
<m.circle
animate={{ r: [300, 310, 300], opacity: [0.1, 0.2, 0.1] }}
transition={{
duration: 4,
repeat: Infinity,
ease: "easeInOut",
delay: 0.5,
}}
cx="400"
cy="0"
r="300"
stroke="white"
strokeWidth="2"
/>
<m.circle
animate={{ r: [200, 210, 200], opacity: [0.1, 0.2, 0.1] }}
transition={{
duration: 3,
repeat: Infinity,
ease: "easeInOut",
delay: 1,
}}
cx="400"
cy="0"
r="200"
stroke="white"
strokeWidth="2"
/>
</svg>
</div>
<div className="relative z-10">
<h2 className="text-3xl md:text-6xl font-bold text-white mb-6 md:mb-8 leading-tight">
{t("cta.title")}
</h2>
<p className="text-slate-300 text-lg md:text-xl mb-8 md:mb-12 leading-relaxed">
{t("cta.subtitle")}
</p>
<Button
href="/kontakt"
variant="accent"
showArrow
className="w-full sm:w-auto !px-10 !py-5 text-lg"
>
{t("cta.button")}
</Button>
</div>
</div>
</Reveal>
</div>
</section>
);
};

View File

@@ -0,0 +1,64 @@
"use client";
import React from "react";
import Image from "next/image";
import { CheckCircle2 } from "lucide-react";
import { Reveal } from "../Reveal";
import { Counter } from "../Counter";
import { TechBackground } from "../TechBackground";
import { useTranslations } from "next-intl";
export const ExpertiseSection = () => {
const t = useTranslations("Index");
return (
<section className="bg-white relative overflow-hidden">
<TechBackground />
<div className="container-custom relative z-10">
<Counter value={3} className="section-number" />
<div className="grid grid-cols-1 lg:grid-cols-2 items-center gap-16 md:gap-24">
<Reveal direction="right">
<div className="relative overflow-hidden rounded-2xl shadow-lg group">
<div className="absolute inset-0 bg-accent/10 opacity-0 group-hover:opacity-100 transition-opacity duration-500 z-10 pointer-events-none" />
<Image
src="/media/cables/hs-kabel.png"
alt="Technische Beratung"
width={800}
height={600}
className="w-full h-[400px] md:h-[500px] object-cover hover:scale-105 transition-transform duration-700"
/>
<div className="tech-corner top-4 left-4 border-t-2 border-l-2 z-20" />
<div className="tech-corner bottom-4 right-4 border-b-2 border-r-2 z-20" />
</div>
</Reveal>
<div>
<Reveal>
<span className="text-accent font-bold uppercase tracking-widest text-sm mb-4 block">
{t("expertise.tag")}
</span>
<h2 className="text-3xl md:text-5xl font-bold text-primary mb-6 md:mb-8">
{t("expertise.title")}
</h2>
<p className="text-slate-600 text-base md:text-xl mb-8 md:mb-12">
{t("expertise.description")}
</p>
</Reveal>
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
{t.raw("expertise.groups").map((item: string, i: number) => (
<Reveal key={i} delay={i * 0.05}>
<div className="flex items-center gap-4 p-4 bg-slate-50 rounded-xl border border-slate-100 hover:border-accent/30 transition-colors group relative overflow-hidden">
<div className="absolute top-0 left-0 w-1 h-full bg-accent/0 group-hover:bg-accent/100 transition-all duration-300" />
<div className="w-8 h-8 rounded-full bg-white flex items-center justify-center shadow-sm group-hover:bg-accent group-hover:text-white transition-colors">
<CheckCircle2 size={16} />
</div>
<span className="text-primary font-semibold">{item}</span>
</div>
</Reveal>
))}
</div>
</div>
</div>
</div>
</section>
);
};

View File

@@ -0,0 +1,80 @@
"use client";
import React from "react";
import Link from "next/link";
import { ChevronRight, Zap, Shield, BarChart3 } from "lucide-react";
import { Reveal } from "../Reveal";
import { Counter } from "../Counter";
import { TechBackground } from "../TechBackground";
import { useTranslations } from "next-intl";
export const PortfolioSection = () => {
const t = useTranslations("Index");
return (
<section className="bg-slate-950 text-accent relative overflow-hidden">
<TechBackground />
<div className="container-custom relative z-10">
<Counter value={2} className="section-number !text-white/5" />
<Reveal className="flex flex-col md:flex-row md:items-end justify-between gap-8 mb-16">
<div>
<span className="text-accent font-bold uppercase tracking-widest text-sm mb-4 block">
{t("portfolio.tag")}
</span>
<h2 className="text-3xl md:text-5xl font-bold text-white mb-6">
{t("portfolio.title")}
</h2>
<p className="text-slate-400 text-base md:text-xl">
{t("portfolio.description")}
</p>
</div>
<Link
href="/ueber-uns"
className="text-accent font-bold flex items-center gap-2 hover:text-white transition-colors group"
>
{t("portfolio.link")}{" "}
<ChevronRight
className="transition-transform group-hover:translate-x-1"
size={20}
/>
</Link>
</Reveal>
<div className="grid grid-cols-1 md:grid-cols-3 gap-8">
{[
{
icon: <Zap size={32} />,
title: t("portfolio.items.beratung.title"),
desc: t("portfolio.items.beratung.desc"),
},
{
icon: <Shield size={32} />,
title: t("portfolio.items.begleitung.title"),
desc: t("portfolio.items.begleitung.desc"),
},
{
icon: <BarChart3 size={32} />,
title: t("portfolio.items.beschaffung.title"),
desc: t("portfolio.items.beschaffung.desc"),
},
].map((item, i) => (
<Reveal key={i} delay={i * 0.1}>
<div className="bg-white/5 p-8 rounded-2xl border border-white/10 group hover:-translate-y-2 transition-[box-shadow,transform] duration-300 h-full relative overflow-hidden">
<div className="absolute top-0 right-0 w-16 h-16 bg-accent/5 -mr-8 -mt-8 rounded-full group-hover:bg-accent/10 transition-colors" />
<div className="w-16 h-16 rounded-2xl bg-accent/10 text-accent flex items-center justify-center mb-8 group-hover:bg-accent group-hover:text-white transition-colors relative z-10">
{item.icon}
</div>
<h3 className="text-2xl font-bold text-white mb-4 relative z-10">
{item.title}
</h3>
<p className="text-slate-400 leading-relaxed relative z-10">
{item.desc}
</p>
</div>
</Reveal>
))}
</div>
</div>
</section>
);
};

View File

@@ -0,0 +1,76 @@
"use client";
import React from "react";
import Image from "next/image";
import { Reveal } from "../Reveal";
import { Counter } from "../Counter";
import { TechBackground } from "../TechBackground";
import { useTranslations } from "next-intl";
export const TechnicalSpecsSection = () => {
const t = useTranslations("Index");
return (
<section className="relative py-24 md:py-32 text-white overflow-hidden bg-slate-900">
<div className="absolute inset-0 opacity-20">
<Image
src="/media/drums/about-hero.jpg"
alt="Background"
fill
className="object-cover"
/>
<div className="absolute inset-0 bg-gradient-to-b from-slate-900 via-slate-900/80 to-slate-900" />
</div>
<TechBackground />
<div className="container-custom relative z-10">
<Counter value={4} className="section-number !text-white/5" />
{/* Data Stream Effect */}
<div className="absolute -top-10 right-0 w-px h-64 bg-gradient-to-b from-transparent via-accent/50 to-transparent animate-pulse" />
<div className="absolute -bottom-10 left-10 w-px h-64 bg-gradient-to-b from-transparent via-accent/30 to-transparent animate-pulse delay-700" />
<Reveal className="mb-20">
<span className="text-accent font-bold uppercase tracking-widest text-sm mb-4 block">
{t("specs.tag")}
</span>
<h2 className="text-3xl md:text-5xl font-bold text-white mb-6">
{t("specs.title")}
</h2>
</Reveal>
<div className="grid grid-cols-1 md:grid-cols-3 gap-6 md:gap-8">
{[
{
label: t("specs.items.kabel.label"),
value: t("specs.items.kabel.value"),
desc: t("specs.items.kabel.desc"),
},
{
label: t("specs.items.spannung.label"),
value: t("specs.items.spannung.value"),
desc: t("specs.items.spannung.desc"),
},
{
label: t("specs.items.technologie.label"),
value: t("specs.items.technologie.value"),
desc: t("specs.items.technologie.desc"),
},
].map((item, i) => (
<Reveal key={i} delay={i * 0.1}>
<div className="p-10 rounded-3xl bg-white/5 border border-white/10 backdrop-blur-sm hover:bg-white/10 transition-colors h-full relative group overflow-hidden">
<div className="absolute top-0 left-0 w-full h-1 bg-accent/0 group-hover:bg-accent/50 transition-all duration-500" />
<h4 className="text-accent font-bold text-xs uppercase tracking-widest mb-6">
{item.label}
</h4>
<p className="text-2xl font-bold text-white mb-4 leading-tight">
{item.value}
</p>
<p className="text-slate-400 leading-relaxed">{item.desc}</p>
</div>
</Reveal>
))}
</div>
</div>
</section>
);
};

View File

@@ -8,13 +8,12 @@ services:
- .:/app
environment:
NODE_ENV: development
# Docker Internal Communication
DIRECTUS_URL: http://directus:8055
DATABASE_URI: postgresql://postgres:postgres@mb-grid-db:5432/payload
# Build / dependency installation
NPM_TOKEN: ${NPM_TOKEN}
CI: 'true'
ports:
- "3000:3000"
# ports:
# - "3000:3000"
labels:
- "traefik.enable=true"
# Clear all production-related TLS/Middleware settings for the main routers
@@ -26,15 +25,3 @@ services:
# Actually, gatekeeper is a separate service. We can keep it or ignore it.
# But the app router normally points to gatekeeper middleware.
# By clearing middlewares above, we bypass gatekeeper for local dev.
directus:
labels:
- "traefik.enable=true"
- "traefik.http.routers.${PROJECT_NAME:-mb-grid-solutions}-directus.entrypoints=web"
- "traefik.http.routers.${PROJECT_NAME:-mb-grid-solutions}-directus.rule=Host(`${DIRECTUS_HOST:-cms.mb-grid-solutions.localhost}`)"
- "traefik.http.routers.${PROJECT_NAME:-mb-grid-solutions}-directus.tls=false"
- "traefik.http.routers.${PROJECT_NAME:-mb-grid-solutions}-directus.middlewares="
ports:
- "8055:8055"
environment:
PUBLIC_URL: http://${DIRECTUS_HOST:-cms.mb-grid-solutions.localhost}

View File

@@ -1,32 +1,36 @@
services:
app:
mb-grid-app:
image: registry.infra.mintel.me/mintel/mb-grid-solutions:${IMAGE_TAG:-latest}
restart: always
networks:
- default
- infra
env_file:
- ${ENV_FILE:-.env}
labels:
- "traefik.enable=true"
- "traefik.http.routers.${PROJECT_NAME}.rule=${TRAEFIK_RULE:-Host(`${TRAEFIK_HOST:-mb-grid-solutions.localhost}`)}"
- "traefik.http.routers.${PROJECT_NAME}.entrypoints=websecure"
- "traefik.http.routers.${PROJECT_NAME}.tls.certresolver=le"
- "traefik.http.routers.${PROJECT_NAME}.tls=true"
- "traefik.http.services.${PROJECT_NAME}.loadbalancer.server.port=3000"
- "traefik.http.routers.${PROJECT_NAME}.middlewares=${TRAEFIK_MIDDLEWARES:-${PROJECT_NAME}-auth}"
- "traefik.http.routers.${PROJECT_NAME:-mb-grid}.rule=Host(`${TRAEFIK_HOST:-mb-grid-solutions.localhost}`)"
- "traefik.http.routers.${PROJECT_NAME:-mb-grid}.entrypoints=websecure"
- "traefik.http.routers.${PROJECT_NAME:-mb-grid}.tls.certresolver=le"
- "traefik.http.routers.${PROJECT_NAME:-mb-grid}.tls=true"
- "traefik.http.routers.${PROJECT_NAME:-mb-grid}.priority=1000"
- "traefik.http.routers.${PROJECT_NAME:-mb-grid}.service=${PROJECT_NAME:-mb-grid}-app-svc"
- "traefik.http.services.${PROJECT_NAME:-mb-grid}-app-svc.loadbalancer.server.port=3000"
- "traefik.http.routers.${PROJECT_NAME:-mb-grid}.middlewares=${TRAEFIK_MIDDLEWARES:-mb-grid-auth,mb-grid-forward,compress}"
- "traefik.docker.network=infra"
# Gatekeeper Router (Shared Host + dedicated Subdomain)
- "traefik.http.routers.${PROJECT_NAME}-gatekeeper.rule=${GATEKEEPER_RULE:-(Host(`${TRAEFIK_HOST:-mb-grid-solutions.localhost}`) && PathPrefix(`/gatekeeper`)) || Host(`gatekeeper.${TRAEFIK_HOST:-mb-grid-solutions.localhost}`)}"
- "traefik.http.routers.${PROJECT_NAME}-gatekeeper.entrypoints=websecure"
- "traefik.http.routers.${PROJECT_NAME}-gatekeeper.tls.certresolver=le"
- "traefik.http.routers.${PROJECT_NAME}-gatekeeper.tls=true"
- "traefik.http.routers.${PROJECT_NAME}-gatekeeper.service=${PROJECT_NAME}-gatekeeper"
# Public Router paths that bypass Gatekeeper auth
- "traefik.http.routers.${PROJECT_NAME:-mb-grid}-public.rule=Host(`${TRAEFIK_HOST:-mb-grid-solutions.localhost}`) && PathRegexp(`^/([a-z]{2}/)?(health|login|gatekeeper|uploads|media|robots\\.txt|manifest\\.webmanifest|sitemap(-[0-9]+)?\\.xml|(.*/)?api/og(/.*)?|(.*/)?opengraph-image.*)`)"
- "traefik.http.routers.${PROJECT_NAME:-mb-grid}-public.entrypoints=websecure"
- "traefik.http.routers.${PROJECT_NAME:-mb-grid}-public.tls.certresolver=le"
- "traefik.http.routers.${PROJECT_NAME:-mb-grid}-public.tls=true"
- "traefik.http.routers.${PROJECT_NAME:-mb-grid}-public.service=${PROJECT_NAME:-mb-grid}-app-svc"
- "traefik.http.routers.${PROJECT_NAME:-mb-grid}-public.priority=2000"
- "traefik.http.middlewares.${PROJECT_NAME}-auth.forwardauth.address=http://${PROJECT_NAME}-gatekeeper:3000/api/verify"
- "traefik.http.middlewares.${PROJECT_NAME}-auth.forwardauth.trustForwardHeader=true"
- "traefik.http.middlewares.${PROJECT_NAME}-auth.forwardauth.authResponseHeaders=X-Auth-User"
- "traefik.docker.network=infra"
# Forwarded Headers (Protocol Normalization)
- "traefik.http.middlewares.${PROJECT_NAME:-mb-grid}-forward.headers.customrequestheaders.X-Forwarded-Proto=https"
- "traefik.http.middlewares.${PROJECT_NAME:-mb-grid}-forward.headers.customrequestheaders.X-Forwarded-Ssl=on"
- "traefik.http.middlewares.compress.compress=true"
healthcheck:
test: [ "CMD", "node", "-e", "fetch('http://127.0.0.1:3000/api/health').then(r => r.ok ? process.exit(0) : process.exit(1)).catch(() => process.exit(1))" ]
interval: 10s
@@ -34,88 +38,68 @@ services:
retries: 5
start_period: 30s
gatekeeper:
image: registry.infra.mintel.me/mintel/gatekeeper:latest
mb-grid-gatekeeper:
image: registry.infra.mintel.me/mintel/gatekeeper:testing
container_name: ${PROJECT_NAME:-mb-grid-solutions}-gatekeeper
restart: always
profiles: [ "gatekeeper" ]
restart: unless-stopped
networks:
infra:
aliases:
- ${PROJECT_NAME:-mb-grid-solutions}-gatekeeper
- mb-grid-gatekeeper
env_file:
- ${ENV_FILE:-.env}
environment:
PORT: ${PORT:-3000}
PROJECT_NAME: ${PROJECT_NAME:-MB Grid Solutions}
PROJECT_COLOR: ${PROJECT_COLOR:-#82ed20}
COOKIE_DOMAIN: ${COOKIE_DOMAIN:-.mb-grid-solutions.com}
AUTH_COOKIE_NAME: ${AUTH_COOKIE_NAME:-mintel_gatekeeper_session}
GATEKEEPER_PASSWORD: ${GATEKEEPER_PASSWORD:-mintel}
# Dedicated Base URL for Gatekeeper subdomain to prevent redirect loops
NEXT_PUBLIC_BASE_URL: https://${GATEKEEPER_HOST:-gatekeeper.mb-grid-solutions.localhost}
COOKIE_DOMAIN: ${COOKIE_DOMAIN}
AUTH_COOKIE_NAME: ${AUTH_COOKIE_NAME}
GATEKEEPER_PASSWORD: ${GATEKEEPER_PASSWORD}
NEXT_PUBLIC_BASE_URL: ${GATEKEEPER_ORIGIN}
healthcheck:
test: [ "CMD", "node", "-e", "fetch('http://127.0.0.1:3000/gatekeeper/login').then(r => r.ok ? process.exit(0) : process.exit(1)).catch(() => process.exit(1))" ]
interval: 10s
timeout: 5s
retries: 5
labels:
- "traefik.enable=true"
- "traefik.http.services.${PROJECT_NAME}-gatekeeper.loadbalancer.server.port=3000"
- "traefik.http.services.mb-grid-gatekeeper-svc.loadbalancer.server.port=3000"
# Gatekeeper Verification Middleware
- "traefik.http.middlewares.${PROJECT_NAME:-mb-grid}-auth.forwardauth.address=http://${PROJECT_NAME:-mb-grid}-gatekeeper:3000/gatekeeper/api/verify"
- "traefik.http.middlewares.${PROJECT_NAME:-mb-grid}-auth.forwardauth.trustForwardHeader=true"
- "traefik.http.middlewares.${PROJECT_NAME:-mb-grid}-auth.forwardauth.authRequestHeaders=X-Forwarded-Host,X-Forwarded-Proto,X-Forwarded-For,Cookie"
- "traefik.http.middlewares.${PROJECT_NAME:-mb-grid}-auth.forwardauth.authResponseHeaders=X-Auth-User"
# Gatekeeper Public Router (Login/Auth UI)
- "traefik.http.routers.${PROJECT_NAME:-mb-grid}-gatekeeper.rule=(Host(`${TRAEFIK_HOST:-mb-grid-solutions.localhost}`) && PathPrefix(`/gatekeeper`))"
- "traefik.http.routers.${PROJECT_NAME:-mb-grid}-gatekeeper.entrypoints=websecure"
- "traefik.http.routers.${PROJECT_NAME:-mb-grid}-gatekeeper.tls.certresolver=le"
- "traefik.http.routers.${PROJECT_NAME:-mb-grid}-gatekeeper.tls=true"
- "traefik.http.routers.${PROJECT_NAME:-mb-grid}-gatekeeper.priority=2000"
- "traefik.http.routers.${PROJECT_NAME:-mb-grid}-gatekeeper.service=${PROJECT_NAME:-mb-grid}-gatekeeper-svc"
- "traefik.docker.network=infra"
directus:
image: directus/directus:11
restart: always
networks:
- infra
- backend
env_file:
- ${ENV_FILE:-.env}
environment:
DB_CLIENT: 'pg'
DB_HOST: 'directus-db'
DB_PORT: '5432'
WEBSOCKETS_ENABLED: 'true'
PUBLIC_URL: ${DIRECTUS_URL}
KEY: ${DIRECTUS_KEY}
SECRET: ${DIRECTUS_SECRET}
ADMIN_EMAIL: ${DIRECTUS_ADMIN_EMAIL}
ADMIN_PASSWORD: ${DIRECTUS_ADMIN_PASSWORD}
DB_DATABASE: ${DIRECTUS_DB_NAME:-directus}
DB_USER: ${DIRECTUS_DB_USER:-directus}
DB_PASSWORD: ${DIRECTUS_DB_PASSWORD:-directus}
# Telemetry & Performance
LOGGER_LEVEL: ${LOG_LEVEL:-info}
SENTRY_DSN: ${SENTRY_DSN}
SENTRY_ENVIRONMENT: ${TARGET:-development}
volumes:
- ./directus/uploads:/directus/uploads
- ./directus/extensions:/directus/extensions
labels:
- "traefik.enable=true"
- "traefik.http.routers.${PROJECT_NAME}-directus.rule=Host(`${DIRECTUS_HOST:-cms.mb-grid-solutions.localhost}`)"
- "traefik.http.routers.${PROJECT_NAME}-directus.entrypoints=websecure"
- "traefik.http.routers.${PROJECT_NAME}-directus.tls.certresolver=le"
- "traefik.http.routers.${PROJECT_NAME}-directus.tls=true"
- "traefik.http.routers.${PROJECT_NAME}-directus.middlewares=${PROJECT_NAME}-forward,compress"
- "traefik.http.services.${PROJECT_NAME}-directus.loadbalancer.server.port=8055"
- "traefik.http.middlewares.${PROJECT_NAME}-forward.headers.customrequestheaders.X-Forwarded-Proto=https"
- "traefik.docker.network=infra"
directus-db:
mb-grid-db:
image: postgres:15-alpine
restart: always
networks:
- backend
- default
env_file:
- ${ENV_FILE:-.env}
environment:
POSTGRES_DB: ${DIRECTUS_DB_NAME:-directus}
POSTGRES_USER: ${DIRECTUS_DB_USER:-directus}
POSTGRES_PASSWORD: ${DIRECTUS_DB_PASSWORD:-directus}
POSTGRES_DB: ${POSTGRES_DB:-payload}
POSTGRES_USER: ${POSTGRES_USER:-postgres}
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:-postgres}
volumes:
- directus-db-data:/var/lib/postgresql/data
- mb-grid-db-data:/var/lib/postgresql/data
networks:
default:
name: mb-grid-solutions-internal
infra:
external: true
backend:
internal: true
volumes:
directus-db-data:
mb-grid-db-data:

View File

@@ -20,7 +20,7 @@ CI (Woodpecker)
https://ci.infra.mintel.me
Container Registry
https://registry.infra.mintel.me
https://git.infra.mintel.me
Errors (GlitchTip)
https://errors.infra.mintel.me
@@ -76,13 +76,13 @@ This directory contains:
All production images must be built by CI and pushed to the Mintel Registry.
Registry:
registry.infra.mintel.me
git.infra.mintel.me
Image naming:
registry.infra.mintel.me/ORG/APP_NAME:TAG
git.infra.mintel.me/mmintel/APP_NAME:TAG
Example:
registry.infra.mintel.me/mintel/mb-grid-solutions:latest
git.infra.mintel.me/mmintel/mb-grid-solutions:latest
---
@@ -204,8 +204,8 @@ steps:
build:
image: woodpeckerci/plugin-docker
settings:
registry: registry.infra.mintel.me
repo: registry.infra.mintel.me/mintel/mb-grid-solutions
registry: git.infra.mintel.me
repo: git.infra.mintel.me/mmintel/mb-grid-solutions
username:
from_secret: REGISTRY_USER
password:

View File

@@ -61,14 +61,6 @@ function createConfig() {
from: env.MAIL_FROM,
recipients: env.MAIL_RECIPIENTS,
},
directus: {
url: env.DIRECTUS_URL,
adminEmail: env.DIRECTUS_ADMIN_EMAIL,
password: env.DIRECTUS_ADMIN_PASSWORD,
token: env.DIRECTUS_API_TOKEN,
internalUrl: env.INTERNAL_DIRECTUS_URL,
proxyPath: "/cms",
},
notifications: {
gotify: {
url: env.GOTIFY_URL,
@@ -131,9 +123,6 @@ export const config = {
get mail() {
return getConfig().mail;
},
get directus() {
return getConfig().directus;
},
get notifications() {
return getConfig().notifications;
},
@@ -176,12 +165,6 @@ export function getMaskedConfig() {
from: c.mail.from,
recipients: c.mail.recipients,
},
directus: {
url: c.directus.url,
adminEmail: mask(c.directus.adminEmail),
password: mask(c.directus.password),
token: mask(c.directus.token),
},
notifications: {
gotify: {
url: c.notifications.gotify.url,

View File

@@ -1,43 +0,0 @@
import { createDirectus, rest, authentication } from "@directus/sdk";
import { config } from "./config";
import { getServerAppServices } from "./services/create-services.server";
const { url, adminEmail, password, token, internalUrl } = config.directus;
// Use internal URL if on server to bypass Gatekeeper/Auth/Proxy issues
const effectiveUrl =
typeof window === "undefined" && internalUrl ? internalUrl : url;
const client = createDirectus(effectiveUrl).with(rest()).with(authentication());
/**
* Ensures the client is authenticated.
* Falls back to login with admin credentials if no static token is provided.
*/
export async function ensureAuthenticated() {
if (token) {
client.setToken(token);
return;
}
if (adminEmail && password) {
try {
await client.login({ email: adminEmail, password: password });
return;
} catch (e) {
if (typeof window === "undefined") {
getServerAppServices().errors.captureException(e, {
phase: "directus_auth_fallback",
});
}
console.error("Failed to authenticate with Directus login fallback:", e);
throw e;
}
}
throw new Error(
"Missing Directus authentication credentials (token or admin email/password)",
);
}
export default client;

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

@@ -1,144 +1,39 @@
import { z } from "zod";
/**
* Helper to treat empty strings as undefined.
*/
const preprocessEmptyString = (val: unknown) => (val === "" ? undefined : val);
import {
validateMintelEnv,
mintelEnvSchema,
withMintelRefinements,
} from "@mintel/next-utils";
/**
* Environment variable schema.
* Extends the default Mintel environment schema which already includes:
* - Directus (URL, TOKEN, INTERNAL_URL, etc.)
* - Mail (HOST, PORT, etc.)
* - Gotify
* - Logging
* - Analytics
*/
export const envSchema = z
.object({
NODE_ENV: z
.enum(["development", "production", "test"])
.default("development"),
NEXT_PUBLIC_BASE_URL: z.preprocess(
preprocessEmptyString,
z.string().url().optional(),
),
NEXT_PUBLIC_TARGET: z
.enum(["development", "testing", "staging", "production"])
.optional(),
// Analytics
UMAMI_WEBSITE_ID: z.preprocess(
preprocessEmptyString,
z.string().optional(),
),
NEXT_PUBLIC_UMAMI_WEBSITE_ID: z.preprocess(
preprocessEmptyString,
z.string().optional(),
),
UMAMI_API_ENDPOINT: z.preprocess(
preprocessEmptyString,
z.string().url().default("https://analytics.infra.mintel.me"),
),
// Error Tracking
SENTRY_DSN: z.preprocess(preprocessEmptyString, z.string().optional()),
// Logging
LOG_LEVEL: z.enum(["debug", "info", "warn", "error"]).default("info"),
// Mail
MAIL_HOST: z.preprocess(preprocessEmptyString, z.string().optional()),
MAIL_PORT: z.preprocess(
preprocessEmptyString,
z.coerce.number().default(587),
),
MAIL_USERNAME: z.preprocess(preprocessEmptyString, z.string().optional()),
MAIL_PASSWORD: z.preprocess(preprocessEmptyString, z.string().optional()),
MAIL_FROM: z.preprocess(preprocessEmptyString, z.string().optional()),
MAIL_RECIPIENTS: z.preprocess(
(val) => (typeof val === "string" ? val.split(",").filter(Boolean) : val),
z.array(z.string()).default([]),
),
// Directus
DIRECTUS_URL: z.preprocess(
preprocessEmptyString,
z.string().url().default("http://localhost:8055"),
),
DIRECTUS_ADMIN_EMAIL: z.preprocess(
preprocessEmptyString,
z.string().optional(),
),
DIRECTUS_ADMIN_PASSWORD: z.preprocess(
preprocessEmptyString,
z.string().optional(),
),
DIRECTUS_API_TOKEN: z.preprocess(
preprocessEmptyString,
z.string().optional(),
),
INTERNAL_DIRECTUS_URL: z.preprocess(
preprocessEmptyString,
z.string().url().optional(),
),
// Deploy Target
TARGET: z
.enum(["development", "testing", "staging", "production"])
.optional(),
// Gotify
GOTIFY_URL: z.preprocess(
preprocessEmptyString,
z.string().url().optional(),
),
GOTIFY_TOKEN: z.preprocess(preprocessEmptyString, z.string().optional()),
})
.superRefine((data, ctx) => {
const target = data.NEXT_PUBLIC_TARGET || data.TARGET;
const isDev = target === "development" || !target;
const isBuildTimeValidation =
process.env.SKIP_RUNTIME_ENV_VALIDATION === "true";
const isServer = typeof window === "undefined";
// Only enforce server-only variables when running on the server.
// In the browser, non-NEXT_PUBLIC_ variables are undefined and should not trigger validation errors.
if (isServer && !isDev && !isBuildTimeValidation && !data.MAIL_HOST) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
message: "MAIL_HOST is required in non-development environments",
path: ["MAIL_HOST"],
});
}
});
export type Env = z.infer<typeof envSchema>;
const envExtension = {
// Project specific overrides or additions
AUTH_COOKIE_NAME: z.string().default("mb_gatekeeper_session"),
};
/**
* Collects all environment variables from the process.
* Explicitly references NEXT_PUBLIC_ variables for Next.js inlining.
* Full schema including Mintel base and refinements
*/
export const envSchema = withMintelRefinements(
z.object(mintelEnvSchema).extend(envExtension),
);
/**
* Validated environment object.
*/
export const env = validateMintelEnv(envExtension);
/**
* For legacy compatibility with existing code.
*/
export function getRawEnv() {
return {
NODE_ENV: process.env.NODE_ENV,
NEXT_PUBLIC_BASE_URL: process.env.NEXT_PUBLIC_BASE_URL,
NEXT_PUBLIC_TARGET: process.env.NEXT_PUBLIC_TARGET,
UMAMI_WEBSITE_ID:
process.env.UMAMI_WEBSITE_ID || process.env.NEXT_PUBLIC_UMAMI_WEBSITE_ID,
NEXT_PUBLIC_UMAMI_WEBSITE_ID: process.env.NEXT_PUBLIC_UMAMI_WEBSITE_ID,
UMAMI_API_ENDPOINT:
process.env.UMAMI_API_ENDPOINT ||
process.env.UMAMI_SCRIPT_URL ||
process.env.NEXT_PUBLIC_UMAMI_SCRIPT_URL,
SENTRY_DSN: process.env.SENTRY_DSN,
LOG_LEVEL: process.env.LOG_LEVEL,
MAIL_HOST: process.env.MAIL_HOST,
MAIL_PORT: process.env.MAIL_PORT,
MAIL_USERNAME: process.env.MAIL_USERNAME,
MAIL_PASSWORD: process.env.MAIL_PASSWORD,
MAIL_FROM: process.env.MAIL_FROM,
MAIL_RECIPIENTS: process.env.MAIL_RECIPIENTS,
DIRECTUS_URL: process.env.DIRECTUS_URL,
DIRECTUS_ADMIN_EMAIL: process.env.DIRECTUS_ADMIN_EMAIL,
DIRECTUS_ADMIN_PASSWORD: process.env.DIRECTUS_ADMIN_PASSWORD,
DIRECTUS_API_TOKEN: process.env.DIRECTUS_API_TOKEN,
INTERNAL_DIRECTUS_URL: process.env.INTERNAL_DIRECTUS_URL,
TARGET: process.env.TARGET,
GOTIFY_URL: process.env.GOTIFY_URL,
GOTIFY_TOKEN: process.env.GOTIFY_TOKEN,
};
return env;
}

View File

@@ -32,48 +32,60 @@ export class PinoLoggerService implements LoggerService {
trace(msg: string, ...args: unknown[]) {
if (args.length > 0 && typeof args[0] === "object" && args[0] !== null) {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
(this.logger as any).trace(args[0] as object, msg, ...args.slice(1));
} else {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
(this.logger as any).trace(msg, ...args);
}
}
debug(msg: string, ...args: unknown[]) {
if (args.length > 0 && typeof args[0] === "object" && args[0] !== null) {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
(this.logger as any).debug(args[0] as object, msg, ...args.slice(1));
} else {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
(this.logger as any).debug(msg, ...args);
}
}
info(msg: string, ...args: unknown[]) {
if (args.length > 0 && typeof args[0] === "object" && args[0] !== null) {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
(this.logger as any).info(args[0] as object, msg, ...args.slice(1));
} else {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
(this.logger as any).info(msg, ...args);
}
}
warn(msg: string, ...args: unknown[]) {
if (args.length > 0 && typeof args[0] === "object" && args[0] !== null) {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
(this.logger as any).warn(args[0] as object, msg, ...args.slice(1));
} else {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
(this.logger as any).warn(msg, ...args);
}
}
error(msg: string, ...args: unknown[]) {
if (args.length > 0 && typeof args[0] === "object" && args[0] !== null) {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
(this.logger as any).error(args[0] as object, msg, ...args.slice(1));
} else {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
(this.logger as any).error(msg, ...args);
}
}
fatal(msg: string, ...args: unknown[]) {
if (args.length > 0 && typeof args[0] === "object" && args[0] !== null) {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
(this.logger as any).fatal(args[0] as object, msg, ...args.slice(1));
} else {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
(this.logger as any).fatal(msg, ...args);
}
}

View File

@@ -15,7 +15,7 @@ export const config = {
// Matcher for all pages and internationalized pathnames
// excluding api, _next, static files, etc.
matcher: [
"/((?!api|stats|errors|_next|_vercel|.*\\..*).*)",
"/((?!api|admin|stats|errors|_next|_vercel|.*\\..*).*)",
"/",
"/(de)/:path*",
],

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,183 @@
import { MigrateUpArgs, MigrateDownArgs, sql } from "@payloadcms/db-postgres";
export async function up({ db, payload, req }: MigrateUpArgs): Promise<void> {
await db.execute(sql`
CREATE TABLE "users_sessions" (
"_order" integer NOT NULL,
"_parent_id" integer NOT NULL,
"id" varchar PRIMARY KEY NOT NULL,
"created_at" timestamp(3) with time zone,
"expires_at" timestamp(3) with time zone NOT NULL
);
CREATE TABLE "users" (
"id" serial PRIMARY KEY NOT NULL,
"updated_at" timestamp(3) with time zone DEFAULT now() NOT NULL,
"created_at" timestamp(3) with time zone DEFAULT now() NOT NULL,
"email" varchar NOT NULL,
"reset_password_token" varchar,
"reset_password_expiration" timestamp(3) with time zone,
"salt" varchar,
"hash" varchar,
"login_attempts" numeric DEFAULT 0,
"lock_until" timestamp(3) with time zone
);
CREATE TABLE "media" (
"id" serial PRIMARY KEY NOT NULL,
"alt" varchar NOT NULL,
"prefix" varchar DEFAULT 'mb-grid-solutions/media',
"updated_at" timestamp(3) with time zone DEFAULT now() NOT NULL,
"created_at" timestamp(3) with time zone DEFAULT now() NOT NULL,
"url" varchar,
"thumbnail_u_r_l" varchar,
"filename" varchar,
"mime_type" varchar,
"filesize" numeric,
"width" numeric,
"height" numeric,
"focal_x" numeric,
"focal_y" numeric,
"sizes_thumbnail_url" varchar,
"sizes_thumbnail_width" numeric,
"sizes_thumbnail_height" numeric,
"sizes_thumbnail_mime_type" varchar,
"sizes_thumbnail_filesize" numeric,
"sizes_thumbnail_filename" varchar,
"sizes_card_url" varchar,
"sizes_card_width" numeric,
"sizes_card_height" numeric,
"sizes_card_mime_type" varchar,
"sizes_card_filesize" numeric,
"sizes_card_filename" varchar
);
CREATE TABLE "form_submissions" (
"id" serial PRIMARY KEY NOT NULL,
"name" varchar NOT NULL,
"email" varchar NOT NULL,
"company" varchar,
"message" varchar NOT NULL,
"updated_at" timestamp(3) with time zone DEFAULT now() NOT NULL,
"created_at" timestamp(3) with time zone DEFAULT now() NOT NULL
);
CREATE TABLE "pages" (
"id" serial PRIMARY KEY NOT NULL,
"title" varchar NOT NULL,
"slug" varchar NOT NULL,
"content" jsonb NOT NULL,
"updated_at" timestamp(3) with time zone DEFAULT now() NOT NULL,
"created_at" timestamp(3) with time zone DEFAULT now() NOT NULL
);
CREATE TABLE "payload_kv" (
"id" serial PRIMARY KEY NOT NULL,
"key" varchar NOT NULL,
"data" jsonb NOT NULL
);
CREATE TABLE "payload_locked_documents" (
"id" serial PRIMARY KEY NOT NULL,
"global_slug" varchar,
"updated_at" timestamp(3) with time zone DEFAULT now() NOT NULL,
"created_at" timestamp(3) with time zone DEFAULT now() NOT NULL
);
CREATE TABLE "payload_locked_documents_rels" (
"id" serial PRIMARY KEY NOT NULL,
"order" integer,
"parent_id" integer NOT NULL,
"path" varchar NOT NULL,
"users_id" integer,
"media_id" integer,
"form_submissions_id" integer,
"pages_id" integer
);
CREATE TABLE "payload_preferences" (
"id" serial PRIMARY KEY NOT NULL,
"key" varchar,
"value" jsonb,
"updated_at" timestamp(3) with time zone DEFAULT now() NOT NULL,
"created_at" timestamp(3) with time zone DEFAULT now() NOT NULL
);
CREATE TABLE "payload_preferences_rels" (
"id" serial PRIMARY KEY NOT NULL,
"order" integer,
"parent_id" integer NOT NULL,
"path" varchar NOT NULL,
"users_id" integer
);
CREATE TABLE "payload_migrations" (
"id" serial PRIMARY KEY NOT NULL,
"name" varchar,
"batch" numeric,
"updated_at" timestamp(3) with time zone DEFAULT now() NOT NULL,
"created_at" timestamp(3) with time zone DEFAULT now() NOT NULL
);
ALTER TABLE "users_sessions" ADD CONSTRAINT "users_sessions_parent_id_fk" FOREIGN KEY ("_parent_id") REFERENCES "public"."users"("id") ON DELETE cascade ON UPDATE no action;
ALTER TABLE "payload_locked_documents_rels" ADD CONSTRAINT "payload_locked_documents_rels_parent_fk" FOREIGN KEY ("parent_id") REFERENCES "public"."payload_locked_documents"("id") ON DELETE cascade ON UPDATE no action;
ALTER TABLE "payload_locked_documents_rels" ADD CONSTRAINT "payload_locked_documents_rels_users_fk" FOREIGN KEY ("users_id") REFERENCES "public"."users"("id") ON DELETE cascade ON UPDATE no action;
ALTER TABLE "payload_locked_documents_rels" ADD CONSTRAINT "payload_locked_documents_rels_media_fk" FOREIGN KEY ("media_id") REFERENCES "public"."media"("id") ON DELETE cascade ON UPDATE no action;
ALTER TABLE "payload_locked_documents_rels" ADD CONSTRAINT "payload_locked_documents_rels_form_submissions_fk" FOREIGN KEY ("form_submissions_id") REFERENCES "public"."form_submissions"("id") ON DELETE cascade ON UPDATE no action;
ALTER TABLE "payload_locked_documents_rels" ADD CONSTRAINT "payload_locked_documents_rels_pages_fk" FOREIGN KEY ("pages_id") REFERENCES "public"."pages"("id") ON DELETE cascade ON UPDATE no action;
ALTER TABLE "payload_preferences_rels" ADD CONSTRAINT "payload_preferences_rels_parent_fk" FOREIGN KEY ("parent_id") REFERENCES "public"."payload_preferences"("id") ON DELETE cascade ON UPDATE no action;
ALTER TABLE "payload_preferences_rels" ADD CONSTRAINT "payload_preferences_rels_users_fk" FOREIGN KEY ("users_id") REFERENCES "public"."users"("id") ON DELETE cascade ON UPDATE no action;
CREATE INDEX "users_sessions_order_idx" ON "users_sessions" USING btree ("_order");
CREATE INDEX "users_sessions_parent_id_idx" ON "users_sessions" USING btree ("_parent_id");
CREATE INDEX "users_updated_at_idx" ON "users" USING btree ("updated_at");
CREATE INDEX "users_created_at_idx" ON "users" USING btree ("created_at");
CREATE UNIQUE INDEX "users_email_idx" ON "users" USING btree ("email");
CREATE INDEX "media_updated_at_idx" ON "media" USING btree ("updated_at");
CREATE INDEX "media_created_at_idx" ON "media" USING btree ("created_at");
CREATE UNIQUE INDEX "media_filename_idx" ON "media" USING btree ("filename");
CREATE INDEX "media_sizes_thumbnail_sizes_thumbnail_filename_idx" ON "media" USING btree ("sizes_thumbnail_filename");
CREATE INDEX "media_sizes_card_sizes_card_filename_idx" ON "media" USING btree ("sizes_card_filename");
CREATE INDEX "form_submissions_updated_at_idx" ON "form_submissions" USING btree ("updated_at");
CREATE INDEX "form_submissions_created_at_idx" ON "form_submissions" USING btree ("created_at");
CREATE INDEX "pages_updated_at_idx" ON "pages" USING btree ("updated_at");
CREATE INDEX "pages_created_at_idx" ON "pages" USING btree ("created_at");
CREATE UNIQUE INDEX "payload_kv_key_idx" ON "payload_kv" USING btree ("key");
CREATE INDEX "payload_locked_documents_global_slug_idx" ON "payload_locked_documents" USING btree ("global_slug");
CREATE INDEX "payload_locked_documents_updated_at_idx" ON "payload_locked_documents" USING btree ("updated_at");
CREATE INDEX "payload_locked_documents_created_at_idx" ON "payload_locked_documents" USING btree ("created_at");
CREATE INDEX "payload_locked_documents_rels_order_idx" ON "payload_locked_documents_rels" USING btree ("order");
CREATE INDEX "payload_locked_documents_rels_parent_idx" ON "payload_locked_documents_rels" USING btree ("parent_id");
CREATE INDEX "payload_locked_documents_rels_path_idx" ON "payload_locked_documents_rels" USING btree ("path");
CREATE INDEX "payload_locked_documents_rels_users_id_idx" ON "payload_locked_documents_rels" USING btree ("users_id");
CREATE INDEX "payload_locked_documents_rels_media_id_idx" ON "payload_locked_documents_rels" USING btree ("media_id");
CREATE INDEX "payload_locked_documents_rels_form_submissions_id_idx" ON "payload_locked_documents_rels" USING btree ("form_submissions_id");
CREATE INDEX "payload_locked_documents_rels_pages_id_idx" ON "payload_locked_documents_rels" USING btree ("pages_id");
CREATE INDEX "payload_preferences_key_idx" ON "payload_preferences" USING btree ("key");
CREATE INDEX "payload_preferences_updated_at_idx" ON "payload_preferences" USING btree ("updated_at");
CREATE INDEX "payload_preferences_created_at_idx" ON "payload_preferences" USING btree ("created_at");
CREATE INDEX "payload_preferences_rels_order_idx" ON "payload_preferences_rels" USING btree ("order");
CREATE INDEX "payload_preferences_rels_parent_idx" ON "payload_preferences_rels" USING btree ("parent_id");
CREATE INDEX "payload_preferences_rels_path_idx" ON "payload_preferences_rels" USING btree ("path");
CREATE INDEX "payload_preferences_rels_users_id_idx" ON "payload_preferences_rels" USING btree ("users_id");
CREATE INDEX "payload_migrations_updated_at_idx" ON "payload_migrations" USING btree ("updated_at");
CREATE INDEX "payload_migrations_created_at_idx" ON "payload_migrations" USING btree ("created_at");`);
}
export async function down({
db,
payload,
req,
}: MigrateDownArgs): Promise<void> {
await db.execute(sql`
DROP TABLE "users_sessions" CASCADE;
DROP TABLE "users" CASCADE;
DROP TABLE "media" CASCADE;
DROP TABLE "form_submissions" CASCADE;
DROP TABLE "pages" CASCADE;
DROP TABLE "payload_kv" CASCADE;
DROP TABLE "payload_locked_documents" CASCADE;
DROP TABLE "payload_locked_documents_rels" CASCADE;
DROP TABLE "payload_preferences" CASCADE;
DROP TABLE "payload_preferences_rels" CASCADE;
DROP TABLE "payload_migrations" CASCADE;`);
}

9
migrations/index.ts Normal file
View File

@@ -0,0 +1,9 @@
import * as migration_20260227_113637_v1_initial from "./20260227_113637_v1_initial";
export const migrations = [
{
up: migration_20260227_113637_v1_initial.up,
down: migration_20260227_113637_v1_initial.down,
name: "20260227_113637_v1_initial",
},
];

2
next-env.d.ts vendored
View File

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

View File

@@ -1,3 +1,4 @@
import { withPayload } from "@payloadcms/next/withPayload";
import withMintelConfig from "@mintel/next-config";
/** @type {import('next').NextConfig} */
@@ -33,4 +34,4 @@ const nextConfig = {
},
};
export default withMintelConfig(nextConfig);
export default withPayload(withMintelConfig(nextConfig));

View File

@@ -4,48 +4,61 @@
"type": "module",
"packageManager": "pnpm@10.18.3",
"scripts": {
"dev": "docker network create infra 2>/dev/null || true && echo '\\n🚀 Development Environment Starting...\\n\\n📱 App: http://mb-grid-solutions.localhost\\n🗄 CMS: http://cms.mb-grid-solutions.localhost/admin\\n🚦 Traefik: http://localhost:8080\\n\\n(Press Ctrl+C to stop)\\n' && docker compose down --remove-orphans && docker compose up app directus directus-db",
"dev": "docker network create infra 2>/dev/null || true && echo '\\n🚀 Development Environment Starting...\\n\\n📱 App: http://mb-grid-solutions.localhost\\n🗄 CMS: http://mb-grid-solutions.localhost/admin\\n🚦 Traefik: http://localhost:8080\\n\\n(Press Ctrl+C to stop)\\n' && docker compose down --remove-orphans && docker compose up app mb-grid-db",
"dev:next": "next dev",
"build": "next build",
"start": "next start",
"lint": "eslint app components lib scripts",
"test": "vitest",
"prepare": "husky",
"cms:bootstrap": "DIRECTUS_URL=http://localhost:8055 npx tsx --env-file=.env scripts/setup-directus.ts",
"cms:push:staging": "./scripts/sync-directus.sh push staging",
"cms:pull:staging": "./scripts/sync-directus.sh pull staging",
"cms:push:testing": "./scripts/sync-directus.sh push testing",
"cms:pull:testing": "./scripts/sync-directus.sh pull testing",
"cms:push:prod": "./scripts/sync-directus.sh push production",
"cms:pull:prod": "./scripts/sync-directus.sh pull production",
"pagespeed:test": "mintel pagespeed test"
"generate:types": "payload generate:types",
"generate:importmap": "payload generate:importmap",
"pagespeed:test": "mintel pagespeed test",
"check:http": "tsx ./scripts/check-http.ts",
"check:apis": "tsx ./scripts/check-apis.ts",
"check:locale": "tsx ./scripts/check-locale.ts",
"backup:db": "bash ./scripts/backup-db.sh"
},
"keywords": [],
"author": "",
"license": "ISC",
"description": "",
"dependencies": {
"@mintel/next-config": "^1.1.13",
"@mintel/next-utils": "^1.1.13",
"@aws-sdk/client-s3": "^3.999.0",
"@mintel/mail": "^1.8.21",
"@mintel/next-config": "^1.8.20",
"@mintel/next-utils": "^1.8.20",
"@payloadcms/db-postgres": "^3.77.0",
"@payloadcms/email-nodemailer": "^3.77.0",
"@payloadcms/next": "^3.77.0",
"@payloadcms/richtext-lexical": "^3.77.0",
"@payloadcms/storage-s3": "^3.77.0",
"@payloadcms/ui": "^3.77.0",
"@react-email/components": "^1.0.8",
"@sentry/nextjs": "^10.38.0",
"bcrypt": "^6.0.0",
"bcryptjs": "^3.0.3",
"framer-motion": "^12.29.2",
"graphql": "^16.13.0",
"lucide-react": "^0.562.0",
"next": "^16.1.6",
"next-intl": "^4.8.2",
"nodemailer": "^7.0.12",
"payload": "^3.77.0",
"pino": "^10.3.0",
"react": "^19.2.4",
"react-dom": "^19.2.4",
"zod": "^4.3.6"
"react-email": "^5.2.8",
"sharp": "^0.34.5",
"zod": "^3.24.1"
},
"devDependencies": {
"@commitlint/cli": "^20.4.0",
"@commitlint/config-conventional": "^20.4.0",
"@directus/sdk": "^21.0.0",
"@mintel/cli": "^1.1.13",
"@mintel/eslint-config": "^1.1.13",
"@mintel/husky-config": "^1.1.13",
"@mintel/tsconfig": "^1.1.13",
"@mintel/cli": "^1.8.20",
"@mintel/eslint-config": "^1.8.20",
"@mintel/husky-config": "^1.8.20",
"@mintel/tsconfig": "^1.8.20",
"@tailwindcss/postcss": "^4.1.18",
"@testing-library/jest-dom": "^6.9.1",
"@testing-library/react": "^16.3.2",
@@ -55,8 +68,11 @@
"@types/react-dom": "^19.2.3",
"@vitejs/plugin-react": "^5.1.2",
"autoprefixer": "^10.4.23",
"axios": "^1.13.5",
"cheerio": "^1.2.0",
"eslint": "^8.57.1",
"eslint-config-next": "15.1.6",
"happy-dom": "^20.6.1",
"husky": "^9.1.7",
"jsdom": "^27.4.0",
"lint-staged": "^16.2.7",
@@ -64,7 +80,12 @@
"postcss": "^8.5.6",
"prettier": "^3.5.0",
"tailwindcss": "^4.1.18",
"tsx": "^4.21.0",
"typescript": "^5.9.3",
"vitest": "^4.0.18"
},
"repository": {
"type": "git",
"url": "ssh://git@git.infra.mintel.me:2222/mmintel/mb-grid-solutions.com.git"
}
}

7281
pnpm-lock.yaml generated

File diff suppressed because it is too large Load Diff

Binary file not shown.

Binary file not shown.

Before

Width:  |  Height:  |  Size: 204 KiB

After

Width:  |  Height:  |  Size: 21 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.4 MiB

After

Width:  |  Height:  |  Size: 1.1 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 736 KiB

After

Width:  |  Height:  |  Size: 428 KiB

53
scripts/backup-db.sh Normal file
View File

@@ -0,0 +1,53 @@
#!/usr/bin/env bash
# ────────────────────────────────────────────────────────────────────────────
# Payload CMS Database Backup
# Creates a timestamped pg_dump of the Payload Postgres database.
# Usage: npm run backup:db
# ────────────────────────────────────────────────────────────────────────────
set -euo pipefail
# Load environment variables
if [ -f .env ]; then
set -a; source .env; set +a
fi
# Fallback for local development if not in .env
DB_NAME="${POSTGRES_DB:-payload}"
DB_USER="${POSTGRES_USER:-postgres}"
# For production, we need the container name.
# We'll use the PROJECT_NAME to find it if possible, otherwise use a default.
PROJECT_NAME="${PROJECT_NAME:-mb-grid-solutions-production}"
DB_CONTAINER="${DB_CONTAINER:-${PROJECT_NAME}-mb-grid-db-1}"
BACKUP_DIR="./backups"
TIMESTAMP=$(date +"%Y%m%d_%H%M%S")
BACKUP_FILE="${BACKUP_DIR}/payload_${TIMESTAMP}.sql.gz"
# Ensure backup directory exists
mkdir -p "$BACKUP_DIR"
# Check if container is running
if ! docker ps --format '{{.Names}}' | grep -q "$DB_CONTAINER"; then
echo "❌ Database container '$DB_CONTAINER' is not running."
echo " Check your docker-compose status."
exit 1
fi
echo "📦 Backing up Payload database..."
echo " Container: $DB_CONTAINER"
echo " Database: $DB_NAME"
echo " Output: $BACKUP_FILE"
# Run pg_dump inside the container and compress
# We use directus as user for now if we haven't fully switched to postgres user in all environments
# But the script should be consistent with the environment.
docker exec "$DB_CONTAINER" pg_dump -U "$DB_USER" -d "$DB_NAME" --clean --if-exists | gzip > "$BACKUP_FILE"
# Show result
SIZE=$(du -h "$BACKUP_FILE" | cut -f1)
echo ""
echo "✅ Backup complete: $BACKUP_FILE ($SIZE)"
echo ""
# Show existing backups
echo "📋 Available backups:"
ls -lh "$BACKUP_DIR"/*.sql.gz 2>/dev/null | awk '{print " " $NF " (" $5 ")"}'

136
scripts/check-apis.ts Normal file
View File

@@ -0,0 +1,136 @@
import axios from "axios";
import dns from "dns";
import { promisify } from "util";
import url from "url";
const resolve4 = promisify(dns.resolve4);
// This script verifies that external logging and analytics APIs are reachable
// from the deployment environment (which could be behind corporate firewalls or VPNs).
const umamiEndpoint =
process.env.UMAMI_API_ENDPOINT || "https://analytics.infra.mintel.me";
const sentryDsn = process.env.SENTRY_DSN || "";
async function checkUmami() {
console.log(`\n🔍 Checking Umami Analytics API Availability...`);
console.log(` Endpoint: ${umamiEndpoint}`);
try {
// Umami usually exposes a /api/heartbeat or /api/health if we know the route.
// Trying root or /api/auth/verify (which will give 401 but proves routing works).
// A simple GET to the configured endpoint should return a 200 or 401, not a 5xx/timeout.
const response = await axios.get(
`${umamiEndpoint.replace(/\/$/, "")}/api/health`,
{
timeout: 5000,
validateStatus: () => true, // Accept any status, we just want to know it's reachable and not 5xx
},
);
// As long as it's not a 502/503/504 Bad Gateway/Timeout, the service is "up" from our perspective
if (response.status >= 500) {
throw new Error(
`Umami API responded with server error HTTP ${response.status}`,
);
}
console.log(` ✅ Umami Analytics is reachable (HTTP ${response.status})`);
return true;
} catch (error) {
const err = error as Error;
// If /api/health fails completely, maybe try a DNS check as a fallback
try {
console.warn(
` ⚠️ HTTP check failed, falling back to DNS resolution...`,
);
const umamiHost = new url.URL(umamiEndpoint).hostname;
await resolve4(umamiHost);
console.log(
` ✅ Umami Analytics DNS resolved successfully (${umamiHost})`,
);
return true;
} catch (error) {
const dnsErr = error as Error;
console.error(
` ❌ CRITICAL: Umami Analytics is completely unreachable! ${err.message} | DNS: ${dnsErr.message}`,
);
return false;
}
}
}
async function checkSentry() {
console.log(`\n🔍 Checking Glitchtip/Sentry Error Tracking Availability...`);
if (!sentryDsn) {
console.log(` No SENTRY_DSN provided in environment. Skipping.`);
return true;
}
try {
const parsedDsn = new url.URL(sentryDsn);
const host = parsedDsn.hostname;
console.log(` Host: ${host}`);
// We do a DNS lookup to ensure the runner can actually resolve the tracking server
const addresses = await resolve4(host);
if (addresses && addresses.length > 0) {
console.log(` ✅ Glitchtip/Sentry domain resolved: ${addresses[0]}`);
// Optional: Quick TCP/HTTP check to the host root (Glitchtip usually runs on 80/443 root)
try {
const proto = parsedDsn.protocol || "https:";
await axios.get(`${proto}//${host}/api/0/`, {
timeout: 5000,
validateStatus: () => true,
});
console.log(` ✅ Glitchtip/Sentry API root responds to HTTP.`);
} catch {
console.log(
` ⚠️ Glitchtip/Sentry HTTP ping failed or timed out, but DNS is valid. Proceeding.`,
);
}
return true;
}
throw new Error("No IP addresses found for DSN host");
} catch (error) {
const err = error as Error;
console.error(
` ❌ CRITICAL: Glitchtip/Sentry DSN is invalid or hostname is unresolvable! ${err.message}`,
);
return false;
}
}
async function main() {
console.log("🚀 Starting External API Connectivity Smoke Test...");
let hasErrors = false;
const umamiOk = await checkUmami();
if (!umamiOk) hasErrors = true;
const sentryOk = await checkSentry();
if (!sentryOk) hasErrors = true;
if (hasErrors) {
console.error(
`\n🚨 POST-DEPLOY CHECK FAILED: One or more critical external APIs are unreachable.`,
);
console.error(
` This might mean the deployment environment lacks outbound internet access, `,
);
console.error(
` DNS is misconfigured, or the upstream services are down.`,
);
process.exit(1);
}
console.log(`\n🎉 SUCCESS: All required external APIs are reachable!`);
process.exit(0);
}
main();

91
scripts/check-http.ts Normal file
View File

@@ -0,0 +1,91 @@
import axios from "axios";
import * as cheerio from "cheerio";
const targetUrl =
process.argv[2] ||
process.env.NEXT_PUBLIC_BASE_URL ||
"http://localhost:3000";
const gatekeeperPassword = process.env.GATEKEEPER_PASSWORD || "lassmichrein";
async function main() {
console.log(`\n🚀 Starting HTTP Sitemap Validation for: ${targetUrl}\n`);
try {
const sitemapUrl = `${targetUrl.replace(/\/$/, "")}/sitemap.xml`;
console.log(`📥 Fetching sitemap from ${sitemapUrl}...`);
const response = await axios.get(sitemapUrl, {
headers: { Cookie: `mintel_gatekeeper_session=${gatekeeperPassword}` },
validateStatus: (status) => status < 400,
});
const $ = cheerio.load(response.data, { xmlMode: true });
let urls = $("url loc")
.map((i, el) => $(el).text())
.get();
const urlPattern = /https?:\/\/[^\/]+/;
urls = [...new Set(urls)]
.filter((u) => u.startsWith("http"))
.map((u) => u.replace(urlPattern, targetUrl.replace(/\/$/, "")))
.sort();
console.log(`✅ Found ${urls.length} target URLs in sitemap.`);
if (urls.length === 0) {
console.error("❌ No URLs found in sitemap. Is the site up?");
process.exit(1);
}
console.log(`\n🔍 Verifying HTTP Status Codes (Limit: None)...`);
let hasErrors = false;
// Run fetches sequentially to avoid overwhelming the server during CI
for (let i = 0; i < urls.length; i++) {
const u = urls[i];
try {
const res = await axios.get(u, {
headers: {
Cookie: `mintel_gatekeeper_session=${gatekeeperPassword}`,
},
validateStatus: null, // Don't throw on error status
});
if (res.status >= 400) {
console.error(`❌ ERROR ${res.status}: ${res.statusText} -> ${u}`);
hasErrors = true;
} else {
console.log(`✅ OK ${res.status} -> ${u}`);
}
} catch (error) {
const err = error as Error;
console.error(`❌ NETWORK ERROR: ${err.message} -> ${u}`);
hasErrors = true;
}
}
if (hasErrors) {
console.error(
`\n❌ HTTP Sitemap Validation Failed. One or more pages returned an error.`,
);
process.exit(1);
} else {
console.log(
`\n✨ Success: All ${urls.length} pages are healthy! (HTTP 200)`,
);
process.exit(0);
}
} catch (e: unknown) {
if (axios.isAxiosError(e) && e.response) {
console.error(
`\n❌ Critical Error during Sitemap Fetch: HTTP ${e.response.status} ${e.response.statusText}`,
);
} else {
const errorMsg = e instanceof Error ? e.message : String(e);
console.error(`\n❌ Critical Error during Sitemap Fetch: ${errorMsg}`);
}
process.exit(1);
}
}
main();

199
scripts/check-locale.ts Normal file
View File

@@ -0,0 +1,199 @@
import axios from "axios";
import * as cheerio from "cheerio";
/**
* Locale & Language Switcher Smoke Test
*
* For every URL in the sitemap:
* 1. Fetches the page HTML
* 2. Extracts <link rel="alternate" hreflang="..." href="..."> tags
* 3. Verifies each alternate URL uses correctly translated slugs
* 4. Verifies each alternate URL returns HTTP 200
*/
const targetUrl =
process.argv[2] ||
process.env.NEXT_PUBLIC_BASE_URL ||
"http://localhost:3000";
const gatekeeperPassword = process.env.GATEKEEPER_PASSWORD || "lassmichrein";
// Expected slug translations: German key → English value
const SLUG_MAP: Record<string, string> = {
// Add translations if mb-grid translates URLs: e.g. produkte: 'products'
};
// Reverse map: English → German
const REVERSE_SLUG_MAP: Record<string, string> = Object.fromEntries(
Object.entries(SLUG_MAP).map(([de, en]) => [en, de]),
);
const headers = { Cookie: `mintel_gatekeeper_session=${gatekeeperPassword}` };
function getExpectedTranslation(
sourcePath: string,
sourceLocale: string,
targetLocale: string,
): string {
const segments = sourcePath.split("/").filter(Boolean);
// First segment is locale
segments[0] = targetLocale;
const map = sourceLocale === "de" ? SLUG_MAP : REVERSE_SLUG_MAP;
return (
"/" +
segments
.map((seg, i) => {
if (i === 0) return seg; // locale
return map[seg] || seg; // translate or keep (product names like n2x2y stay the same)
})
.join("/")
);
}
async function main() {
console.log(`\n🌐 Starting Locale Smoke Test for: ${targetUrl}\n`);
// 1. Fetch sitemap
const sitemapUrl = `${targetUrl.replace(/\/$/, "")}/sitemap.xml`;
console.log(`📥 Fetching sitemap from ${sitemapUrl}...`);
const sitemapRes = await axios.get(sitemapUrl, {
headers,
validateStatus: (s) => s < 400,
});
const $sitemap = cheerio.load(sitemapRes.data, { xmlMode: true });
let urls = $sitemap("url loc")
.map((_i, el) => $sitemap(el).text())
.get();
const urlPattern = /https?:\/\/[^/]+/;
urls = [...new Set(urls)]
.filter((u) => u.startsWith("http"))
.map((u) => u.replace(urlPattern, targetUrl.replace(/\/$/, "")))
.sort();
console.log(`✅ Found ${urls.length} URLs in sitemap.\n`);
let totalChecked = 0;
let totalPassed = 0;
let totalFailed = 0;
const failures: string[] = [];
for (const url of urls) {
const path = new URL(url).pathname;
const locale = path.split("/")[1];
if (!locale || !["de", "en"].includes(locale)) continue;
try {
const res = await axios.get(url, { headers, validateStatus: null });
if (res.status >= 400) continue; // Skip pages that are already broken (check-http catches those)
const $ = cheerio.load(res.data);
// Extract hreflang alternate links
const alternates: { hreflang: string; href: string }[] = [];
$('link[rel="alternate"][hreflang]').each((_i, el) => {
const hreflang = $(el).attr("hreflang") || "";
let href = $(el).attr("href") || "";
if (href && hreflang && hreflang !== "x-default") {
href = href.replace(urlPattern, targetUrl.replace(/\/$/, ""));
alternates.push({ hreflang, href });
}
});
if (alternates.length === 0) {
// Some pages may not have alternates, that's OK
continue;
}
totalChecked++;
// Validate each alternate
let pageOk = true;
for (const alt of alternates) {
if (alt.hreflang === locale) continue; // Same locale, skip
// 1. Check slug translation is correct
const expectedPath = getExpectedTranslation(path, locale, alt.hreflang);
const actualPath = new URL(alt.href).pathname;
if (actualPath !== expectedPath) {
console.error(
`❌ SLUG MISMATCH: ${path} → hreflang="${alt.hreflang}" expected ${expectedPath} but got ${actualPath}`,
);
failures.push(
`Slug mismatch: ${path}${alt.hreflang}: expected ${expectedPath}, got ${actualPath}`,
);
pageOk = false;
continue;
}
// 2. Check alternate URL returns 200
try {
const altRes = await axios.get(alt.href, {
headers,
validateStatus: null,
maxRedirects: 5,
});
if (altRes.status >= 400) {
console.error(
`❌ BROKEN ALTERNATE: ${path}${alt.href} returned ${altRes.status}`,
);
failures.push(
`Broken alternate: ${path}${alt.href} (${altRes.status})`,
);
pageOk = false;
}
} catch (error) {
const err = error as Error;
console.error(
`❌ NETWORK ERROR: ${path}${alt.href}: ${err.message}`,
);
failures.push(`Network error: ${path}${alt.href}: ${err.message}`);
pageOk = false;
}
}
if (pageOk) {
console.log(
`${path} — alternates OK (${alternates
.map((a) => a.hreflang)
.filter((h) => h !== locale)
.join(", ")})`,
);
totalPassed++;
} else {
totalFailed++;
}
} catch (error) {
const err = error as Error;
console.error(`❌ NETWORK ERROR fetching ${url}: ${err.message}`);
totalFailed++;
}
}
console.log(`\n${"─".repeat(60)}`);
console.log(`📊 Locale Smoke Test Results:`);
console.log(` Pages checked: ${totalChecked}`);
console.log(` Passed: ${totalPassed}`);
console.log(` Failed: ${totalFailed}`);
if (failures.length > 0) {
console.log(`\n❌ Failures:`);
failures.forEach((f) => console.log(`${f}`));
console.log(`\n❌ Locale Smoke Test FAILED.`);
process.exit(1);
} else {
console.log(
`\n✨ All locale alternates are correctly translated and reachable!`,
);
process.exit(0);
}
}
main().catch((err) => {
console.error(`\n❌ Critical error:`, err.message);
process.exit(1);
});

49
scripts/create-admin.ts Normal file
View File

@@ -0,0 +1,49 @@
import { getPayload } from "payload";
import config from "./src/payload/payload.config";
const createAdmin = async () => {
const payload = await getPayload({ config });
const email = "marc@mintel.me";
const password = "Tim300493.";
console.log(`Creating/Updating admin: ${email}`);
try {
// Check if user exists
const users = await payload.find({
collection: "users",
where: {
email: {
equals: email,
},
},
});
if (users.totalDocs > 0) {
console.log("User already exists. Updating password.");
await payload.update({
collection: "users",
id: users.docs[0].id,
data: {
password,
},
});
} else {
await payload.create({
collection: "users",
data: {
email,
password,
},
});
console.log("Admin user created successfully.");
}
} catch (error) {
console.error("Error creating admin:", error);
process.exit(1);
}
process.exit(0);
};
createAdmin();

View File

@@ -1,150 +0,0 @@
import {
createMintelDirectusClient,
ensureDirectusAuthenticated,
} from "@mintel/next-utils";
import { createCollection, createField, updateSettings } from "@directus/sdk";
const client = createMintelDirectusClient();
async function setupBranding() {
const prjName = process.env.PROJECT_NAME || "MB Grid Solutions";
const prjColor = process.env.PROJECT_COLOR || "#82ed20";
console.log(`🎨 Refining Directus Branding for ${prjName}...`);
await ensureDirectusAuthenticated(client);
const cssInjection = `
<style>
@import url('https://fonts.googleapis.com/css2?family=Outfit:wght@400;500;600;700&display=swap');
body, .v-app { font-family: 'Outfit', sans-serif !important; }
.public-view .v-card {
backdrop-filter: blur(20px);
background: rgba(255, 255, 255, 0.9) !important;
border-radius: 32px !important;
box-shadow: 0 50px 100px -20px rgba(0, 0, 0, 0.4) !important;
border: 1px solid rgba(255, 255, 255, 0.3) !important;
}
.v-navigation-drawer { background: #000c24 !important; }
.v-list-item--active {
color: ${prjColor} !important;
background: rgba(130, 237, 32, 0.1) !important;
}
</style>
<div style="font-family: 'Outfit', sans-serif; text-align: center; margin-top: 24px;">
<p style="color: rgba(255,255,255,0.6); font-size: 11px; letter-spacing: 2px; margin-bottom: 4px; font-weight: 600; text-transform: uppercase;">Mintel Infrastructure Engine</p>
<h1 style="color: #ffffff; font-size: 20px; font-weight: 700; margin: 0; letter-spacing: -0.5px;">${prjName.toUpperCase()} <span style="color: ${prjColor};">SYNC.</span></h1>
</div>
`;
try {
await client.request(
updateSettings({
project_name: prjName,
project_color: prjColor,
public_note: cssInjection,
module_bar_background: "#00081a",
theme_light_overrides: {
primary: prjColor,
borderRadius: "12px",
navigationBackground: "#000c24",
navigationForeground: "#ffffff",
moduleBarBackground: "#00081a",
},
// eslint-disable-next-line @typescript-eslint/no-explicit-any
} as any),
);
console.log("✨ Branding applied!");
await createCollectionAndFields();
console.log("🏗️ Schema alignment complete!");
} catch (error) {
console.error("❌ Error during bootstrap:", error);
}
}
async function createCollectionAndFields() {
const collectionName = "contact_submissions";
try {
await client.request(
createCollection({
collection: collectionName,
schema: {},
meta: {
icon: "contact_mail",
display_template: "{{name}} <{{email}}>",
group: null,
sort: null,
collapse: "open",
},
}),
);
// Add ID field
await client.request(
createField(collectionName, {
field: "id",
type: "integer",
meta: { hidden: true },
schema: { is_primary_key: true, has_auto_increment: true },
}),
);
console.log(`✅ Collection ${collectionName} created.`);
} catch {
console.log(` Collection ${collectionName} exists.`);
}
const safeAddField = async (
field: string,
type: string,
meta: Record<string, unknown> = {},
) => {
try {
await client.request(createField(collectionName, { field, type, meta }));
console.log(`✅ Field ${field} added.`);
} catch {
// Ignore if exists
}
};
await safeAddField("name", "string", {
interface: "input",
display: "raw",
width: "half",
});
await safeAddField("email", "string", {
interface: "input",
display: "raw",
width: "half",
});
await safeAddField("company", "string", {
interface: "input",
display: "raw",
width: "half",
});
await safeAddField("message", "text", {
interface: "textarea",
display: "raw",
width: "full",
});
await safeAddField("date_created", "timestamp", {
interface: "datetime",
special: ["date-created"],
display: "datetime",
display_options: { relative: true },
width: "half",
});
}
setupBranding()
.then(() => {
process.exit(0);
})
.catch((err) => {
console.error("🚨 Fatal bootstrap error:", err);
process.exit(1);
});

View File

@@ -1,131 +0,0 @@
#!/bin/bash
# Configuration
REMOTE_HOST="${SSH_HOST:-root@alpha.mintel.me}"
ACTION=$1
ENV=$2
# Help
if [ -z "$ACTION" ] || [ -z "$ENV" ]; then
echo "Usage: ./scripts/sync-directus.sh [push|pull] [testing|staging|production]"
echo ""
echo "Commands:"
echo " push Sync LOCAL data -> REMOTE"
echo " pull Sync REMOTE data -> LOCAL"
echo ""
echo "Environments:"
echo " testing, staging, production"
exit 1
fi
# Project Configuration (extracted from package.json and aligned with deploy.yml)
PRJ_ID=$(jq -r .name package.json | sed 's/@mintel\///' | sed 's/\.com$//')
REMOTE_DIR="/home/deploy/sites/${PRJ_ID}.com"
case $ENV in
testing) PROJECT_NAME="${PRJ_ID}-testing"; ENV_FILE=".env.testing" ;;
staging) PROJECT_NAME="${PRJ_ID}-staging"; ENV_FILE=".env.staging" ;;
production) PROJECT_NAME="${PRJ_ID}-production"; ENV_FILE=".env.prod" ;;
*) echo "❌ Invalid environment: $ENV"; exit 1 ;;
esac
# DB Details (matching docker-compose defaults)
DB_USER="directus"
DB_NAME="directus"
echo "🔍 Detecting local database..."
LOCAL_DB_CONTAINER=$(docker compose ps -q directus-db)
if [ -z "$LOCAL_DB_CONTAINER" ]; then
# Check if it exists but is stopped
LOCAL_DB_EXISTS=$(docker compose ps -a -q directus-db)
if [ -n "$LOCAL_DB_EXISTS" ]; then
echo "⏳ Local directus-db is stopped. Starting it..."
docker compose up -d directus-db
# Wait a few seconds for PG to be ready
sleep 2
LOCAL_DB_CONTAINER=$(docker compose ps -q directus-db)
fi
fi
if [ -z "$LOCAL_DB_CONTAINER" ]; then
echo "❌ Local directus-db container not found. Is it defined in docker-compose.yaml?"
exit 1
fi
if [ "$ACTION" == "push" ]; then
echo "🚀 Pushing LOCAL -> $ENV ($PROJECT_NAME)..."
# 1. DB Dump
echo "📦 Dumping local database..."
docker exec "$LOCAL_DB_CONTAINER" pg_dump -U "$DB_USER" --clean --if-exists --no-owner --no-privileges "$DB_NAME" > dump.sql
# 2. Upload Dump
echo "📤 Uploading dump to remote server..."
scp dump.sql "$REMOTE_HOST:$REMOTE_DIR/dump.sql"
# 3. Restore on Remote
echo "🔄 Restoring dump on $ENV..."
REMOTE_DB_CONTAINER=$(ssh "$REMOTE_HOST" "cd $REMOTE_DIR && docker compose -p $PROJECT_NAME ps -q directus-db")
if [ -z "$REMOTE_DB_CONTAINER" ]; then
echo "❌ Remote $ENV-db container not found!"
exit 1
fi
echo "🧹 Wiping remote database schema..."
ssh "$REMOTE_HOST" "docker exec $REMOTE_DB_CONTAINER psql -U $DB_USER $DB_NAME -c 'DROP SCHEMA public CASCADE; CREATE SCHEMA public;'"
echo "⚡ Restoring database..."
ssh "$REMOTE_HOST" "docker exec -i $REMOTE_DB_CONTAINER psql -U $DB_USER $DB_NAME < $REMOTE_DIR/dump.sql"
# 4. Sync Uploads
echo "📁 Syncing uploads (Local -> $ENV)..."
rsync -avz --progress ./directus/uploads/ "$REMOTE_HOST:$REMOTE_DIR/directus/uploads/"
# Clean up
rm dump.sql
ssh "$REMOTE_HOST" "rm $REMOTE_DIR/dump.sql"
# 5. Restart Directus to trigger migrations and refresh schema cache
echo "🔄 Restarting remote Directus to apply migrations..."
ssh "$REMOTE_HOST" "cd $REMOTE_DIR && docker compose -p $PROJECT_NAME restart directus"
echo "✨ Push to $ENV complete!"
elif [ "$ACTION" == "pull" ]; then
echo "📥 Pulling $ENV Data -> LOCAL..."
# 1. DB Dump on Remote
echo "📦 Dumping remote database ($ENV)..."
REMOTE_DB_CONTAINER=$(ssh "$REMOTE_HOST" "cd $REMOTE_DIR && docker compose -p $PROJECT_NAME ps -q directus-db")
if [ -z "$REMOTE_DB_CONTAINER" ]; then
echo "❌ Remote $ENV-db container not found!"
exit 1
fi
ssh "$REMOTE_HOST" "docker exec $REMOTE_DB_CONTAINER pg_dump -U $DB_USER --clean --if-exists --no-owner --no-privileges $DB_NAME > $REMOTE_DIR/dump.sql"
# 2. Download Dump
echo "📥 Downloading dump..."
scp "$REMOTE_HOST:$REMOTE_DIR/dump.sql" dump.sql
# 3. Restore 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
# 4. Sync Uploads
echo "📁 Syncing uploads ($ENV -> Local)..."
rsync -avz --progress "$REMOTE_HOST:$REMOTE_DIR/directus/uploads/" ./directus/uploads/
# Clean up
rm dump.sql
ssh "$REMOTE_HOST" "rm $REMOTE_DIR/dump.sql"
echo "✨ Pull to Local complete!"
fi

86
scripts/upload-s3.ts Normal file
View File

@@ -0,0 +1,86 @@
import { S3Client, PutObjectCommand } from "@aws-sdk/client-s3";
import * as fs from "fs";
import * as path from "path";
const S3_ENDPOINT = process.env.S3_ENDPOINT;
const S3_REGION = process.env.S3_REGION || "fsn1";
const S3_BUCKET = process.env.S3_BUCKET;
const S3_PREFIX = process.env.S3_PREFIX;
const S3_ACCESS_KEY = process.env.S3_ACCESS_KEY;
const S3_SECRET_KEY = process.env.S3_SECRET_KEY;
if (!S3_ENDPOINT || !S3_BUCKET || !S3_ACCESS_KEY || !S3_SECRET_KEY) {
console.error("Missing S3 credentials in environment");
process.exit(1);
}
const s3Client = new S3Client({
region: S3_REGION,
endpoint: S3_ENDPOINT,
credentials: {
accessKeyId: S3_ACCESS_KEY,
secretAccessKey: S3_SECRET_KEY,
},
forcePathStyle: true,
});
async function uploadDirectory(dirPath: string, prefix: string) {
const files = fs.readdirSync(dirPath, { withFileTypes: true });
for (const file of files) {
if (file.name === ".DS_Store" || file.name === ".gitkeep") continue;
const fullPath = path.join(dirPath, file.name);
// Combine prefix with filename, ensuring no double slashes, e.g., mb-grid-solutions/media/filename.ext
const s3Key = `${prefix}/${file.name}`.replace(/\/+/g, "/");
if (file.isDirectory()) {
await uploadDirectory(fullPath, s3Key);
} else {
const fileContent = fs.readFileSync(fullPath);
let contentType = "application/octet-stream";
if (file.name.endsWith(".png")) contentType = "image/png";
else if (file.name.endsWith(".jpg") || file.name.endsWith(".jpeg"))
contentType = "image/jpeg";
else if (file.name.endsWith(".svg")) contentType = "image/svg+xml";
else if (file.name.endsWith(".webp")) contentType = "image/webp";
else if (file.name.endsWith(".pdf")) contentType = "application/pdf";
try {
await s3Client.send(
new PutObjectCommand({
Bucket: S3_BUCKET,
Key: s3Key,
Body: fileContent,
ContentType: contentType,
ACL: "public-read", // Hetzner requires public-read for public access usually
}),
);
console.log(`✅ Uploaded ${file.name} to ${S3_BUCKET}/${s3Key}`);
} catch (err) {
console.error(`❌ Failed to upload ${file.name}:`, err);
}
}
}
}
async function main() {
const mediaDir = path.resolve(process.cwd(), "public/media");
if (fs.existsSync(mediaDir)) {
console.log("Uploading public/media...");
// Media inside Payload CMS uses prefix/media usually, like mb-grid-solutions/media
await uploadDirectory(mediaDir, `${S3_PREFIX}/media`);
} else {
console.log("No public/media directory found.");
}
const assetsDir = path.resolve(process.cwd(), "public/assets");
if (fs.existsSync(assetsDir)) {
console.log("Uploading public/assets...");
await uploadDirectory(assetsDir, `${S3_PREFIX}/assets`);
} else {
console.log("No public/assets directory found.");
}
}
main().catch(console.error);

View File

@@ -4,7 +4,7 @@ import { config } from "./lib/config";
if (config.errors.glitchtip.enabled) {
Sentry.init({
dsn: config.errors.glitchtip.dsn,
tracesSampleRate: 1.0,
tracesSampleRate: 0.1,
debug: config.isDevelopment,
environment: config.target || "production",
// Use the proxy path defined in config

View File

@@ -2,10 +2,15 @@ import * as Sentry from "@sentry/nextjs";
import { config } from "./lib/config";
if (config.errors.glitchtip.enabled) {
console.log("Initializing Sentry in Edge runtime...", {
environment: config.target || "production",
});
Sentry.init({
dsn: config.errors.glitchtip.dsn,
tracesSampleRate: 1.0,
debug: config.isDevelopment,
debug: true, // Force debug for now to see why it's failing
environment: config.target || "production",
});
} else {
console.warn("Sentry is DISABLED in Edge runtime (missing DSN)");
}

View File

@@ -0,0 +1,4 @@
import { Block } from "payload";
// Define any custom blocks you want here. Leaving empty for now.
export const payloadBlocks: Block[] = [];

View File

@@ -0,0 +1,44 @@
import type { CollectionConfig } from "payload";
export const FormSubmissions: CollectionConfig = {
slug: "form-submissions",
admin: {
useAsTitle: "name",
defaultColumns: ["name", "email", "company", "createdAt"],
description: "Captured leads from Contact Form.",
},
access: {
read: ({ req: { user } }) =>
Boolean(user) || process.env.NODE_ENV === "development",
update: ({ req: { user } }) =>
Boolean(user) || process.env.NODE_ENV === "development",
delete: ({ req: { user } }) =>
Boolean(user) || process.env.NODE_ENV === "development",
create: () => false, // Only system creates submissions
},
fields: [
{
name: "name",
type: "text",
required: true,
admin: { readOnly: true },
},
{
name: "email",
type: "email",
required: true,
admin: { readOnly: true },
},
{
name: "company",
type: "text",
admin: { readOnly: true },
},
{
name: "message",
type: "textarea",
required: true,
admin: { readOnly: true },
},
],
};

View File

@@ -0,0 +1,42 @@
import type { CollectionConfig } from "payload";
import path from "path";
import { fileURLToPath } from "url";
const filename = fileURLToPath(import.meta.url);
const dirname = path.dirname(filename);
export const Media: CollectionConfig = {
slug: "media",
admin: {
useAsTitle: "filename",
defaultColumns: ["filename", "alt", "updatedAt"],
},
access: {
read: () => true, // Publicly readable
},
upload: {
staticDir: path.resolve(dirname, "../../../public/media"),
adminThumbnail: "thumbnail",
imageSizes: [
{
name: "thumbnail",
width: 400,
height: 300,
position: "centre",
},
{
name: "card",
width: 768,
height: undefined,
position: "centre",
},
],
},
fields: [
{
name: "alt",
type: "text",
required: true,
},
],
};

View File

@@ -0,0 +1,42 @@
import { CollectionConfig } from "payload";
import { lexicalEditor, BlocksFeature } from "@payloadcms/richtext-lexical";
import { payloadBlocks } from "../blocks/allBlocks";
export const Pages: CollectionConfig = {
slug: "pages",
admin: {
useAsTitle: "title",
defaultColumns: ["title", "slug", "updatedAt"],
},
access: {
read: () => true, // Publicly readable
},
fields: [
{
name: "title",
type: "text",
required: true,
},
{
name: "slug",
type: "text",
required: true,
admin: {
position: "sidebar",
},
},
{
name: "content",
type: "richText",
editor: lexicalEditor({
features: ({ defaultFeatures }) => [
...defaultFeatures,
BlocksFeature({
blocks: payloadBlocks,
}),
],
}),
required: true,
},
],
};

View File

@@ -0,0 +1,12 @@
import type { CollectionConfig } from "payload";
export const Users: CollectionConfig = {
slug: "users",
admin: {
useAsTitle: "email",
},
auth: true,
fields: [
// Email added by default
],
};

View File

@@ -0,0 +1,453 @@
/* tslint:disable */
/* eslint-disable */
/**
* This file was automatically generated by Payload.
* DO NOT MODIFY IT BY HAND. Instead, modify your source Payload config,
* and re-run `payload generate:types` to regenerate this file.
*/
/**
* Supported timezones in IANA format.
*
* This interface was referenced by `Config`'s JSON-Schema
* via the `definition` "supportedTimezones".
*/
export type SupportedTimezones =
| "Pacific/Midway"
| "Pacific/Niue"
| "Pacific/Honolulu"
| "Pacific/Rarotonga"
| "America/Anchorage"
| "Pacific/Gambier"
| "America/Los_Angeles"
| "America/Tijuana"
| "America/Denver"
| "America/Phoenix"
| "America/Chicago"
| "America/Guatemala"
| "America/New_York"
| "America/Bogota"
| "America/Caracas"
| "America/Santiago"
| "America/Buenos_Aires"
| "America/Sao_Paulo"
| "Atlantic/South_Georgia"
| "Atlantic/Azores"
| "Atlantic/Cape_Verde"
| "Europe/London"
| "Europe/Berlin"
| "Africa/Lagos"
| "Europe/Athens"
| "Africa/Cairo"
| "Europe/Moscow"
| "Asia/Riyadh"
| "Asia/Dubai"
| "Asia/Baku"
| "Asia/Karachi"
| "Asia/Tashkent"
| "Asia/Calcutta"
| "Asia/Dhaka"
| "Asia/Almaty"
| "Asia/Jakarta"
| "Asia/Bangkok"
| "Asia/Shanghai"
| "Asia/Singapore"
| "Asia/Tokyo"
| "Asia/Seoul"
| "Australia/Brisbane"
| "Australia/Sydney"
| "Pacific/Guam"
| "Pacific/Noumea"
| "Pacific/Auckland"
| "Pacific/Fiji";
export interface Config {
auth: {
users: UserAuthOperations;
};
blocks: {};
collections: {
users: User;
media: Media;
"form-submissions": FormSubmission;
pages: Page;
"payload-kv": PayloadKv;
"payload-locked-documents": PayloadLockedDocument;
"payload-preferences": PayloadPreference;
"payload-migrations": PayloadMigration;
};
collectionsJoins: {};
collectionsSelect: {
users: UsersSelect<false> | UsersSelect<true>;
media: MediaSelect<false> | MediaSelect<true>;
"form-submissions":
| FormSubmissionsSelect<false>
| FormSubmissionsSelect<true>;
pages: PagesSelect<false> | PagesSelect<true>;
"payload-kv": PayloadKvSelect<false> | PayloadKvSelect<true>;
"payload-locked-documents":
| PayloadLockedDocumentsSelect<false>
| PayloadLockedDocumentsSelect<true>;
"payload-preferences":
| PayloadPreferencesSelect<false>
| PayloadPreferencesSelect<true>;
"payload-migrations":
| PayloadMigrationsSelect<false>
| PayloadMigrationsSelect<true>;
};
db: {
defaultIDType: number;
};
fallbackLocale: null;
globals: {};
globalsSelect: {};
locale: null;
user: User;
jobs: {
tasks: unknown;
workflows: unknown;
};
}
export interface UserAuthOperations {
forgotPassword: {
email: string;
password: string;
};
login: {
email: string;
password: string;
};
registerFirstUser: {
email: string;
password: string;
};
unlock: {
email: string;
password: string;
};
}
/**
* This interface was referenced by `Config`'s JSON-Schema
* via the `definition` "users".
*/
export interface User {
id: number;
updatedAt: string;
createdAt: string;
email: string;
resetPasswordToken?: string | null;
resetPasswordExpiration?: string | null;
salt?: string | null;
hash?: string | null;
loginAttempts?: number | null;
lockUntil?: string | null;
sessions?:
| {
id: string;
createdAt?: string | null;
expiresAt: string;
}[]
| null;
password?: string | null;
collection: "users";
}
/**
* This interface was referenced by `Config`'s JSON-Schema
* via the `definition` "media".
*/
export interface Media {
id: number;
alt: string;
prefix?: string | null;
updatedAt: string;
createdAt: string;
url?: string | null;
thumbnailURL?: string | null;
filename?: string | null;
mimeType?: string | null;
filesize?: number | null;
width?: number | null;
height?: number | null;
focalX?: number | null;
focalY?: number | null;
sizes?: {
thumbnail?: {
url?: string | null;
width?: number | null;
height?: number | null;
mimeType?: string | null;
filesize?: number | null;
filename?: string | null;
};
card?: {
url?: string | null;
width?: number | null;
height?: number | null;
mimeType?: string | null;
filesize?: number | null;
filename?: string | null;
};
};
}
/**
* Captured leads from Contact Form.
*
* This interface was referenced by `Config`'s JSON-Schema
* via the `definition` "form-submissions".
*/
export interface FormSubmission {
id: number;
name: string;
email: string;
company?: string | null;
message: string;
updatedAt: string;
createdAt: string;
}
/**
* This interface was referenced by `Config`'s JSON-Schema
* via the `definition` "pages".
*/
export interface Page {
id: number;
title: string;
slug: string;
content: {
root: {
type: string;
children: {
type: any;
version: number;
[k: string]: unknown;
}[];
direction: ("ltr" | "rtl") | null;
format: "left" | "start" | "center" | "right" | "end" | "justify" | "";
indent: number;
version: number;
};
[k: string]: unknown;
};
updatedAt: string;
createdAt: string;
}
/**
* This interface was referenced by `Config`'s JSON-Schema
* via the `definition` "payload-kv".
*/
export interface PayloadKv {
id: number;
key: string;
data:
| {
[k: string]: unknown;
}
| unknown[]
| string
| number
| boolean
| null;
}
/**
* This interface was referenced by `Config`'s JSON-Schema
* via the `definition` "payload-locked-documents".
*/
export interface PayloadLockedDocument {
id: number;
document?:
| ({
relationTo: "users";
value: number | User;
} | null)
| ({
relationTo: "media";
value: number | Media;
} | null)
| ({
relationTo: "form-submissions";
value: number | FormSubmission;
} | null)
| ({
relationTo: "pages";
value: number | Page;
} | null);
globalSlug?: string | null;
user: {
relationTo: "users";
value: number | User;
};
updatedAt: string;
createdAt: string;
}
/**
* This interface was referenced by `Config`'s JSON-Schema
* via the `definition` "payload-preferences".
*/
export interface PayloadPreference {
id: number;
user: {
relationTo: "users";
value: number | User;
};
key?: string | null;
value?:
| {
[k: string]: unknown;
}
| unknown[]
| string
| number
| boolean
| null;
updatedAt: string;
createdAt: string;
}
/**
* This interface was referenced by `Config`'s JSON-Schema
* via the `definition` "payload-migrations".
*/
export interface PayloadMigration {
id: number;
name?: string | null;
batch?: number | null;
updatedAt: string;
createdAt: string;
}
/**
* This interface was referenced by `Config`'s JSON-Schema
* via the `definition` "users_select".
*/
export interface UsersSelect<T extends boolean = true> {
updatedAt?: T;
createdAt?: T;
email?: T;
resetPasswordToken?: T;
resetPasswordExpiration?: T;
salt?: T;
hash?: T;
loginAttempts?: T;
lockUntil?: T;
sessions?:
| T
| {
id?: T;
createdAt?: T;
expiresAt?: T;
};
}
/**
* This interface was referenced by `Config`'s JSON-Schema
* via the `definition` "media_select".
*/
export interface MediaSelect<T extends boolean = true> {
alt?: T;
prefix?: T;
updatedAt?: T;
createdAt?: T;
url?: T;
thumbnailURL?: T;
filename?: T;
mimeType?: T;
filesize?: T;
width?: T;
height?: T;
focalX?: T;
focalY?: T;
sizes?:
| T
| {
thumbnail?:
| T
| {
url?: T;
width?: T;
height?: T;
mimeType?: T;
filesize?: T;
filename?: T;
};
card?:
| T
| {
url?: T;
width?: T;
height?: T;
mimeType?: T;
filesize?: T;
filename?: T;
};
};
}
/**
* This interface was referenced by `Config`'s JSON-Schema
* via the `definition` "form-submissions_select".
*/
export interface FormSubmissionsSelect<T extends boolean = true> {
name?: T;
email?: T;
company?: T;
message?: T;
updatedAt?: T;
createdAt?: T;
}
/**
* This interface was referenced by `Config`'s JSON-Schema
* via the `definition` "pages_select".
*/
export interface PagesSelect<T extends boolean = true> {
title?: T;
slug?: T;
content?: T;
updatedAt?: T;
createdAt?: T;
}
/**
* This interface was referenced by `Config`'s JSON-Schema
* via the `definition` "payload-kv_select".
*/
export interface PayloadKvSelect<T extends boolean = true> {
key?: T;
data?: T;
}
/**
* This interface was referenced by `Config`'s JSON-Schema
* via the `definition` "payload-locked-documents_select".
*/
export interface PayloadLockedDocumentsSelect<T extends boolean = true> {
document?: T;
globalSlug?: T;
user?: T;
updatedAt?: T;
createdAt?: T;
}
/**
* This interface was referenced by `Config`'s JSON-Schema
* via the `definition` "payload-preferences_select".
*/
export interface PayloadPreferencesSelect<T extends boolean = true> {
user?: T;
key?: T;
value?: T;
updatedAt?: T;
createdAt?: T;
}
/**
* This interface was referenced by `Config`'s JSON-Schema
* via the `definition` "payload-migrations_select".
*/
export interface PayloadMigrationsSelect<T extends boolean = true> {
name?: T;
batch?: T;
updatedAt?: T;
createdAt?: T;
}
/**
* This interface was referenced by `Config`'s JSON-Schema
* via the `definition` "auth".
*/
export interface Auth {
[k: string]: unknown;
}
declare module "payload" {
export interface GeneratedTypes extends Config {}
}

View File

@@ -0,0 +1,95 @@
import { buildConfig } from "payload";
import { postgresAdapter } from "@payloadcms/db-postgres";
import { lexicalEditor, BlocksFeature } from "@payloadcms/richtext-lexical";
import { nodemailerAdapter } from "@payloadcms/email-nodemailer";
import { s3Storage } from "@payloadcms/storage-s3";
import sharp from "sharp";
import path from "path";
import { fileURLToPath } from "url";
import { payloadBlocks } from "./blocks/allBlocks";
import { Users } from "./collections/Users";
import { Media } from "./collections/Media";
import { FormSubmissions } from "./collections/FormSubmissions";
import { Pages } from "./collections/Pages";
import { migrations } from "../../migrations/index";
const filename = fileURLToPath(import.meta.url);
const dirname = path.dirname(filename);
export default buildConfig({
admin: {
user: Users.slug,
importMap: {
baseDir: path.resolve(dirname),
},
meta: {
titleSuffix: " MB Grid Solutions",
},
},
collections: [Users, Media, FormSubmissions, Pages],
editor: lexicalEditor({
features: ({ defaultFeatures }) => [
...defaultFeatures,
BlocksFeature({
blocks: payloadBlocks,
}),
],
}),
secret: process.env.PAYLOAD_SECRET || "fallback-secret-for-dev",
typescript: {
outputFile: path.resolve(dirname, "payload-types.ts"),
},
db: postgresAdapter({
pool: {
connectionString:
process.env.DATABASE_URI ||
process.env.POSTGRES_URI ||
`postgresql://${process.env.POSTGRES_USER || "postgres"}:${process.env.POSTGRES_PASSWORD || "postgres"}@127.0.0.1:5432/${process.env.POSTGRES_DB || "payload"}`,
},
prodMigrations: migrations,
}),
...(process.env.SMTP_HOST
? {
email: nodemailerAdapter({
defaultFromAddress:
process.env.SMTP_FROM || "info@mb-grid-solutions.com",
defaultFromName: "MB Grid Solutions CMS",
transportOptions: {
host: process.env.SMTP_HOST,
port: parseInt(process.env.SMTP_PORT || "587"),
auth: {
user: process.env.SMTP_USER,
pass: process.env.SMTP_PASS,
},
secure: process.env.SMTP_SECURE === "true",
},
}),
}
: {}),
sharp,
plugins: [
...(process.env.S3_ENDPOINT
? [
s3Storage({
collections: {
media: {
prefix: `${process.env.S3_PREFIX || "mb-grid-solutions"}/media`,
},
},
bucket: process.env.S3_BUCKET || "",
config: {
credentials: {
accessKeyId: process.env.S3_ACCESS_KEY || "",
secretAccessKey: process.env.S3_SECRET_KEY || "",
},
region: process.env.S3_REGION || "fsn1",
endpoint: process.env.S3_ENDPOINT,
forcePathStyle: true,
},
}),
]
: []),
],
});

View File

View File

@@ -0,0 +1 @@
export default {};

168
tests/api-contact.test.ts Normal file
View File

@@ -0,0 +1,168 @@
import { describe, it, expect, vi, beforeEach } from "vitest";
// Mock Payload CMS
const { mockCreate, mockSendEmail } = vi.hoisted(() => ({
mockCreate: vi.fn(),
mockSendEmail: vi.fn(),
}));
vi.mock("payload", () => ({
getPayload: vi.fn().mockResolvedValue({
create: mockCreate,
sendEmail: mockSendEmail,
}),
}));
// Mock Email Template renders
vi.mock("@mintel/mail", () => ({
render: vi.fn().mockResolvedValue("<html>Mocked Email HTML</html>"),
ContactFormNotification: () => "ContactFormNotification",
ConfirmationMessage: () => "ConfirmationMessage",
}));
// Mock Notifications and Analytics
const { mockNotify, mockTrack, mockCaptureException } = vi.hoisted(() => ({
mockNotify: vi.fn(),
mockTrack: vi.fn(),
mockCaptureException: vi.fn(),
}));
vi.mock("@/lib/services/create-services.server", () => ({
getServerAppServices: () => ({
logger: {
child: () => ({
info: vi.fn(),
warn: vi.fn(),
error: vi.fn(),
}),
},
analytics: {
setServerContext: vi.fn(),
track: mockTrack,
},
notifications: {
notify: mockNotify,
},
errors: {
captureException: mockCaptureException,
},
}),
}));
// Import the route handler we want to test
import { POST } from "../app/api/contact/route";
import { NextResponse } from "next/server";
import type { Mock } from "vitest";
describe("Contact API Integration", () => {
beforeEach(() => {
vi.clearAllMocks();
(NextResponse.json as Mock).mockImplementation((body: any, init?: any) => ({
status: init?.status || 200,
json: async () => body,
}));
});
it("should validate and decline empty or short messages", async () => {
const req = new Request("http://localhost/api/contact", {
method: "POST",
body: JSON.stringify({
name: "Test User",
email: "test@example.com",
message: "too short",
}),
});
const response = await POST(req);
expect(response.status).toBe(400);
const data = await response.json();
expect(data.error).toBe("message_too_short");
// Ensure payload and email were NOT called
expect(mockCreate).not.toHaveBeenCalled();
expect(mockSendEmail).not.toHaveBeenCalled();
});
it("should catch honeypot submissions", async () => {
const req = new Request("http://localhost/api/contact", {
method: "POST",
body: JSON.stringify({
name: "Spam Bot",
email: "spam@example.com",
message: "This is a very long spam message that passes length checks.",
website: "http://spam.com", // Honeypot filled
}),
});
const response = await POST(req);
// Honeypot returns 200 OK so the bot thinks it succeeded
expect(response.status).toBe(200);
// But it actually does NOTHING internally
expect(mockCreate).not.toHaveBeenCalled();
expect(mockSendEmail).not.toHaveBeenCalled();
});
it("should successfully save to Payload and send emails", async () => {
const req = new Request("http://localhost/api/contact", {
method: "POST",
headers: {
"user-agent": "vitest",
"x-forwarded-for": "127.0.0.1",
},
body: JSON.stringify({
name: "Jane Doe",
email: "jane@example.com",
company: "Jane Tech",
message:
"Hello, I am interested in exploring your high-voltage grid solutions.",
}),
});
const response = await POST(req);
expect(response.status).toBe(200);
const data = await response.json();
expect(data.message).toBe("Ok");
// 1. Verify Payload creation
expect(mockCreate).toHaveBeenCalledTimes(1);
expect(mockCreate).toHaveBeenCalledWith({
collection: "form-submissions",
data: {
name: "Jane Doe",
email: "jane@example.com",
company: "Jane Tech",
message:
"Hello, I am interested in exploring your high-voltage grid solutions.",
},
});
// 2. Verify Email Sending
// Note: sendEmail is called twice (Notification + User Confirmation)
expect(mockSendEmail).toHaveBeenCalledTimes(2);
expect(mockSendEmail).toHaveBeenNthCalledWith(
1,
expect.objectContaining({
subject: "Kontaktanfrage von Jane Doe",
replyTo: "jane@example.com",
}),
);
expect(mockSendEmail).toHaveBeenNthCalledWith(
2,
expect.objectContaining({
to: "jane@example.com",
subject: "Ihre Kontaktanfrage bei MB Grid Solutions",
}),
);
// 3. Verify notification and analytics
expect(mockNotify).toHaveBeenCalledTimes(1);
expect(mockTrack).toHaveBeenCalledWith("contact-form-success", {
has_company: true,
});
});
});

129
tests/contact.test.tsx Normal file
View File

@@ -0,0 +1,129 @@
import { render, screen, fireEvent, waitFor } from "@testing-library/react";
import { describe, it, expect, vi, beforeEach } from "vitest";
import { NextIntlClientProvider } from "next-intl";
import messages from "../messages/de.json";
// Mocks MUST be defined before component import to ensure they are picked up
vi.mock("../components/Reveal", () => ({
Reveal: ({ children }: any) => <>{children}</>,
Stagger: ({ children }: any) => <>{children}</>,
}));
// Better FormData mock for happy-dom
global.FormData = class MockFormData {
private data = new Map();
constructor(form?: HTMLFormElement) {
if (form) {
const elements = form.elements as any;
for (let i = 0; i < elements.length; i++) {
const item = elements.item(i);
if (item.name && item.value) {
this.data.set(item.name, item.value);
}
}
}
}
append(key: string, value: any) {
this.data.set(key, value);
}
get(key: string) {
return this.data.get(key);
}
entries() {
return Array.from(this.data.entries())[Symbol.iterator]();
}
} as any;
// Mock alert
const alertMock = vi.fn();
global.alert = alertMock;
// Import component AFTER mocks
import Contact from "../components/ContactContent";
// Mock fetch
const fetchMock = vi.fn();
global.fetch = fetchMock;
const renderContact = () => {
return render(
<NextIntlClientProvider locale="de" messages={messages}>
<Contact />
</NextIntlClientProvider>,
);
};
describe("Contact Page", () => {
beforeEach(() => {
vi.clearAllMocks();
fetchMock.mockReset();
fetchMock.mockResolvedValue({
ok: true,
json: async () => ({ success: true }),
});
});
it("renders the contact form correctly", () => {
renderContact();
expect(screen.getByLabelText(/Name \*/i)).toBeInTheDocument();
expect(screen.getByLabelText(/Firma/i)).toBeInTheDocument();
expect(screen.getByLabelText(/E-Mail \*/i)).toBeInTheDocument();
expect(screen.getByLabelText(/Nachricht \*/i)).toBeInTheDocument();
});
it("submits the form successfully", async () => {
renderContact();
fireEvent.change(screen.getByLabelText(/Name \*/i), {
target: { value: "John Doe" },
});
fireEvent.change(screen.getByLabelText(/E-Mail \*/i), {
target: { value: "john@example.com" },
});
fireEvent.change(screen.getByLabelText(/Nachricht \*/i), {
target: { value: "This is a test message that is long enough." },
});
const form = screen.getByRole("form");
fireEvent.submit(form);
await waitFor(
() => {
expect(fetchMock).toHaveBeenCalled();
},
{ timeout: 2000 },
);
expect(
(await screen.findAllByText(/Anfrage erfolgreich übermittelt/i)).length,
).toBeGreaterThanOrEqual(1);
expect(
(await screen.findAllByText(/Ihr Anliegen wurde erfasst/i)).length,
).toBeGreaterThanOrEqual(1);
});
it("handles submission errors", async () => {
fetchMock.mockResolvedValueOnce({
ok: false,
json: async () => ({ error: "Server error" }),
});
renderContact();
fireEvent.change(screen.getByLabelText(/Name \*/i), {
target: { value: "John Doe" },
});
fireEvent.change(screen.getByLabelText(/E-Mail \*/i), {
target: { value: "john@example.com" },
});
fireEvent.change(screen.getByLabelText(/Nachricht \*/i), {
target: { value: "This is a test message." },
});
const form = screen.getByRole("form");
fireEvent.submit(form);
expect(await screen.findByText(/Server error/i)).toBeInTheDocument();
});
});

37
tests/home.test.tsx Normal file
View File

@@ -0,0 +1,37 @@
import { render, screen } from "@testing-library/react";
import { describe, it, expect } from "vitest";
import Home from "../components/HomeContent";
import { NextIntlClientProvider } from "next-intl";
import messages from "../messages/de.json";
const renderHome = () => {
return render(
<NextIntlClientProvider locale="de" messages={messages}>
<Home />
</NextIntlClientProvider>,
);
};
describe("Home Page", () => {
it("renders the hero section with correct title", () => {
renderHome();
expect(
screen.getByRole("heading", { name: /Spezialisierter Partner/i }),
).toBeInTheDocument();
});
it("contains the CTA button", () => {
renderHome();
const ctaButton = screen.getByRole("link", { name: /Projekt anfragen/i });
expect(ctaButton).toBeInTheDocument();
expect(ctaButton).toHaveAttribute("href", "/kontakt");
});
it("renders the portfolio section", async () => {
renderHome();
expect(await screen.findByText(/Unsere Leistungen/i)).toBeInTheDocument();
// Use getAllByText because it appears in both hero description and card title
const elements = await screen.findAllByText(/Technische Beratung/i);
expect(elements.length).toBeGreaterThan(0);
});
});

56
tests/setup.tsx Normal file
View File

@@ -0,0 +1,56 @@
import "@testing-library/jest-dom/vitest";
import React from "react";
import { vi } from "vitest";
// Mock next/navigation
vi.mock("next/navigation", () => ({
usePathname: () => "/",
useRouter: () => ({
push: vi.fn(),
replace: vi.fn(),
prefetch: vi.fn(),
back: vi.fn(),
}),
useSearchParams: () => new URLSearchParams(),
}));
// Mock next-intl to avoid transitive next/server issues
vi.mock("next-intl/middleware", () => ({
default: vi.fn(() => (req: any) => req),
}));
vi.mock("next-intl/server", () => ({
getRequestConfig: vi.fn(),
}));
// Mock next/server
vi.mock("next/server", () => ({
NextResponse: {
json: vi.fn(),
next: vi.fn(),
redirect: vi.fn(),
},
}));
// Mock next/dynamic to be synchronous in tests
vi.mock("next/dynamic", () => ({
default: vi.fn((loader) => {
return (props: any) => {
const [Component, setComponent] = React.useState<any>(null);
React.useEffect(() => {
loader().then((mod: any) => {
setComponent(
() =>
mod.default ||
mod.PortfolioSection ||
mod.ExpertiseSection ||
mod.TechnicalSpecsSection ||
mod.CTASection ||
mod,
);
});
}, []);
return Component ? <Component {...props} /> : null;
};
}),
}));

View File

@@ -1,126 +0,0 @@
import { render, screen, fireEvent, waitFor } from "@testing-library/react";
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
import Contact from "../app/kontakt/page";
// Mock fetch
const fetchMock = vi.fn();
global.fetch = fetchMock;
// Mock alert
const alertMock = vi.fn();
global.alert = alertMock;
describe("Contact Page", () => {
beforeEach(() => {
fetchMock.mockClear();
alertMock.mockClear();
});
it("renders the contact form correctly", () => {
render(<Contact />);
expect(screen.getByLabelText(/Name \*/i)).toBeInTheDocument();
expect(screen.getByLabelText(/Firma/i)).toBeInTheDocument();
expect(screen.getByLabelText(/E-Mail \*/i)).toBeInTheDocument();
expect(screen.getByLabelText(/Nachricht \*/i)).toBeInTheDocument();
expect(
screen.getByRole("button", { name: /Nachricht senden/i }),
).toBeInTheDocument();
});
it("submits the form successfully", async () => {
fetchMock.mockResolvedValueOnce({
ok: true,
json: async () => ({ success: true }),
});
render(<Contact />);
fireEvent.change(screen.getByLabelText(/Name \*/i), {
target: { value: "John Doe" },
});
fireEvent.change(screen.getByLabelText(/Firma/i), {
target: { value: "Acme Corp" },
});
fireEvent.change(screen.getByLabelText(/E-Mail \*/i), {
target: { value: "john@example.com" },
});
fireEvent.change(screen.getByLabelText(/Nachricht \*/i), {
target: { value: "This is a test message that is long enough." },
});
fireEvent.click(screen.getByRole("button", { name: /Nachricht senden/i }));
await waitFor(() => {
expect(fetchMock).toHaveBeenCalledTimes(1);
expect(fetchMock).toHaveBeenCalledWith(
"/api/contact",
expect.objectContaining({
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
name: "John Doe",
company: "Acme Corp",
email: "john@example.com",
message: "This is a test message that is long enough.",
website: "",
}),
}),
);
});
expect(screen.getByText(/Nachricht gesendet/i)).toBeInTheDocument();
expect(
screen.getByText(/Vielen Dank für Ihre Anfrage/i),
).toBeInTheDocument();
});
it("handles submission errors", async () => {
fetchMock.mockResolvedValueOnce({
ok: false,
json: async () => ({ error: "Server error" }),
});
render(<Contact />);
fireEvent.change(screen.getByLabelText(/Name \*/i), {
target: { value: "John Doe" },
});
fireEvent.change(screen.getByLabelText(/E-Mail \*/i), {
target: { value: "john@example.com" },
});
fireEvent.change(screen.getByLabelText(/Nachricht \*/i), {
target: { value: "This is a test message that is long enough." },
});
fireEvent.click(screen.getByRole("button", { name: /Nachricht senden/i }));
await waitFor(() => {
expect(alertMock).toHaveBeenCalledWith("Fehler: Server error");
});
});
it("handles network errors", async () => {
fetchMock.mockRejectedValueOnce(new Error("Network error"));
render(<Contact />);
fireEvent.change(screen.getByLabelText(/Name \*/i), {
target: { value: "John Doe" },
});
fireEvent.change(screen.getByLabelText(/E-Mail \*/i), {
target: { value: "john@example.com" },
});
fireEvent.change(screen.getByLabelText(/Nachricht \*/i), {
target: { value: "This is a test message that is long enough." },
});
fireEvent.click(screen.getByRole("button", { name: /Nachricht senden/i }));
await waitFor(() => {
expect(alertMock).toHaveBeenCalledWith(
"Es gab einen Fehler beim Senden Ihrer Nachricht.",
);
});
});
});

View File

@@ -1,27 +0,0 @@
import { render, screen } from "@testing-library/react";
import { describe, it, expect } from "vitest";
import Home from "../app/page";
describe("Home Page", () => {
it("renders the hero section with correct title", () => {
render(<Home />);
expect(
screen.getByText(/Spezialisierter Partner für Energiekabelprojekte/i),
).toBeInTheDocument();
});
it("contains the CTA button", () => {
render(<Home />);
const ctaButton = screen.getByRole("link", { name: /Projekt anfragen/i });
expect(ctaButton).toBeInTheDocument();
expect(ctaButton).toHaveAttribute("href", "/kontakt");
});
it("renders the portfolio section", () => {
render(<Home />);
expect(screen.getByText(/Unsere Leistungen/i)).toBeInTheDocument();
// Use getAllByText because it appears in both hero description and card title
const elements = screen.getAllByText(/Technische Beratung/i);
expect(elements.length).toBeGreaterThan(0);
});
});

View File

@@ -1,12 +0,0 @@
import "@testing-library/jest-dom";
import { vi } from "vitest";
// Mock next/navigation
vi.mock("next/navigation", () => ({
usePathname: () => "/",
useRouter: () => ({
push: vi.fn(),
replace: vi.fn(),
prefetch: vi.fn(),
}),
}));

View File

@@ -3,7 +3,8 @@
"compilerOptions": {
"strict": true,
"paths": {
"@/*": ["./*"]
"@/*": ["./*"],
"@payload-config": ["./src/payload/payload.config.ts"]
}
},
"include": [

5
types/mintel-mail.d.ts vendored Normal file
View File

@@ -0,0 +1,5 @@
declare module "@mintel/mail" {
export const render: any;
export const ContactFormNotification: any;
export const ConfirmationMessage: any;
}

31
vitest.config.mts Normal file
View File

@@ -0,0 +1,31 @@
import { defineConfig } from 'vitest/config';
import react from '@vitejs/plugin-react';
export default defineConfig({
plugins: [react()],
test: {
environment: 'happy-dom',
environmentOptions: {
happyDOM: {
settings: {
disableIframePageLoading: true,
disableJavaScriptFileLoading: true,
disableCSSFileLoading: true,
}
}
},
globals: true,
setupFiles: ['./tests/setup.tsx'],
alias: {
'next/server': 'next/server.js',
'@payload-config': new URL('./tests/__mocks__/payload-config.ts', import.meta.url).pathname,
'@': new URL('./', import.meta.url).pathname,
},
exclude: ['**/node_modules/**', '**/.next/**'],
server: {
deps: {
inline: ['next-intl', '@mintel/next-utils'],
},
},
},
});

View File

@@ -1,17 +0,0 @@
import { defineConfig } from 'vitest/config'
import react from '@vitejs/plugin-react'
import path from 'path'
export default defineConfig({
plugins: [react()],
test: {
environment: 'jsdom',
globals: true,
setupFiles: './tests/setup.ts',
},
resolve: {
alias: {
'@': path.resolve(__dirname, './'),
},
},
})