51 Commits

Author SHA1 Message Date
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
237bd46593 feat: more transparency
Some checks failed
Build & Deploy / 🔍 Prepare Environment (push) Successful in 4s
Build & Deploy / 🧪 QA (push) Failing after 39s
Build & Deploy / 🏗️ Build (push) Failing after 30s
Build & Deploy / 🚀 Deploy (push) Has been skipped
Build & Deploy / 🔔 Notifications (push) Successful in 2s
2026-02-10 00:22:53 +01:00
40ebdb31d9 fix: analytics 2026-02-10 00:15:36 +01:00
8f39ec3d35 fix: resolve lint errors in layout and route by updating analytics interface
Some checks failed
Build & Deploy / 🔍 Prepare Environment (push) Successful in 9s
Build & Deploy / 🧪 QA (push) Successful in 1m22s
Build & Deploy / 🏗️ Build (push) Failing after 1m59s
Build & Deploy / 🚀 Deploy (push) Has been skipped
Build & Deploy / 🔔 Notifications (push) Successful in 2s
2026-02-10 00:01:31 +01:00
7734440b90 refactor: remove arrogant marketing terms and localize to German
Some checks failed
Build & Deploy / 🔍 Prepare Environment (push) Successful in 8s
Build & Deploy / 🧪 QA (push) Failing after 48s
Build & Deploy / 🏗️ Build (push) Failing after 3m1s
Build & Deploy / 🚀 Deploy (push) Has been skipped
Build & Deploy / 🔔 Notifications (push) Successful in 2s
2026-02-09 23:50:44 +01:00
42295c3c41 feat: improved analytics
Some checks failed
Build & Deploy / 🔍 Prepare Environment (push) Successful in 5s
Build & Deploy / 🧪 QA (push) Failing after 36s
Build & Deploy / 🏗️ Build (push) Failing after 1m56s
Build & Deploy / 🚀 Deploy (push) Has been skipped
Build & Deploy / 🔔 Notifications (push) Successful in 2s
2026-02-09 23:36:05 +01:00
1e00690dd8 fix: umami tracking internationalization
Some checks failed
Build & Deploy / 🔍 Prepare Environment (push) Successful in 8s
Build & Deploy / 🧪 QA (push) Successful in 1m23s
Build & Deploy / 🏗️ Build (push) Failing after 1m56s
Build & Deploy / 🚀 Deploy (push) Has been skipped
Build & Deploy / 🔔 Notifications (push) Successful in 2s
2026-02-09 23:10:27 +01:00
90e9f37849 fix: umami
Some checks failed
Build & Deploy / 🔍 Prepare Environment (push) Successful in 4s
Build & Deploy / 🧪 QA (push) Successful in 1m23s
Build & Deploy / 🏗️ Build (push) Failing after 3m6s
Build & Deploy / 🚀 Deploy (push) Has been skipped
Build & Deploy / 🔔 Notifications (push) Successful in 2s
2026-02-09 22:32:35 +01:00
9eaaa798a3 fix: umami
All checks were successful
Build & Deploy / 🔍 Prepare Environment (push) Successful in 12s
Build & Deploy / 🧪 QA (push) Successful in 1m24s
Build & Deploy / 🏗️ Build (push) Successful in 5m58s
Build & Deploy / 🚀 Deploy (push) Successful in 15s
Build & Deploy / 🔔 Notifications (push) Successful in 2s
2026-02-09 19:30:52 +01:00
f7685fdb2f fix: deploy
All checks were successful
Build & Deploy / 🔍 Prepare Environment (push) Successful in 6s
Build & Deploy / 🧪 QA (push) Successful in 1m16s
Build & Deploy / 🏗️ Build (push) Successful in 5m32s
Build & Deploy / 🚀 Deploy (push) Successful in 18s
Build & Deploy / 🔔 Notifications (push) Successful in 1s
2026-02-09 12:33:16 +01:00
609422b5b9 fix: zero downtime deploy
Some checks failed
Build & Deploy / 🔍 Prepare Environment (push) Successful in 4s
Build & Deploy / 🧪 QA (push) Successful in 1m17s
Build & Deploy / 🏗️ Build (push) Successful in 6m10s
Build & Deploy / 🚀 Deploy (push) Failing after 1m22s
Build & Deploy / 🔔 Notifications (push) Successful in 2s
2026-02-09 12:02:33 +01:00
76cf6e7b62 fix: contact form
All checks were successful
Build & Deploy / 🔍 Prepare Environment (push) Successful in 5s
Build & Deploy / 🧪 QA (push) Successful in 1m53s
Build & Deploy / 🏗️ Build (push) Successful in 2m8s
Build & Deploy / 🚀 Deploy (push) Successful in 14s
Build & Deploy / 🔔 Notifications (push) Successful in 2s
2026-02-09 11:58:59 +01:00
cc04b71327 refactor: standardize mailer configuration by introducing a config module and renaming related environment variables.
All checks were successful
Build & Deploy / 🔍 Prepare Environment (push) Successful in 4s
Build & Deploy / 🧪 QA (push) Successful in 1m16s
Build & Deploy / 🏗️ Build (push) Successful in 5m31s
Build & Deploy / 🚀 Deploy (push) Successful in 9s
Build & Deploy / 🔔 Notifications (push) Successful in 1s
2026-02-08 11:33:17 +01:00
1d5d86d07c feat: remove tiles
All checks were successful
Build & Deploy / 🔍 Prepare Environment (push) Successful in 6s
Build & Deploy / 🧪 QA (push) Successful in 1m15s
Build & Deploy / 🏗️ Build (push) Successful in 4m52s
Build & Deploy / 🚀 Deploy (push) Successful in 11s
Build & Deploy / 🔔 Notifications (push) Successful in 2s
2026-02-07 15:25:47 +01:00
e2b7131adc fix: env issue
All checks were successful
Build & Deploy / 🔍 Prepare Environment (push) Successful in 5s
Build & Deploy / 🧪 QA (push) Successful in 1m17s
Build & Deploy / 🏗️ Build (push) Successful in 4m39s
Build & Deploy / 🚀 Deploy (push) Successful in 10s
Build & Deploy / 🔔 Notifications (push) Successful in 1s
2026-02-07 10:17:16 +01:00
c2ced7185b fix: lint and build
Some checks failed
Build & Deploy / 🔍 Prepare Environment (push) Successful in 6s
Build & Deploy / 🧪 QA (push) Successful in 1m21s
Build & Deploy / 🏗️ Build (push) Failing after 4m36s
Build & Deploy / 🚀 Deploy (push) Has been skipped
Build & Deploy / 🔔 Notifications (push) Successful in 2s
2026-02-07 10:07:55 +01:00
fd8f068594 fix: false gatekeeper on prod
Some checks failed
Build & Deploy / 🔍 Prepare Environment (push) Successful in 5s
Build & Deploy / 🧪 QA (push) Failing after 37s
Build & Deploy / 🏗️ Build (push) Failing after 1m59s
Build & Deploy / 🚀 Deploy (push) Has been skipped
Build & Deploy / 🔔 Notifications (push) Successful in 2s
2026-02-07 09:40:09 +01:00
00bafa761b fix: performance issues
Some checks failed
Build & Deploy / 🔍 Prepare Environment (push) Successful in 31s
Build & Deploy / 🧪 QA (push) Failing after 37s
Build & Deploy / 🚀 Deploy (push) Has been cancelled
Build & Deploy / 🔔 Notifications (push) Has been cancelled
Build & Deploy / 🏗️ Build (push) Has been cancelled
2026-02-07 09:37:44 +01:00
d0d66dd85f fix: linting
Some checks failed
Build & Deploy / 🔍 Prepare Environment (push) Successful in 5s
Build & Deploy / 🧪 QA (push) Failing after 1m14s
Build & Deploy / 🏗️ Build (push) Failing after 4m44s
Build & Deploy / 🚀 Deploy (push) Has been skipped
Build & Deploy / 🔔 Notifications (push) Successful in 1s
2026-02-07 01:25:15 +01:00
6f5c9bd613 fix: umami
Some checks failed
Build & Deploy / 🔍 Prepare Environment (push) Successful in 5s
Build & Deploy / 🧪 QA (push) Failing after 35s
Build & Deploy / 🏗️ Build (push) Failing after 4m45s
Build & Deploy / 🚀 Deploy (push) Has been skipped
Build & Deploy / 🔔 Notifications (push) Successful in 1s
2026-02-07 01:15:55 +01:00
9f6168592c feat: umami migration 2026-02-07 01:11:28 +01:00
63 changed files with 2751 additions and 2460 deletions

View File

@@ -80,5 +80,5 @@ SENTRY_DSN=
# GOTIFY_TOKEN=
# Analytics (Umami)
NEXT_PUBLIC_UMAMI_WEBSITE_ID=
NEXT_PUBLIC_UMAMI_SCRIPT_URL=https://analytics.infra.mintel.me/script.js
UMAMI_WEBSITE_ID=
UMAMI_API_ENDPOINT=https://analytics.infra.mintel.me

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,134 +7,124 @@ 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 }}
next_public_url: ${{ steps.determine.outputs.next_public_url }}
directus_url: ${{ steps.determine.outputs.directus_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}\`)"
# 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_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 "directus_url=https://cms.$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 | head -1 | cut -d'"' -f4 | sed 's/\^//; s/\~//')
TAG_TO_WAIT="v$UPSTREAM_VERSION"
if [[ -n "$UPSTREAM_VERSION" && "$UPSTREAM_VERSION" != "workspace:"* ]]; then
echo "⏳ This release depends on @mintel v$UPSTREAM_VERSION. Waiting for upstream build..."
# Fetch script from monorepo (main)
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=${{ secrets.GITHUB_TOKEN }} ./wait-for-upstream.sh "mmintel/at-mintel" "$TAG_TO_WAIT"
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 "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
@@ -149,25 +139,29 @@ 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 install --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
@@ -178,131 +172,223 @@ 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 }} \
-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/arm64
build-args: |
NEXT_PUBLIC_BASE_URL=${{ needs.prepare.outputs.next_public_url }}
NEXT_PUBLIC_TARGET=${{ needs.prepare.outputs.target }}
DIRECTUS_URL=${{ needs.prepare.outputs.directus_url }}
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 }}
DIRECTUS_URL: ${{ needs.prepare.outputs.directus_url }}
DIRECTUS_HOST: cms.${{ needs.prepare.outputs.traefik_host }}
# Secrets mapping (Directus)
DIRECTUS_KEY: ${{ (needs.prepare.outputs.target == 'testing' && secrets.TESTING_DIRECTUS_KEY) || (needs.prepare.outputs.target == 'staging' && secrets.STAGING_DIRECTUS_KEY) || secrets.DIRECTUS_KEY || vars.DIRECTUS_KEY }}
DIRECTUS_SECRET: ${{ (needs.prepare.outputs.target == 'testing' && secrets.TESTING_DIRECTUS_SECRET) || (needs.prepare.outputs.target == 'staging' && secrets.STAGING_DIRECTUS_SECRET) || secrets.DIRECTUS_SECRET || vars.DIRECTUS_SECRET }}
DIRECTUS_ADMIN_EMAIL: ${{ (needs.prepare.outputs.target == 'testing' && secrets.TESTING_DIRECTUS_ADMIN_EMAIL) || (needs.prepare.outputs.target == 'staging' && secrets.STAGING_DIRECTUS_ADMIN_EMAIL) || secrets.DIRECTUS_ADMIN_EMAIL || vars.DIRECTUS_ADMIN_EMAIL || 'admin@mintel.me' }}
DIRECTUS_ADMIN_PASSWORD: ${{ (needs.prepare.outputs.target == 'testing' && secrets.TESTING_DIRECTUS_ADMIN_PASSWORD) || (needs.prepare.outputs.target == 'staging' && secrets.STAGING_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: ${{ (needs.prepare.outputs.target == 'testing' && secrets.TESTING_DIRECTUS_DB_PASSWORD) || (needs.prepare.outputs.target == 'staging' && secrets.STAGING_DIRECTUS_DB_PASSWORD) || secrets.DIRECTUS_DB_PASSWORD || vars.DIRECTUS_DB_PASSWORD || 'directus' }}
DIRECTUS_API_TOKEN: ${{ (needs.prepare.outputs.target == 'testing' && secrets.TESTING_DIRECTUS_API_TOKEN) || (needs.prepare.outputs.target == 'staging' && secrets.STAGING_DIRECTUS_API_TOKEN) || secrets.DIRECTUS_API_TOKEN || vars.DIRECTUS_API_TOKEN }}
# 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
with:
fetch-depth: 1
- name: 🚀 Deploy via SSH
- name: 📝 Generate Environment
shell: bash
env:
TRAEFIK_RULE: ${{ needs.prepare.outputs.traefik_rule }}
TRAEFIK_HOST: ${{ needs.prepare.outputs.traefik_host }}
ENV_FILE: ${{ needs.prepare.outputs.env_file }}
run: |
echo "Deploying to alpha.mintel.me"
# 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
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
# Directus
DIRECTUS_URL=$DIRECTUS_URL
DIRECTUS_HOST=$DIRECTUS_HOST
DIRECTUS_KEY=$DIRECTUS_KEY
DIRECTUS_SECRET=$DIRECTUS_SECRET
DIRECTUS_ADMIN_EMAIL=$DIRECTUS_ADMIN_EMAIL
DIRECTUS_ADMIN_PASSWORD=$DIRECTUS_ADMIN_PASSWORD
DIRECTUS_DB_NAME=$DIRECTUS_DB_NAME
DIRECTUS_DB_USER=$DIRECTUS_DB_USER
DIRECTUS_DB_PASSWORD=$DIRECTUS_DB_PASSWORD
DIRECTUS_API_TOKEN=$DIRECTUS_API_TOKEN
INTERNAL_DIRECTUS_URL=http://directus:8055
# 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_RULE="${TRAEFIK_RULE}"
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
# 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 }}
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 }}
# Transfer and Restart
SITE_DIR="/home/deploy/sites/mb-grid-solutions.com"
ssh root@alpha.mintel.me "mkdir -p $SITE_DIR/directus/schema $SITE_DIR/directus/uploads $SITE_DIR/directus/extensions"
# 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 }}
scp .env.deploy root@alpha.mintel.me:$SITE_DIR/$ENV_FILE
scp docker-compose.yaml root@alpha.mintel.me:$SITE_DIR/docker-compose.yaml
# SMTP Config
SMTP_HOST=${{ secrets.SMTP_HOST || vars.SMTP_HOST }}
SMTP_PORT=${{ secrets.SMTP_PORT || vars.SMTP_PORT || '587' }}
SMTP_SECURE=${{ secrets.SMTP_SECURE || vars.SMTP_SECURE || 'false' }}
SMTP_USER=${{ secrets.SMTP_USER || vars.SMTP_USER }}
SMTP_PASS=${{ secrets.SMTP_PASS || vars.SMTP_PASS }}
SMTP_FROM=${{ secrets.SMTP_FROM || vars.SMTP_FROM }}
CONTACT_RECIPIENT=${{ secrets.CONTACT_RECIPIENT || vars.CONTACT_RECIPIENT }}
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 Directus Schema Snapshot if available
ssh root@alpha.mintel.me "cd $SITE_DIR && if docker compose -p '${{ needs.prepare.outputs.project_name }}' --env-file '$ENV_FILE' exec -T directus ls /directus/schema/snapshot.yaml >/dev/null 2>&1; then echo '→ Applying Directus Schema Snapshot...' && docker compose -p '${{ needs.prepare.outputs.project_name }}' --env-file '$ENV_FILE' exec -T directus npx directus schema apply /directus/schema/snapshot.yaml --yes; fi"
ssh root@alpha.mintel.me "docker system prune -f --filter 'until=24h'"
# 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' }}
- name: 🧹 Post-Deploy Cleanup (Runner)
if: always()
run: docker builder prune -f --filter "until=1h"
# 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 }}
NEXT_PUBLIC_UMAMI_WEBSITE_ID=${{ secrets.NEXT_PUBLIC_UMAMI_WEBSITE_ID || vars.NEXT_PUBLIC_UMAMI_WEBSITE_ID }}
NEXT_PUBLIC_UMAMI_SCRIPT_URL=${{ secrets.NEXT_PUBLIC_UMAMI_SCRIPT_URL || vars.NEXT_PUBLIC_UMAMI_SCRIPT_URL }}
# Project
PROJECT_COLOR=${{ secrets.PROJECT_COLOR || vars.PROJECT_COLOR || '#82ed20' }}
EOF
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 --remove-orphans
docker system prune -f --filter "until=24h"
EOF
notifications:
name: 🔔 Notifications
# ──────────────────────────────────────────────────────────────────────────────
# JOB 5: Health Check
# ──────────────────────────────────────────────────────────────────────────────
healthcheck:
name: 🩺 Health Check
needs: [prepare, deploy]
if: needs.deploy.result == 'success'
runs-on: docker
container:
image: catthehacker/ubuntu:act-latest
steps:
- name: 🔍 Smoke Test
run: |
URL="${{ needs.prepare.outputs.next_public_url }}"
echo "Checking health of $URL..."
for i in {1..12}; do
if curl -s -f "$URL" > /dev/null; then
echo "✅ Health check passed!"
exit 0
fi
echo "Waiting for service to be ready... ($i/12)"
sleep 10
done
echo "❌ Health check failed after 2 minutes."
exit 1
# ──────────────────────────────────────────────────────────────────────────────
# JOB 6: Notifications
# ──────────────────────────────────────────────────────────────────────────────
notifications:
name: 🔔 Notify
needs: [prepare, deploy, healthcheck]
if: always()
runs-on: docker
container:
image: catthehacker/ubuntu:act-latest
steps:
- name: Notify Gotify
shell: bash
- name: 🔔 Gotify
run: |
STATUS="${{ needs.deploy.result }}"
COLOR="info"
TITLE="mb-grid-solutions.com: $STATUS"
[[ "$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"
curl -s -k -X POST "${{ secrets.GOTIFY_URL }}/message?token=${{ secrets.GOTIFY_TOKEN }}" \
-F "title=$TITLE" \
-F "message=Deploy to ${{ needs.prepare.outputs.target }} finished with status $STATUS.\nVersion: ${{ needs.prepare.outputs.image_tag }}" \
-F "priority=$PRIORITY" || true

View File

@@ -1,50 +1,67 @@
# 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 NEXT_PUBLIC_UMAMI_WEBSITE_ID
ARG NEXT_PUBLIC_UMAMI_SCRIPT_URL
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 NEXT_PUBLIC_UMAMI_WEBSITE_ID=$NEXT_PUBLIC_UMAMI_WEBSITE_ID
ENV NEXT_PUBLIC_UMAMI_SCRIPT_URL=$NEXT_PUBLIC_UMAMI_SCRIPT_URL
ENV NEXT_PUBLIC_TARGET=$NEXT_PUBLIC_TARGET
ENV DIRECTUS_URL=$DIRECTUS_URL
ENV NPM_TOKEN=$NPM_TOKEN
ENV SENTRY_SUPPRESS_TURBOPACK_WARNING=1
ENV UMAMI_API_ENDPOINT=$UMAMI_API_ENDPOINT
ENV NEXT_PUBLIC_UMAMI_WEBSITE_ID=$NEXT_PUBLIC_UMAMI_WEBSITE_ID
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 install --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
# 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
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

@@ -5,11 +5,15 @@ import "../globals.css";
import { NextIntlClientProvider } from "next-intl";
import { getMessages } from "next-intl/server";
import { notFound } from "next/navigation";
import { LazyMotion, domAnimation } from "framer-motion";
import AnalyticsProvider from "@/components/analytics/AnalyticsProvider";
import { config } from "@/lib/config";
const inter = Inter({
subsets: ["latin"],
display: "swap",
variable: "--font-inter",
weight: ["400", "700", "800"], // Explicit weights to optimize download
});
export const metadata: Metadata = {
@@ -105,6 +109,29 @@ export default async function RootLayout({
},
};
// Track pageview on the server
// This is safe to call here because layout is a Server Component
const serverServices = (
await import("@/lib/services/create-services.server")
).getServerAppServices();
// Populate analytics context with headers for high-fidelity server-side tracking
const { headers } = await import("next/headers");
const requestHeaders = await headers();
if (serverServices.analytics.setServerContext) {
serverServices.analytics.setServerContext({
userAgent: requestHeaders.get("user-agent") || undefined,
language:
requestHeaders.get("accept-language")?.split(",")[0] || undefined,
referrer: requestHeaders.get("referer") || undefined,
ip: requestHeaders.get("x-forwarded-for")?.split(",")[0] || undefined,
});
}
// Track server-side (initial load)
// serverServices.analytics.trackPageview("/"); // Removed to avoid double-tracking and incorrect path reporting
return (
<html lang={locale} className={`${inter.variable}`}>
<head>
@@ -115,7 +142,10 @@ export default async function RootLayout({
</head>
<body className="antialiased">
<NextIntlClientProvider messages={messages}>
<Layout>{children}</Layout>
<AnalyticsProvider websiteId={config.analytics.umami.websiteId} />
<LazyMotion features={domAnimation}>
<Layout>{children}</Layout>
</LazyMotion>
</NextIntlClientProvider>
</body>
</html>

View File

@@ -86,7 +86,7 @@ export default async function Image() {
letterSpacing: "0.1em",
}}
>
Engineering Excellence
Technische Beratung
</div>
</div>
@@ -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

@@ -86,7 +86,7 @@ export default async function Image() {
letterSpacing: "0.1em",
}}
>
Engineering Excellence
Technische Beratung
</div>
</div>
@@ -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

@@ -8,9 +8,23 @@ 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,
language: req.headers.get("accept-language")?.split(",")[0] || undefined,
referrer: req.headers.get("referer") || undefined,
ip: req.headers.get("x-forwarded-for")?.split(",")[0] || undefined,
});
}
try {
const { name, email, company, message, website } = await req.json();
// Track attempt
services.analytics.track("contact-form-attempt");
// Honeypot check
if (website) {
logger.info("Spam detected (honeypot)");
@@ -47,7 +61,14 @@ export async function POST(req: Request) {
logger.info("Contact submission saved to Directus");
directusSaved = true;
} catch (directusError) {
logger.error("Failed to save to Directus", { error: 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",
});
@@ -56,19 +77,20 @@ export async function POST(req: Request) {
// 2. Email sending
try {
const { config } = await import("@/lib/config");
const transporter = nodemailer.createTransport({
host: process.env.SMTP_HOST,
port: parseInt(process.env.SMTP_PORT || "587"),
secure: process.env.SMTP_SECURE === "true",
host: config.mail.host,
port: config.mail.port,
secure: config.mail.port === 465,
auth: {
user: process.env.SMTP_USER,
pass: process.env.SMTP_PASS,
user: config.mail.user,
pass: config.mail.pass,
},
});
await transporter.sendMail({
from: process.env.SMTP_FROM,
to: process.env.CONTACT_RECIPIENT || "info@mb-grid-solutions.com",
from: config.mail.from,
to: config.mail.recipients.join(",") || "info@mb-grid-solutions.com",
replyTo: email,
subject: `Kontaktanfrage von ${name}`,
text: `
@@ -110,6 +132,11 @@ ${message}
});
}
// Track success
services.analytics.track("contact-form-success", {
has_company: Boolean(company),
});
return NextResponse.json({ message: "Ok" });
} catch (error) {
logger.error("Global API Error", { error });

View File

@@ -1,7 +1,7 @@
"use client";
import React, { useState } from "react";
import { m, LazyMotion, domAnimation } from "framer-motion";
import { m } from "framer-motion";
import Link from "next/link";
import { ArrowRight } from "lucide-react";
@@ -62,15 +62,13 @@ export const Button = ({
);
const spotlight = (
<LazyMotion features={domAnimation}>
<m.div
className="absolute inset-0 z-0 pointer-events-none transition-opacity duration-500"
style={{
opacity: isHovered ? 1 : 0,
background: `radial-gradient(600px circle at ${mousePosition.x}px ${mousePosition.y}px, rgba(255,255,255,0.15), transparent 40%)`,
}}
/>
</LazyMotion>
<m.div
className="absolute inset-0 z-0 pointer-events-none transition-opacity duration-500"
style={{
opacity: isHovered ? 1 : 0,
background: `radial-gradient(600px circle at ${mousePosition.x}px ${mousePosition.y}px, rgba(255,255,255,0.15), transparent 40%)`,
}}
/>
);
const buttonProps = {

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,22 +1,29 @@
"use client";
import { m, LazyMotion, domAnimation } 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 { TileGrid } from "./TileGrid";
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");
@@ -75,12 +82,11 @@ 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 />
</div>
<TileGrid />
<div className="container-custom relative z-10">
<div className="text-left relative">
@@ -129,274 +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="Technical Engineering"
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">
<LazyMotion features={domAnimation}>
<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>
</LazyMotion>
</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

@@ -1,6 +1,6 @@
"use client";
import { AnimatePresence, m, LazyMotion, domAnimation } from "framer-motion";
import { AnimatePresence, m } from "framer-motion";
import { ArrowUp, Home, Info, Menu, X } from "lucide-react";
import Image from "next/image";
import Link from "next/link";
@@ -116,38 +116,36 @@ const Layout = ({ children }: { children: React.ReactNode }) => {
</Reveal>
{/* Mobile Menu Overlay */}
<LazyMotion features={domAnimation}>
<AnimatePresence>
{isMobileMenuOpen && (
<m.div
initial={{ opacity: 0, y: -20 }}
animate={{ opacity: 1, y: 0 }}
exit={{ opacity: 0, y: -20 }}
className="fixed inset-0 z-[90] bg-white pt-32 px-6 md:hidden"
>
<nav className="flex flex-col gap-4">
{navLinks.map((link) => (
<Link
key={link.href}
href={link.href}
className={`flex items-center gap-4 p-4 rounded-xl text-lg font-semibold transition-all ${
isActive(link.href)
? "text-accent bg-accent/5"
: "text-slate-600 hover:text-primary hover:bg-slate-50"
}`}
>
<link.icon size={24} />
{link.label}
</Link>
))}
<Button href="/kontakt" className="mt-4 w-full">
{t("nav.cta")}
</Button>
</nav>
</m.div>
)}
</AnimatePresence>
</LazyMotion>
<AnimatePresence>
{isMobileMenuOpen && (
<m.div
initial={{ opacity: 0, y: -20 }}
animate={{ opacity: 1, y: 0 }}
exit={{ opacity: 0, y: -20 }}
className="fixed inset-0 z-[90] bg-white pt-32 px-6 md:hidden"
>
<nav className="flex flex-col gap-4">
{navLinks.map((link) => (
<Link
key={link.href}
href={link.href}
className={`flex items-center gap-4 p-4 rounded-xl text-lg font-semibold transition-all ${
isActive(link.href)
? "text-accent bg-accent/5"
: "text-slate-600 hover:text-primary hover:bg-slate-50"
}`}
>
<link.icon size={24} />
{link.label}
</Link>
))}
<Button href="/kontakt" className="mt-4 w-full">
{t("nav.cta")}
</Button>
</nav>
</m.div>
)}
</AnimatePresence>
<main className="flex-grow">{children}</main>
@@ -168,18 +166,16 @@ const Layout = ({ children }: { children: React.ReactNode }) => {
<div className="absolute inset-0 grid-pattern opacity-[0.08] pointer-events-none" />
{/* Animated Tech Lines */}
<LazyMotion features={domAnimation}>
<m.div
animate={{ x: ["-100%", "100%"] }}
transition={{ duration: 15, repeat: Infinity, ease: "linear" }}
className="absolute top-0 left-0 w-full h-px bg-gradient-to-r from-transparent via-accent/30 to-transparent"
/>
<m.div
animate={{ x: ["100%", "-100%"] }}
transition={{ duration: 20, repeat: Infinity, ease: "linear" }}
className="absolute bottom-0 left-0 w-full h-px bg-gradient-to-r from-transparent via-accent/20 to-transparent"
/>
</LazyMotion>
<m.div
animate={{ x: ["-100%", "100%"] }}
transition={{ duration: 15, repeat: Infinity, ease: "linear" }}
className="absolute top-0 left-0 w-full h-px bg-gradient-to-r from-transparent via-accent/30 to-transparent"
/>
<m.div
animate={{ x: ["100%", "-100%"] }}
transition={{ duration: 20, repeat: Infinity, ease: "linear" }}
className="absolute bottom-0 left-0 w-full h-px bg-gradient-to-r from-transparent via-accent/20 to-transparent"
/>
{/* Corner Accents */}
<div className="tech-corner top-8 left-8 border-t border-l border-white/10 group-hover:border-accent/30 transition-colors duration-700" />
@@ -279,7 +275,7 @@ const Layout = ({ children }: { children: React.ReactNode }) => {
<div className="bg-slate-950 py-6 border-t border-white/5">
<div className="container-custom">
<p className="text-[10px] uppercase tracking-[0.2em] text-slate-600 text-center md:text-left">
Website developed by{" "}
Website entwickelt von{" "}
<a
href="https://mintel.me"
target="_blank"

View File

@@ -1,26 +1,26 @@
'use client';
"use client";
import React from 'react';
import { m, LazyMotion, domAnimation } from 'framer-motion';
import React from "react";
import { m } from "framer-motion";
interface RevealProps {
children: React.ReactNode;
className?: string;
delay?: number;
direction?: 'up' | 'down' | 'left' | 'right';
direction?: "up" | "down" | "left" | "right";
fullWidth?: boolean;
viewportMargin?: string;
trigger?: 'inView' | 'mount';
trigger?: "inView" | "mount";
}
export const Reveal = ({
children,
className = '',
className = "",
delay = 0,
direction = 'up',
direction = "up",
fullWidth = false,
viewportMargin = "-50px",
trigger = 'inView'
trigger = "inView",
}: RevealProps) => {
const directions = {
up: { y: 30 },
@@ -30,35 +30,45 @@ export const Reveal = ({
};
return (
<LazyMotion features={domAnimation}>
<m.div
initial={{
opacity: 0,
...directions[direction]
...directions[direction],
}}
animate={trigger === 'mount' ? {
opacity: 1,
x: 0,
y: 0
} : undefined}
whileInView={trigger === 'inView' ? {
opacity: 1,
x: 0,
y: 0
} : undefined}
viewport={trigger === 'inView' ? { once: true, margin: viewportMargin } : undefined}
animate={
trigger === "mount"
? {
opacity: 1,
x: 0,
y: 0,
}
: undefined
}
whileInView={
trigger === "inView"
? {
opacity: 1,
x: 0,
y: 0,
}
: undefined
}
viewport={
trigger === "inView"
? { once: true, margin: viewportMargin }
: undefined
}
transition={{
type: "spring",
stiffness: 50,
damping: 20,
mass: 1,
delay: delay
delay: delay,
}}
className={`${fullWidth ? 'w-full' : ''} ${className} motion-fix`}
className={`${fullWidth ? "w-full" : ""} ${className} motion-fix will-change-[transform,opacity]`}
>
{children}
</m.div>
</LazyMotion>
);
};
@@ -68,13 +78,12 @@ interface StaggerProps {
staggerDelay?: number;
}
export const Stagger = ({
children,
className = '',
staggerDelay = 0.1
export const Stagger = ({
children,
className = "",
staggerDelay = 0.1,
}: StaggerProps) => {
return (
<LazyMotion features={domAnimation}>
<m.div
initial="initial"
whileInView="animate"
@@ -90,6 +99,5 @@ export const Stagger = ({
>
{children}
</m.div>
</LazyMotion>
);
};

View File

@@ -1,53 +0,0 @@
'use client';
import React, { useEffect, useState } from 'react';
import { m, LazyMotion, domAnimation } from 'framer-motion';
export const TileGrid = () => {
const [mounted, setMounted] = useState(false);
useEffect(() => {
setMounted(true);
}, []);
if (!mounted) return null;
const rows = 15;
const cols = 20;
return (
<div className="absolute inset-0 pointer-events-none overflow-hidden z-[1]">
<LazyMotion features={domAnimation}>
<div className="flex flex-col gap-3 min-w-[120%] min-h-[120%] -left-[10%] -top-[10%] absolute">
{[...Array(rows)].map((_, rowIndex) => (
<div
key={rowIndex}
className="flex gap-3 justify-center"
style={{
transform: rowIndex % 2 === 0 ? 'translateX(0)' : 'translateX(80px)',
}}
>
{[...Array(cols)].map((_, colIndex) => (
<m.div
key={`${rowIndex}-${colIndex}`}
initial={{ opacity: 0.05 }}
animate={{
opacity: [0.05, Math.random() > 0.9 ? 0.25 : 0.05, 0.05],
scale: [1, Math.random() > 0.9 ? 1.05 : 1, 1]
}}
transition={{
duration: 5 + Math.random() * 5,
repeat: Infinity,
delay: Math.random() * 20,
ease: "easeInOut"
}}
className="w-24 h-24 md:w-40 md:h-40 bg-white/10 backdrop-blur-[2px] rounded-2xl md:rounded-3xl border border-white/20 shadow-[0_8px_32px_0_rgba(31,38,135,0.07)] shrink-0"
/>
))}
</div>
))}
</div>
</LazyMotion>
</div>
);
};

View File

@@ -0,0 +1,48 @@
"use client";
import { useEffect } from "react";
import { usePathname, useSearchParams } from "next/navigation";
import { getAppServices } from "@/lib/services/create-services";
/**
* AnalyticsProvider Component
*
* Automatically tracks pageviews on client-side route changes.
* This component should be placed inside your layout to handle navigation events.
*
* @param {Object} props - Component props
* @param {string} [props.websiteId] - The Umami website ID (passed from server config)
*
* @example
* ```tsx
* // In your layout.tsx
* const { websiteId } = config.analytics.umami;
* <AnalyticsProvider websiteId={websiteId} />
* ```
*/
export default function AnalyticsProvider({
websiteId,
}: {
websiteId?: string;
}) {
const pathname = usePathname();
const searchParams = useSearchParams();
useEffect(() => {
if (!pathname) return;
const services = getAppServices();
const url = `${pathname}${searchParams?.size ? `?${searchParams.toString()}` : ""}`;
// Track pageview with the full URL
services.analytics.trackPageview(url);
if (process.env.NODE_ENV === "development") {
console.log("[Umami] Tracked pageview:", url);
}
}, [pathname, searchParams]);
if (!websiteId) return null;
return null;
}

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

@@ -12,30 +12,44 @@ services:
- "traefik.http.routers.${PROJECT_NAME}.entrypoints=websecure"
- "traefik.http.routers.${PROJECT_NAME}.tls.certresolver=le"
- "traefik.http.routers.${PROJECT_NAME}.tls=true"
- "traefik.http.routers.${PROJECT_NAME}.priority=1000"
- "traefik.http.services.${PROJECT_NAME}.loadbalancer.server.port=3000"
- "traefik.http.routers.${PROJECT_NAME}.middlewares=${PROJECT_NAME}-auth"
- "traefik.http.routers.${PROJECT_NAME}.middlewares=${TRAEFIK_MIDDLEWARES:-${PROJECT_NAME}-auth,${PROJECT_NAME}-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}`)}"
# Forwarded Headers (Protocol Normalization)
- "traefik.http.middlewares.${PROJECT_NAME}-forward.headers.customrequestheaders.X-Forwarded-Proto=https"
- "traefik.http.middlewares.${PROJECT_NAME}-forward.headers.customrequestheaders.X-Forwarded-Ssl=on"
# Gatekeeper Router (Path-based)
- "traefik.http.routers.${PROJECT_NAME}-gatekeeper.rule=(Host(`${TRAEFIK_HOST}`) && PathPrefix(`/gatekeeper`))"
- "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.priority=2000"
- "traefik.http.routers.${PROJECT_NAME}-gatekeeper.service=${PROJECT_NAME}-gatekeeper"
- "traefik.http.middlewares.${PROJECT_NAME}-auth.forwardauth.address=http://${PROJECT_NAME}-gatekeeper:3000/api/verify"
- "traefik.http.middlewares.${PROJECT_NAME}-auth.forwardauth.address=http://${PROJECT_NAME}-gatekeeper:3000/gatekeeper/api/verify"
- "traefik.http.middlewares.${PROJECT_NAME}-auth.forwardauth.trustForwardHeader=true"
- "traefik.http.middlewares.${PROJECT_NAME}-auth.forwardauth.authRequestHeaders=Host,X-Forwarded-Host,X-Forwarded-Proto,X-Forwarded-For"
- "traefik.http.middlewares.${PROJECT_NAME}-auth.forwardauth.authResponseHeaders=X-Auth-User"
- "traefik.docker.network=infra"
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
timeout: 5s
retries: 5
start_period: 30s
gatekeeper:
image: registry.infra.mintel.me/mintel/gatekeeper:latest
image: registry.infra.mintel.me/mintel/gatekeeper:v1.7.12
container_name: ${PROJECT_NAME:-mb-grid-solutions}-gatekeeper
restart: always
profiles: [ "gatekeeper" ]
restart: unless-stopped
networks:
infra:
aliases:
- ${PROJECT_NAME:-mb-grid-solutions}-gatekeeper
- gatekeeper
env_file:
- ${ENV_FILE:-.env}
environment:
@@ -45,8 +59,7 @@ services:
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}
NEXT_PUBLIC_BASE_URL: ${GATEKEEPER_ORIGIN}
labels:
- "traefik.enable=true"
- "traefik.http.services.${PROJECT_NAME}-gatekeeper.loadbalancer.server.port=3000"
@@ -56,8 +69,10 @@ services:
image: directus/directus:11
restart: always
networks:
- infra
- backend
infra:
aliases:
- ${PROJECT_NAME:-mb-grid-solutions}-directus
backend:
env_file:
- ${ENV_FILE:-.env}
environment:
@@ -86,9 +101,9 @@ services:
- "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.priority=1000"
- "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:

View File

@@ -27,11 +27,11 @@ function createConfig() {
analytics: {
umami: {
websiteId: env.NEXT_PUBLIC_UMAMI_WEBSITE_ID,
scriptUrl: env.NEXT_PUBLIC_UMAMI_SCRIPT_URL,
// The proxied path used in the frontend
proxyPath: "/stats/script.js",
enabled: Boolean(env.NEXT_PUBLIC_UMAMI_WEBSITE_ID),
websiteId: env.NEXT_PUBLIC_UMAMI_WEBSITE_ID || env.UMAMI_WEBSITE_ID,
apiEndpoint: env.UMAMI_API_ENDPOINT,
enabled: Boolean(
env.NEXT_PUBLIC_UMAMI_WEBSITE_ID || env.UMAMI_WEBSITE_ID,
),
},
},
@@ -153,7 +153,7 @@ export function getMaskedConfig() {
analytics: {
umami: {
websiteId: mask(c.analytics.umami.websiteId),
scriptUrl: c.analytics.umami.scriptUrl,
apiEndpoint: c.analytics.umami.apiEndpoint,
enabled: c.analytics.umami.enabled,
},
},

View File

@@ -1,36 +1,27 @@
import { createDirectus, rest, authentication } from "@directus/sdk";
import { config } from "./config";
import {
createMintelDirectusClient,
ensureDirectusAuthenticated,
} from "@mintel/next-utils";
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());
// Initialize client using Mintel standards (environment-aware)
const client = createMintelDirectusClient();
/**
* Ensures the client is authenticated.
* Falls back to login with admin credentials if no static token is provided.
* Standardized using @mintel/next-utils ensureDirectusAuthenticated.
*/
export async function ensureAuthenticated() {
if (token) {
client.setToken(token);
return;
}
if (adminEmail && password) {
try {
await client.login({ email: adminEmail, password: password });
} catch (e) {
if (typeof window === "undefined") {
getServerAppServices().errors.captureException(e, {
phase: "directus_auth",
});
}
console.error("Failed to authenticate with Directus login fallback:", e);
try {
await ensureDirectusAuthenticated(client);
} catch (e) {
if (typeof window === "undefined") {
getServerAppServices().errors.captureException(e, {
phase: "directus_auth_standardized",
});
}
console.error("Failed to authenticate with Directus:", e);
throw e;
}
}

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,114 +1,42 @@
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(),
const envExtension = {
// Project specific overrides or additions
AUTH_COOKIE_NAME: z.string().default("mb_gatekeeper_session"),
// Analytics
NEXT_PUBLIC_UMAMI_WEBSITE_ID: z.preprocess(
preprocessEmptyString,
z.string().optional(),
),
NEXT_PUBLIC_UMAMI_SCRIPT_URL: z.preprocess(
preprocessEmptyString,
z.string().url().default("https://analytics.infra.mintel.me/script.js"),
),
// 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()),
});
export type Env = z.infer<typeof envSchema>;
INFRA_DIRECTUS_URL: z.string().url().optional(),
INFRA_DIRECTUS_TOKEN: z.string().optional(),
};
/**
* 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,
NEXT_PUBLIC_UMAMI_WEBSITE_ID: process.env.NEXT_PUBLIC_UMAMI_WEBSITE_ID,
NEXT_PUBLIC_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

@@ -0,0 +1,445 @@
# Analytics Service Layer
This directory contains the service layer implementation for analytics tracking in the KLZ Cables application.
## Overview
The analytics service layer provides a clean abstraction over different analytics implementations (Umami, Google Analytics, etc.) while maintaining a consistent API.
## Architecture
```
lib/services/analytics/
├── analytics-service.ts # Interface definition
├── umami-analytics-service.ts # Umami implementation
├── noop-analytics-service.ts # No-op fallback implementation
└── README.md # This file
```
## Components
### 1. AnalyticsService Interface (`analytics-service.ts`)
Defines the contract for all analytics services:
```typescript
export interface AnalyticsService {
track(eventName: string, props?: AnalyticsEventProperties): void;
trackPageview(url?: string): void;
}
```
**Key Features:**
- Type-safe event properties
- Consistent API across implementations
- Well-documented with JSDoc comments
### 2. UmamiAnalyticsService (`umami-analytics-service.ts`)
Implements the `AnalyticsService` interface for Umami analytics.
**Features:**
- Type-safe event tracking
- Automatic pageview tracking
- Browser environment detection
- Graceful error handling
- Comprehensive JSDoc documentation
**Usage:**
```typescript
import { UmamiAnalyticsService } from "@/lib/services/analytics/umami-analytics-service";
const service = new UmamiAnalyticsService({ enabled: true });
service.track("button_click", { button_id: "cta" });
service.trackPageview("/products/123");
```
### 3. NoopAnalyticsService (`noop-analytics-service.ts`)
A no-op implementation used as a fallback when analytics are disabled.
**Features:**
- Maintains the same API as other services
- Safe to call even when analytics are disabled
- No performance impact
- Comprehensive JSDoc documentation
**Usage:**
```typescript
import { NoopAnalyticsService } from "@/lib/services/analytics/noop-analytics-service";
const service = new NoopAnalyticsService();
service.track("button_click", { button_id: "cta" }); // Does nothing
service.trackPageview("/products/123"); // Does nothing
```
## Service Selection
The service layer automatically selects the appropriate implementation based on environment variables:
```typescript
// In lib/services/create-services.ts
const umamiEnabled = Boolean(process.env.NEXT_PUBLIC_UMAMI_WEBSITE_ID);
const analytics = umamiEnabled
? new UmamiAnalyticsService({ enabled: true })
: new NoopAnalyticsService();
```
## Environment Variables
### Required for Umami
```bash
NEXT_PUBLIC_UMAMI_WEBSITE_ID=59a7db94-0100-4c7e-98ef-99f45b17f9c3
```
### Optional (defaults provided)
```bash
NEXT_PUBLIC_UMAMI_SCRIPT_URL=https://analytics.infra.mintel.me/script.js
```
## API Reference
### AnalyticsService Interface
#### `track(eventName: string, props?: AnalyticsEventProperties): void`
Track a custom event with optional properties.
**Parameters:**
- `eventName` - The name of the event to track
- `props` - Optional event properties (metadata)
**Example:**
```typescript
service.track("product_add_to_cart", {
product_id: "123",
product_name: "Cable",
price: 99.99,
quantity: 1,
});
```
#### `trackPageview(url?: string): void`
Track a pageview.
**Parameters:**
- `url` - The URL to track (defaults to current location)
**Example:**
```typescript
// Track current page
service.trackPageview();
// Track custom URL
service.trackPageview("/products/123?category=cables");
```
### UmamiAnalyticsService
#### Constructor
```typescript
new UmamiAnalyticsService(options: UmamiAnalyticsServiceOptions)
```
**Options:**
- `enabled: boolean` - Whether analytics are enabled
**Example:**
```typescript
const service = new UmamiAnalyticsService({ enabled: true });
```
### NoopAnalyticsService
#### Constructor
```typescript
new NoopAnalyticsService();
```
**Example:**
```typescript
const service = new NoopAnalyticsService();
```
## Type Definitions
### AnalyticsEventProperties
```typescript
type AnalyticsEventProperties = Record<
string,
string | number | boolean | null | undefined
>;
```
**Example:**
```typescript
const properties: AnalyticsEventProperties = {
product_id: "123",
product_name: "Cable",
price: 99.99,
quantity: 1,
in_stock: true,
discount: null,
};
```
### UmamiAnalyticsServiceOptions
```typescript
type UmamiAnalyticsServiceOptions = {
enabled: boolean;
};
```
## Best Practices
### 1. Use the Service Layer
Always use the service layer instead of calling Umami directly:
```typescript
// ✅ Good
import { getAppServices } from "@/lib/services/create-services";
const services = getAppServices();
services.analytics.track("button_click", { button_id: "cta" });
// ❌ Avoid
(window as any).umami?.track("button_click", { button_id: "cta" });
```
### 2. Check Environment
The service layer automatically handles environment detection:
```typescript
// ✅ Safe - works in both server and client
const services = getAppServices();
services.analytics.track("event", { prop: "value" });
// ❌ Unsafe - may fail in server environment
if (typeof window !== "undefined") {
window.umami?.track("event", { prop: "value" });
}
```
### 3. Use Type-Safe Events
Import events from the centralized definitions:
```typescript
import { AnalyticsEvents } from "@/components/analytics/analytics-events";
// ✅ Type-safe
services.analytics.track(AnalyticsEvents.BUTTON_CLICK, {
button_id: "cta",
});
// ❌ Prone to typos
services.analytics.track("button_click", {
button_id: "cta",
});
```
### 4. Handle Disabled Analytics
The service layer gracefully handles disabled analytics:
```typescript
// When NEXT_PUBLIC_UMAMI_WEBSITE_ID is not set:
// - NoopAnalyticsService is used
// - All calls are safe (no-op)
// - No errors are thrown
const services = getAppServices();
services.analytics.track("event", { prop: "value" }); // Safe, does nothing
```
## Testing
### Mocking for Tests
```typescript
// __tests__/analytics-mock.ts
export const mockAnalytics = {
track: jest.fn(),
trackPageview: jest.fn(),
};
jest.mock("@/lib/services/create-services", () => ({
getAppServices: () => ({
analytics: mockAnalytics,
}),
}));
// Usage in tests
import { mockAnalytics } from "./analytics-mock";
test("tracks button click", () => {
// ... test code ...
expect(mockAnalytics.track).toHaveBeenCalledWith("button_click", {
button_id: "cta",
});
});
```
### Development Mode
In development, the service layer logs to console:
```bash
# Console output:
[Umami] Tracked event: button_click { button_id: 'cta' }
[Umami] Tracked pageview: /products/123
```
## Error Handling
The service layer includes built-in error handling:
1. **Environment Detection** - Checks for browser environment
2. **Service Availability** - Checks if Umami is loaded
3. **Graceful Degradation** - Falls back to NoopAnalyticsService if needed
```typescript
// These are all safe:
const services = getAppServices();
services.analytics.track("event", { prop: "value" }); // Works or does nothing
services.analytics.trackPageview("/path"); // Works or does nothing
```
## Performance
### Singleton Pattern
The service layer uses a singleton pattern for performance:
```typescript
// First call creates the singleton
const services1 = getAppServices();
// Subsequent calls return the cached singleton
const services2 = getAppServices();
// services1 === services2 (same instance)
```
### Lazy Initialization
Services are only created when first accessed:
```typescript
// Services are not created until getAppServices() is called
// This keeps initial bundle size minimal
```
## Integration with Components
### Client Components
```typescript
'use client';
import { getAppServices } from '@/lib/services/create-services';
function MyComponent() {
const handleClick = () => {
const services = getAppServices();
services.analytics.track('button_click', { button_id: 'my-button' });
};
return <button onClick={handleClick}>Click Me</button>;
}
```
### Server Components
```typescript
import { getAppServices } from '@/lib/services/create-services';
async function MyServerComponent() {
const services = getAppServices();
// Note: Analytics won't work in server components
// Use client components for analytics tracking
// But you can still access other services like cache
const data = await services.cache.get('key');
return <div>{data}</div>;
}
```
## Troubleshooting
### Analytics Not Working
1. **Check environment variables:**
```bash
echo $NEXT_PUBLIC_UMAMI_WEBSITE_ID
```
2. **Verify service selection:**
```typescript
import { getAppServices } from "@/lib/services/create-services";
const services = getAppServices();
console.log(services.analytics); // Should be UmamiAnalyticsService
```
3. **Check Umami dashboard:**
- Log into Umami
- Verify website ID matches
- Check if data is being received
### Common Issues
| Issue | Solution |
| ------------------- | ----------------------------------- |
| No data in Umami | Check website ID and script URL |
| Events not tracking | Verify service is being used |
| Script not loading | Check network connection, CORS |
| Wrong data | Verify event properties are correct |
## Related Files
- [`components/analytics/useAnalytics.ts`](../components/analytics/useAnalytics.ts) - Custom hook for easy event tracking
- [`components/analytics/analytics-events.ts`](../components/analytics/analytics-events.ts) - Event definitions
- [`components/analytics/UmamiScript.tsx`](../components/analytics/UmamiScript.tsx) - Script loader component
- [`components/analytics/AnalyticsProvider.tsx`](../components/analytics/AnalyticsProvider.tsx) - Route change tracker
- [`lib/services/create-services.ts`](../lib/services/create-services.ts) - Service factory
## Summary
The analytics service layer provides:
-**Type-safe API** - TypeScript throughout
-**Clean abstraction** - Easy to switch analytics providers
-**Graceful degradation** - Safe no-op fallback
-**Comprehensive documentation** - JSDoc comments and examples
-**Performance optimized** - Singleton pattern, lazy initialization
-**Error handling** - Safe in all environments
This layer is the foundation for all analytics tracking in the application.

View File

@@ -1,3 +1,87 @@
/**
* Type definition for analytics event properties.
*
* @example
* ```typescript
* const properties: AnalyticsEventProperties = {
* product_id: '123',
* product_name: 'Cable',
* price: 99.99,
* quantity: 1,
* in_stock: true,
* };
* ```
*/
export type AnalyticsEventProperties = Record<
string,
string | number | boolean | null | undefined
>;
/**
* Interface for analytics service implementations.
*
* This interface defines the contract for all analytics services,
* allowing for different implementations (Umami, Google Analytics, etc.)
* while maintaining a consistent API.
*
* @example
* ```typescript
* // Using the service directly
* const service = new UmamiAnalyticsService({ enabled: true });
* service.track('button_click', { button_id: 'cta' });
* service.trackPageview('/products/123');
* ```
*
* @example
* ```typescript
* // Using the useAnalytics hook (recommended)
* const { trackEvent, trackPageview } = useAnalytics();
* trackEvent('button_click', { button_id: 'cta' });
* trackPageview('/products/123');
* ```
*/
export interface AnalyticsService {
trackEvent(name: string, properties?: Record<string, unknown>): void;
/**
* Track a custom event with optional properties.
*
* @param eventName - The name of the event to track
* @param props - Optional event properties (metadata)
*
* @example
* ```typescript
* track('product_add_to_cart', {
* product_id: '123',
* product_name: 'Cable',
* price: 99.99,
* });
* ```
*/
track(eventName: string, props?: AnalyticsEventProperties): void;
/**
* Track a pageview.
*
* @param url - The URL to track (defaults to current location)
*
* @example
* ```typescript
* // Track current page
* trackPageview();
*
* // Track custom URL
* trackPageview('/products/123?category=cables');
* ```
*/
trackPageview(url?: string): void;
/**
* Set the server-side context for the current request.
* This is used for server-side tracking (e.g. from Next.js proxy).
*/
setServerContext?(context: {
userAgent?: string;
language?: string;
referrer?: string;
ip?: string;
}): void;
}

View File

@@ -1,5 +1,83 @@
import type { AnalyticsService } from "./analytics-service";
/* eslint-disable @typescript-eslint/no-unused-vars */
import type {
AnalyticsEventProperties,
AnalyticsService,
} from "./analytics-service";
/**
* No-op Analytics Service Implementation.
*
* This service implements the AnalyticsService interface but does nothing.
* It's used as a fallback when analytics are disabled or not configured.
*
* @example
* ```typescript
* // Service creation (usually done by create-services.ts)
* const service = new NoopAnalyticsService();
*
* // These calls do nothing but are safe to execute
* service.track('button_click', { button_id: 'cta' });
* service.trackPageview('/products/123');
* ```
*
* @example
* ```typescript
* // Automatic fallback in create-services.ts
* const umamiEnabled = Boolean(process.env.NEXT_PUBLIC_UMAMI_WEBSITE_ID);
* const analytics = umamiEnabled
* ? new UmamiAnalyticsService({ enabled: true })
* : new NoopAnalyticsService(); // Fallback when no website ID
* ```
*/
export class NoopAnalyticsService implements AnalyticsService {
trackEvent() {}
/**
* No-op implementation of track.
*
* This method does nothing but maintains the same signature as other
* analytics services for consistency.
*
* @param _eventName - Event name (ignored)
* @param _props - Event properties (ignored)
*
* @example
* ```typescript
* // Safe to call even when analytics are disabled
* service.track('button_click', { button_id: 'cta' });
* // No error, no action taken
* ```
*/
track(_eventName: string, _props?: AnalyticsEventProperties) {
// intentionally noop - analytics are disabled
}
/**
* No-op implementation of trackPageview.
*
* This method does nothing but maintains the same signature as other
* analytics services for consistency.
*
* @param _url - URL to track (ignored)
*
* @example
* ```typescript
* // Safe to call even when analytics are disabled
* service.trackPageview('/products/123');
* // No error, no action taken
* ```
*/
trackPageview(_url?: string) {
// intentionally noop - analytics are disabled
}
/**
* No-op implementation of setServerContext.
*/
setServerContext(_context: {
userAgent?: string;
language?: string;
referrer?: string;
ip?: string;
}) {
// intentionally noop - analytics are disabled
}
}

View File

@@ -0,0 +1,158 @@
import type {
AnalyticsEventProperties,
AnalyticsService,
} from "./analytics-service";
import { config } from "../../config";
/**
* Configuration options for UmamiAnalyticsService.
*
* @property enabled - Whether analytics are enabled
*/
export type UmamiAnalyticsServiceOptions = {
enabled: boolean;
};
/**
* Umami Analytics Service Implementation (Script-less/Proxy edition).
*
* This version implements the Umami tracking protocol directly via fetch,
* eliminating the need to load an external script.js file.
*
* In the browser, it gathers standard metadata (screen, language, referrer)
* and sends it to the proxied '/stats/api/send' endpoint.
*/
export class UmamiAnalyticsService implements AnalyticsService {
private websiteId?: string;
private endpoint: string;
private serverContext?: {
userAgent?: string;
language?: string;
referrer?: string;
ip?: string;
};
constructor(private readonly options: UmamiAnalyticsServiceOptions) {
this.websiteId = config.analytics.umami.websiteId;
// On server, use the full internal URL; on client, use the proxied path
this.endpoint =
typeof window === "undefined"
? config.analytics.umami.apiEndpoint
: "/stats";
}
/**
* Set the server-side context for the current request.
* This allows the service to use real request headers for tracking.
*/
setServerContext(context: {
userAgent?: string;
language?: string;
referrer?: string;
ip?: string;
}) {
this.serverContext = context;
}
/**
* Internal method to send the payload to Umami API.
*/
// eslint-disable-next-line @typescript-eslint/no-explicit-any
private async sendPayload(type: "event", data: Record<string, unknown>) {
if (!this.options.enabled || !this.websiteId) return;
try {
const payload = {
website: this.websiteId,
hostname:
typeof window !== "undefined"
? window.location.hostname
: this.serverContext?.referrer
? new URL(this.serverContext.referrer).hostname
: "server",
screen:
typeof window !== "undefined"
? `${window.screen.width}x${window.screen.height}`
: undefined,
language:
typeof window !== "undefined"
? navigator.language
: this.serverContext?.language,
referrer:
typeof window !== "undefined"
? document.referrer
: this.serverContext?.referrer,
...data,
};
const headers: Record<string, string> = {
"Content-Type": "application/json",
};
// Set User-Agent
if (typeof window !== "undefined") {
headers["User-Agent"] = navigator.userAgent;
} else if (this.serverContext?.userAgent) {
headers["User-Agent"] = this.serverContext.userAgent;
} else {
headers["User-Agent"] = "Mintel-Server-Proxy";
}
// Forward client IP if available (Umami must be configured to trust this)
if (this.serverContext?.ip) {
headers["X-Forwarded-For"] = this.serverContext.ip;
}
const response = await fetch(`${this.endpoint}/api/send`, {
method: "POST",
headers,
body: JSON.stringify({ type, payload }),
keepalive: true,
// eslint-disable-next-line @typescript-eslint/no-explicit-any
} as any);
if (!response.ok && process.env.NODE_ENV === "development") {
const errorText = await response.text();
console.warn(
`[Umami] API responded with ${response.status}: ${errorText}`,
);
}
} catch (error) {
if (process.env.NODE_ENV === "development") {
console.error("[Umami] Failed to send analytics:", error);
}
}
}
/**
* Track a custom event.
*/
track(eventName: string, props?: AnalyticsEventProperties) {
this.sendPayload("event", {
name: eventName,
data: props,
url:
typeof window !== "undefined"
? window.location.pathname + window.location.search
: this.serverContext?.referrer
? new URL(this.serverContext.referrer).pathname
: "/",
});
}
/**
* Track a pageview.
*/
trackPageview(url?: string) {
this.sendPayload("event", {
url:
url ||
(typeof window !== "undefined"
? window.location.pathname + window.location.search
: this.serverContext?.referrer
? new URL(this.serverContext.referrer).pathname
: "/"),
});
}
}

View File

@@ -4,6 +4,7 @@ import type { ErrorReportingService } from "./errors/error-reporting-service";
import type { LoggerService } from "./logging/logger-service";
import type { NotificationService } from "./notifications/notification-service";
// Simple constructor-based DI container.
export class AppServices {
constructor(
public readonly analytics: AnalyticsService,

View File

@@ -1,5 +1,9 @@
export type CacheSetOptions = {
ttlSeconds?: number;
};
export interface CacheService {
get<T>(key: string): Promise<T | null>;
set<T>(key: string, value: T, ttlSeconds?: number): Promise<void>;
delete(key: string): Promise<void>;
get<T>(key: string): Promise<T | undefined>;
set<T>(key: string, value: T, options?: CacheSetOptions): Promise<void>;
del(key: string): Promise<void>;
}

View File

@@ -1,26 +1,30 @@
import type { CacheService } from "./cache-service";
import type { CacheService, CacheSetOptions } from "./cache-service";
type Entry = {
value: unknown;
expiresAt?: number;
};
export class MemoryCacheService implements CacheService {
private cache = new Map<string, { value: unknown; expiry: number | null }>();
private readonly store = new Map<string, Entry>();
async get<T>(key: string): Promise<T | null> {
const item = this.cache.get(key);
if (!item) return null;
if (item.expiry && item.expiry < Date.now()) {
this.cache.delete(key);
return null;
async get<T>(key: string) {
const entry = this.store.get(key);
if (!entry) return undefined;
if (entry.expiresAt && Date.now() > entry.expiresAt) {
this.store.delete(key);
return undefined;
}
return item.value as T;
return entry.value as T;
}
async set<T>(key: string, value: T, ttlSeconds?: number): Promise<void> {
const expiry = ttlSeconds ? Date.now() + ttlSeconds * 1000 : null;
this.cache.set(key, { value, expiry });
async set<T>(key: string, value: T, options?: CacheSetOptions) {
const ttl = options?.ttlSeconds;
const expiresAt = ttl ? Date.now() + ttl * 1000 : undefined;
this.store.set(key, { value, expiresAt });
}
async delete(key: string): Promise<void> {
this.cache.delete(key);
async del(key: string) {
this.store.delete(key);
}
}

View File

@@ -1,18 +1,21 @@
import { AppServices } from "./app-services";
import { NoopAnalyticsService } from "./analytics/noop-analytics-service";
import { UmamiAnalyticsService } from "./analytics/umami-analytics-service";
import { MemoryCacheService } from "./cache/memory-cache-service";
import { GlitchtipErrorReportingService } from "./errors/glitchtip-error-reporting-service";
import { NoopErrorReportingService } from "./errors/noop-error-reporting-service";
import { GotifyNotificationService } from "./notifications/gotify-notification-service";
import { NoopNotificationService } from "./notifications/noop-notification-service";
import {
GotifyNotificationService,
NoopNotificationService,
} from "./notifications/gotify-notification-service";
import { PinoLoggerService } from "./logging/pino-logger-service";
import { config, getMaskedConfig } from "../config";
let singleton: AppServices | undefined;
export function getServerAppServices(): AppServices {
if (singleton) return singleton;
// Create logger first to log initialization
const logger = new PinoLoggerService("server");
logger.info("Initializing server application services", {
@@ -20,7 +23,22 @@ export function getServerAppServices(): AppServices {
timestamp: new Date().toISOString(),
});
const analytics = new NoopAnalyticsService();
logger.info("Service configuration", {
umamiEnabled: config.analytics.umami.enabled,
sentryEnabled: config.errors.glitchtip.enabled,
mailEnabled: Boolean(config.mail.host && config.mail.user),
gotifyEnabled: config.notifications.gotify.enabled,
});
const analytics = config.analytics.umami.enabled
? new UmamiAnalyticsService({ enabled: true })
: new NoopAnalyticsService();
if (config.analytics.umami.enabled) {
logger.info("Umami analytics service initialized");
} else {
logger.info("Noop analytics service initialized (analytics disabled)");
}
const notifications = config.notifications.gotify.enabled
? new GotifyNotificationService({
@@ -30,11 +48,35 @@ export function getServerAppServices(): AppServices {
})
: new NoopNotificationService();
if (config.notifications.gotify.enabled) {
logger.info("Gotify notification service initialized");
} else {
logger.info(
"Noop notification service initialized (notifications disabled)",
);
}
const errors = config.errors.glitchtip.enabled
? new GlitchtipErrorReportingService({ enabled: true }, notifications)
: new NoopErrorReportingService();
if (config.errors.glitchtip.enabled) {
logger.info("GlitchTip error reporting service initialized", {
dsnPresent: Boolean(config.errors.glitchtip.dsn),
});
} else {
logger.info(
"Noop error reporting service initialized (error reporting disabled)",
);
}
const cache = new MemoryCacheService();
logger.info("Memory cache service initialized");
logger.info("Pino logger service initialized", {
name: "server",
level: config.logging.level,
});
singleton = new AppServices(analytics, errors, cache, logger, notifications);

View File

@@ -0,0 +1,154 @@
import { AppServices } from "./app-services";
import { NoopAnalyticsService } from "./analytics/noop-analytics-service";
import { MemoryCacheService } from "./cache/memory-cache-service";
import { GlitchtipErrorReportingService } from "./errors/glitchtip-error-reporting-service";
import { NoopErrorReportingService } from "./errors/noop-error-reporting-service";
import { NoopLoggerService } from "./logging/noop-logger-service";
import { PinoLoggerService } from "./logging/pino-logger-service";
import { NoopNotificationService } from "./notifications/gotify-notification-service";
import { config, getMaskedConfig } from "../config";
/**
* Singleton instance of AppServices.
*
* In Next.js, module singletons are per-process (server) and per-tab (client).
* This is sufficient for a small service layer and provides better performance
* than creating new instances on every request.
*
* @private
*/
let singleton: AppServices | undefined;
/**
* Get the application services singleton.
*
* This function creates and caches the application services, including:
* - Analytics service (Umami or no-op)
* - Error reporting service (GlitchTip/Sentry or no-op)
* - Cache service (in-memory)
*
* The services are configured based on environment variables:
* - `UMAMI_WEBSITE_ID` - Enables Umami analytics
* - `NEXT_PUBLIC_SENTRY_DSN` - Enables client-side error reporting
* - `SENTRY_DSN` - Enables server-side error reporting
*
* @returns {AppServices} The application services singleton
*
* @example
* ```typescript
* // Get services in a client component
* import { getAppServices } from '@/lib/services/create-services';
*
* const services = getAppServices();
* services.analytics.track('button_click', { button_id: 'cta' });
* ```
*
* @example
* ```typescript
* // Get services in a server component or API route
* import { getAppServices } from '@/lib/services/create-services';
*
* const services = getAppServices();
* await services.cache.set('key', 'value');
* ```
*
* @example
* ```typescript
* // Automatic service selection based on environment
* // If NEXT_PUBLIC_UMAMI_WEBSITE_ID is set:
* // services.analytics = UmamiAnalyticsService
* // If not set:
* // services.analytics = NoopAnalyticsService (safe no-op)
* ```
*
* @see {@link UmamiAnalyticsService} for analytics implementation
* @see {@link NoopAnalyticsService} for no-op fallback
* @see {@link GlitchtipErrorReportingService} for error reporting
* @see {@link MemoryCacheService} for caching
*/
export function getAppServices(): AppServices {
// Return cached instance if available
if (singleton) return singleton;
// Create logger first to log initialization
const logger =
typeof window === "undefined"
? new PinoLoggerService("server")
: new NoopLoggerService();
// Log initialization
if (typeof window === "undefined") {
// Server-side
logger.info("Initializing server application services", {
environment: getMaskedConfig(),
timestamp: new Date().toISOString(),
});
} else {
// Client-side
logger.info("Initializing client application services", {
environment: getMaskedConfig(),
timestamp: new Date().toISOString(),
});
}
// Determine which services to enable based on environment variables
const umamiEnabled = config.analytics.umami.enabled;
const sentryEnabled = config.errors.glitchtip.enabled;
logger.info("Service configuration", {
umamiEnabled,
sentryEnabled,
isServer: typeof window === "undefined",
});
// Create analytics service (Umami or no-op)
// Use dynamic import to avoid importing server-only code in client components
const analytics = umamiEnabled
? (() => {
const { UmamiAnalyticsService } =
// eslint-disable-next-line @typescript-eslint/no-require-imports
require("./analytics/umami-analytics-service");
return new UmamiAnalyticsService({ enabled: true });
})()
: new NoopAnalyticsService();
if (umamiEnabled) {
logger.info("Umami analytics service initialized");
} else {
logger.info("Noop analytics service initialized (analytics disabled)");
}
// Create error reporting service (GlitchTip/Sentry or no-op)
const errors = sentryEnabled
? new GlitchtipErrorReportingService({ enabled: true })
: new NoopErrorReportingService();
if (sentryEnabled) {
logger.info(
`GlitchTip error reporting service initialized (${typeof window === "undefined" ? "server" : "client"})`,
);
} else {
logger.info(
"Noop error reporting service initialized (error reporting disabled)",
);
}
// IMPORTANT: This module is imported by client components.
// Do not import Node-only modules (like the `redis` client) here.
// Use [`getServerAppServices()`](lib/services/create-services.server.ts:1) on the server.
const cache = new MemoryCacheService();
logger.info("Memory cache service initialized");
logger.info("Pino logger service initialized", {
name: typeof window === "undefined" ? "server" : "client",
level: config.logging.level,
});
// Create and cache the singleton
const notifications = new NoopNotificationService();
singleton = new AppServices(analytics, errors, cache, logger, notifications);
logger.info("All application services initialized successfully");
return singleton;
}

View File

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

View File

@@ -1,48 +1,77 @@
import * as Sentry from "@sentry/nextjs";
import type { ErrorReportingService } from "./error-reporting-service";
import type {
ErrorReportingLevel,
ErrorReportingService,
ErrorReportingUser,
} from "./error-reporting-service";
import type { NotificationService } from "../notifications/notification-service";
export interface GlitchtipConfig {
enabled: boolean;
}
type SentryLike = typeof Sentry;
export type GlitchtipErrorReportingServiceOptions = {
enabled: boolean;
};
// GlitchTip speaks the Sentry protocol; @sentry/nextjs can send to GlitchTip via DSN.
export class GlitchtipErrorReportingService implements ErrorReportingService {
constructor(
private readonly config: GlitchtipConfig,
private readonly options: GlitchtipErrorReportingServiceOptions,
private readonly notifications?: NotificationService,
private readonly sentry: SentryLike = Sentry,
) {}
captureException(error: unknown, context?: Record<string, unknown>) {
if (!this.config.enabled) return;
Sentry.withScope((scope) => {
if (context) {
scope.setExtras(context);
}
Sentry.captureException(error);
});
async captureException(error: unknown, context?: Record<string, unknown>) {
if (!this.options.enabled) return undefined;
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const result = this.sentry.captureException(error, context as any) as any;
// Send to Gotify if it's considered critical or if we just want all exceptions there
// For now, let's send all exceptions to Gotify as requested "notify me via gotify about critical error messages"
// We'll treat all captureException calls as potentially critical or at least noteworthy
if (this.notifications) {
this.notifications
.notify({
title: "🚨 Exception Captured",
message: error instanceof Error ? error.message : String(error),
priority: 10,
})
.catch((err) =>
console.error("Failed to send notification for exception", err),
);
const errorMessage =
error instanceof Error ? error.message : String(error);
const contextStr = context
? `\nContext: ${JSON.stringify(context, null, 2)}`
: "";
await this.notifications.notify({
title: "🔥 Critical Error Captured",
message: `Error: ${errorMessage}${contextStr}`,
priority: 7,
});
}
return result;
}
captureMessage(message: string, context?: Record<string, unknown>) {
if (!this.config.enabled) return;
captureMessage(message: string, level: ErrorReportingLevel = "error") {
if (!this.options.enabled) return undefined;
// eslint-disable-next-line @typescript-eslint/no-explicit-any
return this.sentry.captureMessage(message, level as any) as any;
}
Sentry.withScope((scope) => {
setUser(user: ErrorReportingUser | null) {
if (!this.options.enabled) return;
// eslint-disable-next-line @typescript-eslint/no-explicit-any
this.sentry.setUser(user as any);
}
setTag(key: string, value: string) {
if (!this.options.enabled) return;
this.sentry.setTag(key, value);
}
withScope<T>(fn: () => T, context?: Record<string, unknown>) {
if (!this.options.enabled) return fn();
return this.sentry.withScope((scope) => {
if (context) {
scope.setExtras(context);
for (const [key, value] of Object.entries(context)) {
scope.setExtra(key, value);
}
}
Sentry.captureMessage(message);
return fn();
});
}
}

View File

@@ -1,6 +1,23 @@
import type { ErrorReportingService } from "./error-reporting-service";
/* eslint-disable @typescript-eslint/no-unused-vars */
import type {
ErrorReportingLevel,
ErrorReportingService,
ErrorReportingUser,
} from "./error-reporting-service";
export class NoopErrorReportingService implements ErrorReportingService {
captureException() {}
captureMessage() {}
async captureException(_error: unknown, _context?: Record<string, unknown>) {
return undefined;
}
async captureMessage(_message: string, _level?: ErrorReportingLevel) {
return undefined;
}
setUser(_user: ErrorReportingUser | null) {}
setTag(_key: string, _value: string) {}
withScope<T>(fn: () => T, _context?: Record<string, unknown>) {
return fn();
}
}

View File

@@ -1,7 +1,11 @@
export type LogLevel = "trace" | "debug" | "info" | "warn" | "error" | "fatal";
export interface LoggerService {
debug(message: string, context?: Record<string, unknown>): void;
info(message: string, context?: Record<string, unknown>): void;
warn(message: string, context?: Record<string, unknown>): void;
error(message: string, context?: Record<string, unknown>): void;
child(context: Record<string, unknown>): LoggerService;
trace(msg: string, ...args: unknown[]): void;
debug(msg: string, ...args: unknown[]): void;
info(msg: string, ...args: unknown[]): void;
warn(msg: string, ...args: unknown[]): void;
error(msg: string, ...args: unknown[]): void;
fatal(msg: string, ...args: unknown[]): void;
child(bindings: Record<string, unknown>): LoggerService;
}

View File

@@ -0,0 +1,13 @@
import type { LoggerService } from "./logger-service";
export class NoopLoggerService implements LoggerService {
trace() {}
debug() {}
info() {}
warn() {}
error() {}
fatal() {}
child() {
return this;
}
}

View File

@@ -1,14 +1,17 @@
import { pino, type Logger as PinoLogger } from "pino";
import pino, { Logger as PinoLogger } from "pino";
import type { LoggerService } from "./logger-service";
import { config } from "../../config";
export class PinoLoggerService implements LoggerService {
private logger: PinoLogger;
private readonly logger: PinoLogger;
constructor(name?: string, parent?: PinoLogger) {
if (parent) {
this.logger = parent.child({ name });
} else {
// In Next.js, especially in the Edge runtime or during instrumentation,
// pino transports (which use worker threads) can cause issues.
// We disable transport in production and during instrumentation.
const useTransport =
config.isDevelopment && typeof window === "undefined";
@@ -27,30 +30,71 @@ export class PinoLoggerService implements LoggerService {
}
}
debug(message: string, context?: Record<string, unknown>) {
if (context) this.logger.debug(context, message);
else this.logger.debug(message);
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);
}
}
info(message: string, context?: Record<string, unknown>) {
if (context) this.logger.info(context, message);
else this.logger.info(message);
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);
}
}
warn(message: string, context?: Record<string, unknown>) {
if (context) this.logger.warn(context, message);
else this.logger.warn(message);
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);
}
}
error(message: string, context?: Record<string, unknown>) {
if (context) this.logger.error(context, message);
else this.logger.error(message);
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);
}
}
child(context: Record<string, unknown>): LoggerService {
const childPino = this.logger.child(context);
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);
}
}
child(bindings: Record<string, unknown>): LoggerService {
const childPino = this.logger.child(bindings);
const service = new PinoLoggerService();
service.logger = childPino;
// eslint-disable-next-line @typescript-eslint/no-explicit-any
(service as any).logger = childPino;
return service;
}
}

View File

@@ -1,5 +1,5 @@
import type {
NotificationMessage,
import {
NotificationOptions,
NotificationService,
} from "./notification-service";
@@ -10,35 +10,44 @@ export interface GotifyConfig {
}
export class GotifyNotificationService implements NotificationService {
constructor(private readonly config: GotifyConfig) {}
constructor(private config: GotifyConfig) {}
async notify(message: NotificationMessage): Promise<void> {
async notify(options: NotificationOptions): Promise<void> {
if (!this.config.enabled) return;
try {
const response = await fetch(
`${this.config.url}/message?token=${this.config.token}`,
{
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify({
title: message.title,
message: message.message,
priority: message.priority ?? 5,
}),
const { title, message, priority = 4 } = options;
const url = new URL("message", this.config.url);
url.searchParams.set("token", this.config.token);
const response = await fetch(url.toString(), {
method: "POST",
headers: {
"Content-Type": "application/json",
},
);
body: JSON.stringify({
title,
message,
priority,
}),
});
if (!response.ok) {
console.error(
"Failed to send Gotify notification",
await response.text(),
);
const errorText = await response.text();
console.error("Gotify notification failed:", {
status: response.status,
error: errorText,
});
}
} catch (error) {
console.error("Error sending Gotify notification", error);
console.error("Gotify notification error:", error);
}
}
}
export class NoopNotificationService implements NotificationService {
// eslint-disable-next-line @typescript-eslint/no-unused-vars
async notify(_options: NotificationOptions): Promise<void> {
// Do nothing
}
}

View File

@@ -1,5 +0,0 @@
import type { NotificationService } from "./notification-service";
export class NoopNotificationService implements NotificationService {
async notify() {}
}

View File

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

View File

@@ -1,7 +1,7 @@
{
"Index": {
"hero": {
"tag": "Engineering Excellence",
"tag": "Technische Beratung",
"title": "Spezialisierter Partner für Energiekabelprojekte",
"titleHighlight": "Energiekabelprojekte",
"subtitle": "Herstellerneutrale technische Beratung für Ihre Projekte in Mittel- und Hochspannungsnetzen bis zu 110 kV.",
@@ -31,7 +31,7 @@
"expertise": {
"tag": "Expertise",
"title": "Anwendungen & Zielgruppen",
"description": "Wir unterstützen Akteure der Energiewende bei der Realisierung komplexer Kabelprojekte mit höchster Präzision.",
"description": "Wir unterstützen Sie bei der Realisierung Ihrer Kabelprojekte.",
"groups": [
"Energieversorger",
"Ingenieurbüros",
@@ -83,16 +83,16 @@
"datenschutz": "Datenschutz",
"agb": "AGB",
"rights": "Alle Rechte vorbehalten.",
"madeWith": "Made with",
"precision": "precision",
"inGermany": "in Germany"
"madeWith": "Entwickelt mit",
"precision": "Präzision",
"inGermany": "in Deutschland"
}
},
"About": {
"hero": {
"tagline": "Über uns",
"title": "Wir gestalten die Infrastructure der Zukunft",
"subtitle": "MB Grid Solution steht für technische Exzellenz in der Energiekabeltechnologie. Wir verstehen uns als Ihr technischer Lotse."
"title": "Zuverlässige Begleitung für Ihre Netzinfrastruktur",
"subtitle": "Herstellerneutrale Beratung in der Energiekabeltechnologie. Wir verstehen uns als Ihr technischer Lotse."
},
"intro": {
"p1": "Unsere Wurzeln liegen in der tiefen praktischen Erfahrung unserer technischen Berater und unserer Netzwerke im globalem Kabelmarkt. Wir vereinen Tradition mit modernster Innovation, um zuverlässige Energielösungen für Projekte bis 110 kV zu realisieren.",

View File

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

2
next-env.d.ts vendored
View File

@@ -1,6 +1,6 @@
/// <reference types="next" />
/// <reference types="next/image-types/global" />
import "./.next/dev/types/routes.d.ts";
import "./.next/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,6 +1,36 @@
import withMintelConfig from "@mintel/next-config";
/** @type {import('next').NextConfig} */
const nextConfig = {};
const nextConfig = {
async rewrites() {
const umamiUrl =
process.env.UMAMI_API_ENDPOINT ||
process.env.UMAMI_SCRIPT_URL ||
process.env.NEXT_PUBLIC_UMAMI_SCRIPT_URL ||
"https://analytics.infra.mintel.me";
const glitchtipUrl = process.env.SENTRY_DSN
? new URL(process.env.SENTRY_DSN).origin
: "https://errors.infra.mintel.me";
return [
{
source: "/stats/:path*",
destination: `${umamiUrl}/:path*`,
},
{
source: "/:locale(de)/stats/:path*",
destination: `${umamiUrl}/:path*`,
},
{
source: "/errors/:path*",
destination: `${glitchtipUrl}/:path*`,
},
{
source: "/:locale(de)/errors/:path*",
destination: `${glitchtipUrl}/:path*`,
},
];
},
};
export default withMintelConfig(nextConfig);

View File

@@ -25,8 +25,8 @@
"license": "ISC",
"description": "",
"dependencies": {
"@mintel/next-config": "^1.1.13",
"@mintel/next-utils": "^1.1.13",
"@mintel/next-config": "^1.6.0",
"@mintel/next-utils": "^1.7.15",
"@sentry/nextjs": "^10.38.0",
"framer-motion": "^12.29.2",
"lucide-react": "^0.562.0",
@@ -36,16 +36,16 @@
"pino": "^10.3.0",
"react": "^19.2.4",
"react-dom": "^19.2.4",
"zod": "^4.3.6"
"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.6.0",
"@mintel/eslint-config": "^1.6.0",
"@mintel/husky-config": "^1.6.0",
"@mintel/tsconfig": "^1.6.0",
"@tailwindcss/postcss": "^4.1.18",
"@testing-library/jest-dom": "^6.9.1",
"@testing-library/react": "^16.3.2",
@@ -57,6 +57,7 @@
"autoprefixer": "^10.4.23",
"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",

1527
pnpm-lock.yaml generated

File diff suppressed because it is too large Load Diff

7
pnpm-workspace.yaml Normal file
View File

@@ -0,0 +1,7 @@
onlyBuiltDependencies:
- '@parcel/watcher'
- '@sentry/cli'
- '@swc/core'
- esbuild
- sharp
- unrs-resolver

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

View File

@@ -25,7 +25,7 @@ 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}-prod"; ENV_FILE=".env.prod" ;;
production) PROJECT_NAME="${PRJ_ID}-production"; ENV_FILE=".env.prod" ;;
*) echo "❌ Invalid environment: $ENV"; exit 1 ;;
esac
@@ -35,8 +35,21 @@ DB_NAME="directus"
echo "🔍 Detecting local database..."
LOCAL_DB_CONTAINER=$(docker compose ps -q directus-db)
if [ -z "$LOCAL_DB_CONTAINER" ]; then
echo "❌ Local directus-db container not found. Is it running? (npm run dev)"
# 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

13
sentry.client.config.ts Normal file
View File

@@ -0,0 +1,13 @@
import * as Sentry from "@sentry/nextjs";
import { config } from "./lib/config";
if (config.errors.glitchtip.enabled) {
Sentry.init({
dsn: config.errors.glitchtip.dsn,
tracesSampleRate: 0.1,
debug: config.isDevelopment,
environment: config.target || "production",
// Use the proxy path defined in config
tunnel: config.errors.glitchtip.proxyPath,
});
}

16
sentry.edge.config.ts Normal file
View File

@@ -0,0 +1,16 @@
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: 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)");
}

11
sentry.server.config.ts Normal file
View File

@@ -0,0 +1,11 @@
import * as Sentry from "@sentry/nextjs";
import { config } from "./lib/config";
if (config.errors.glitchtip.enabled) {
Sentry.init({
dsn: config.errors.glitchtip.dsn,
tracesSampleRate: 1.0,
debug: config.isDevelopment,
environment: config.target || "production",
});
}

View File

@@ -1,97 +1,129 @@
import { render, screen, fireEvent, waitFor } from '@testing-library/react'
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'
import Contact from '../app/kontakt/page'
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";
// Mock fetch
const fetchMock = vi.fn()
global.fetch = fetchMock
// 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
const alertMock = vi.fn();
global.alert = alertMock;
describe('Contact Page', () => {
// 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(() => {
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({
vi.clearAllMocks();
fetchMock.mockReset();
fetchMock.mockResolvedValue({
ok: true,
json: async () => ({ success: true }),
})
});
});
render(<Contact />)
it("renders the contact form correctly", () => {
renderContact();
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.' } })
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();
});
fireEvent.click(screen.getByRole('button', { name: /Nachricht senden/i }))
it("submits the form successfully", async () => {
renderContact();
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: ''
}),
}))
})
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." },
});
expect(screen.getByText(/Nachricht gesendet/i)).toBeInTheDocument()
expect(screen.getByText(/Vielen Dank für Ihre Anfrage/i)).toBeInTheDocument()
})
const form = screen.getByRole("form");
fireEvent.submit(form);
it('handles submission errors', async () => {
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' }),
})
json: async () => ({ error: "Server error" }),
});
render(<Contact />)
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.' } })
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." },
});
fireEvent.click(screen.getByRole('button', { name: /Nachricht senden/i }))
const form = screen.getByRole("form");
fireEvent.submit(form);
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.')
})
})
})
expect(await screen.findByText(/Server error/i)).toBeInTheDocument();
});
});

View File

@@ -1,25 +1,37 @@
import { render, screen } from '@testing-library/react'
import { describe, it, expect } from 'vitest'
import Home from '../app/page'
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";
describe('Home Page', () => {
it('renders the hero section with correct title', () => {
render(<Home />)
expect(screen.getByText(/Spezialisierter Partner für Energiekabelprojekte/i)).toBeInTheDocument()
})
const renderHome = () => {
return render(
<NextIntlClientProvider locale="de" messages={messages}>
<Home />
</NextIntlClientProvider>,
);
};
it('contains the CTA button', () => {
render(<Home />)
const ctaButton = screen.getByRole('link', { name: /Projekt anfragen/i })
expect(ctaButton).toBeInTheDocument()
expect(ctaButton).toHaveAttribute('href', '/kontakt')
})
describe("Home Page", () => {
it("renders the hero section with correct title", () => {
renderHome();
expect(
screen.getByRole("heading", { name: /Spezialisierter Partner/i }),
).toBeInTheDocument();
});
it('renders the portfolio section', () => {
render(<Home />)
expect(screen.getByText(/Unsere Leistungen/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 = screen.getAllByText(/Technische Beratung/i)
expect(elements.length).toBeGreaterThan(0)
})
})
const elements = await screen.findAllByText(/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(),
}),
}))

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

@@ -13,5 +13,5 @@
".next/types/**/*.ts",
".next/dev/types/**/*.ts"
],
"exclude": ["node_modules"]
"exclude": ["node_modules", "tests", "tests_bak"]
}

20
vitest.config.mts Normal file
View File

@@ -0,0 +1,20 @@
import { defineConfig } from 'vitest/config';
import react from '@vitejs/plugin-react';
export default defineConfig({
plugins: [react()],
test: {
environment: 'happy-dom',
globals: true,
setupFiles: ['./tests/setup.tsx'],
alias: {
'next/server': 'next/server.js',
},
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, './'),
},
},
})