59 Commits

Author SHA1 Message Date
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
29d474a102 fix: traefik issues
All checks were successful
Build & Deploy / 🔍 Prepare Environment (push) Successful in 8s
Build & Deploy / 🧪 QA (push) Successful in 1m13s
Build & Deploy / 🚀 Deploy (push) Successful in 9s
Build & Deploy / 🏗️ Build (push) Successful in 2m5s
Build & Deploy / 🔔 Notifications (push) Successful in 2s
2026-02-06 23:15:22 +01:00
a31202f63b refactor: use explicit Git reference variables for more robust deployment target and image tag determination in Gitea workflow.
All checks were successful
Build & Deploy / 🔍 Prepare Environment (push) Successful in 5s
Build & Deploy / 🧪 QA (push) Successful in 1m33s
Build & Deploy / 🏗️ Build (push) Successful in 2m7s
Build & Deploy / 🚀 Deploy (push) Successful in 9s
Build & Deploy / 🔔 Notifications (push) Successful in 2s
2026-02-06 22:51:40 +01:00
0afd6bbb60 fix: logo position
All checks were successful
Build & Deploy / 🔍 Prepare Environment (push) Successful in 7s
Build & Deploy / 🧪 QA (push) Successful in 1m32s
Build & Deploy / 🏗️ Build (push) Successful in 4m44s
Build & Deploy / 🚀 Deploy (push) Successful in 13s
Build & Deploy / 🔔 Notifications (push) Successful in 2s
2026-02-06 21:42:43 +01:00
2c647f0284 chore: directus sync
All checks were successful
Build & Deploy / 🔍 Prepare Environment (push) Successful in 4s
Build & Deploy / 🧪 QA (push) Successful in 1m57s
Build & Deploy / 🏗️ Build (push) Successful in 2m3s
Build & Deploy / 🚀 Deploy (push) Successful in 33s
Build & Deploy / 🔔 Notifications (push) Successful in 2s
2026-02-06 21:35:00 +01:00
d9ff6d640d feat: Configure Traefik to use the infra network for services, add an internal Directus URL, and enhance Directus and Gatekeeper configurations.
All checks were successful
Build & Deploy / 🔍 Prepare Environment (push) Successful in 9s
Build & Deploy / 🧪 QA (push) Successful in 1m13s
Build & Deploy / 🏗️ Build (push) Successful in 4m51s
Build & Deploy / 🚀 Deploy (push) Successful in 10s
Build & Deploy / 🔔 Notifications (push) Successful in 1s
2026-02-06 19:23:35 +01:00
8ab9ec7d1f chore: bootstrap command 2026-02-06 19:11:19 +01:00
0cc67d54ef refactor: overhaul Directus sync script with schema wiping and restart, update branding, and rename CMS scripts. 2026-02-06 19:09:56 +01:00
cbb95a38cf feat: Introduce COOKIE_DOMAIN and NEXT_PUBLIC_BASE_URL environment variables for gatekeeper service configuration.
All checks were successful
Build & Deploy / 🔍 Prepare Environment (push) Successful in 9s
Build & Deploy / 🧪 QA (push) Successful in 1m17s
Build & Deploy / 🏗️ Build (push) Successful in 5m1s
Build & Deploy / 🚀 Deploy (push) Successful in 10s
Build & Deploy / 🔔 Notifications (push) Successful in 2s
2026-02-06 18:05:04 +01:00
5b163d6d74 feat: Enable dynamic app environment variable configuration via workflow and docker-compose.
All checks were successful
Build & Deploy / 🔍 Prepare Environment (push) Successful in 9s
Build & Deploy / 🧪 QA (push) Successful in 1m27s
Build & Deploy / 🏗️ Build (push) Successful in 2m6s
Build & Deploy / 🚀 Deploy (push) Successful in 23s
Build & Deploy / 🔔 Notifications (push) Successful in 2s
2026-02-06 17:26:28 +01:00
f6e774b5c9 feat: Add GATEKEEPER_PASSWORD environment variable for authentication.
All checks were successful
Build & Deploy / 🔍 Prepare Environment (push) Successful in 4s
Build & Deploy / 🧪 QA (push) Successful in 1m13s
Build & Deploy / 🏗️ Build (push) Successful in 5m2s
Build & Deploy / 🚀 Deploy (push) Successful in 12s
Build & Deploy / 🔔 Notifications (push) Successful in 2s
2026-02-06 17:07:25 +01:00
613c8b1645 fix: Streamline variable interpolation in deploy workflow and Traefik labels by removing unnecessary quoting and default fallbacks.
All checks were successful
Build & Deploy / 🔍 Prepare Environment (push) Successful in 4s
Build & Deploy / 🧪 QA (push) Successful in 1m14s
Build & Deploy / 🏗️ Build (push) Successful in 2m5s
Build & Deploy / 🚀 Deploy (push) Successful in 28s
Build & Deploy / 🔔 Notifications (push) Successful in 1s
2026-02-06 16:32:21 +01:00
9e1aae5d76 feat: Add dedicated subdomain routing for Gatekeeper and update its service alias for forward authentication middleware.
All checks were successful
Build & Deploy / 🔍 Prepare Environment (push) Successful in 5s
Build & Deploy / 🧪 QA (push) Successful in 1m14s
Build & Deploy / 🏗️ Build (push) Successful in 4m34s
Build & Deploy / 🚀 Deploy (push) Successful in 15s
Build & Deploy / 🔔 Notifications (push) Successful in 1s
2026-02-06 16:11:39 +01:00
f1e3ad1357 chore: npm update
All checks were successful
Build & Deploy / 🔍 Prepare Environment (push) Successful in 7s
Build & Deploy / 🧪 QA (push) Successful in 1m12s
Build & Deploy / 🏗️ Build (push) Successful in 4m41s
Build & Deploy / 🚀 Deploy (push) Successful in 15s
Build & Deploy / 🔔 Notifications (push) Successful in 2s
2026-02-06 15:52:49 +01:00
39b044c2c2 chore: Use caret version specifiers for Mintel dependencies.
Some checks failed
Build & Deploy / 🔍 Prepare Environment (push) Successful in 7s
Build & Deploy / 🧪 QA (push) Failing after 10s
Build & Deploy / 🏗️ Build (push) Successful in 5m32s
Build & Deploy / 🚀 Deploy (push) Has been skipped
Build & Deploy / 🔔 Notifications (push) Successful in 2s
2026-02-06 15:28:07 +01:00
c0c73315c8 chore(deploy): switch main branch to testing domain and add staging tag logic (aligned with klz-2026)
All checks were successful
Build & Deploy / 🔍 Prepare Environment (push) Successful in 5s
Build & Deploy / 🧪 QA (push) Successful in 1m29s
Build & Deploy / 🏗️ Build (push) Successful in 4m45s
Build & Deploy / 🚀 Deploy (push) Successful in 20s
Build & Deploy / 🔔 Notifications (push) Successful in 1s
2026-02-05 22:16:27 +01:00
72fbae0666 fix(deploy): remove redundant backticks from Traefik Host labels to fix double-quoting
All checks were successful
Build & Deploy / 🔍 Prepare Environment (push) Successful in 5s
Build & Deploy / 🧪 QA (push) Successful in 1m34s
Build & Deploy / 🏗️ Build (push) Successful in 2m6s
Build & Deploy / 🚀 Deploy (push) Successful in 14s
Build & Deploy / 🔔 Notifications (push) Successful in 2s
2026-02-05 22:10:04 +01:00
3ed32210ad fix(ci): quote .env heredoc and fix docker-compose extension
All checks were successful
Build & Deploy / 🔍 Prepare Environment (push) Successful in 4s
Build & Deploy / 🧪 QA (push) Successful in 1m56s
Build & Deploy / 🏗️ Build (push) Successful in 2m5s
Build & Deploy / 🚀 Deploy (push) Successful in 16s
Build & Deploy / 🔔 Notifications (push) Successful in 1s
2026-02-05 19:23:46 +01:00
f2366b5a38 fix(ci): refactor SSH deployment to manual ssh/scp (aligned with klz-2026)
Some checks failed
Build & Deploy / 🔍 Prepare Environment (push) Successful in 5s
Build & Deploy / 🧪 QA (push) Successful in 1m13s
Build & Deploy / 🏗️ Build (push) Successful in 4m47s
Build & Deploy / 🚀 Deploy (push) Failing after 6s
Build & Deploy / 🔔 Notifications (push) Successful in 2s
2026-02-05 14:19:57 +01:00
dccf6ad2ce fix(ci): pass NEXT_PUBLIC_TARGET to docker build and suppress sentry warnings
Some checks failed
Build & Deploy / 🔍 Prepare Environment (push) Successful in 4s
Build & Deploy / 🧪 QA (push) Successful in 1m12s
Build & Deploy / 🏗️ Build (push) Successful in 4m38s
Build & Deploy / 🚀 Deploy (push) Failing after 3s
Build & Deploy / 🔔 Notifications (push) Successful in 1s
2026-02-05 13:00:21 +01:00
788c9ca7ac fix(ci): restore Next 16 and isolate Docker build from base image workspace
Some checks failed
Build & Deploy / 🔍 Prepare Environment (push) Successful in 7s
Build & Deploy / 🧪 QA (push) Successful in 1m30s
Build & Deploy / 🏗️ Build (push) Failing after 1m58s
Build & Deploy / 🚀 Deploy (push) Has been skipped
Build & Deploy / 🔔 Notifications (push) Successful in 2s
2026-02-05 12:53:18 +01:00
34474de163 chore: delete pnpm-workspace.yaml.
Some checks failed
Build & Deploy / 🔍 Prepare Environment (push) Successful in 5s
Build & Deploy / 🧪 QA (push) Successful in 1m59s
Build & Deploy / 🏗️ Build (push) Failing after 2m42s
Build & Deploy / 🚀 Deploy (push) Has been skipped
Build & Deploy / 🔔 Notifications (push) Successful in 1s
2026-02-05 12:04:31 +01:00
12646e45e4 fix(ci): regenerate lockfile and relax frozen-lockfile for Docker 2026-02-05 12:03:46 +01:00
b25299a3a8 chore: Remove /dev from Next.js routes types import path in next-env.d.ts.
Some checks failed
Build & Deploy / 🔍 Prepare Environment (push) Successful in 8s
Build & Deploy / 🏗️ Build (push) Failing after 53s
Build & Deploy / 🧪 QA (push) Successful in 1m12s
Build & Deploy / 🚀 Deploy (push) Has been skipped
Build & Deploy / 🔔 Notifications (push) Successful in 2s
2026-02-05 11:58:26 +01:00
aa9b280f5c fix(ci): fix docker build by adding pnpm install in Dockerfile
Some checks failed
Build & Deploy / 🔍 Prepare Environment (push) Successful in 8s
Build & Deploy / 🏗️ Build (push) Failing after 39s
Build & Deploy / 🧪 QA (push) Successful in 1m30s
Build & Deploy / 🚀 Deploy (push) Has been skipped
Build & Deploy / 🔔 Notifications (push) Successful in 1s
2026-02-05 11:53:29 +01:00
2ec9a29565 fix(ci): align registry secret names with Mintel standard (USER/PASS)
Some checks failed
Build & Deploy / 🔍 Prepare Environment (push) Successful in 4s
Build & Deploy / 🏗️ Build (push) Failing after 42s
Build & Deploy / 🧪 QA (push) Successful in 1m30s
Build & Deploy / 🚀 Deploy (push) Has been skipped
Build & Deploy / 🔔 Notifications (push) Successful in 2s
2026-02-05 11:50:56 +01:00
20cafce97d fix(ci): fix eslint compatibility, downgrade to v8, and fix lint errors
Some checks failed
Build & Deploy / 🔍 Prepare Environment (push) Successful in 8s
Build & Deploy / 🏗️ Build (push) Failing after 12s
Build & Deploy / 🧪 QA (push) Successful in 1m13s
Build & Deploy / 🚀 Deploy (push) Has been skipped
Build & Deploy / 🔔 Notifications (push) Successful in 2s
2026-02-05 11:15:41 +01:00
31f931f7ce fix(ci): use eslint directly and fix lint step environment
Some checks failed
Build & Deploy / 🔍 Prepare Environment (push) Successful in 4s
Build & Deploy / 🏗️ Build (push) Failing after 9s
Build & Deploy / 🧪 QA (push) Failing after 43s
Build & Deploy / 🚀 Deploy (push) Has been skipped
Build & Deploy / 🔔 Notifications (push) Successful in 2s
2026-02-05 11:10:43 +01:00
e415b5118b fix(ci): use corepack enable for pnpm to avoid hang in runner
Some checks failed
Build & Deploy / 🔍 Prepare Environment (push) Successful in 4s
Build & Deploy / 🏗️ Build (push) Failing after 10s
Build & Deploy / 🧪 QA (push) Failing after 33s
Build & Deploy / 🚀 Deploy (push) Has been skipped
Build & Deploy / 🔔 Notifications (push) Successful in 1s
2026-02-05 11:08:34 +01:00
84aef6b860 fix(ci): use linux/arm64 platform to match infra
Some checks failed
Build & Deploy / 🔍 Prepare Environment (push) Successful in 4s
Build & Deploy / 🧪 QA (push) Failing after 6s
Build & Deploy / 🏗️ Build (push) Failing after 15s
Build & Deploy / 🚀 Deploy (push) Has been skipped
Build & Deploy / 🔔 Notifications (push) Successful in 2s
2026-02-05 10:57:04 +01:00
195932dde4 fix(ci): explicitly use shell: bash for all steps 2026-02-05 10:56:56 +01:00
977773fe94 fix(ci): add debug info and make maintenance optional 2026-02-05 10:56:42 +01:00
a5e2e5a2db fix: Align CI workflow with Mintel standards (add container image & buildx)
All checks were successful
Build & Deploy / 🔍 Prepare Environment (push) Successful in 31s
Build & Deploy / 🧪 QA (push) Has been skipped
Build & Deploy / 🏗️ Build (push) Has been skipped
Build & Deploy / 🚀 Deploy (push) Has been skipped
Build & Deploy / 🔔 Notifications (push) Successful in 1s
2026-02-05 10:45:30 +01:00
5559a36de0 feat: Add commitlint dependencies and simplify lint-staged configuration by removing the buildEslintCommand.
Some checks failed
Build & Deploy / 🔍 Prepare Environment (push) Successful in 4s
Build & Deploy / 🧪 QA (push) Failing after 5s
Build & Deploy / 🏗️ Build (push) Failing after 26s
Build & Deploy / 🚀 Deploy (push) Has been skipped
Build & Deploy / 🔔 Notifications (push) Successful in 1s
2026-02-05 01:42:40 +01:00
e80140f7cf feat: Integrate Directus CMS, add i18n with next-intl, and configure project tooling with pnpm, husky, and commitlint.** 2026-02-05 01:18:06 +01:00
765cfd4c69 fix header reveal
All checks were successful
Build & Deploy MB Grid Solutions / build-and-deploy (push) Successful in 1m29s
2026-01-29 17:23:32 +01:00
0eaa47e2c6 input forms
All checks were successful
Build & Deploy MB Grid Solutions / build-and-deploy (push) Successful in 1m38s
2026-01-29 17:19:54 +01:00
25759f3d4a performance
All checks were successful
Build & Deploy MB Grid Solutions / build-and-deploy (push) Successful in 1m38s
2026-01-29 17:08:45 +01:00
e033fd6290 mobile
All checks were successful
Build & Deploy MB Grid Solutions / build-and-deploy (push) Successful in 1m49s
2026-01-29 17:00:06 +01:00
44d4ac38b6 terms
All checks were successful
Build & Deploy MB Grid Solutions / build-and-deploy (push) Successful in 1m42s
2026-01-29 16:38:33 +01:00
d1c235ce39 terms
All checks were successful
Build & Deploy MB Grid Solutions / build-and-deploy (push) Successful in 1m38s
2026-01-29 16:31:16 +01:00
6889db8ad5 build
All checks were successful
Build & Deploy MB Grid Solutions / build-and-deploy (push) Successful in 1m38s
2026-01-29 16:15:03 +01:00
93 changed files with 15024 additions and 6145 deletions

View File

@@ -1,7 +1,84 @@
# ==============================================================================
# PROJECT SETTINGS
# ==============================================================================
PROJECT_NAME=mb-grid-solutions.com
PROJECT_COLOR=#82ed20
# ==============================================================================
# HOST CONFIGURATION (LOCAL DEV)
# ==============================================================================
# These are used by Traefik in local development.
# In CI/CD, these are automatically set by the deployment pipeline.
TRAEFIK_HOST=mb-grid-solutions.localhost
DIRECTUS_HOST=cms.mb-grid-solutions.localhost
# ==============================================================================
# NEXT.JS SETTINGS
# ==============================================================================
# The public URL of the frontend. Used for absolute links and meta tags.
NEXT_PUBLIC_BASE_URL=http://mb-grid-solutions.localhost
# ==============================================================================
# DIRECTUS CMS SETTINGS
# ==============================================================================
# Public URL of the CMS (must be accessible from the browser)
# Automatisierung: Wird in CI/CD automatisch basierend auf der Umgebung gesetzt.
DIRECTUS_URL=http://cms.mb-grid-solutions.localhost
# CMS Authentication - Create a Static Token in Directus User Settings
# Automatisierung: Wird in CI/CD aus den Gitea Secrets (DIRECTUS_API_TOKEN) gelesen.
# Smart Fallback: Wenn kein Token gesetzt ist, wird automatisch der Admin-Login verwendet.
DIRECTUS_API_TOKEN=
# Initial Setup (Admin User)
DIRECTUS_ADMIN_EMAIL=marc@mintel.me
DIRECTUS_ADMIN_PASSWORD=Tim300493.
# Database Settings (Local Docker)
DIRECTUS_DB_NAME=directus
DIRECTUS_DB_USER=directus
DIRECTUS_DB_PASSWORD=mintel-db-pass
# Security Keys (Generate random strings for production)
# Automatisierung: Werden in CI/CD aus Gitea Secrets gelesen.
# DIRECTUS_KEY=
# DIRECTUS_SECRET=
# ==============================================================================
# SMTP CONFIGURATION (CONTACT FORM)
# ==============================================================================
SMTP_HOST=smtp.example.com
SMTP_PORT=587
# SMTP_SECURE:
# - true: Use SSL/TLS (usually Port 465).
# - false: Use STARTTLS (usually Port 587) or no encryption.
SMTP_SECURE=false
SMTP_USER=user@example.com
SMTP_PASS=your_password
SMTP_FROM="MB Grid Solutions <noreply@mb-grid-solutions.com>"
# Comma-separated list of recipients for contact form submissions
CONTACT_RECIPIENT=info@mb-grid-solutions.com,admin@mb-grid-solutions.com
# ==============================================================================
# AUTHENTICATION (GATEKEEPER)
# ==============================================================================
GATEKEEPER_PASSWORD=lassmichrein
AUTH_COOKIE_NAME=mintel_gatekeeper_session
# ==============================================================================
# EXTERNAL SERVICES
# ==============================================================================
# Sentry / Glitchtip (Error Tracking)
SENTRY_DSN=
# Gotify (In-App Notifications)
# GOTIFY_URL=
# GOTIFY_TOKEN=
# Analytics (Umami)
UMAMI_WEBSITE_ID=
UMAMI_API_ENDPOINT=https://analytics.infra.mintel.me

3
.eslintrc.json Normal file
View File

@@ -0,0 +1,3 @@
{
"extends": ["next/core-web-vitals", "next/typescript"]
}

View File

@@ -1,280 +1,315 @@
name: Build & Deploy MB Grid Solutions
name: Build & Deploy
on:
push:
branches: [main]
branches:
- main
tags:
- 'v*'
workflow_dispatch:
concurrency:
group: ${{ github.workflow }}
cancel-in-progress: false
jobs:
build-and-deploy:
# ────────────────────────────────────────────────
# WICHTIG: Kein "docker" mehr sondern eines der neuen Labels
prepare:
name: 🔍 Prepare Environment
runs-on: docker
container:
image: catthehacker/ubuntu:act-latest
outputs:
target: ${{ steps.determine.outputs.target }}
image_tag: ${{ steps.determine.outputs.image_tag }}
env_file: ${{ steps.determine.outputs.env_file }}
traefik_host: ${{ steps.determine.outputs.traefik_host }}
next_public_base_url: ${{ steps.determine.outputs.next_public_base_url }}
directus_url: ${{ steps.determine.outputs.directus_url }}
directus_host: ${{ steps.determine.outputs.directus_host }}
gatekeeper_host: ${{ steps.determine.outputs.gatekeeper_host }}
traefik_rule: ${{ steps.determine.outputs.traefik_rule }}
gatekeeper_rule: ${{ steps.determine.outputs.gatekeeper_rule }}
traefik_middlewares: ${{ steps.determine.outputs.traefik_middlewares }}
project_name: ${{ steps.determine.outputs.project_name }}
steps:
# ═══════════════════════════════════════════════════════════════════════════════
# LOGGING: Workflow Start - Full Transparency
# ═══════════════════════════════════════════════════════════════════════════════
- name: 📋 Log Workflow Start
- name: 🔍 Debug Info
shell: bash
run: |
echo "╔══════════════════════════════════════════════════════════════════════════════╗"
echo "║ MB Grid Solutions Deployment Workflow Started ║"
echo "╚══════════════════════════════════════════════════════════════════════════════╝"
echo ""
echo "📋 Workflow Information:"
echo " • Repository: ${{ github.repository }}"
echo " • Branch: ${{ github.ref }}"
echo " • Commit: ${{ github.sha }}"
echo " • Actor: ${{ github.actor }}"
echo " • Run ID: ${{ github.run_id }}"
echo " • Timestamp: $(date -u +'%Y-%m-%d %H:%M:%S UTC')"
echo ""
echo "🔍 Environment Details:"
echo " • Runner OS: ${{ runner.os }}"
echo " • Workspace: ${{ github.workspace }}"
echo ""
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
- name: Checkout repository
uses: actions/checkout@v4
with:
fetch-depth: 2
# ═══════════════════════════════════════════════════════════════════════════════
# LOGGING: Registry Login Phase
# ═══════════════════════════════════════════════════════════════════════════════
- name: 🔐 Login to private registry
- name: 🔍 Determine Environment
id: determine
shell: bash
run: |
echo "╔══════════════════════════════════════════════════════════════════════════════╗"
echo "║ Step: Registry Login ║"
echo "╚══════════════════════════════════════════════════════════════════════════════╝"
echo ""
echo "🔐 Authenticating with private registry..."
echo " Registry: registry.infra.mintel.me"
echo " User: ${{ secrets.REGISTRY_USER != '' && '***' || 'NOT SET' }}"
echo ""
# Execute login with error handling
if echo "${{ secrets.REGISTRY_PASS }}" | docker login registry.infra.mintel.me -u "${{ secrets.REGISTRY_USER }}" --password-stdin 2>&1; then
echo "✅ Registry login successful"
else
echo "❌ Registry login failed"
exit 1
REF="${{ github.ref }}"
REF_NAME="${{ github.ref_name }}"
REF_TYPE="${{ github.ref_type }}"
SHORT_SHA=$(echo "${{ github.sha }}" | cut -c1-7)
DOMAIN_BASE="mb-grid-solutions.com"
PRJ_ID="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
echo ""
# ═══════════════════════════════════════════════════════════════════════════════
# LOGGING: Build Phase
# ═══════════════════════════════════════════════════════════════════════════════
- name: 🏗️ Build Docker image
run: |
echo "╔══════════════════════════════════════════════════════════════════════════════╗"
echo "║ Step: Build Docker Image ║"
echo "╚══════════════════════════════════════════════════════════════════════════════╝"
echo ""
echo "🏗️ Building Docker image with buildx..."
echo " Platform: linux/arm64"
echo " Target: registry.infra.mintel.me/mintel/mb-grid-solutions:latest"
echo ""
echo "⏱️ Build started at: $(date -u +'%Y-%m-%d %H:%M:%S UTC')"
echo ""
if [[ "$REF_TYPE" == "branch" && "$REF_NAME" == "main" ]]; then
TARGET="testing"
IMAGE_TAG="testing-${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
TARGET="production"
IMAGE_TAG="$REF_NAME"
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}"
else
TARGET="skip"
echo "Tag $REF_NAME did not match any environment pattern."
fi
else
TARGET="skip"
echo "Ref type $REF_TYPE is not handled for deployment."
fi
# Determine Rules based on target (if not skipped)
if [[ "$TARGET" != "skip" ]]; then
if [[ "$TARGET" == "production" ]]; then
TRAEFIK_RULE="Host(\`${DOMAIN_BASE}\`) || Host(\`www.${DOMAIN_BASE}\`)"
GATEKEEPER_RULE="(Host(\`${DOMAIN_BASE}\`) || Host(\`www.${DOMAIN_BASE}\`)) && PathPrefix(\`/gatekeeper\`) || Host(\`gatekeeper.${DOMAIN_BASE}\`)"
TRAEFIK_MIDDLEWARES="compress"
else
TRAEFIK_RULE="Host(\`${TRAEFIK_HOST}\`)"
GATEKEEPER_RULE="(Host(\`${TRAEFIK_HOST}\`) && PathPrefix(\`/gatekeeper\`)) || Host(\`gatekeeper.${TRAEFIK_HOST}\`)"
TRAEFIK_MIDDLEWARES="${PRJ_ID}-${TARGET}-auth"
fi
fi
echo "Target determined: $TARGET"
echo "Image tag: $IMAGE_TAG"
# Execute build with detailed logging
set -e
echo "target=$TARGET" >> "$GITHUB_OUTPUT"
echo "image_tag=$IMAGE_TAG" >> "$GITHUB_OUTPUT"
echo "env_file=$ENV_FILE" >> "$GITHUB_OUTPUT"
echo "traefik_host=$TRAEFIK_HOST" >> "$GITHUB_OUTPUT"
echo "traefik_rule=$TRAEFIK_RULE" >> "$GITHUB_OUTPUT"
echo "gatekeeper_rule=$GATEKEEPER_RULE" >> "$GITHUB_OUTPUT"
echo "traefik_middlewares=$TRAEFIK_MIDDLEWARES" >> "$GITHUB_OUTPUT"
echo "gatekeeper_host=$GATEKEEPER_HOST" >> "$GITHUB_OUTPUT"
echo "next_public_base_url=$NEXT_PUBLIC_BASE_URL" >> "$GITHUB_OUTPUT"
echo "directus_url=$DIRECTUS_URL" >> "$GITHUB_OUTPUT"
echo "directus_host=$DIRECTUS_HOST" >> "$GITHUB_OUTPUT"
echo "project_name=$PRJ_ID-$TARGET" >> "$GITHUB_OUTPUT"
qa:
name: 🧪 QA
needs: prepare
if: needs.prepare.outputs.target != 'skip'
runs-on: docker
container:
image: catthehacker/ubuntu:act-latest
steps:
- name: Checkout repository
uses: actions/checkout@v4
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: 20
- name: Install dependencies
shell: bash
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 }}
- name: 🏗️ Build Test
shell: bash
run: pnpm build
env:
NPM_TOKEN: ${{ secrets.NPM_TOKEN }}
NEXT_PUBLIC_BASE_URL: https://dummy.test
build:
name: 🏗️ Build
needs: prepare
if: needs.prepare.outputs.target != 'skip'
runs-on: docker
container:
image: catthehacker/ubuntu:act-latest
steps:
- name: Checkout repository
uses: actions/checkout@v4
- 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 NEXT_PUBLIC_BASE_URL="${{ secrets.NEXT_PUBLIC_BASE_URL }}" \
--build-arg NEXT_PUBLIC_UMAMI_WEBSITE_ID="${{ secrets.NEXT_PUBLIC_UMAMI_WEBSITE_ID }}" \
--build-arg NEXT_PUBLIC_UMAMI_SCRIPT_URL="${{ secrets.NEXT_PUBLIC_UMAMI_SCRIPT_URL }}" \
-t registry.infra.mintel.me/mintel/mb-grid-solutions:latest \
--build-arg NPM_TOKEN=${{ secrets.NPM_TOKEN }} \
--build-arg NEXT_PUBLIC_BASE_URL=${{ needs.prepare.outputs.next_public_base_url }} \
--build-arg NEXT_PUBLIC_TARGET=${{ needs.prepare.outputs.target }} \
--build-arg DIRECTUS_URL=${{ needs.prepare.outputs.directus_url }} \
--build-arg UMAMI_API_ENDPOINT=${{ secrets.UMAMI_API_ENDPOINT || secrets.NEXT_PUBLIC_UMAMI_SCRIPT_URL || vars.UMAMI_API_ENDPOINT || 'https://analytics.infra.mintel.me' }} \
--build-arg NEXT_PUBLIC_UMAMI_WEBSITE_ID=${{ secrets.UMAMI_WEBSITE_ID || secrets.NEXT_PUBLIC_UMAMI_WEBSITE_ID || vars.UMAMI_WEBSITE_ID || vars.NEXT_PUBLIC_UMAMI_WEBSITE_ID }} \
-t registry.infra.mintel.me/mintel/mb-grid-solutions:${{ needs.prepare.outputs.image_tag }} \
--push .
BUILD_EXIT_CODE=$?
if [ $BUILD_EXIT_CODE -eq 0 ]; then
echo ""
echo "✅ Build completed successfully at: $(date -u +'%Y-%m-%d %H:%M:%S UTC')"
echo ""
echo "📊 Image Details:"
IMAGE_SIZE=$(docker inspect registry.infra.mintel.me/mintel/mb-grid-solutions:latest --format='{{.Size}}')
IMAGE_SIZE_MB=$((IMAGE_SIZE / 1024 / 1024))
echo " • Size: ${IMAGE_SIZE_MB}MB"
docker inspect registry.infra.mintel.me/mintel/mb-grid-solutions:latest --format=' • Created: {{.Created}}'
docker inspect registry.infra.mintel.me/mintel/mb-grid-solutions:latest --format=' • Architecture: {{.Architecture}}'
else
echo ""
echo "❌ Build failed with exit code: $BUILD_EXIT_CODE"
exit $BUILD_EXIT_CODE
fi
echo ""
# ═══════════════════════════════════════════════════════════════════════════════
# LOGGING: Deployment Phase
# ═══════════════════════════════════════════════════════════════════════════════
- name: 🚀 Deploy to production server
deploy:
name: 🚀 Deploy
needs: [prepare, build, qa]
if: needs.prepare.outputs.target != 'skip'
runs-on: docker
container:
image: catthehacker/ubuntu:act-latest
steps:
- name: Checkout repository
uses: actions/checkout@v4
with:
fetch-depth: 1
- name: 🚀 Deploy via SSH
shell: bash
run: |
echo "╔══════════════════════════════════════════════════════════════════════════════╗"
echo "║ Step: Deploy to Production Server ║"
echo "╚══════════════════════════════════════════════════════════════════════════════╝"
echo ""
echo "🚀 Starting deployment process..."
echo " Target Server: alpha.mintel.me"
echo " Deploy User: deploy (via sudo from root)"
echo " Target Path: /home/deploy/sites/mb-grid-solutions.com"
echo ""
echo "Deploying to alpha.mintel.me"
# Setup SSH with logging
echo "🔐 Setting up SSH connection..."
mkdir -p ~/.ssh
echo "${{ secrets.ALPHA_SSH_KEY }}" > ~/.ssh/id_ed25519
chmod 600 ~/.ssh/id_ed25519
echo "🔑 Adding host to known_hosts..."
ssh-keyscan -H alpha.mintel.me >> ~/.ssh/known_hosts 2>/dev/null
if [ $? -eq 0 ]; then
echo "✅ Host key added successfully"
else
echo "⚠️ Warning: Could not add host key"
fi
echo ""
# Sync docker-compose.yaml first
echo "📦 Syncing docker-compose.yaml..."
tar czf - docker-compose.yaml | \
ssh -o StrictHostKeyChecking=accept-new \
-o IPQoS=0x00 \
root@alpha.mintel.me \
"mkdir -p /home/deploy/sites/mb-grid-solutions.com/ && tar xzf - -C /home/deploy/sites/mb-grid-solutions.com/ && chown -R deploy:deploy /home/deploy/sites/mb-grid-solutions.com/"
if [ $? -eq 0 ]; then
echo "✅ Files synced successfully"
else
echo "❌ File sync failed"
exit 1
fi
echo ""
# Execute deployment commands with detailed logging
echo "📡 Connecting to server and executing deployment commands..."
echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"
echo ""
# Generate Environment File
cat > .env.deploy << 'EOF'
ENV_FILE=${{ needs.prepare.outputs.env_file }}
IMAGE_TAG=${{ needs.prepare.outputs.image_tag }}
TRAEFIK_HOST=${{ needs.prepare.outputs.traefik_host }}
TRAEFIK_RULE=${{ needs.prepare.outputs.traefik_rule }}
TRAEFIK_MIDDLEWARES=${{ needs.prepare.outputs.traefik_middlewares }}
GATEKEEPER_RULE=${{ needs.prepare.outputs.gatekeeper_rule }}
GATEKEEPER_HOST=${{ needs.prepare.outputs.gatekeeper_host }}
PROJECT_NAME=${{ needs.prepare.outputs.project_name }}
NEXT_PUBLIC_BASE_URL=${{ needs.prepare.outputs.next_public_base_url }}
# SSH as root and use sudo to run deployment script as deploy user
# This works around the broken SSH output issue with deploy user
ssh -o StrictHostKeyChecking=accept-new \
-o ServerAliveInterval=30 \
-o ServerAliveCountMax=3 \
-o ConnectTimeout=10 \
root@alpha.mintel.me \
"CONTACT_RECIPIENT='${{ secrets.CONTACT_RECIPIENT }}' \
SMTP_FROM='${{ secrets.SMTP_FROM }}' \
SMTP_HOST='${{ secrets.SMTP_HOST }}' \
SMTP_PASS='${{ secrets.SMTP_PASS }}' \
SMTP_PORT='${{ secrets.SMTP_PORT }}' \
SMTP_SECURE='${{ secrets.SMTP_SECURE }}' \
SMTP_USER='${{ secrets.SMTP_USER }}' \
NEXT_PUBLIC_BASE_URL='${{ secrets.NEXT_PUBLIC_BASE_URL }}' \
NEXT_PUBLIC_UMAMI_WEBSITE_ID='${{ secrets.NEXT_PUBLIC_UMAMI_WEBSITE_ID }}' \
NEXT_PUBLIC_UMAMI_SCRIPT_URL='${{ secrets.NEXT_PUBLIC_UMAMI_SCRIPT_URL }}' \
SENTRY_DSN='${{ secrets.SENTRY_DSN }}' \
SITE_NAME='mb-grid-solutions.com' \
sudo -u deploy -E HOME=/home/deploy /home/deploy/deploy.sh --zero-downtime"
# 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 }}
DEPLOY_EXIT_CODE=$?
echo ""
if [ $DEPLOY_EXIT_CODE -eq 0 ]; then
echo "✅ Deployment completed successfully at: $(date -u +'%Y-%m-%d %H:%M:%S UTC')"
else
echo "❌ Deployment failed with exit code: $DEPLOY_EXIT_CODE"
echo ""
echo "🔍 Troubleshooting Tips:"
echo " • Check server connectivity: ping alpha.mintel.me"
echo " • Verify SSH key permissions on server"
echo " • Check disk space on target server"
echo " • Review docker compose configuration"
echo " • Ensure /home/deploy/deploy.sh exists and is executable"
exit $DEPLOY_EXIT_CODE
fi
echo ""
# 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 }}
# ═══════════════════════════════════════════════════════════════════════════════
# LOGGING: Workflow Summary
# ═══════════════════════════════════════════════════════════════════════════════
- name: 📊 Workflow Summary
if: always()
run: |
echo "╔══════════════════════════════════════════════════════════════════════════════╗"
echo "║ Workflow Summary ║"
echo "╚══════════════════════════════════════════════════════════════════════════════╝"
echo ""
echo "📊 Final Status:"
echo " • Workflow: ${{ job.status }}"
echo " • Completed: $(date -u +'%Y-%m-%d %H:%M:%S UTC')"
echo ""
echo "🎯 Deployment Target:"
echo " • Image: registry.infra.mintel.me/mintel/mb-grid-solutions:latest"
echo " • Server: alpha.mintel.me"
echo " • Service: mb-grid-solutions.com"
echo ""
echo "🔐 Security Notes:"
echo " • All secrets are masked (*** ) in logs"
echo " • SSH keys are created with 600 permissions"
echo " • Passwords are never displayed in plain text"
echo ""
echo "╔══════════════════════════════════════════════════════════════════════════════╗"
if [ "${{ job.status }}" == "success" ]; then
echo "║ ✅ DEPLOYMENT SUCCESSFUL ║"
else
echo "║ ❌ DEPLOYMENT FAILED ║"
fi
echo "╚══════════════════════════════════════════════════════════════════════════════╝"
# 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' }}
# ═══════════════════════════════════════════════════════════════════════════════
# NOTIFICATION: Gotify
# ═══════════════════════════════════════════════════════════════════════════════
- name: 🔔 Gotify Notification (Success)
if: success()
run: |
echo "Sending success notification to Gotify..."
RESPONSE=$(curl -k -s -w "\n%{http_code}" -X POST "${{ secrets.GOTIFY_URL }}/message?token=${{ secrets.GOTIFY_TOKEN }}" \
-F "title=✅ Deployment Success: ${{ github.repository }}" \
-F "message=The deployment of ${{ github.repository }} (branch: ${{ github.ref }}) was successful.
# External Services
SENTRY_DSN=${{ secrets.SENTRY_DSN || vars.SENTRY_DSN }}
GOTIFY_URL=${{ secrets.GOTIFY_URL || vars.GOTIFY_URL }}
GOTIFY_TOKEN=${{ secrets.GOTIFY_TOKEN || vars.GOTIFY_TOKEN }}
UMAMI_WEBSITE_ID=${{ secrets.UMAMI_WEBSITE_ID || secrets.NEXT_PUBLIC_UMAMI_WEBSITE_ID || vars.UMAMI_WEBSITE_ID || vars.NEXT_PUBLIC_UMAMI_WEBSITE_ID }}
NEXT_PUBLIC_UMAMI_WEBSITE_ID=${{ secrets.UMAMI_WEBSITE_ID || secrets.NEXT_PUBLIC_UMAMI_WEBSITE_ID || vars.UMAMI_WEBSITE_ID || vars.NEXT_PUBLIC_UMAMI_WEBSITE_ID }}
UMAMI_API_ENDPOINT=${{ secrets.UMAMI_API_ENDPOINT || secrets.NEXT_PUBLIC_UMAMI_SCRIPT_URL || vars.UMAMI_API_ENDPOINT || 'https://analytics.infra.mintel.me' }}
# Project
PROJECT_COLOR=${{ secrets.PROJECT_COLOR || vars.PROJECT_COLOR || '#82ed20' }}
EOF
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
Commit: ${{ github.sha }}
Actor: ${{ github.actor }}
Run ID: ${{ github.run_id }}" \
-F "priority=5")
HTTP_CODE=$(echo "$RESPONSE" | tail -n1)
BODY=$(echo "$RESPONSE" | sed '$d')
echo "HTTP Status: $HTTP_CODE"
echo "Response Body: $BODY"
if [ "$HTTP_CODE" -lt 200 ] || [ "$HTTP_CODE" -ge 300 ]; then
echo "Failed to send Gotify notification"
exit 0 # Don't fail the workflow because of notification failure
fi
echo "${{ secrets.REGISTRY_PASS }}" | docker login registry.infra.mintel.me -u "${{ secrets.REGISTRY_USER }}" --password-stdin
docker compose -p "${{ needs.prepare.outputs.project_name }}" --env-file ${{ needs.prepare.outputs.env_file }} pull
docker compose -p "${{ needs.prepare.outputs.project_name }}" --env-file ${{ needs.prepare.outputs.env_file }} up -d --wait --remove-orphans
docker system prune -f --filter "until=24h"
EOF
- name: 🔔 Gotify Notification (Failure)
if: failure()
notifications:
name: 🔔 Notifications
needs: [prepare, deploy]
if: always()
runs-on: docker
container:
image: catthehacker/ubuntu:act-latest
steps:
- name: Notify Gotify
shell: bash
run: |
echo "Sending failure notification to Gotify..."
RESPONSE=$(curl -k -s -w "\n%{http_code}" -X POST "${{ secrets.GOTIFY_URL }}/message?token=${{ secrets.GOTIFY_TOKEN }}" \
-F "title=❌ Deployment Failed: ${{ github.repository }}" \
-F "message=The deployment of ${{ github.repository }} (branch: ${{ github.ref }}) failed!
Commit: ${{ github.sha }}
Actor: ${{ github.actor }}
Run ID: ${{ github.run_id }}
Please check the logs for details." \
-F "priority=8")
STATUS="${{ needs.deploy.result }}"
COLOR="info"
[[ "$STATUS" == "success" ]] && PRIORITY=5 || PRIORITY=8
HTTP_CODE=$(echo "$RESPONSE" | tail -n1)
BODY=$(echo "$RESPONSE" | sed '$d')
echo "HTTP Status: $HTTP_CODE"
echo "Response Body: $BODY"
if [ "$HTTP_CODE" -lt 200 ] || [ "$HTTP_CODE" -ge 300 ]; then
echo "Failed to send Gotify notification"
exit 0 # Don't fail the workflow because of notification failure
fi
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"

2
.gitignore vendored
View File

@@ -7,6 +7,8 @@ yarn-error.log*
pnpm-debug.log*
lerna-debug.log*
.pnpm-store
node_modules
dist
dist-ssr

1
.husky/commit-msg Normal file
View File

@@ -0,0 +1 @@
pnpm commitlint --edit "$1"

1
.husky/pre-commit Normal file
View File

@@ -0,0 +1 @@
pnpm lint-staged

4
.lintstagedrc.cjs Normal file
View File

@@ -0,0 +1,4 @@
module.exports = {
'*.{js,jsx,ts,tsx}': [/* 'eslint --fix', */ 'prettier --write'],
'*.{json,md,css,scss}': ['prettier --write'],
};

4
.npmrc Normal file
View File

@@ -0,0 +1,4 @@
@mintel:registry=https://npm.infra.mintel.me/
registry=https://npm.infra.mintel.me/
//npm.infra.mintel.me/:_authToken=${NPM_TOKEN}
always-auth=true

View File

@@ -1,27 +1,58 @@
# Build Stage
FROM node:20-slim AS build
# Start from the pre-built Nextjs Base image
FROM registry.infra.mintel.me/mintel/nextjs:latest AS builder
WORKDIR /app
COPY package*.json ./
RUN npm ci
# Ensure we are in a clean, standalone environment
RUN rm -rf packages apps pnpm-workspace.yaml 2>/dev/null || true
# Build-time environment variables for Next.js
ARG NEXT_PUBLIC_BASE_URL
ARG UMAMI_API_ENDPOINT
ARG NEXT_PUBLIC_TARGET
ARG DIRECTUS_URL
ARG NEXT_PUBLIC_UMAMI_WEBSITE_ID
ARG NPM_TOKEN
ENV NEXT_PUBLIC_BASE_URL=$NEXT_PUBLIC_BASE_URL
ENV UMAMI_API_ENDPOINT=$UMAMI_API_ENDPOINT
ENV NEXT_PUBLIC_TARGET=$NEXT_PUBLIC_TARGET
ENV DIRECTUS_URL=$DIRECTUS_URL
ENV NEXT_PUBLIC_UMAMI_WEBSITE_ID=$NEXT_PUBLIC_UMAMI_WEBSITE_ID
ENV NPM_TOKEN=$NPM_TOKEN
ENV SENTRY_SUPPRESS_TURBOPACK_WARNING=1
ENV SKIP_RUNTIME_ENV_VALIDATION=true
# Enable corepack
RUN corepack enable
# Copy package files
COPY package.json pnpm-lock.yaml* .npmrc ./
# Install dependencies
RUN pnpm install --no-frozen-lockfile
# Copy local files
COPY . .
# Build Application
RUN npm run build
# Build the specific application
RUN pnpm build
# Runtime Stage
FROM node:20-slim
# Production runner image
FROM registry.infra.mintel.me/mintel/runtime:latest AS runner
# Production environment configuration
ENV HOSTNAME="0.0.0.0"
ENV PORT=3000
ENV NODE_ENV=production
WORKDIR /app
# 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 necessary files for production
COPY --from=build /app/package*.json ./
COPY --from=build /app/.next ./.next
COPY --from=build /app/public ./public
COPY --from=build /app/node_modules ./node_modules
# Ensure the cache directory specifically is writeable (Mintel Standard #16)
RUN mkdir -p .next/cache && chown -R nextjs:nodejs .next/cache
EXPOSE 3000
USER nextjs
CMD ["npm", "start"]
CMD ["node", "server.js"]

108
app/[locale]/agb/page.tsx Normal file
View File

@@ -0,0 +1,108 @@
import { Download } from "lucide-react";
import fs from "fs";
import path from "path";
export default function AGB() {
const filePath = path.join(process.cwd(), "context/agbs.md");
const fileContent = fs.readFileSync(filePath, "utf8");
// Split by double newlines to get major blocks (headers + their first paragraphs, or subsequent paragraphs)
const blocks = fileContent
.split(/\n\s*\n/)
.map((b) => b.trim())
.filter((b) => b !== "");
const title = blocks[0] || "Liefer- und Zahlungsbedingungen";
const stand = blocks[1] || "Stand Januar 2026";
const sections: { title: string; content: string[] }[] = [];
let currentSection: { title: string; content: string[] } | null = null;
// Skip title and stand
blocks.slice(2).forEach((block) => {
const lines = block
.split("\n")
.map((l) => l.trim())
.filter((l) => l !== "");
if (lines.length === 0) return;
const firstLine = lines[0];
if (/^\d+\./.test(firstLine)) {
// New section
if (currentSection) sections.push(currentSection);
currentSection = { title: firstLine, content: [] };
// If there are more lines in this block, they form the first paragraph(s)
if (lines.length > 1) {
// Join subsequent lines as they might be part of the same paragraph
// In this MD, we'll assume lines in the same block belong together
// unless they are clearly separate paragraphs (but we already split by double newline)
const remainingText = lines.slice(1).join(" ");
if (remainingText) currentSection.content.push(remainingText);
}
} else if (currentSection) {
// Continuation of current section
const blockText = lines.join(" ");
if (blockText) currentSection.content.push(blockText);
}
});
if (currentSection) sections.push(currentSection);
// The last block is the footer
const footer = blocks[blocks.length - 1];
if (sections.length > 0) {
const lastSection = sections[sections.length - 1];
if (lastSection.content.includes(footer) || lastSection.title === footer) {
lastSection.content = lastSection.content.filter((c) => c !== footer);
if (sections[sections.length - 1].title === footer) {
sections.pop();
}
}
}
return (
<div className="bg-slate-50 min-h-screen pt-40 pb-20">
<div className="container-custom">
<div className="max-w-4xl mx-auto bg-white p-8 md:p-12 rounded-[2.5rem] shadow-sm border border-slate-100">
<div className="flex flex-col md:flex-row md:items-center justify-between gap-6 mb-8">
<div>
<h1 className="text-4xl font-extrabold text-primary mb-2">
{title}
</h1>
<p className="text-slate-500 font-medium">{stand}</p>
</div>
<a
href="/assets/AGB MB Grid 1-2026.pdf"
download
className="btn-primary !py-3 !px-6 flex items-center gap-2"
>
<Download size={18} />
Als PDF herunterladen
</a>
</div>
<div className="space-y-8 text-slate-600 leading-relaxed">
{sections.map((section, index) => (
<div key={index}>
<h2 className="text-2xl font-bold text-primary mb-4">
{section.title}
</h2>
<div className="space-y-4">
{section.content.map((paragraph, pIndex) => (
<p key={pIndex}>{paragraph}</p>
))}
</div>
</div>
))}
<div className="pt-8 border-t border-slate-100">
<p className="font-bold text-primary">{footer}</p>
</div>
</div>
</div>
</div>
</div>
);
}

View File

@@ -0,0 +1,66 @@
export default function Privacy() {
return (
<div className="bg-slate-50 min-h-screen pt-40 pb-20">
<div className="container-custom">
<div className="max-w-4xl mx-auto bg-white p-8 md:p-12 rounded-[2.5rem] shadow-sm border border-slate-100">
<h1 className="text-4xl font-extrabold text-primary mb-8">
Datenschutzerklärung
</h1>
<div className="space-y-8 text-slate-600 leading-relaxed">
<div>
<h2 className="text-2xl font-bold text-primary mb-4">
1. Datenschutz auf einen Blick
</h2>
<p>
Wir nehmen den Schutz Ihrer persönlichen Daten sehr ernst. Wir
behandeln Ihre personenbezogenen Daten vertraulich und
entsprechend der gesetzlichen Datenschutzvorschriften sowie
dieser Datenschutzerklärung.
</p>
</div>
<div>
<h2 className="text-2xl font-bold text-primary mb-4">
2. Hosting
</h2>
<p>
Unsere Website wird bei Hetzner Online GmbH gehostet. Der
Serverstandort ist Deutschland. Wir haben einen Vertrag über
Auftragsverarbeitung (AVV) mit Hetzner geschlossen.
</p>
</div>
<div>
<h2 className="text-2xl font-bold text-primary mb-4">
3. Kontaktformular
</h2>
<p>
Wenn Sie uns per Kontaktformular Anfragen zukommen lassen,
werden Ihre Angaben aus dem Anfrageformular inklusive der von
Ihnen dort angegebenen Kontaktdaten zwecks Bearbeitung der
Anfrage und für den Fall von Anschlussfragen bei uns
gespeichert. Diese Daten geben wir nicht ohne Ihre Einwilligung
weiter.
</p>
</div>
<div>
<h2 className="text-2xl font-bold text-primary mb-4">
4. Server-Log-Dateien
</h2>
<p>
Der Provider der Seiten erhebt und speichert automatisch
Informationen in sogenannten Server-Log-Dateien, die Ihr Browser
automatisch an uns übermittelt. Dies sind: Browsertyp und
Browserversion, verwendetes Betriebssystem, Referrer URL,
Hostname des zugreifenden Rechners, Uhrzeit der Serveranfrage,
IP-Adresse.
</p>
</div>
</div>
</div>
</div>
</div>
);
}

View File

@@ -1,9 +1,9 @@
'use client';
"use client";
import { useEffect } from 'react';
import { motion } from 'framer-motion';
import { RefreshCcw, Home } from 'lucide-react';
import Link from 'next/link';
import { useEffect } from "react";
import { motion } from "framer-motion";
import { RefreshCcw, Home } from "lucide-react";
import Link from "next/link";
export default function Error({
error,
@@ -27,17 +27,19 @@ export default function Error({
>
500
</motion.div>
<motion.div
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
transition={{ delay: 0.2, duration: 0.5 }}
>
<h1 className="text-4xl font-bold text-primary mb-4">Etwas ist schiefgelaufen</h1>
<h1 className="text-4xl font-bold text-primary mb-4">
Etwas ist schiefgelaufen
</h1>
<p className="text-slate-600 text-lg mb-12 max-w-md mx-auto">
Es gab ein technisches Problem. Wir arbeiten bereits an der Lösung.
</p>
<div className="flex flex-col sm:flex-row items-center justify-center gap-4">
<button
onClick={() => reset()}
@@ -46,7 +48,10 @@ export default function Error({
<RefreshCcw size={18} />
Erneut versuchen
</button>
<Link href="/" className="btn-primary bg-slate-100 !text-primary hover:bg-slate-200 flex items-center gap-2">
<Link
href="/"
className="btn-primary bg-slate-100 !text-primary hover:bg-slate-200 flex items-center gap-2"
>
<Home size={18} />
Zur Startseite
</Link>

View File

@@ -1,11 +1,11 @@
'use client';
"use client";
import { motion } from 'framer-motion';
import { TechBackground } from '@/components/TechBackground';
import { motion } from "framer-motion";
import { TechBackground } from "@/components/TechBackground";
export default function Legal() {
return (
<div className="bg-slate-50 min-h-screen pt-28 pb-20 relative overflow-hidden">
<div className="bg-slate-50 min-h-screen pt-40 pb-20 relative overflow-hidden">
<TechBackground />
<div className="container-custom relative z-10">
<motion.div
@@ -16,23 +16,32 @@ export default function Legal() {
>
<div className="tech-corner top-8 left-8 border-t-2 border-l-2 opacity-20" />
<div className="tech-corner bottom-8 right-8 border-b-2 border-r-2 opacity-20" />
<h1 className="text-4xl font-extrabold text-primary mb-8 relative z-10">Impressum</h1>
<h1 className="text-4xl font-extrabold text-primary mb-8 relative z-10">
Impressum
</h1>
<div className="space-y-8 text-slate-600 leading-relaxed relative z-10">
<div>
<h2 className="text-xl font-bold text-primary mb-4">Angaben gemäß § 5 TMG</h2>
<h2 className="text-xl font-bold text-primary mb-4">
Angaben gemäß § 5 TMG
</h2>
<p>
MB Grid Solutions & Services GmbH<br />
Raiffeisenstraße 22<br />
MB Grid Solutions & Services GmbH
<br />
Raiffeisenstraße 22
<br />
73630 Remshalden
</p>
</div>
<div>
<h2 className="text-xl font-bold text-primary mb-4">Vertreten durch</h2>
<h2 className="text-xl font-bold text-primary mb-4">
Vertreten durch
</h2>
<p>
Michael Bodemer<br />
Michael Bodemer
<br />
Klaus Mintel
</p>
</div>
@@ -40,24 +49,48 @@ export default function Legal() {
<div>
<h2 className="text-xl font-bold text-primary mb-4">Kontakt</h2>
<p>
E-Mail: <a href="mailto:info@mb-grid-solutions.com" className="text-accent hover:underline">info@mb-grid-solutions.com</a><br />
Web: <a href="https://www.mb-grid-solutions.com" className="text-accent hover:underline">www.mb-grid-solutions.com</a>
E-Mail:{" "}
<a
href="mailto:info@mb-grid-solutions.com"
className="text-accent hover:underline"
>
info@mb-grid-solutions.com
</a>
<br />
Web:{" "}
<a
href="https://www.mb-grid-solutions.com"
className="text-accent hover:underline"
>
www.mb-grid-solutions.com
</a>
</p>
</div>
<div>
<h2 className="text-xl font-bold text-primary mb-4">Registereintrag</h2>
<h2 className="text-xl font-bold text-primary mb-4">
Registereintrag
</h2>
<p>
Eintragung im Handelsregister.<br />
Registergericht: Amtsgericht Stuttgart<br />
Eintragung im Handelsregister.
<br />
Registergericht: Amtsgericht Stuttgart
<br />
Registernummer: HRB 803379
</p>
</div>
<div>
<h2 className="text-xl font-bold text-primary mb-4">Urheberrecht</h2>
<h2 className="text-xl font-bold text-primary mb-4">
Urheberrecht
</h2>
<p>
Alle auf der Website veröffentlichten Texte, Bilder und sonstigen Informationen unterliegen sofern nicht anders gekennzeichnet dem Urheberrecht. Jede Vervielfältigung, Verbreitung, Speicherung, Übermittlung, Wiedergabe bzw. Weitergabe der Inhalte ohne schriftliche Genehmigung ist ausdrücklich untersagt.
Alle auf der Website veröffentlichten Texte, Bilder und
sonstigen Informationen unterliegen sofern nicht anders
gekennzeichnet dem Urheberrecht. Jede Vervielfältigung,
Verbreitung, Speicherung, Übermittlung, Wiedergabe bzw.
Weitergabe der Inhalte ohne schriftliche Genehmigung ist
ausdrücklich untersagt.
</p>
</div>
</div>

View File

@@ -3,7 +3,8 @@ import ContactContent from "@/components/ContactContent";
export const metadata: Metadata = {
title: "Kontakt",
description: "Haben Sie Fragen zu einem Projekt oder benötigen Sie technische Beratung? Wir freuen uns auf Ihre Nachricht.",
description:
"Haben Sie Fragen zu einem Projekt oder benötigen Sie technische Beratung? Wir freuen uns auf Ihre Nachricht.",
};
export default function Page() {

152
app/[locale]/layout.tsx Normal file
View File

@@ -0,0 +1,152 @@
import Layout from "@/components/Layout";
import type { Metadata } from "next";
import { Inter } from "next/font/google";
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",
});
export const metadata: Metadata = {
metadataBase: new URL("https://www.mb-grid-solutions.com"),
title: {
default: "MB Grid Solutions | Energiekabelprojekte & Technische Beratung",
template: "%s | MB Grid Solutions",
},
description:
"Ihr spezialisierter Partner für herstellerneutrale technische Beratung und Projektbegleitung bei Energiekabelprojekten bis 110 kV. Expertise in Mittel- und Hochspannungsnetzen.",
keywords: [
"Energiekabel",
"Hochspannung",
"Mittelspannung",
"Kabelprojekte",
"Technische Beratung",
"Engineering",
"Energiewende",
"110 kV",
],
authors: [{ name: "MB Grid Solutions & Services GmbH" }],
creator: "MB Grid Solutions & Services GmbH",
publisher: "MB Grid Solutions & Services GmbH",
formatDetection: {
email: false,
address: false,
telephone: false,
},
openGraph: {
type: "website",
locale: "de_DE",
url: "https://www.mb-grid-solutions.com",
siteName: "MB Grid Solutions",
title: "MB Grid Solutions | Energiekabelprojekte & Technische Beratung",
description:
"Spezialisierter Partner für Energiekabelprojekte bis 110 kV. Herstellerneutrale technische Beratung und Projektbegleitung.",
},
twitter: {
card: "summary_large_image",
title: "MB Grid Solutions | Energiekabelprojekte & Technische Beratung",
description: "Spezialisierter Partner für Energiekabelprojekte bis 110 kV.",
},
robots: {
index: true,
follow: true,
googleBot: {
index: true,
follow: true,
"max-video-preview": -1,
"max-image-preview": "large",
"max-snippet": -1,
},
},
};
export default async function RootLayout({
children,
params,
}: {
children: React.ReactNode;
params: Promise<{ locale: string }>;
}) {
const { locale } = await params;
// Validate that the incoming `locale` is supported
if (locale !== "de") {
notFound();
}
// Providing all messages to the client
// side is the easiest way to get started
const messages = await getMessages();
const jsonLd = {
"@context": "https://schema.org",
"@type": "Organization",
name: "MB Grid Solutions & Services GmbH",
url: "https://www.mb-grid-solutions.com",
logo: "https://www.mb-grid-solutions.com/assets/logo.png",
description:
"Ihr spezialisierter Partner für herstellerneutrale technische Beratung und Projektbegleitung bei Energiekabelprojekten bis 110 kV.",
address: {
"@type": "PostalAddress",
streetAddress: "Raiffeisenstraße 22",
addressLocality: "Remshalden",
postalCode: "73630",
addressCountry: "DE",
},
contactPoint: {
"@type": "ContactPoint",
email: "info@mb-grid-solutions.com",
contactType: "customer service",
},
};
// 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("/");
return (
<html lang={locale} className={`${inter.variable}`}>
<head>
<script
type="application/ld+json"
dangerouslySetInnerHTML={{ __html: JSON.stringify(jsonLd) }}
/>
</head>
<body className="antialiased">
<NextIntlClientProvider messages={messages}>
<AnalyticsProvider websiteId={config.analytics.umami.websiteId} />
<LazyMotion features={domAnimation}>
<Layout>{children}</Layout>
</LazyMotion>
</NextIntlClientProvider>
</body>
</html>
);
}

View File

@@ -1,8 +1,8 @@
'use client';
"use client";
import Link from 'next/link';
import { motion } from 'framer-motion';
import { Home, ArrowLeft } from 'lucide-react';
import Link from "next/link";
import { motion } from "framer-motion";
import { Home, ArrowLeft } from "lucide-react";
export default function NotFound() {
return (
@@ -16,23 +16,26 @@ export default function NotFound() {
>
404
</motion.div>
<motion.div
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
transition={{ delay: 0.2, duration: 0.5 }}
>
<h1 className="text-4xl font-bold text-primary mb-4">Seite nicht gefunden</h1>
<h1 className="text-4xl font-bold text-primary mb-4">
Seite nicht gefunden
</h1>
<p className="text-slate-600 text-lg mb-12 max-w-md mx-auto">
Die von Ihnen gesuchte Seite scheint nicht zu existieren oder wurde verschoben.
Die von Ihnen gesuchte Seite scheint nicht zu existieren oder wurde
verschoben.
</p>
<div className="flex flex-col sm:flex-row items-center justify-center gap-4">
<Link href="/" className="btn-primary flex items-center gap-2">
<Home size={18} />
Zur Startseite
</Link>
<button
<button
onClick={() => window.history.back()}
className="btn-primary bg-slate-100 !text-primary hover:bg-slate-200 flex items-center gap-2"
>

View File

@@ -0,0 +1,175 @@
import { ImageResponse } from "next/og";
export const runtime = "edge";
export const alt =
"MB Grid Solutions | Energiekabelprojekte & Technische Beratung";
export const size = {
width: 1200,
height: 630,
};
export const contentType = "image/png";
export default async function Image() {
return new ImageResponse(
<div
style={{
background: "#ffffff",
width: "100%",
height: "100%",
display: "flex",
flexDirection: "column",
alignItems: "center",
justifyContent: "center",
position: "relative",
fontFamily: "sans-serif",
}}
>
{/* Grid Pattern Background - matching .grid-pattern in globals.css */}
<div
style={{
position: "absolute",
top: 0,
left: 0,
right: 0,
bottom: 0,
backgroundImage:
"radial-gradient(circle, #e2e8f0 1.5px, transparent 1.5px)",
backgroundSize: "40px 40px",
zIndex: 0,
}}
/>
{/* Content Container - matching .card-modern / .glass-panel style */}
<div
style={{
display: "flex",
flexDirection: "column",
alignItems: "center",
justifyContent: "center",
backgroundColor: "rgba(255, 255, 255, 0.95)",
padding: "60px 80px",
borderRadius: "48px",
border: "1px solid #e2e8f0",
boxShadow: "0 25px 50px -12px rgba(0, 0, 0, 0.1)",
zIndex: 1,
position: "relative",
}}
>
{/* Engineering Excellence Badge */}
<div
style={{
display: "flex",
alignItems: "center",
gap: "12px",
padding: "8px 20px",
backgroundColor: "rgba(16, 185, 129, 0.1)",
borderRadius: "100px",
marginBottom: "32px",
}}
>
<div
style={{
width: "10px",
height: "10px",
backgroundColor: "#10b981",
borderRadius: "50%",
}}
/>
<div
style={{
fontSize: "14px",
fontWeight: "bold",
color: "#10b981",
textTransform: "uppercase",
letterSpacing: "0.1em",
}}
>
Technische Beratung
</div>
</div>
{/* Brand Mark */}
<div
style={{
display: "flex",
alignItems: "center",
justifyContent: "center",
width: "100px",
height: "100px",
backgroundColor: "#0f172a",
borderRadius: "24px",
marginBottom: "32px",
boxShadow: "0 10px 15px -3px rgba(15, 23, 42, 0.3)",
}}
>
<div
style={{
fontSize: "48px",
fontWeight: "bold",
color: "#10b981",
}}
>
MB
</div>
</div>
{/* Title */}
<div
style={{
fontSize: "72px",
fontWeight: "900",
color: "#0f172a",
marginBottom: "16px",
textAlign: "center",
letterSpacing: "-0.02em",
}}
>
MB Grid <span style={{ color: "#10b981" }}>Solutions</span>
</div>
{/* Subtitle */}
<div
style={{
fontSize: "32px",
fontWeight: "500",
color: "#64748b",
textAlign: "center",
maxWidth: "800px",
lineHeight: 1.4,
}}
>
Energiekabelprojekte & Technische Beratung
<br />
bis 110 kV
</div>
</div>
{/* Tech Lines - matching .tech-line style */}
<div
style={{
position: "absolute",
top: "10%",
left: 0,
width: "200px",
height: "1px",
backgroundColor: "rgba(16, 185, 129, 0.2)",
}}
/>
<div
style={{
position: "absolute",
bottom: "15%",
right: 0,
width: "300px",
height: "1px",
backgroundColor: "rgba(16, 185, 129, 0.2)",
}}
/>
</div>,
{
...size,
},
);
}

View File

@@ -3,7 +3,8 @@ import HomeContent from "@/components/HomeContent";
export const metadata: Metadata = {
title: "MB Grid Solutions | Energiekabelprojekte & Technische Beratung",
description: "Ihr spezialisierter Partner für herstellerneutrale technische Beratung und Projektbegleitung bei Energiekabelprojekten bis 110 kV.",
description:
"Ihr spezialisierter Partner für herstellerneutrale technische Beratung und Projektbegleitung bei Energiekabelprojekten bis 110 kV.",
};
export default function Page() {

View File

@@ -0,0 +1,175 @@
import { ImageResponse } from "next/og";
export const runtime = "edge";
export const alt =
"MB Grid Solutions | Energiekabelprojekte & Technische Beratung";
export const size = {
width: 1200,
height: 630,
};
export const contentType = "image/png";
export default async function Image() {
return new ImageResponse(
<div
style={{
background: "#ffffff",
width: "100%",
height: "100%",
display: "flex",
flexDirection: "column",
alignItems: "center",
justifyContent: "center",
position: "relative",
fontFamily: "sans-serif",
}}
>
{/* Grid Pattern Background */}
<div
style={{
position: "absolute",
top: 0,
left: 0,
right: 0,
bottom: 0,
backgroundImage:
"radial-gradient(circle, #e2e8f0 1.5px, transparent 1.5px)",
backgroundSize: "40px 40px",
zIndex: 0,
}}
/>
{/* Content Container */}
<div
style={{
display: "flex",
flexDirection: "column",
alignItems: "center",
justifyContent: "center",
backgroundColor: "rgba(255, 255, 255, 0.95)",
padding: "60px 80px",
borderRadius: "48px",
border: "1px solid #e2e8f0",
boxShadow: "0 25px 50px -12px rgba(0, 0, 0, 0.1)",
zIndex: 1,
position: "relative",
}}
>
{/* Engineering Excellence Badge */}
<div
style={{
display: "flex",
alignItems: "center",
gap: "12px",
padding: "8px 20px",
backgroundColor: "rgba(16, 185, 129, 0.1)",
borderRadius: "100px",
marginBottom: "32px",
}}
>
<div
style={{
width: "10px",
height: "10px",
backgroundColor: "#10b981",
borderRadius: "50%",
}}
/>
<div
style={{
fontSize: "14px",
fontWeight: "bold",
color: "#10b981",
textTransform: "uppercase",
letterSpacing: "0.1em",
}}
>
Technische Beratung
</div>
</div>
{/* Brand Mark */}
<div
style={{
display: "flex",
alignItems: "center",
justifyContent: "center",
width: "100px",
height: "100px",
backgroundColor: "#0f172a",
borderRadius: "24px",
marginBottom: "32px",
boxShadow: "0 10px 15px -3px rgba(15, 23, 42, 0.3)",
}}
>
<div
style={{
fontSize: "48px",
fontWeight: "bold",
color: "#10b981",
}}
>
MB
</div>
</div>
{/* Title */}
<div
style={{
fontSize: "72px",
fontWeight: "900",
color: "#0f172a",
marginBottom: "16px",
textAlign: "center",
letterSpacing: "-0.02em",
}}
>
MB Grid <span style={{ color: "#10b981" }}>Solutions</span>
</div>
{/* Subtitle */}
<div
style={{
fontSize: "32px",
fontWeight: "500",
color: "#64748b",
textAlign: "center",
maxWidth: "800px",
lineHeight: 1.4,
}}
>
Energiekabelprojekte & Technische Beratung
<br />
bis 110 kV
</div>
</div>
{/* Tech Lines */}
<div
style={{
position: "absolute",
top: "10%",
left: 0,
width: "200px",
height: "1px",
backgroundColor: "rgba(16, 185, 129, 0.2)",
}}
/>
<div
style={{
position: "absolute",
bottom: "15%",
right: 0,
width: "300px",
height: "1px",
backgroundColor: "rgba(16, 185, 129, 0.2)",
}}
/>
</div>,
{
...size,
},
);
}

View File

@@ -3,7 +3,8 @@ import AboutContent from "@/components/AboutContent";
export const metadata: Metadata = {
title: "Über uns",
description: "Erfahren Sie mehr über MB Grid Solutions, unsere Expertise und unser Manifest für technische Exzellenz.",
description:
"Erfahren Sie mehr über MB Grid Solutions, unsere Expertise und unser Manifest für technische Exzellenz.",
};
export default function Page() {

View File

@@ -1,83 +0,0 @@
import { Download } from 'lucide-react';
export default function AGB() {
return (
<div className="bg-slate-50 min-h-screen pt-28 pb-20">
<div className="container-custom">
<div className="max-w-4xl mx-auto bg-white p-8 md:p-12 rounded-[2.5rem] shadow-sm border border-slate-100">
<div className="flex flex-col md:flex-row md:items-center justify-between gap-6 mb-8">
<div>
<h1 className="text-4xl font-extrabold text-primary mb-2">Liefer- und Zahlungsbedingungen</h1>
<p className="text-slate-500 font-medium">Stand Januar 2026</p>
</div>
<a
href="/assets/AGB MB Grid 1-2026.pdf"
download
className="btn-primary !py-3 !px-6 flex items-center gap-2"
>
<Download size={18} />
Als PDF herunterladen
</a>
</div>
<div className="space-y-8 text-slate-600 leading-relaxed">
<div>
<h2 className="text-2xl font-bold text-primary mb-4">1. Allgemeines</h2>
<div className="space-y-4">
<p>
Diese Liefer- und Zahlungsbedingungen (L&Z) der MB Grid Solutions & Services gelten ausschließlich;
entgegenstehende oder von unseren Bedingungen abweichende Bedingungen des Kunden erkennen wir nicht an,
es sei denn, wir hätten ausdrücklich schriftlich ihrer Geltung zugestimmt.
</p>
<p>
Unsere L&Z gelten nur gegenüber Unternehmern im Sinn von § 310 Abs. 1 BGB sowie juristischen Personen des öffentlichen Rechts oder öffentlich-rechtliches Sondervermögen.
</p>
</div>
</div>
<div>
<h2 className="text-2xl font-bold text-primary mb-4">2. Angebote</h2>
<p>
Sofern nicht ausdrücklich als bindend bezeichnet, sind unsere Angebote freibleibend; die Bestellung
des Kunden ist als Angebot gemäß § 145 BGB zu qualifizieren.
</p>
</div>
<div>
<h2 className="text-2xl font-bold text-primary mb-4">3. Preise</h2>
<p>
Die Preise gelten für den in unseren Angeboten und Auftragsbestätigungen aufgeführten Leistungs- und
Lieferumfang. Mehrleistungen werden gesondert berechnet. Die Hohlpreise verstehen sich in Euro zuzüglich
Metallzuschlag, gegebenenfalls Verpackung, auftragsspezifischer Schnittkosten und der gesetzlichen Mehrwertsteuer.
</p>
</div>
<div>
<h2 className="text-2xl font-bold text-primary mb-4">4. Metallnotierung</h2>
<p>
Basis zur Kupferabrechnung ist die Notierung LME Copper official price cash offer, Durchschnitt des
Liefervormonats zuzüglich der dann aktuellen von uns benannten Kupfer-Prämie.
</p>
</div>
<div>
<h2 className="text-2xl font-bold text-primary mb-4">17. Technische Beratungsdienstleistungen</h2>
<p>
Die technische Unterstützung ersetzt weder die Fachplanung noch die Ausführungs- oder Prüvverantwortung
des beauftragten Ingenieurbüros, Planers oder der ausführenden Fachfirma bzw. verantwortlichen Abteilung.
</p>
<p className="mt-4">
Alle Hinweise, Einschätzungen und Empfehlungen der MB Grid Solutions and Services erfolgen ohne Gewähr
und entbinden den jeweiligen Auftragnehmer nicht von seiner eigenen fachlichen Prüfung, Planung und Verantwortung.
</p>
</div>
<div className="pt-8 border-t border-slate-100">
<p className="font-bold text-primary">Remshalden, 22.1.2026</p>
</div>
</div>
</div>
</div>
</div>
);
}

View File

@@ -1,56 +1,149 @@
import { NextResponse } from 'next/server';
import * as nodemailer from 'nodemailer';
import { NextResponse } from "next/server";
import * as nodemailer from "nodemailer";
import directus, { ensureAuthenticated } from "@/lib/directus";
import { createItem } from "@directus/sdk";
import { getServerAppServices } from "@/lib/services/create-services.server";
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) {
console.log('Spam detected (honeypot)');
return NextResponse.json({ message: 'Ok' });
logger.info("Spam detected (honeypot)");
return NextResponse.json({ message: "Ok" });
}
// Validation
if (!name || name.length < 2 || name.length > 100) {
return NextResponse.json({ error: 'Ungültiger Name' }, { status: 400 });
return NextResponse.json({ error: "Ungültiger Name" }, { status: 400 });
}
if (!email || !/^\S+@\S+\.\S+$/.test(email)) {
return NextResponse.json({ error: 'Ungültige E-Mail' }, { status: 400 });
return NextResponse.json({ error: "Ungültige E-Mail" }, { status: 400 });
}
if (!message || message.length < 20 || message.length > 4000) {
return NextResponse.json({ error: 'Nachricht zu kurz oder zu lang' }, { status: 400 });
if (!message || message.length < 20) {
return NextResponse.json({ error: "message_too_short" }, { status: 400 });
}
const transporter = nodemailer.createTransport({
host: process.env.SMTP_HOST,
port: parseInt(process.env.SMTP_PORT || '587'),
secure: process.env.SMTP_SECURE === 'true',
auth: {
user: process.env.SMTP_USER,
pass: process.env.SMTP_PASS,
},
});
if (message.length > 4000) {
return NextResponse.json({ error: "message_too_long" }, { status: 400 });
}
await transporter.sendMail({
from: process.env.SMTP_FROM,
to: process.env.CONTACT_RECIPIENT,
replyTo: email,
subject: `Kontaktanfrage von ${name}`,
text: `
// 1. Directus save
let directusSaved = false;
try {
await ensureAuthenticated();
await directus.request(
createItem("contact_submissions", {
name,
email,
company: company || "Nicht angegeben",
message,
}),
);
logger.info("Contact submission saved to Directus");
directusSaved = true;
} catch (directusError) {
const errorMessage =
directusError instanceof Error
? directusError.message
: String(directusError);
logger.error("Failed to save to Directus", {
error: errorMessage,
details: directusError,
});
services.errors.captureException(directusError, {
phase: "directus_save",
});
// We still try to send the email even if Directus fails
}
// 2. Email sending
try {
const { config } = await import("@/lib/config");
const transporter = nodemailer.createTransport({
host: config.mail.host,
port: config.mail.port,
secure: config.mail.port === 465,
auth: {
user: config.mail.user,
pass: config.mail.pass,
},
});
await transporter.sendMail({
from: config.mail.from,
to: config.mail.recipients.join(",") || "info@mb-grid-solutions.com",
replyTo: email,
subject: `Kontaktanfrage von ${name}`,
text: `
Name: ${name}
Firma: ${company || 'Nicht angegeben'}
Firma: ${company || "Nicht angegeben"}
E-Mail: ${email}
Zeitpunkt: ${new Date().toISOString()}
Nachricht:
${message}
`,
`,
});
logger.info("Email sent successfully");
// Notify success for important leads
await services.notifications.notify({
title: "📩 Neue Kontaktanfrage",
message: `Anfrage von ${name} (${email}) erhalten.\nFirma: ${company || "Nicht angegeben"}`,
priority: 5,
});
} catch (smtpError) {
logger.error("SMTP Error", { error: smtpError });
services.errors.captureException(smtpError, { phase: "smtp_send" });
// If Directus failed AND SMTP failed, then we really have a problem
if (!directusSaved) {
return NextResponse.json(
{ error: "Systemfehler (Speicherung und Versand fehlgeschlagen)" },
{ status: 500 },
);
}
// If Directus was successful, we tell the user "Ok" but we know internally it was a partial failure
await services.notifications.notify({
title: "🚨 SMTP Fehler (Kontaktformular)",
message: `Anfrage von ${name} (${email}) in Directus gespeichert, aber E-Mail-Versand fehlgeschlagen: ${smtpError instanceof Error ? smtpError.message : String(smtpError)}`,
priority: 8,
});
}
// Track success
services.analytics.track("contact-form-success", {
has_company: Boolean(company),
});
return NextResponse.json({ message: 'Ok' });
return NextResponse.json({ message: "Ok" });
} catch (error) {
console.error('SMTP Error:', error);
return NextResponse.json({ error: 'Interner Serverfehler' }, { status: 500 });
logger.error("Global API Error", { error });
services.errors.captureException(error, { phase: "api_global" });
return NextResponse.json(
{ error: "Interner Serverfehler" },
{ status: 500 },
);
}
}

View File

@@ -1,33 +0,0 @@
export default function Privacy() {
return (
<div className="bg-slate-50 min-h-screen pt-28 pb-20">
<div className="container-custom">
<div className="max-w-4xl mx-auto bg-white p-8 md:p-12 rounded-[2.5rem] shadow-sm border border-slate-100">
<h1 className="text-4xl font-extrabold text-primary mb-8">Datenschutzerklärung</h1>
<div className="space-y-8 text-slate-600 leading-relaxed">
<div>
<h2 className="text-2xl font-bold text-primary mb-4">1. Datenschutz auf einen Blick</h2>
<p>Wir nehmen den Schutz Ihrer persönlichen Daten sehr ernst. Wir behandeln Ihre personenbezogenen Daten vertraulich und entsprechend der gesetzlichen Datenschutzvorschriften sowie dieser Datenschutzerklärung.</p>
</div>
<div>
<h2 className="text-2xl font-bold text-primary mb-4">2. Hosting</h2>
<p>Unsere Website wird bei Hetzner Online GmbH gehostet. Der Serverstandort ist Deutschland. Wir haben einen Vertrag über Auftragsverarbeitung (AVV) mit Hetzner geschlossen.</p>
</div>
<div>
<h2 className="text-2xl font-bold text-primary mb-4">3. Kontaktformular</h2>
<p>Wenn Sie uns per Kontaktformular Anfragen zukommen lassen, werden Ihre Angaben aus dem Anfrageformular inklusive der von Ihnen dort angegebenen Kontaktdaten zwecks Bearbeitung der Anfrage und für den Fall von Anschlussfragen bei uns gespeichert. Diese Daten geben wir nicht ohne Ihre Einwilligung weiter.</p>
</div>
<div>
<h2 className="text-2xl font-bold text-primary mb-4">4. Server-Log-Dateien</h2>
<p>Der Provider der Seiten erhebt und speichert automatisch Informationen in sogenannten Server-Log-Dateien, die Ihr Browser automatisch an uns übermittelt. Dies sind: Browsertyp und Browserversion, verwendetes Betriebssystem, Referrer URL, Hostname des zugreifenden Rechners, Uhrzeit der Serveranfrage, IP-Adresse.</p>
</div>
</div>
</div>
</div>
</div>
);
}

View File

@@ -10,14 +10,18 @@
--color-text-main: #0f172a;
--color-text-muted: #64748b;
--color-border: #e2e8f0;
--font-sans: var(--font-inter), -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
--font-sans:
var(--font-inter), -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto,
sans-serif;
--radius-xl: 1rem;
--radius-2xl: 1.5rem;
--shadow-soft: 0 4px 6px -1px rgb(0 0 0 / 0.05), 0 2px 4px -2px rgb(0 0 0 / 0.05);
--shadow-card: 0 10px 15px -3px rgb(0 0 0 / 0.03), 0 4px 6px -4px rgb(0 0 0 / 0.03);
--shadow-soft:
0 4px 6px -1px rgb(0 0 0 / 0.05), 0 2px 4px -2px rgb(0 0 0 / 0.05);
--shadow-card:
0 10px 15px -3px rgb(0 0 0 / 0.03), 0 4px 6px -4px rgb(0 0 0 / 0.03);
}
:root {
@@ -43,7 +47,11 @@
}
.grid-pattern {
background-image: radial-gradient(circle, var(--color-border) 1px, transparent 1px);
background-image: radial-gradient(
circle,
var(--color-border) 1px,
transparent 1px
);
background-size: 40px 40px;
}
@@ -56,7 +64,11 @@
background-image:
radial-gradient(at 0% 0%, rgba(16, 185, 129, 0.05) 0px, transparent 50%),
radial-gradient(at 100% 0%, rgba(15, 23, 42, 0.05) 0px, transparent 50%),
radial-gradient(at 100% 100%, rgba(16, 185, 129, 0.05) 0px, transparent 50%),
radial-gradient(
at 100% 100%,
rgba(16, 185, 129, 0.05) 0px,
transparent 50%
),
radial-gradient(at 0% 100%, rgba(15, 23, 42, 0.05) 0px, transparent 50%);
}
@@ -78,7 +90,7 @@
}
.tech-card-border::before {
content: '';
content: "";
@apply absolute -inset-px bg-gradient-to-br from-accent/20 via-transparent to-accent/20 rounded-[inherit] opacity-0 transition-opacity duration-500;
}
@@ -86,11 +98,20 @@
@apply opacity-100;
}
h1, h2, h3, h4, h5, h6 {
h1,
h2,
h3,
h4,
h5,
h6 {
@apply font-bold tracking-tight text-primary;
text-wrap: balance;
}
button {
@apply cursor-pointer;
}
section {
@apply py-20 md:py-32;
}
@@ -102,11 +123,11 @@
}
.btn-primary {
@apply inline-flex items-center justify-center px-6 py-3 rounded-lg bg-primary text-white font-semibold transition-all hover:bg-primary-light hover:shadow-lg active:scale-[0.98] disabled:opacity-50;
@apply inline-flex items-center justify-center px-6 py-3 rounded-lg bg-primary text-white font-semibold transition-all hover:bg-primary-light hover:shadow-lg active:scale-[0.98] cursor-pointer disabled:opacity-50 disabled:cursor-not-allowed;
}
.btn-accent {
@apply inline-flex items-center justify-center px-6 py-3 rounded-lg bg-accent text-white font-semibold transition-all hover:bg-accent-hover hover:shadow-lg active:scale-[0.98] disabled:opacity-50;
@apply inline-flex items-center justify-center px-6 py-3 rounded-lg bg-accent text-white font-semibold transition-all hover:bg-accent-hover hover:shadow-lg active:scale-[0.98] cursor-pointer disabled:opacity-50 disabled:cursor-not-allowed;
}
.glass-panel {

View File

@@ -1,95 +0,0 @@
import Layout from "@/components/Layout";
import type { Metadata } from "next";
import { Inter } from "next/font/google";
import "./globals.css";
const inter = Inter({
subsets: ["latin"],
display: "swap",
variable: "--font-inter",
});
export const metadata: Metadata = {
metadataBase: new URL("https://www.mb-grid-solutions.com"),
title: {
default: "MB Grid Solutions | Energiekabelprojekte & Technische Beratung",
template: "%s | MB Grid Solutions"
},
description: "Ihr spezialisierter Partner für herstellerneutrale technische Beratung und Projektbegleitung bei Energiekabelprojekten bis 110 kV. Expertise in Mittel- und Hochspannungsnetzen.",
keywords: ["Energiekabel", "Hochspannung", "Mittelspannung", "Kabelprojekte", "Technische Beratung", "Engineering", "Energiewende", "110 kV"],
authors: [{ name: "MB Grid Solutions & Services GmbH" }],
creator: "MB Grid Solutions & Services GmbH",
publisher: "MB Grid Solutions & Services GmbH",
formatDetection: {
email: false,
address: false,
telephone: false,
},
openGraph: {
type: "website",
locale: "de_DE",
url: "https://www.mb-grid-solutions.com",
siteName: "MB Grid Solutions",
title: "MB Grid Solutions | Energiekabelprojekte & Technische Beratung",
description: "Spezialisierter Partner für Energiekabelprojekte bis 110 kV. Herstellerneutrale technische Beratung und Projektbegleitung.",
},
twitter: {
card: "summary_large_image",
title: "MB Grid Solutions | Energiekabelprojekte & Technische Beratung",
description: "Spezialisierter Partner für Energiekabelprojekte bis 110 kV.",
},
robots: {
index: true,
follow: true,
googleBot: {
index: true,
follow: true,
'max-video-preview': -1,
'max-image-preview': 'large',
'max-snippet': -1,
},
},
};
export default function RootLayout({
children,
}: Readonly<{
children: React.ReactNode;
}>) {
const jsonLd = {
"@context": "https://schema.org",
"@type": "Organization",
"name": "MB Grid Solutions & Services GmbH",
"url": "https://www.mb-grid-solutions.com",
"logo": "https://www.mb-grid-solutions.com/assets/logo.png",
"description": "Ihr spezialisierter Partner für herstellerneutrale technische Beratung und Projektbegleitung bei Energiekabelprojekten bis 110 kV.",
"address": {
"@type": "PostalAddress",
"streetAddress": "Raiffeisenstraße 22",
"addressLocality": "Remshalden",
"postalCode": "73630",
"addressCountry": "DE"
},
"contactPoint": {
"@type": "ContactPoint",
"email": "info@mb-grid-solutions.com",
"contactType": "customer service"
}
};
return (
<html lang="de" className={`${inter.variable}`}>
<head>
<script
type="application/ld+json"
dangerouslySetInnerHTML={{ __html: JSON.stringify(jsonLd) }}
/>
</head>
<body className="antialiased">
<Layout>
{children}
</Layout>
</body>
</html>
);
}

View File

@@ -1,175 +0,0 @@
import { ImageResponse } from 'next/og';
export const runtime = 'edge';
export const alt = 'MB Grid Solutions | Energiekabelprojekte & Technische Beratung';
export const size = {
width: 1200,
height: 630,
};
export const contentType = 'image/png';
export default async function Image() {
return new ImageResponse(
(
<div
style={{
background: '#ffffff',
width: '100%',
height: '100%',
display: 'flex',
flexDirection: 'column',
alignItems: 'center',
justifyContent: 'center',
position: 'relative',
fontFamily: 'sans-serif',
}}
>
{/* Grid Pattern Background - matching .grid-pattern in globals.css */}
<div
style={{
position: 'absolute',
top: 0,
left: 0,
right: 0,
bottom: 0,
backgroundImage: 'radial-gradient(circle, #e2e8f0 1.5px, transparent 1.5px)',
backgroundSize: '40px 40px',
zIndex: 0,
}}
/>
{/* Content Container - matching .card-modern / .glass-panel style */}
<div
style={{
display: 'flex',
flexDirection: 'column',
alignItems: 'center',
justifyContent: 'center',
backgroundColor: 'rgba(255, 255, 255, 0.95)',
padding: '60px 80px',
borderRadius: '48px',
border: '1px solid #e2e8f0',
boxShadow: '0 25px 50px -12px rgba(0, 0, 0, 0.1)',
zIndex: 1,
position: 'relative',
}}
>
{/* Engineering Excellence Badge */}
<div
style={{
display: 'flex',
alignItems: 'center',
gap: '12px',
padding: '8px 20px',
backgroundColor: 'rgba(16, 185, 129, 0.1)',
borderRadius: '100px',
marginBottom: '32px',
}}
>
<div
style={{
width: '10px',
height: '10px',
backgroundColor: '#10b981',
borderRadius: '50%',
}}
/>
<div
style={{
fontSize: '14px',
fontWeight: 'bold',
color: '#10b981',
textTransform: 'uppercase',
letterSpacing: '0.1em',
}}
>
Engineering Excellence
</div>
</div>
{/* Brand Mark */}
<div
style={{
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
width: '100px',
height: '100px',
backgroundColor: '#0f172a',
borderRadius: '24px',
marginBottom: '32px',
boxShadow: '0 10px 15px -3px rgba(15, 23, 42, 0.3)',
}}
>
<div
style={{
fontSize: '48px',
fontWeight: 'bold',
color: '#10b981',
}}
>
MB
</div>
</div>
{/* Title */}
<div
style={{
fontSize: '72px',
fontWeight: '900',
color: '#0f172a',
marginBottom: '16px',
textAlign: 'center',
letterSpacing: '-0.02em',
}}
>
MB Grid <span style={{ color: '#10b981' }}>Solutions</span>
</div>
{/* Subtitle */}
<div
style={{
fontSize: '32px',
fontWeight: '500',
color: '#64748b',
textAlign: 'center',
maxWidth: '800px',
lineHeight: 1.4,
}}
>
Energiekabelprojekte & Technische Beratung
<br />
bis 110 kV
</div>
</div>
{/* Tech Lines - matching .tech-line style */}
<div
style={{
position: 'absolute',
top: '10%',
left: 0,
width: '200px',
height: '1px',
backgroundColor: 'rgba(16, 185, 129, 0.2)',
}}
/>
<div
style={{
position: 'absolute',
bottom: '15%',
right: 0,
width: '300px',
height: '1px',
backgroundColor: 'rgba(16, 185, 129, 0.2)',
}}
/>
</div>
),
{
...size,
}
);
}

View File

@@ -1,175 +0,0 @@
import { ImageResponse } from 'next/og';
export const runtime = 'edge';
export const alt = 'MB Grid Solutions | Energiekabelprojekte & Technische Beratung';
export const size = {
width: 1200,
height: 630,
};
export const contentType = 'image/png';
export default async function Image() {
return new ImageResponse(
(
<div
style={{
background: '#ffffff',
width: '100%',
height: '100%',
display: 'flex',
flexDirection: 'column',
alignItems: 'center',
justifyContent: 'center',
position: 'relative',
fontFamily: 'sans-serif',
}}
>
{/* Grid Pattern Background */}
<div
style={{
position: 'absolute',
top: 0,
left: 0,
right: 0,
bottom: 0,
backgroundImage: 'radial-gradient(circle, #e2e8f0 1.5px, transparent 1.5px)',
backgroundSize: '40px 40px',
zIndex: 0,
}}
/>
{/* Content Container */}
<div
style={{
display: 'flex',
flexDirection: 'column',
alignItems: 'center',
justifyContent: 'center',
backgroundColor: 'rgba(255, 255, 255, 0.95)',
padding: '60px 80px',
borderRadius: '48px',
border: '1px solid #e2e8f0',
boxShadow: '0 25px 50px -12px rgba(0, 0, 0, 0.1)',
zIndex: 1,
position: 'relative',
}}
>
{/* Engineering Excellence Badge */}
<div
style={{
display: 'flex',
alignItems: 'center',
gap: '12px',
padding: '8px 20px',
backgroundColor: 'rgba(16, 185, 129, 0.1)',
borderRadius: '100px',
marginBottom: '32px',
}}
>
<div
style={{
width: '10px',
height: '10px',
backgroundColor: '#10b981',
borderRadius: '50%',
}}
/>
<div
style={{
fontSize: '14px',
fontWeight: 'bold',
color: '#10b981',
textTransform: 'uppercase',
letterSpacing: '0.1em',
}}
>
Engineering Excellence
</div>
</div>
{/* Brand Mark */}
<div
style={{
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
width: '100px',
height: '100px',
backgroundColor: '#0f172a',
borderRadius: '24px',
marginBottom: '32px',
boxShadow: '0 10px 15px -3px rgba(15, 23, 42, 0.3)',
}}
>
<div
style={{
fontSize: '48px',
fontWeight: 'bold',
color: '#10b981',
}}
>
MB
</div>
</div>
{/* Title */}
<div
style={{
fontSize: '72px',
fontWeight: '900',
color: '#0f172a',
marginBottom: '16px',
textAlign: 'center',
letterSpacing: '-0.02em',
}}
>
MB Grid <span style={{ color: '#10b981' }}>Solutions</span>
</div>
{/* Subtitle */}
<div
style={{
fontSize: '32px',
fontWeight: '500',
color: '#64748b',
textAlign: 'center',
maxWidth: '800px',
lineHeight: 1.4,
}}
>
Energiekabelprojekte & Technische Beratung
<br />
bis 110 kV
</div>
</div>
{/* Tech Lines */}
<div
style={{
position: 'absolute',
top: '10%',
left: 0,
width: '200px',
height: '1px',
backgroundColor: 'rgba(16, 185, 129, 0.2)',
}}
/>
<div
style={{
position: 'absolute',
bottom: '15%',
right: 0,
width: '300px',
height: '1px',
backgroundColor: 'rgba(16, 185, 129, 0.2)',
}}
/>
</div>
),
{
...size,
}
);
}

1
commitlint.config.js Normal file
View File

@@ -0,0 +1 @@
export { default } from "@mintel/husky-config/commitlint";

View File

@@ -1,21 +1,45 @@
'use client';
"use client";
import React from 'react';
import { Award, Clock, Lightbulb, Linkedin, MessageSquare, ShieldCheck, Truck } from 'lucide-react';
import { Reveal } from './Reveal';
import { TechBackground } from './TechBackground';
import { Counter } from './Counter';
import { Button } from './Button';
import React from "react";
import Image from "next/image";
import {
Award,
Clock,
Lightbulb,
Linkedin,
MessageSquare,
ShieldCheck,
Truck,
} from "lucide-react";
import { Reveal } from "./Reveal";
import { TechBackground } from "./TechBackground";
import { Counter } from "./Counter";
import { Button } from "./Button";
import { useTranslations } from "next-intl";
export default function About() {
const t = useTranslations("About");
const manifestIcons = [
Award,
Clock,
Lightbulb,
Truck,
MessageSquare,
ShieldCheck,
];
return (
<div className="overflow-hidden relative">
{/* Hero Section */}
<section className="relative min-h-[60vh] flex items-center pt-32 pb-20 overflow-hidden">
<section className="relative min-h-[60vh] flex items-center pt-44 pb-20 overflow-hidden">
<div className="absolute inset-0 z-0">
<div
className="absolute inset-0 bg-cover bg-center"
style={{ backgroundImage: 'url("/media/drums/iStock-487538226 (1).jpg")' }}
<Image
src="/media/drums/about-hero.jpg"
alt="About MB Grid Solutions"
fill
className="object-cover"
priority
/>
<div className="absolute inset-0 bg-gradient-to-r from-white via-white/95 to-white/40" />
<TechBackground />
@@ -26,17 +50,21 @@ export default function About() {
<Counter value={1} className="section-number" />
<Reveal delay={0.1}>
<span className="text-accent font-bold uppercase tracking-widest text-sm mb-4 block">
Über uns
{t("hero.tagline")}
</span>
</Reveal>
<Reveal delay={0.2}>
<h1 className="text-5xl md:text-6xl font-extrabold text-primary mb-8 leading-tight">
Wir gestalten die <span className="text-accent">Infrastruktur</span> der Zukunft
<h1 className="text-4xl sm:text-5xl md:text-6xl font-extrabold text-primary mb-6 md:mb-8 leading-tight">
{t.rich("hero.title", {
accent: (chunks) => (
<span className="text-accent">{chunks}</span>
),
})}
</h1>
</Reveal>
<Reveal delay={0.3}>
<p className="text-slate-600 text-xl md:text-2xl leading-relaxed mb-8">
MB Grid Solution steht for technische Exzellenz in der Energiekabeltechnologie. Wir verstehen uns als Ihr technischer Lotse.
<p className="text-slate-600 text-lg md:text-2xl leading-relaxed mb-8">
{t("hero.subtitle")}
</p>
</Reveal>
</div>
@@ -52,28 +80,43 @@ export default function About() {
<Reveal direction="right">
<div className="space-y-6 text-lg text-slate-600 leading-relaxed relative">
<div className="absolute -left-4 top-0 w-1 h-full bg-accent/10" />
<p>
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.
</p>
<p>
Wir verstehen die Herausforderungen der Energiewende und bieten herstellerneutrale Beratung, die auf Fakten, Normen und jahrzehntelanger Erfahrung basiert.
</p>
<p>{t("intro.p1")}</p>
<p>{t("intro.p2")}</p>
</div>
</Reveal>
<div className="grid grid-cols-1 sm:grid-cols-2 gap-6">
{[
{ name: 'Michael Bodemer', role: 'Geschäftsführung & Inhaber', linkedin: 'https://www.linkedin.com/in/michael-bodemer-33b493122/' },
{ name: 'Klaus Mintel', role: 'Geschäftsführung', linkedin: 'https://www.linkedin.com/in/klaus-mintel-b80a8b193/' }
{
name: "Michael Bodemer",
role: t("team.bodemer"),
linkedin:
"https://www.linkedin.com/in/michael-bodemer-33b493122/",
},
{
name: "Klaus Mintel",
role: t("team.mintel"),
linkedin:
"https://www.linkedin.com/in/klaus-mintel-b80a8b193/",
},
].map((person, i) => (
<Reveal key={i} delay={i * 0.1}>
<div className="card-modern !p-6 hover:-translate-y-1 transition-[box-shadow,transform] duration-300 relative overflow-hidden tech-card-border">
<div className="flex justify-between items-start mb-4 relative z-10">
<h3 className="text-xl font-bold text-primary">{person.name}</h3>
<a href={person.linkedin} target="_blank" rel="noopener noreferrer" className="text-[#0077b5] hover:scale-110 transition-transform">
<h3 className="text-xl font-bold text-primary">
{person.name}
</h3>
<a
href={person.linkedin}
target="_blank"
rel="noopener noreferrer"
className="text-[#0077b5] hover:scale-110 transition-transform"
>
<Linkedin size={20} />
</a>
</div>
<p className="text-accent text-sm font-bold uppercase tracking-wider relative z-10">{person.role}</p>
<p className="text-accent text-sm font-bold uppercase tracking-wider relative z-10">
{person.role}
</p>
</div>
</Reveal>
))}
@@ -88,31 +131,39 @@ export default function About() {
<div className="container-custom relative z-10">
<Counter value={3} className="section-number !text-white/5" />
<Reveal className="mb-20">
<span className="text-accent font-bold uppercase tracking-widest text-sm mb-4 block">Werte</span>
<h2 className="text-4xl md:text-5xl font-bold text-white mb-6">Unser Manifest</h2>
<p className="text-slate-400 text-lg">Werte, die unsere tägliche Arbeit leiten und den Erfolg Ihrer Projekte sichern.</p>
<span className="text-accent font-bold uppercase tracking-widest text-sm mb-4 block">
{t("manifest.tagline")}
</span>
<h2 className="text-3xl md:text-5xl font-bold text-white mb-6">
{t("manifest.title")}
</h2>
<p className="text-slate-400 text-base md:text-lg">
{t("manifest.subtitle")}
</p>
</Reveal>
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-8">
{[
{ icon: Award, title: 'Kompetenz', desc: 'Jahrzehntelange Erfahrung kombiniert mit europaweitem Know-how in modernsten Anlagen.' },
{ icon: Clock, title: 'Verfügbarkeit', desc: 'Schnelle und verlässliche Unterstützung ohne unnötige Verzögerungen.' },
{ icon: Lightbulb, title: 'Lösungen', desc: 'Wir stellen die richtigen Fragen, um die technisch und wirtschaftlich beste Lösung zu finden.' },
{ icon: Truck, title: 'Logistik', desc: 'Von der Fertigungsüberwachung bis zum termingerechten Fracht-Tracking.' },
{ icon: MessageSquare, title: 'Offenheit', desc: 'Wir hören zu und passen unsere Prozesse kontinuierlich an Ihren Erfolg an.' },
{ icon: ShieldCheck, title: 'Zuverlässigkeit', desc: 'Wir halten, was wir versprechen ohne Ausnahme. Verbindlichkeit ist unser Fundament.' }
].map((item, i) => (
<Reveal key={i} delay={i * 0.1}>
<div className="bg-white/5 p-10 rounded-3xl border border-white/10 group hover:-translate-y-1 transition-[box-shadow,transform] duration-300 h-full motion-fix relative 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" />
<div className="text-accent mb-6">
<item.icon size={32} />
</div>
<h4 className="text-xl font-bold text-white mb-4">{i + 1}. {item.title}</h4>
<p className="text-slate-400 leading-relaxed">{item.desc}</p>
</div>
</Reveal>
))}
{t
.raw("manifest.items")
.map((item: { title: string; desc: string }, i: number) => {
const Icon = manifestIcons[i];
return (
<Reveal key={i} delay={i * 0.1}>
<div className="bg-white/5 p-10 rounded-3xl border border-white/10 group hover:-translate-y-1 transition-[box-shadow,transform] duration-300 h-full motion-fix relative 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" />
<div className="text-accent mb-6">
<Icon size={32} />
</div>
<h4 className="text-xl font-bold text-white mb-4">
{i + 1}. {item.title}
</h4>
<p className="text-slate-400 leading-relaxed">
{item.desc}
</p>
</div>
</Reveal>
);
})}
</div>
</div>
</section>
@@ -123,18 +174,23 @@ export default function About() {
<div className="container-custom relative z-10">
<div className="section-number">04</div>
<Reveal>
<div className="relative rounded-[2.5rem] bg-slate-900 p-12 md:p-24 overflow-hidden group">
<div className="relative rounded-3xl md:rounded-[2.5rem] bg-slate-900 p-8 md:p-24 overflow-hidden group">
<div className="tech-corner top-8 left-8 border-t-2 border-l-2" />
<div className="tech-corner bottom-8 right-8 border-b-2 border-r-2" />
<div className="relative z-10 max-w-2xl">
<h2 className="text-4xl md:text-5xl font-bold text-white mb-8">
Bereit für Ihr nächstes Projekt?
<h2 className="text-3xl md:text-5xl font-bold text-white mb-6 md:mb-8">
{t("cta.title")}
</h2>
<p className="text-slate-400 text-xl mb-12">
Lassen Sie uns gemeinsam die optimale Lösung für Ihre Energieinfrastruktur finden.
<p className="text-slate-400 text-lg md:text-xl mb-8 md:mb-12">
{t("cta.subtitle")}
</p>
<Button href="/kontakt" variant="accent" showArrow className="!px-10 !py-5 text-lg">
Jetzt Kontakt aufnehmen
<Button
href="/kontakt"
variant="accent"
showArrow
className="w-full sm:w-auto !px-10 !py-5 text-lg"
>
{t("cta.button")}
</Button>
</div>
</div>

View File

@@ -1,30 +1,30 @@
'use client';
"use client";
import React, { useRef, useState } from 'react';
import { motion } from 'framer-motion';
import Link from 'next/link';
import { ArrowRight } from 'lucide-react';
import React, { useState } from "react";
import { m } from "framer-motion";
import Link from "next/link";
import { ArrowRight } from "lucide-react";
interface ButtonProps {
children: React.ReactNode;
href?: string;
onClick?: () => void;
variant?: 'primary' | 'accent' | 'outline' | 'ghost';
variant?: "primary" | "accent" | "outline" | "ghost";
className?: string;
showArrow?: boolean;
type?: 'button' | 'submit' | 'reset';
type?: "button" | "submit" | "reset";
disabled?: boolean;
}
export const Button = ({
children,
href,
onClick,
variant = 'primary',
className = '',
export const Button = ({
children,
href,
onClick,
variant = "primary",
className = "",
showArrow = false,
type = 'button',
disabled = false
type = "button",
disabled = false,
}: ButtonProps) => {
const [mousePosition, setMousePosition] = useState({ x: 0, y: 0 });
const [isHovered, setIsHovered] = useState(false);
@@ -37,30 +37,32 @@ export const Button = ({
});
};
const baseStyles = "inline-flex items-center justify-center px-10 py-5 rounded-2xl font-bold uppercase tracking-[0.2em] text-[10px] transition-all duration-500 relative group disabled:opacity-50 disabled:cursor-not-allowed select-none overflow-hidden";
const baseStyles =
"inline-flex items-center justify-center px-6 py-4 md:px-10 md:py-5 rounded-xl md:rounded-2xl font-bold uppercase tracking-[0.2em] text-[10px] transition-all duration-500 relative group cursor-pointer disabled:opacity-50 disabled:cursor-not-allowed select-none overflow-hidden";
const variants = {
primary: "bg-primary text-white shadow-lg",
accent: "bg-accent text-white shadow-lg",
outline: "border-2 border-primary text-primary hover:bg-primary hover:text-white",
ghost: "bg-slate-100 text-primary hover:bg-slate-200"
outline:
"border-2 border-primary text-primary hover:bg-primary hover:text-white",
ghost: "bg-slate-100 text-primary hover:bg-slate-200",
};
const content = (
<span className="relative z-10 flex items-center gap-3">
{children}
{showArrow && (
<ArrowRight
size={14}
strokeWidth={3}
className="group-hover:translate-x-1 transition-transform duration-300"
<ArrowRight
size={14}
strokeWidth={3}
className="group-hover:translate-x-1 transition-transform duration-300"
/>
)}
</span>
);
const spotlight = (
<motion.div
<m.div
className="absolute inset-0 z-0 pointer-events-none transition-opacity duration-500"
style={{
opacity: isHovered ? 1 : 0,

View File

@@ -1,36 +1,71 @@
'use client';
"use client";
import React, { useState } from 'react';
import { Mail, MapPin, CheckCircle } from 'lucide-react';
import { Button } from './Button';
import { Counter } from './Counter';
import { Reveal } from './Reveal';
import { TechBackground } from './TechBackground';
import React, { useState } from "react";
import Image from "next/image";
import Link from "next/link";
import { Mail, MapPin, CheckCircle } from "lucide-react";
import { Button } from "./Button";
import { Counter } from "./Counter";
import { Reveal } from "./Reveal";
import { TechBackground } from "./TechBackground";
import { StatusModal } from "./StatusModal";
import { useTranslations } from "next-intl";
export default function Contact() {
const t = useTranslations("Contact");
const [submitted, setSubmitted] = useState(false);
const [loading, setLoading] = useState(false);
const [statusModal, setStatusModal] = useState({
isOpen: false,
type: "success" as "success" | "error",
title: "",
message: "",
buttonText: "",
});
const handleSubmit = async (e: React.FormEvent<HTMLFormElement>) => {
e.preventDefault();
setLoading(true);
const formData = new FormData(e.currentTarget);
const data = Object.fromEntries(formData.entries());
try {
const response = await fetch('/api/contact', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
const response = await fetch("/api/contact", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(data),
});
if (response.ok) {
setSubmitted(true);
setStatusModal({
isOpen: true,
type: "success",
title: t("form.successTitle"),
message: t("form.successMessage"),
buttonText: t("form.close") || "Schließen",
});
} else {
const err = await response.json();
alert(`Fehler: ${err.error || 'Es gab einen Fehler beim Senden Ihrer Nachricht.'}`);
const errorMsg = t.has(`form.${err.error}`)
? t(`form.${err.error}`)
: err.error || t("form.errorMessage");
setStatusModal({
isOpen: true,
type: "error",
title: t("form.errorTitle"),
message: errorMsg,
buttonText: t("form.tryAgain") || "Erneut versuchen",
});
}
} catch (error) {
alert('Es gab einen Fehler beim Senden Ihrer Nachricht.');
} catch {
setStatusModal({
isOpen: true,
type: "error",
title: t("form.errorTitle"),
message: t("form.errorMessage"),
buttonText: t("form.tryAgain") || "Erneut versuchen",
});
} finally {
setLoading(false);
}
@@ -39,11 +74,14 @@ export default function Contact() {
return (
<div className="overflow-hidden relative">
{/* Hero Section */}
<section className="relative min-h-[40vh] flex items-center pt-32 pb-20 overflow-hidden">
<section className="relative min-h-[40vh] flex items-center pt-44 pb-20 overflow-hidden">
<div className="absolute inset-0 z-0">
<div
className="absolute inset-0 bg-cover bg-center"
style={{ backgroundImage: 'url("/media/laying/iStock-1282259999.jpg")' }}
<Image
src="/media/laying/contact-hero.jpg"
alt="Contact MB Grid Solutions"
fill
className="object-cover"
priority
/>
<div className="absolute inset-0 bg-gradient-to-r from-white via-white/95 to-white/40" />
<TechBackground />
@@ -53,16 +91,22 @@ export default function Contact() {
<div className="text-left relative">
<div className="section-number">01</div>
<Reveal delay={0.1}>
<span className="text-accent font-bold uppercase tracking-widest text-sm mb-4 block">Kontakt</span>
<span className="text-accent font-bold uppercase tracking-widest text-sm mb-4 block">
{t("hero.tagline")}
</span>
</Reveal>
<Reveal delay={0.2}>
<h1 className="text-5xl md:text-6xl font-extrabold text-primary mb-8 leading-tight">
Lassen Sie uns <span className="text-accent">sprechen</span>
<h1 className="text-4xl sm:text-5xl md:text-6xl font-extrabold text-primary mb-6 md:mb-8 leading-tight">
{t.rich("hero.title", {
accent: (chunks) => (
<span className="text-accent">{chunks}</span>
),
})}
</h1>
</Reveal>
<Reveal delay={0.3}>
<p className="text-slate-600 text-xl md:text-2xl leading-relaxed">
Haben Sie Fragen zu einem Projekt oder benötigen Sie technische Beratung? Wir freuen uns auf Ihre Nachricht.
<p className="text-slate-600 text-lg md:text-2xl leading-relaxed">
{t("hero.subtitle")}
</p>
</Reveal>
</div>
@@ -76,13 +120,18 @@ export default function Contact() {
<div className="grid grid-cols-1 lg:grid-cols-2 gap-16 md:gap-24">
<div className="space-y-8">
<Reveal delay={0.1}>
<div className="bg-white/5 p-8 rounded-2xl border border-white/10 flex gap-6 items-start hover:translate-x-1 transition-transform duration-300 relative overflow-hidden">
<div className="w-14 h-14 rounded-2xl bg-accent/10 text-accent flex items-center justify-center shrink-0 relative z-10">
<div className="bg-white/5 p-6 md:p-8 rounded-2xl border border-white/10 flex flex-col sm:flex-row gap-4 sm:gap-6 items-start hover:translate-x-1 transition-transform duration-300 relative overflow-hidden">
<div className="w-12 h-12 md:w-14 md:h-14 rounded-2xl bg-accent/10 text-accent flex items-center justify-center shrink-0 relative z-10">
<Mail size={24} />
</div>
<div className="relative z-10">
<h4 className="text-slate-400 font-bold text-xs uppercase tracking-widest mb-2">E-Mail</h4>
<a href="mailto:info@mb-grid-solutions.com" className="text-white text-xl font-bold hover:text-accent transition-colors">
<h4 className="text-slate-400 font-bold text-xs uppercase tracking-widest mb-1 md:mb-2">
{t("info.email")}
</h4>
<a
href="mailto:info@mb-grid-solutions.com"
className="text-white text-lg md:text-xl font-bold hover:text-accent transition-colors break-all"
>
info@mb-grid-solutions.com
</a>
</div>
@@ -90,15 +139,19 @@ export default function Contact() {
</Reveal>
<Reveal delay={0.2}>
<div className="bg-white/5 p-8 rounded-2xl border border-white/10 flex gap-6 items-start hover:translate-x-1 transition-transform duration-300 relative overflow-hidden">
<div className="w-14 h-14 rounded-2xl bg-accent/10 text-accent flex items-center justify-center shrink-0 relative z-10">
<div className="bg-white/5 p-6 md:p-8 rounded-2xl border border-white/10 flex flex-col sm:flex-row gap-4 sm:gap-6 items-start hover:translate-x-1 transition-transform duration-300 relative overflow-hidden">
<div className="w-12 h-12 md:w-14 md:h-14 rounded-2xl bg-accent/10 text-accent flex items-center justify-center shrink-0 relative z-10">
<MapPin size={24} />
</div>
<div className="relative z-10">
<h4 className="text-slate-400 font-bold text-xs uppercase tracking-widest mb-2">Anschrift</h4>
<p className="text-white text-xl font-bold leading-relaxed">
MB Grid Solutions & Services GmbH<br />
Raiffeisenstraße 22<br />
<h4 className="text-slate-400 font-bold text-xs uppercase tracking-widest mb-1 md:mb-2">
{t("info.address")}
</h4>
<p className="text-white text-lg md:text-xl font-bold leading-relaxed">
{t("info.company")}
<br />
Raiffeisenstraße 22
<br />
73630 Remshalden
</p>
</div>
@@ -108,13 +161,13 @@ export default function Contact() {
<Reveal delay={0.3}>
<div className="w-full h-[300px] rounded-[2.5rem] overflow-hidden border border-white/10 shadow-sm grayscale hover:grayscale-0 transition-all duration-700 relative group">
<div className="absolute inset-0 border-2 border-accent/0 group-hover:border-accent/20 transition-all duration-500 z-10 pointer-events-none rounded-[2.5rem]" />
<iframe
width="100%"
height="100%"
frameBorder="0"
scrolling="no"
marginHeight={0}
marginWidth={0}
<iframe
width="100%"
height="100%"
frameBorder="0"
scrolling="no"
marginHeight={0}
marginWidth={0}
src="https://www.openstreetmap.org/export/embed.html?bbox=9.445,48.815,9.465,48.825&layer=mapnik&marker=48.8198,9.4552"
></iframe>
</div>
@@ -122,83 +175,119 @@ export default function Contact() {
</div>
<Reveal delay={0.4}>
<div className="bg-white p-8 md:p-12 rounded-[2.5rem] border border-slate-100 shadow-xl relative overflow-hidden group">
<div className="bg-white p-6 md:p-12 rounded-3xl md:rounded-[2.5rem] border border-slate-100 shadow-xl relative overflow-hidden group">
<div className="tech-corner top-6 left-6 border-t-2 border-l-2 opacity-0 group-hover:opacity-100 transition-opacity duration-500" />
<div className="tech-corner bottom-6 right-6 border-b-2 border-r-2 opacity-0 group-hover:opacity-100 transition-opacity duration-500" />
{submitted ? (
<div className="text-center py-12">
<div className="w-20 h-20 rounded-full bg-accent/10 text-accent flex items-center justify-center mx-auto mb-8">
<CheckCircle size={40} />
</div>
<h3 className="text-3xl font-bold text-primary mb-4">Nachricht gesendet</h3>
<h3 className="text-3xl font-bold text-primary mb-4">
{t("form.successTitle")}
</h3>
<p className="text-slate-600 text-lg mb-10">
Vielen Dank für Ihre Anfrage. Wir werden uns in Kürze bei Ihnen melden.
{t("form.successMessage")}
</p>
<Button onClick={() => setSubmitted(false)}>
Weitere Nachricht
{t("form.moreMessages")}
</Button>
</div>
) : (
<form onSubmit={handleSubmit} className="space-y-6 relative z-10">
<form
onSubmit={handleSubmit}
className="space-y-6 relative z-10"
>
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
<div className="space-y-2">
<label htmlFor="name" className="text-sm font-bold text-slate-700 ml-1">Name *</label>
<label
htmlFor="name"
className="text-sm font-bold text-slate-700 ml-1"
>
{t("form.name")}
</label>
<input
type="text"
id="name"
name="name"
required
placeholder="Ihr Name"
className="w-full px-5 py-4 bg-slate-50 border border-slate-200 rounded-2xl focus:outline-none focus:border-accent focus:ring-4 focus:ring-accent/5 transition-all"
placeholder={t("form.namePlaceholder")}
className="w-full px-5 py-4 bg-slate-50 border border-slate-200 rounded-2xl focus:outline-none focus:border-accent focus:ring-4 focus:ring-accent/5 transition-all text-slate-900"
/>
</div>
<div className="space-y-2">
<label htmlFor="company" className="text-sm font-bold text-slate-700 ml-1">Firma</label>
<label
htmlFor="company"
className="text-sm font-bold text-slate-700 ml-1"
>
{t("form.company")}
</label>
<input
type="text"
id="company"
name="company"
placeholder="Ihr Unternehmen"
className="w-full px-5 py-4 bg-slate-50 border border-slate-200 rounded-2xl focus:outline-none focus:border-accent focus:ring-4 focus:ring-accent/5 transition-all"
placeholder={t("form.companyPlaceholder")}
className="w-full px-5 py-4 bg-slate-50 border border-slate-200 rounded-2xl focus:outline-none focus:border-accent focus:ring-4 focus:ring-accent/5 transition-all text-slate-900"
/>
</div>
</div>
<div className="space-y-2">
<label htmlFor="email" className="text-sm font-bold text-slate-700 ml-1">E-Mail *</label>
<label
htmlFor="email"
className="text-sm font-bold text-slate-700 ml-1"
>
{t("form.email")}
</label>
<input
type="email"
id="email"
name="email"
required
placeholder="ihre@email.de"
className="w-full px-5 py-4 bg-slate-50 border border-slate-200 rounded-2xl focus:outline-none focus:border-accent focus:ring-4 focus:ring-accent/5 transition-all"
placeholder={t("form.emailPlaceholder")}
className="w-full px-5 py-4 bg-slate-50 border border-slate-200 rounded-2xl focus:outline-none focus:border-accent focus:ring-4 focus:ring-accent/5 transition-all text-slate-900"
/>
</div>
<div className="space-y-2">
<label htmlFor="message" className="text-sm font-bold text-slate-700 ml-1">Nachricht *</label>
<label
htmlFor="message"
className="text-sm font-bold text-slate-700 ml-1"
>
{t("form.message")}
</label>
<textarea
id="message"
name="message"
required
rows={5}
placeholder="Wie können wir Ihnen helfen?"
className="w-full px-5 py-4 bg-slate-50 border border-slate-200 rounded-2xl focus:outline-none focus:border-accent focus:ring-4 focus:ring-accent/5 transition-all resize-none"
placeholder={t("form.messagePlaceholder")}
className="w-full px-5 py-4 bg-slate-50 border border-slate-200 rounded-2xl focus:outline-none focus:border-accent focus:ring-4 focus:ring-accent/5 transition-all resize-none text-slate-900"
></textarea>
</div>
<Button type="submit" variant="accent" disabled={loading} className="w-full py-5 text-lg" showArrow>
{loading ? 'Wird gesendet...' : 'Nachricht senden'}
<Button
type="submit"
variant="accent"
disabled={loading}
className="w-full py-5 text-lg"
showArrow
>
{loading ? t("form.submitting") : t("form.submit")}
</Button>
<p className="text-xs text-slate-400 text-center">
* Pflichtfelder. Mit dem Absenden erklären Sie sich mit unserer{' '}
<a href="/datenschutz" className="text-accent hover:underline font-semibold">
Datenschutzerklärung
</a>{' '}
einverstanden.
{t.rich("form.privacyNote", {
link: (chunks) => (
<Link
href="/datenschutz"
className="text-accent hover:underline font-semibold"
>
{chunks}
</Link>
),
})}
</p>
</form>
)}
@@ -207,6 +296,15 @@ export default function Contact() {
</div>
</div>
</section>
<StatusModal
isOpen={statusModal.isOpen}
onClose={() => setStatusModal({ ...statusModal, isOpen: false })}
type={statusModal.type}
title={statusModal.title}
message={statusModal.message}
buttonText={statusModal.buttonText}
/>
</div>
);
}

View File

@@ -1,51 +1,61 @@
'use client';
"use client";
import { motion } from 'framer-motion';
import { BarChart3, CheckCircle2, ChevronRight, Shield, Zap } from 'lucide-react';
import Link from 'next/link';
import { Counter } from './Counter';
import { Reveal } from './Reveal';
import { TechBackground } from './TechBackground';
import { TileGrid } from './TileGrid';
import { m } from "framer-motion";
import {
BarChart3,
CheckCircle2,
ChevronRight,
Shield,
Zap,
} from "lucide-react";
import Image from "next/image";
import Link from "next/link";
import { Button } from "./Button";
import { Counter } from "./Counter";
import { Reveal } from "./Reveal";
import { TechBackground } from "./TechBackground";
import { useTranslations } from "next-intl";
export default function Home() {
const t = useTranslations("Index");
const serviceJsonLd = {
"@context": "https://schema.org",
"@type": "Service",
"name": "Technische Beratung für Energiekabelprojekte",
"provider": {
name: t("portfolio.items.beratung.title"),
provider: {
"@type": "Organization",
"name": "MB Grid Solutions & Services GmbH"
name: "MB Grid Solutions & Services GmbH",
},
"description": "Herstellerneutrale technische Beratung für Ihre Projekte in Mittel- und Hochspannungsnetzen bis zu 110 kV.",
"areaServed": "Europe",
"hasOfferCatalog": {
description: t("portfolio.description"),
areaServed: "Europe",
hasOfferCatalog: {
"@type": "OfferCatalog",
"name": "Dienstleistungen",
"itemListElement": [
name: t("portfolio.title"),
itemListElement: [
{
"@type": "Offer",
"itemOffered": {
itemOffered: {
"@type": "Service",
"name": "Technische Beratung"
}
name: t("portfolio.items.beratung.title"),
},
},
{
"@type": "Offer",
"itemOffered": {
itemOffered: {
"@type": "Service",
"name": "Projektbegleitung"
}
name: t("portfolio.items.begleitung.title"),
},
},
{
"@type": "Offer",
"itemOffered": {
itemOffered: {
"@type": "Service",
"name": "Produktbeschaffung"
}
}
]
}
name: t("portfolio.items.beschaffung.title"),
},
},
],
},
};
return (
@@ -54,18 +64,21 @@ export default function Home() {
type="application/ld+json"
dangerouslySetInnerHTML={{ __html: JSON.stringify(serviceJsonLd) }}
/>
{/* Hero Section */}
<section className="relative min-h-[90vh] flex items-center pt-32 pb-20 overflow-hidden">
<section className="relative min-h-[90vh] flex items-center pt-44 pb-20 overflow-hidden">
<div className="absolute inset-0 z-0">
<div
className="absolute inset-0 bg-cover bg-center"
style={{ backgroundImage: 'url("/media/business/iStock-1068752548.jpg")' }}
<Image
src="/media/business/hero-bg.jpg"
alt="MB Grid Solutions Hero"
fill
className="object-cover"
priority
quality={90}
/>
<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">
@@ -76,29 +89,37 @@ export default function Home() {
<span className="animate-ping absolute inline-flex h-full w-full rounded-full bg-accent opacity-75"></span>
<span className="relative inline-flex rounded-full h-2 w-2 bg-accent"></span>
</span>
Engineering Excellence
{t("hero.tag")}
</span>
</Reveal>
<Reveal delay={0.2}>
<h1 className="text-5xl md:text-7xl font-extrabold text-primary mb-8 leading-[1.1]">
Spezialisierter Partner für <span className="text-accent">Energiekabelprojekte</span>
<h1 className="text-4xl sm:text-5xl md:text-7xl font-extrabold text-primary mb-6 md:mb-8 leading-[1.1]">
{t("hero.title") ===
"Spezialisierter Partner für Energiekabelprojekte" ? (
<>
Spezialisierter Partner für{" "}
<span className="text-accent">Energiekabelprojekte</span>
</>
) : (
t("hero.title")
)}
</h1>
</Reveal>
<Reveal delay={0.3}>
<p className="text-slate-600 text-xl md:text-2xl leading-relaxed mb-12 max-w-2xl">
Herstellerneutrale technische Beratung für Ihre Projekte in Mittel- und Hochspannungsnetzen bis zu 110 kV.
<p className="text-slate-600 text-lg md:text-2xl leading-relaxed mb-8 md:mb-12 max-w-2xl">
{t("hero.subtitle")}
</p>
</Reveal>
<Reveal delay={0.4}>
<div className="flex flex-wrap gap-4">
<Button href="/kontakt" variant="accent" showArrow>
Projekt anfragen
{t("hero.ctaPrimary")}
</Button>
<Button href="/ueber-uns" variant="ghost">
Mehr erfahren
{t("hero.ctaSecondary")}
</Button>
</div>
</Reveal>
@@ -113,34 +134,45 @@ export default function Home() {
<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">Portfolio</span>
<h2 className="text-4xl md:text-5xl font-bold text-white mb-6">Unsere Leistungen</h2>
<p className="text-slate-400 text-lg md:text-xl">
Beratung durch unabhängige Experten mit jahrzehntelanger Erfahrung aus Engineering, Normengremien, Planung und Produktion.
<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">
Alle Details ansehen <ChevronRight className="transition-transform group-hover:translate-x-1" size={20} />
<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: 'Technische Beratung',
desc: 'Individuelle Konzepte, Vergleiche, Risikobetrachtung und Empfehlungen für Ihre Kabelinfrastruktur.'
{
icon: <Zap size={32} />,
title: t("portfolio.items.beratung.title"),
desc: t("portfolio.items.beratung.desc"),
},
{
icon: <Shield size={32} />,
title: 'Projektbegleitung',
desc: 'Wir begleiten Sie bei der Verlegung und Installation, um Herausforderungen proaktiv zu lösen.'
{
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"),
},
{
icon: <BarChart3 size={32} />,
title: 'Produktbeschaffung',
desc: 'Herstellerneutrale Marktanalyse und Unterstützung bei der Komponentenwahl hinsichtlich Qualität und Preis.'
}
].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">
@@ -148,7 +180,9 @@ export default function Home() {
<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>
<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>
@@ -168,9 +202,11 @@ export default function Home() {
<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" />
<img
src="/media/cables/HS Kabel.png"
alt="Technical Engineering"
<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" />
@@ -179,21 +215,18 @@ export default function Home() {
</Reveal>
<div>
<Reveal>
<span className="text-accent font-bold uppercase tracking-widest text-sm mb-4 block">Expertise</span>
<h2 className="text-4xl md:text-5xl font-bold text-primary mb-8">Anwendungen & Zielgruppen</h2>
<p className="text-slate-600 text-lg md:text-xl mb-12">
Wir unterstützen Akteure der Energiewende bei der Realisierung komplexer Kabelprojekte mit höchster Präzision.
<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">
{[
'Energieversorger',
'Ingenieurbüros',
'Tiefbauunternehmen',
'Industrie',
'Projektierer EE',
'Planungsbüros'
].map((item, i) => (
{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" />
@@ -213,15 +246,16 @@ export default function Home() {
{/* 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">
<img
src="/media/drums/iStock-487538226 (1).jpg"
alt="Background"
className="w-full h-full object-cover"
<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 */}
@@ -229,15 +263,31 @@ export default function Home() {
<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">Spezifikationen</span>
<h2 className="text-4xl md:text-5xl font-bold text-white mb-6">Technische Expertise</h2>
<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-8">
<div className="grid grid-cols-1 md:grid-cols-3 gap-6 md:gap-8">
{[
{ label: 'Kabeltypen', value: 'N2XS(FL)2Y, N2X(F)KLD2Y...', desc: 'Umfassende Expertise im Design gängiger Hochspannungskabel.' },
{ label: 'Spannungsebenen', value: '64/110 kV & Mittelspannung', desc: 'Spezialisierte Beratung für komplexe Infrastrukturprojekte.' },
{ label: 'Leitertechnologie', value: 'Massiv-, Mehrdraht- & Milliken', desc: 'Optimierung des Leiterdesigns hinsichtlich Stromtragfähigkeit.' }
{
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">
@@ -248,9 +298,7 @@ export default function Home() {
<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>
<p className="text-slate-400 leading-relaxed">{item.desc}</p>
</div>
</Reveal>
))}
@@ -268,42 +316,77 @@ export default function Home() {
<div className="container-custom relative z-10">
<Counter value={5} className="section-number" />
<Reveal>
<div className="relative rounded-[2.5rem] bg-primary p-12 md:p-24 overflow-hidden group">
<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">
<motion.circle
<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"
transition={{
duration: 5,
repeat: Infinity,
ease: "easeInOut",
}}
cx="400"
cy="0"
r="400"
stroke="white"
strokeWidth="2"
/>
<motion.circle
<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"
transition={{
duration: 4,
repeat: Infinity,
ease: "easeInOut",
delay: 0.5,
}}
cx="400"
cy="0"
r="300"
stroke="white"
strokeWidth="2"
/>
<motion.circle
<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"
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-4xl md:text-6xl font-bold text-white mb-8 leading-tight">
Bereit für Ihr nächstes Projekt?
<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-xl mb-12 leading-relaxed">
Lassen Sie uns gemeinsam die optimale Lösung für Ihre Energieinfrastruktur finden. Wir beraten Sie herstellerneutral und kompetent.
<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="!px-10 !py-5 text-lg">
Jetzt Kontakt aufnehmen
<Button
href="/kontakt"
variant="accent"
showArrow
className="w-full sm:w-auto !px-10 !py-5 text-lg"
>
{t("cta.button")}
</Button>
</div>
</div>

View File

@@ -1,14 +1,17 @@
'use client';
"use client";
import { AnimatePresence, motion } from 'framer-motion';
import { ArrowUp, Home, Info, Menu, X } from 'lucide-react';
import Link from 'next/link';
import { usePathname } from 'next/navigation';
import React, { useEffect, useState } from 'react';
import { Button } from './Button';
import { Reveal } from './Reveal';
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";
import { usePathname } from "next/navigation";
import React, { useEffect, useState } from "react";
import { Button } from "./Button";
import { Reveal } from "./Reveal";
import { useTranslations } from "next-intl";
const Layout = ({ children }: { children: React.ReactNode }) => {
const t = useTranslations("Layout");
const pathname = usePathname();
const [showScrollTop, setShowScrollTop] = useState(false);
const [isScrolled, setIsScrolled] = useState(false);
@@ -19,9 +22,9 @@ const Layout = ({ children }: { children: React.ReactNode }) => {
setShowScrollTop(window.scrollY > 400);
setIsScrolled(window.scrollY > 20);
};
window.addEventListener('scroll', handleScroll, { passive: true });
return () => window.removeEventListener('scroll', handleScroll);
window.addEventListener("scroll", handleScroll, { passive: true });
return () => window.removeEventListener("scroll", handleScroll);
}, []);
useEffect(() => {
@@ -29,80 +32,93 @@ const Layout = ({ children }: { children: React.ReactNode }) => {
}, [pathname]);
const scrollToTop = () => {
window.scrollTo({ top: 0, behavior: 'smooth' });
window.scrollTo({ top: 0, behavior: "smooth" });
};
const isActive = (path: string) => pathname === path;
const isActive = (path: string) =>
pathname === path || pathname === `/en${path}` || pathname === `/de${path}`;
const navLinks = [
{ href: '/', label: 'Startseite', icon: Home },
{ href: '/ueber-uns', label: 'Über uns', icon: Info },
{ href: "/", label: t("nav.home"), icon: Home },
{ href: "/ueber-uns", label: t("nav.about"), icon: Info },
];
return (
<div className="min-h-screen flex flex-col font-sans">
<Reveal direction="down" fullWidth className="fixed top-0 left-0 right-0 z-[100]">
<Reveal
direction="down"
fullWidth
trigger="mount"
className="fixed top-0 left-0 right-0 z-[100]"
>
<header
className={`transition-all duration-300 flex items-center py-1 ${
className={`transition-all duration-300 flex items-center ${
isScrolled
? 'bg-white/90 backdrop-blur-lg border-b border-slate-200 shadow-sm'
: 'bg-gradient-to-b from-white/80 via-white/40 to-transparent'
? "bg-white/90 backdrop-blur-lg border-b border-slate-200 shadow-sm py-2"
: "bg-gradient-to-b from-white/80 via-white/40 to-transparent py-4"
}`}
>
<div className="container-custom flex justify-between items-center w-full relative z-10">
<Link
href="/"
className="relative z-10 flex items-center group"
aria-label="MB Grid Solutions - Zur Startseite"
>
<img
src="/assets/logo.png"
alt="MB Grid Solutions"
className={`transition-all duration-300 object-contain ${isScrolled ? 'h-[60px] md:h-[80px] my-[-5px]' : 'h-[100px] md:h-[140px] my-[-20px]'}`}
loading="eager"
/>
</Link>
{/* Desktop Navigation */}
<nav className="hidden md:flex items-center gap-8" aria-label="Hauptnavigation">
{navLinks.map((link) => (
<Link
key={link.href}
href={link.href}
className={`relative px-2 py-1 text-sm font-bold tracking-tight transition-all group ${
isActive(link.href)
? 'text-primary'
: `${isScrolled ? 'text-slate-600' : 'text-slate-900'} hover:text-primary`
}`}
>
{link.label}
<span className={`absolute -bottom-1 left-0 w-full h-0.5 bg-accent transition-transform duration-300 origin-left ${isActive(link.href) ? 'scale-x-100' : 'scale-x-0 group-hover:scale-x-100'}`} />
</Link>
))}
<Button
href="/kontakt"
className="ml-4 !py-2 !px-5 !text-[10px]"
<div className="container-custom flex justify-between items-center w-full relative z-10">
<Link
href="/"
className="relative z-10 flex items-center group"
aria-label={`${t("nav.home")} - Zur Startseite`}
>
Projekt anfragen
</Button>
</nav>
<div
className={`relative transition-all duration-300 ${isScrolled ? "h-[50px] md:h-[60px] w-[120px] md:w-[150px]" : "h-[70px] md:h-[100px] w-[160px] md:w-[240px]"}`}
>
<Image
src="/assets/logo.png"
alt="MB Grid Solutions"
fill
className="object-contain"
priority
/>
</div>
</Link>
{/* Mobile Menu Toggle */}
<button
className="md:hidden p-2 text-slate-600 hover:text-primary transition-colors"
onClick={() => setIsMobileMenuOpen(!isMobileMenuOpen)}
aria-label="Menü öffnen"
>
{isMobileMenuOpen ? <X size={24} /> : <Menu size={24} />}
</button>
</div>
{/* Desktop Navigation */}
<nav
className="hidden md:flex items-center gap-8"
aria-label="Hauptnavigation"
>
{navLinks.map((link) => (
<Link
key={link.href}
href={link.href}
className={`relative px-2 py-1 text-sm font-bold tracking-tight transition-all group ${
isActive(link.href)
? "text-primary"
: `${isScrolled ? "text-slate-600" : "text-slate-900"} hover:text-primary`
}`}
>
{link.label}
<span
className={`absolute -bottom-1 left-0 w-full h-0.5 bg-accent transition-transform duration-300 origin-left ${isActive(link.href) ? "scale-x-100" : "scale-x-0 group-hover:scale-x-100"}`}
/>
</Link>
))}
<Button href="/kontakt" className="ml-4 !py-2 !px-5 !text-[10px]">
{t("nav.cta")}
</Button>
</nav>
{/* Mobile Menu Toggle */}
<button
className="md:hidden p-2 text-slate-600 hover:text-primary transition-colors"
onClick={() => setIsMobileMenuOpen(!isMobileMenuOpen)}
aria-label="Menü öffnen"
>
{isMobileMenuOpen ? <X size={24} /> : <Menu size={24} />}
</button>
</div>
</header>
</Reveal>
{/* Mobile Menu Overlay */}
<AnimatePresence>
{isMobileMenuOpen && (
<motion.div
<m.div
initial={{ opacity: 0, y: -20 }}
animate={{ opacity: 1, y: 0 }}
exit={{ opacity: 0, y: -20 }}
@@ -110,38 +126,35 @@ const Layout = ({ children }: { children: React.ReactNode }) => {
>
<nav className="flex flex-col gap-4">
{navLinks.map((link) => (
<Link
<Link
key={link.href}
href={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'
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"
>
Projekt anfragen
<Button href="/kontakt" className="mt-4 w-full">
{t("nav.cta")}
</Button>
</nav>
</motion.div>
</m.div>
)}
</AnimatePresence>
<main className="flex-grow">
{children}
</main>
<main className="flex-grow">{children}</main>
<button
onClick={scrollToTop}
className={`fixed bottom-8 right-8 w-12 h-12 bg-primary text-white rounded-full flex items-center justify-center cursor-pointer z-[80] shadow-xl transition-all duration-300 hover:-translate-y-1 hover:bg-accent ${
showScrollTop ? 'opacity-100 translate-y-0' : 'opacity-0 translate-y-4 pointer-events-none'
showScrollTop
? "opacity-100 translate-y-0"
: "opacity-0 translate-y-4 pointer-events-none"
}`}
aria-label="Nach oben scrollen"
>
@@ -151,15 +164,15 @@ const Layout = ({ children }: { children: React.ReactNode }) => {
<Reveal fullWidth>
<footer className="bg-slate-900 text-slate-300 py-16 md:py-24 relative overflow-hidden group">
<div className="absolute inset-0 grid-pattern opacity-[0.08] pointer-events-none" />
{/* Animated Tech Lines */}
<motion.div
animate={{ x: ['-100%', '100%'] }}
<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"
/>
<motion.div
animate={{ x: ['100%', '-100%'] }}
<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"
/>
@@ -167,67 +180,110 @@ const Layout = ({ children }: { children: React.ReactNode }) => {
{/* 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" />
<div className="tech-corner bottom-8 right-8 border-b border-r border-white/10 group-hover:border-accent/30 transition-colors duration-700" />
<div className="container-custom relative z-10">
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-12 mb-16">
<div className="lg:col-span-2">
<Link href="/" className="inline-block mb-8 group">
<img
src="/assets/logo.png"
alt="MB Grid Solutions"
className="h-20 brightness-0 invert opacity-80 group-hover:opacity-100 transition-opacity"
loading="lazy"
/>
</Link>
<p className="text-slate-400 max-w-md leading-relaxed mb-8">
Ihr spezialisierter Partner für herstellerneutrale technische Beratung und Projektbegleitung bei Energiekabelprojekten bis 110 kV.
</p>
<div className="flex gap-4">
{/* Social links could go here */}
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-10 md:gap-12 mb-12 md:mb-16">
<div className="lg:col-span-2">
<Link href="/" className="inline-block mb-6 md:mb-8 group">
<div className="relative h-16 md:h-20 w-48 brightness-0 invert opacity-80 group-hover:opacity-100 transition-opacity">
<Image
src="/assets/logo.png"
alt="MB Grid Solutions"
fill
className="object-contain object-left"
/>
</div>
</Link>
<p className="text-slate-400 max-w-md leading-relaxed mb-8">
{t("footer.description")}
</p>
<div className="flex gap-4">
{/* Social links could go here */}
</div>
</div>
<div>
<h4 className="text-white font-bold mb-6">
{t("footer.navigation")}
</h4>
<nav className="flex flex-col gap-4">
{navLinks.map((link) => (
<Link
key={link.href}
href={link.href}
className="hover:text-accent transition-colors"
>
{link.label}
</Link>
))}
<Link
href="/kontakt"
className="hover:text-accent transition-colors"
>
{t("nav.contact")}
</Link>
</nav>
</div>
<div>
<h4 className="text-white font-bold mb-6">
{t("footer.legal")}
</h4>
<nav className="flex flex-col gap-4">
<Link
href="/impressum"
className="hover:text-accent transition-colors"
>
{t("footer.impressum")}
</Link>
<Link
href="/datenschutz"
className="hover:text-accent transition-colors"
>
{t("footer.datenschutz")}
</Link>
<Link
href="/agb"
className="hover:text-accent transition-colors"
>
{t("footer.agb")}
</Link>
</nav>
</div>
</div>
<div>
<h4 className="text-white font-bold mb-6">Navigation</h4>
<nav className="flex flex-col gap-4">
{navLinks.map((link) => (
<Link key={link.href} href={link.href} className="hover:text-accent transition-colors">
{link.label}
</Link>
))}
<Link href="/kontakt" className="hover:text-accent transition-colors">Kontakt</Link>
</nav>
</div>
<div>
<h4 className="text-white font-bold mb-6">Rechtliches</h4>
<nav className="flex flex-col gap-4">
<Link href="/impressum" className="hover:text-accent transition-colors">Impressum</Link>
<Link href="/datenschutz" className="hover:text-accent transition-colors">Datenschutz</Link>
<Link href="/agb" className="hover:text-accent transition-colors">AGB</Link>
</nav>
<div className="pt-8 border-t border-slate-800 flex flex-col md:flex-row justify-between items-center gap-6 md:gap-4 text-sm text-slate-500 relative text-center md:text-left">
<div className="absolute -top-px left-1/2 -translate-x-1/2 md:left-0 md:translate-x-0 w-12 h-px bg-accent/50" />
<p>
&copy; {new Date().getFullYear()} MB Grid Solutions & Services
GmbH. <br className="md:hidden" /> {t("footer.rights")}
</p>
<p className="flex items-center gap-2">
<span className="relative flex h-2 w-2">
<span className="animate-ping absolute inline-flex h-full w-full rounded-full bg-accent opacity-75"></span>
<span className="relative inline-flex rounded-full h-2 w-2 bg-accent"></span>
</span>
{t("footer.madeWith")}{" "}
<span className="text-accent">{t("footer.precision")}</span>{" "}
{t("footer.inGermany")}
</p>
</div>
</div>
<div className="pt-8 border-t border-slate-800 flex flex-col md:flex-row justify-between items-center gap-4 text-sm text-slate-500 relative">
<div className="absolute -top-px left-0 w-12 h-px bg-accent/50" />
<p>&copy; {new Date().getFullYear()} MB Grid Solutions & Services GmbH. Alle Rechte vorbehalten.</p>
<p className="flex items-center gap-2">
<span className="relative flex h-2 w-2">
<span className="animate-ping absolute inline-flex h-full w-full rounded-full bg-accent opacity-75"></span>
<span className="relative inline-flex rounded-full h-2 w-2 bg-accent"></span>
</span>
Made with <span className="text-accent">precision</span> in Germany
</p>
</div>
</div>
</footer>
</Reveal>
<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 <a href="https://mintel.me" target="_blank" rel="noopener noreferrer" className="text-slate-500 hover:text-accent transition-colors duration-300">mintel.me</a>
Website entwickelt von{" "}
<a
href="https://mintel.me"
target="_blank"
rel="noopener noreferrer"
className="text-slate-500 hover:text-accent transition-colors duration-300"
>
mintel.me
</a>
</p>
</div>
</div>

View File

@@ -1,22 +1,26 @@
'use client';
"use client";
import React from 'react';
import { motion } 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";
}
export const Reveal = ({
children,
className = '',
delay = 0,
direction = 'up',
fullWidth = false
export const Reveal = ({
children,
className = "",
delay = 0,
direction = "up",
fullWidth = false,
viewportMargin = "-50px",
trigger = "inView",
}: RevealProps) => {
const directions = {
up: { y: 30 },
@@ -26,28 +30,45 @@ export const Reveal = ({
};
return (
<motion.div
initial={{
opacity: 0,
...directions[direction]
<m.div
initial={{
opacity: 0,
...directions[direction],
}}
whileInView={{
opacity: 1,
x: 0,
y: 0
}}
viewport={{ once: true, margin: "-50px" }}
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}
</motion.div>
</m.div>
);
};
@@ -57,13 +78,13 @@ interface StaggerProps {
staggerDelay?: number;
}
export const Stagger = ({
children,
className = '',
staggerDelay = 0.1
export const Stagger = ({
children,
className = "",
staggerDelay = 0.1,
}: StaggerProps) => {
return (
<motion.div
<m.div
initial="initial"
whileInView="animate"
viewport={{ once: true, margin: "-50px" }}
@@ -77,6 +98,6 @@ export const Stagger = ({
className={className}
>
{children}
</motion.div>
</m.div>
);
};

104
components/StatusModal.tsx Normal file
View File

@@ -0,0 +1,104 @@
"use client";
import React from "react";
import { m, AnimatePresence, LazyMotion, domAnimation } from "framer-motion";
import { CheckCircle, AlertCircle, X } from "lucide-react";
import { Button } from "./Button";
interface StatusModalProps {
isOpen: boolean;
onClose: () => void;
type: "success" | "error";
title: string;
message: string;
buttonText: string;
}
export const StatusModal = ({
isOpen,
onClose,
type,
title,
message,
buttonText,
}: StatusModalProps) => {
return (
<LazyMotion features={domAnimation}>
<AnimatePresence>
{isOpen && (
<div className="fixed inset-0 z-[100] flex items-center justify-center p-4 md:p-6">
{/* Backdrop */}
<m.div
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
onClick={onClose}
className="absolute inset-0 bg-slate-950/80 backdrop-blur-sm"
/>
{/* Modal Content */}
<m.div
initial={{ opacity: 0, scale: 0.9, y: 20 }}
animate={{ opacity: 1, scale: 1, y: 0 }}
exit={{ opacity: 0, scale: 0.9, y: 20 }}
transition={{ type: "spring", damping: 25, stiffness: 300 }}
className="relative w-full max-w-lg bg-white rounded-[2.5rem] border border-slate-100 shadow-2xl overflow-hidden group"
>
{/* Tech Decoration */}
<div className="absolute top-0 left-0 w-full h-2 bg-slate-100 overflow-hidden">
<m.div
initial={{ x: "-100%" }}
animate={{ x: "100%" }}
transition={{ duration: 2, repeat: Infinity, ease: "linear" }}
className={`absolute inset-0 w-1/2 ${type === "success" ? "bg-accent" : "bg-red-500"} opacity-30`}
/>
</div>
<button
onClick={onClose}
className="absolute top-6 right-6 p-2 text-slate-400 hover:text-primary transition-colors hover:bg-slate-50 rounded-xl"
>
<X size={20} />
</button>
<div className="p-8 md:p-12 text-center">
<div
className={`w-20 h-20 rounded-full ${type === "success" ? "bg-accent/10 text-accent" : "bg-red-50 text-red-500"} flex items-center justify-center mx-auto mb-8 relative`}
>
<div
className={`absolute inset-0 ${type === "success" ? "bg-accent/20" : "bg-red-500/20"} rounded-full animate-ping opacity-20`}
/>
{type === "success" ? (
<CheckCircle size={40} />
) : (
<AlertCircle size={40} />
)}
</div>
<h3 className="text-3xl font-extrabold text-primary mb-4 leading-tight">
{title}
</h3>
<p className="text-slate-600 text-lg mb-10 leading-relaxed">
{message}
</p>
<Button
onClick={onClose}
variant={type === "success" ? "accent" : "primary"}
className="w-full py-5 text-lg"
showArrow
>
{buttonText}
</Button>
</div>
{/* Decorative Corners */}
<div className="tech-corner top-4 left-4 border-t-2 border-l-2 opacity-20" />
<div className="tech-corner bottom-4 right-4 border-b-2 border-r-2 opacity-20" />
</m.div>
</div>
)}
</AnimatePresence>
</LazyMotion>
);
};

View File

@@ -1,51 +0,0 @@
'use client';
import React, { useEffect, useState } from 'react';
import { motion } 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]">
<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) => (
<motion.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-32 h-32 md:w-40 md:h-40 bg-white/10 backdrop-blur-[2px] rounded-3xl border border-white/20 shadow-[0_8px_32px_0_rgba(31,38,135,0.07)] shrink-0"
/>
))}
</div>
))}
</div>
</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

@@ -1,67 +1,89 @@
Liefer- und Zahlungsbedingungen
Stand Januar 2026
1. Allgemeines
Diese Liefer- und Zahlungsbedingungen (L&Z) der MB Grid Solutions & Services gelten ausschließlich; entgegenstehende oder von unseren Bedingungen abweichende Bedingungen des Kunden erkennen wir nicht an, es sei denn, wir hätten ausdrücklich schriftlich ihrer Geltung zugestimmt. Unsere L&Z gelten auch dann, wenn wir in Kenntnis entgegenstehender oder von unseren L&Z abweichender Bedingungen des Bestellers die Lieferung an diesen vorbehaltlos ausführen. Unsere L&Z gelten nur gegenüber Unternehmern im Sinn von § 310 Abs. 1 BGB sowie juristischen Personen des öffentlichen Rechts oder öffentlich-rechtliches Sondervermögen.
Nebenabreden, Vorbehalte, Änderungen, Ergänzungen usw. bedürfen zu ihrer Wirksamkeit unserer schriftlichen Bestätigung.
Hinweise auf die Geltung gesetzlicher Vorschriften haben nur klarstellende Bedeutung. Auch ohne eine derartige Klarstellung gelten daher die gesetzlichen Vorschriften, soweit sie in diesen L&Z nicht unmittelbar abgeändert oder ausdrücklich ausgeschlossen werden. Bezüglich Beratungsleistungen weisen wir ausdrücklich auf Punkt 17 hin.
2. Angebote
Sofern nicht ausdrücklich als bindend bezeichnet, sind unsere Angebote freibleibend; die Bestellung des Kunden ist als Angebot gemäß § 145 BGB zu qualifizieren.
3. Preise
Die Preise gelten für den in unseren Angeboten und Auftragsbestätigungen aufgeführten Leistungs- und Lieferumfang. Mehrleistungen werden gesondert berechnet. Die Hohlpreise verstehen sich in Euro zuzüglich Metallzuschlag, gegebenenfalls Verpackung, auftragsspezifischer Schnittkosten und der gesetzlichen Mehrwertsteuer.
4. Metallnotierung
Basis zur Kupferabrechnung ist die Notierung „LME Copper official price cash offer“, Durchschnitt des Liefervormonats zuzüglich der dann aktuellen von uns benannten Kupfer-Prämie.
Basis zur Aluminiumabrechnung ist die Notierung „LME Aluminium official price cash offer“, Durchschnitt des Liefervormonats zuzüglich der dann von uns benannten Aluminium-Prämie. USD werden auf Basis des EUR/USD LME-FX-Rate (MTLE) in EUR umgerechnet. Die entsprechenden Notierungen können Sie der Web-Seite www.westmetall.com entnehmen. Die Prämienzuschläge können stark variieren und MB Grid Solutions & Services behält sich das Recht vor, diese fristgerecht anzupassen, ungeachtet der Angebotslegung.
5. Metallzahl
Die von uns ausgewiesene Metallzahl ist eine rein kaufmännische Berechnungsgröße für den Metallinhalt, die in die Berechnung des Gesamtpreises eines Kabels eingeht. Damit entsprechen wir Ihrem Wunsch eine Vergleichbarkeit in ihrem System auf Hohlpreisbasis zu ermöglichen. Die Metallzahl gibt damit nicht das Gewicht des tatsächlich im Kabel enthaltenen Leitermetalls an. Sie ist ein rein kalkulatorischer Berechnungsfaktor, der jedoch keine unmittelbaren Rückschlüsse auf die im Kabel verwendeten Kupfer- bzw. Aluminiummengen zulässt. Wir weisen ausdrücklich darauf hin, final nur den Vollpreis für Vergleichszwecke heranzuziehen. Soweit Sie es wünschen andere Metallzahlen zu Grunde zu legen, sind wir gerne dazu bereit, das Angebot in den Bestandteilen umzurechnen. Bei jeglicher Änderung bleibt aber der Vollpreis der gleiche Betrag.
6. Auftragsänderung / Auftragsstorno
Nach Auftragsbestätigung werden Änderungen an bestätigten Aufträgen nur nach Prüfung und gesonderter ausdrücklicher Zustimmung durch uns akzeptiert. Wir behalten uns bei allen Auftragsänderungen das Recht vor, einen durch die Änderung entstandenen Mehraufwand, wie z.B. Bearbeitungskosten oder Entsorgungskosten in Rechnung zu stellen.
7. Eigentumsvorbehalt
Wir behalten uns an den von uns gelieferten Waren nachfolgend: Vorbehaltsware bis zur vollständigen Begleichung aller unserer Forderungen aus den Geschäftsbeziehungen mit dem Besteller, das Eigentum vor. Der Eigentumsvorbehalt bleibt auch dann bestehen, wenn einzelne Forderungen in eine laufende Rechnung aufgenommen werden (Kontokorrentvorbehalt).
8. Zahlungsbedingungen | Aufrechnung | Zurückbehaltungsrechte
Unsere Rechnungen sind 10 Tage nach Rechnungsdatum ohne jeden Abzug zahlbar. Bei Nichteinhaltung der vereinbarten Zahlungsbedingungen sind wir berechtigt, Zinsen in Höhe von 7 %-Punkten über dem Basiszinssatz zu berechnen; das Recht zur Geltendmachung weitergehender Schäden, insbesondere nachgewiesener höherer Zinsen, bleibt hiervon unberührt.
Unsere Rechnungen sind 10 Tage nach Rechnungsdatum ohne jeden Abzug zahlbar. Rechnungsstellung bzw. Datum ist grundsätzlich der Tag der Übergabe an den Spediteur soweit wir aus unseren deutschen Lägern liefern. Ansonsten gilt bei Direktimporten der Tag der Verzollung, der zeitnah zum Anliefertag liegt. Bei Nichteinhaltung der vereinbarten Zahlungsbedingungen sind wir berechtigt, Zinsen in Höhe von 7 %-Punkten über dem Basiszinssatz zu berechnen; das Recht zur Geltendmachung weitergehender Schäden, insbesondere nachgewiesener höherer Zinsen, bleibt hiervon unberührt.
9. Liefervorbehalt | Teillieferungen
Sämtliche Lieferzusagen unsererseits stehen, sofern nichts anderes ausdrücklich schriftlich vereinbart ist, unter dem Vorbehalt der richtigen und rechtzeitigen Belieferung durch unsere Produzenten. Wir behalten uns jederzeit Teillieferungen vor. Darüber hinaus behalten wir uns branchenübliche Über- oder Unterlieferungen bis zu 10 % der bestellten Menge vor.
10. Lieferfristen und Liefertermine
Die Lieferfrist wird individuell vereinbart bzw. von uns bei Annahme der Bestellung angegeben. Sofern wir verbindliche Lieferfristen aus Gründen, die wir nicht zu vertreten haben, nicht einhalten können (Nichtverfügbarkeit der Leistung), werden wir den Besteller hierüber unverzüglich informieren und gleichzeitig die voraussichtliche, neue Lieferfrist mitteilen. Ist die Leistung auch innerhalb der neuen Lieferfrist nicht verfügbar, sind wir berechtigt, ganz oder teilweise vom Vertrag zurückzutreten. Eine bereits erbrachte Gegenleistung des Bestellers werden wir unverzüglich erstatten. Nichtverfügbarkeit der Leistung liegt beispielsweise vor bei nicht rechtzeitiger Selbstbelieferung durch unseren Zulieferer, wenn wir ein kongruentes Deckungsgeschäft abgeschlossen haben, bei sonstigen Störungen in der Lieferkette etwa aufgrund höherer Gewalt oder wenn wir im Einzelfall zur Beschaffung
nicht verpflichtet sind.
Die Lieferfrist wird individuell vereinbart bzw. von uns bei Annahme der Bestellung angegeben. Sofern wir verbindliche Lieferfristen aus Gründen, die wir nicht zu vertreten haben, nicht einhalten können (Nichtverfügbarkeit der Leistung), werden wir den Besteller hierüber unverzüglich informieren und gleichzeitig die voraussichtliche, neue Lieferfrist mitteilen. Ist die Leistung auch innerhalb der neuen Lieferfrist nicht verfügbar, sind wir berechtigt, ganz oder teilweise vom Vertrag zurückzutreten. Eine bereits erbrachte Gegenleistung des Bestellers werden wir unverzüglich erstatten. Nichtverfügbarkeit der Leistung liegt beispielsweise vor bei nicht rechtzeitiger Selbstbelieferung durch unseren Zulieferer, wenn wir ein kongruentes Deckungsgeschäft abgeschlossen haben, bei sonstigen Störungen in der Lieferkette etwa aufgrund höherer Gewalt oder wenn wir im Einzelfall zur Beschaffung nicht verpflichtet sind.
Der Eintritt unseres Lieferverzugs bestimmt sich nach den gesetzlichen Vorschriften. In jedem Fall ist aber eine Mahnung durch den Käufer erforderlich.
Die gesetzlichen Rechte bleiben im Übrigen unberührt.
Fixgeschäfte setzen die ausdrückliche schriftliche Bezeichnung als solche voraus. Ansonsten ist der Besteller stets verpflichtet, uns schriftlich eine angemessene Nachfrist zu setzen, wenn von uns zugesagte Termine und/ oder Fristen nicht eingehalten werden. Wird auch die Nachfrist nicht eingehalten, ist der Besteller berechtigt, vom Vertrag zurückzutreten. Im Fall höherer Gewalt und/oder sonstiger von uns nicht vorhersehbarer außergewöhnlicher und/oder unverschuldeter Umstände, auch wenn sie bei unserem Vorlieferanten eintreten, verlängert sich eine von uns zugesagte Lieferfrist bis zur Behebung des vorerwähnten Ereignisses. Ist dieser Zeitpunkt nicht überblickbar, sind sowohl der Besteller als auch wir berechtigt, von dem abgeschlossenen Vertrag zurückzutreten. In diesem Fall sind beiderseits Schadensersatzansprüche ausgeschlossen. Wir verpflichten uns, bei Bekanntwerden vorerwähnter Umstände den Besteller hiervon unverzüglich zu benachrichtigen.
Fixgeschäfte setzen die ausdrückliche schriftliche Bezeichnung als solche voraus. Ansonsten ist der Besteller stets verpflichtet, uns schriftlich eine angemessene Nachfrist zu setzen, wenn von uns zugesagte Termine und/ oder Fristen nicht einhalten werden. Wird auch die Nachfrist nicht eingehalten, ist der Besteller berechtigt, vom Vertrag zurückzutreten. Im Fall höherer Gewalt und/oder sonstiger von uns nicht vorhersehbarer außergewöhnlicher und/oder unverschuldeter Umstände, auch wenn sie bei unserem Vorlieferanten eintreten, verlängert sich eine von uns zugesagte Lieferfrist bis zur Behebung des vorerwähnten Ereignisses. Ist dieser Zeitpunkt nicht überblickbar, sind sowohl der Besteller als auch wir berechtigt, von dem abgeschlossenen Vertrag zurückzutreten. In diesem Fall sind beiderseits Schadensersatzansprüche ausgeschlossen. Wir verpflichten uns, bei Bekanntwerden vorerwähnter Umstände den Besteller hiervon unverzüglich zu benachrichtigen.
Ist die Einhaltung eines Termins davon abhängig, dass uns seitens des Bestellers bestimmte Angaben und/oder Pläne, Freigabeerklärungen oder ähnliches erteilt werden, beginnt die Lieferfrist erst von dem Zeitpunkt an zu laufen, zu dem uns die vollständigen Angaben des Bestellers schriftlich vorliegen. Wird die Anlieferung auf Wunsch des Bestellers über den vertraglich vorgesehenen Zeitpunkt hinausgeschoben, kann von uns beginnend mit einer Frist von frühestens 10 Werktagen nach Anzeige der Versandbereitschaft dem Besteller ein Lagergeld in Höhe von 2 % des Rechnungsbetrages für jeden angefangenen Monat, maximal jedoch 10 % insgesamt berechnet werden.
11. Abrufaufträge
Wird uns ein Abrufauftrag erteilt und werden über die Abruftermine keine gesonderten schriftlichen Vereinbarungen getroffen, ist der Besteller verpflichtet, uns die einzelnen Abruftermine so mitzuteilen, dass zwischen Eingang der Abrufmitteilung bei uns und Auslieferung mindestens 14 Werktage und die letzte Auslieferung spätestens 90 Tage nach unserer Auftragsbestätigung liegt.
12. Maß- und Gewichtsangaben
Alle Angaben über Durchmesser, Gewicht, technische Gestaltung, Herstellung und Umfang der von uns zu liefernden Ware stehen unter dem Vorbehalt der Abweichung innerhalb der handelsüblichen zulässigen Toleranzen. Darüber hinaus behalten wir uns Änderungen, die einer technischen Verbesserung dienen, jederzeit vor. Farbabweichungen und/oder Abweichungen in der äußeren Beschaffenheit der von uns zu liefernden Ware, die jedoch deren Qualität und technische Wirksamkeit unbeeinflusst lässt, begründen keine Mängelhaftungsansprüche des Bestellers.
13. Gefahrübergang und -tragung
Die Lieferung erfolgt DAP frei Bestimmungsort Deutschland, wo auch der Erfüllungsort für die Lieferung und eine etwaige Nacherfüllung ist.
Wird die bestellte Ware von uns versandbereit gestellt und/oder verzögert sich die Versendung und/oder der Abruf aus Gründen, die vom Besteller zu vertreten sind, sind wir berechtigt, Ersatz des hieraus entstehenden Schadens einschließlich Mehraufwendungen zu verlangen. Hierfür berechnen wir eine pauschale Entschädigung i.H. von 2% des Rechnungsbetrages für jeden angefangenen Monat, maximal jedoch 10 % insgesamt beginnend mit der
Lieferfrist bzw. mangels einer Lieferfrist mit der Mitteilung der Versandbereitschaft der Ware. Der Nachweis eines höheren Schadens und unsere gesetzlichen Ansprüche (insbesondere Ersatz von Mehraufwendungen, angemessene Entschädigung, Kündigung) bleiben unberührt; die Pauschale ist aber auf weitergehende Geldansprüche anzurechnen. Dem Besteller bleibt der Nachweis gestattet, dass uns überhaupt kein oder nur ein wesentlich geringerer Schaden als vorstehende Pauschale entstanden ist. Rücksendungen an uns, die nicht vorher von uns schriftlich bestätigt worden sind, erfolgen auf alleinige Gefahr des Bestellers.
Wird die bestellte Ware von uns versandbereit gestellt und/oder verzögert sich die Versendung und/oder der Abruf aus Gründen, die vom Besteller zu vertreten sind, sind wir berechtigt, Ersatz des hieraus entstehenden Schadens einschließlich Mehraufwendungen für Einlagerungen zu verlangen. Hierfür berechnen wir eine pauschale Entschädigung i.H. von 2% des Rechnungsbetrages für jeden angefangenen Monat, maximal jedoch 10 % insgesamt beginnend mit der Lieferfrist bzw. mangels einer Lieferfrist mit der Mitteilung der Versandbereitschaft der Ware.
Der Nachweis eines höheren Schadens und unsere gesetzlichen Ansprüche (insbesondere Ersatz von Mehraufwendungen, angemessene Entschädigung, Kündigung) bleiben unberührt; die Pauschale ist aber auf weitergehende Geldansprüche anzurechnen. Dem Besteller bleibt der Nachweis gestattet, dass uns überhaupt kein oder nur ein wesentlich geringerer Schaden als vorstehende Pauschale entstanden ist. Rücksendungen an uns, die nicht vorher von uns schriftlich bestätigt worden sind, erfolgen auf alleinige Gefahr des Bestellers.
14. Mängelhaftung
Wir haften nur dann für die Einhaltung objektiver Anforderungen an der Ware, wenn und soweit zwischen dem Besteller und uns keine Beschaffenheitsvereinbarung getroffen wurde. Die einzuhaltenden subjektiven Anforderungen gehen den einzuhaltenden objektiven Anforderungen vor. Im Zweifel ergeben sich die vereinbarten Anforderungen an die Ware aus dem von uns bereitgestellten Datenblatt. Einzelne, nicht immer auszuschließende marginale Abweichungen, dürfen durch Reparaturen, wie zum Beispiel Mantelmanschetten nachgebessert werden.
Jedwede Mängelhaftungsansprüche des Bestellers setzen voraus, dass dieser die ihm übersandte Ware unverzüglich, d. h. in der Regel sofort bei Anlieferung (noch in Anwesenheit des Transporteurs) auf ihre ordnungsgemäße Beschaffenheit hin überprüft und uns zu verzeichnende sichtbare Mängel unmittelbar nach Erhalt der Ware und verdeckte Mängel unmittelbar nach deren Feststellung schriftlich mitteilt. Soweit ein rechtzeitig gerügter, nicht nur unerheblicher Mangel der Kaufsache vorliegt, sind wir nach unserer Wahl zur Mangelbeseitigung oder zur Ersatzlieferung (Nacherfüllung) berechtigt.
Wir übernehmen im Rahmen der Nacherfüllung in keinem Fall Ein- oder Ausbaukosten, wenn und soweit die Mangelhaftigkeit der Ware zum Zeitpunkt des Einbaus dem Besteller bekannt oder grob fahrlässig unbekannt geblieben ist. Sind wir zur Mangelbeseitigung/Ersatzlieferung nicht bereit oder nicht in der Lage oder verzögert sich diese über angemessene Fristen hinaus aus Gründen, die wir zu vertreten haben, oder schlägt sie in sonstiger Weise fehl, so ist der Besteller nach seiner Wahl berechtigt, vom Vertrag zurückzutreten oder eine entsprechende Minderung des Kaufpreises zu verlangen.
Weitergehende Ansprüche des Bestellers, gleich aus welchem Rechtsgrund, sind nach näherer Maßgabe der Regelungen in nachstehender Ziffer 15 ausgeschlossen bzw. beschränkt. Die Verjährungsfristen für Mängelhaftungsansprüche beträgt 24 Monate ab Übergabe der Ware.
Sollte es bei einer Mängelrüge zu unterschiedlichen Meinungen bezüglich des Kabelschaden kommen, gilt hier im Zweifelsfall nur die Expertise des VDE-Instituts selbst. Andere, auch akkreditierte Testlabore, akzeptieren wir nicht. Wir weisen ausdrücklich daraufhin, dass beim Verlegen des Kabels in den Graben oder in Rohren, bzw. in Bauwerke eine ständige Sichtkontrolle durch den Kabelverleger vorzunehmen ist, ob Auffälligkeiten zu vermerken sind. Eine spätere Reklamation, die fahrlässiges Verhalten vermuten lässt, schränkt sich damit ein. Dies gilt auch bei der Annahme der Ware, wo offensichtliche Beschädigungen direkt zu kommunizieren sind. Spätere Ansprüche nach Akzeptanz einer einwandfreien Belieferung sind detailliert zu beweisen.
15. Schadenersatz | Gesamthaftung
Wir haften unbeschränkt nur für Vorsatz und grobe Fahrlässigkeit sowie für Schäden aus einer Verletzung von Leben, Körper oder Gesundheit, die auf mindestens fahrlässiger Pflichtverletzung unsererseits oder unserer gesetzlichen Vertreter oder Erfüllungsgehilfen beruhen; ebenso haften wir unbeschränkt im Fall von uns übernommenen bzw. abgegebenen Garantien und Zusicherungen, sofern ein davon umfasster Mangel unsere Haftung auslöst sowie im Fall einer Haftung nach dem Produkthaftungsgesetz oder sonstigen Gefährdungshaftungstatbeständen. Im Fall sonstiger schuldhafter Verletzung wesentlicher Vertragspflichten („Kardinalpflichten“) ist unsere verbleibende Haftung auf den vertragstypischen vorhersehbaren Schaden beschränkt. Mangelfolgeschäden sowie entgangener Gewinn schließen wir grundsätzlich aus.
16. Kabeltrommeln
Unsere Kabel werden auf stabilen Vollholztrommeln geliefert. Auf Wunsch vermitteln wir Ihnen Partner, die diese Trommeln gegen eine Gebühr abholen.
17. Technische Beratungsdienstleistungen
Die technische Unterstützung ersetzt weder die Fachplanung noch die Ausführungs- oder Prüfverantwortung des beauftragten Ingenieurbüros, Planers oder der ausführenden Fachfirma bzw. verantwortlichen Abteilung.
Alle Hinweise, Einschätzungen und Empfehlungen der MB Grid Solutions and Services erfolgen ohne Gewähr und entbinden den jeweiligen Auftragnehmer nicht von seiner eigenen fachlichen Prüfung, Planung und Verantwortung.
18. Sonstiges
Es gilt ausschließlich das Recht der Bundesrepublik Deutschland unter Ausschluss des UN-Kaufrechts (CISG). Gerichtsstand ist nach unserer Wahl Stuttgart, der Erfüllungsort der Lieferverpflichtung oder das für den Sitz des Bestellers zuständige Gericht, sofern der Besteller Kaufmann, juristische Person des öffentlichen Rechts oder öffentlich-rechtliches Sondervermögen ist oder keinen allgemeinen Gerichtsstand im Inland hat.
Mit der Veröffentlichung der vorliegenden L&Z im Internet werden alle von uns früher verwendeten Bedingungen gegenstandslos.
Remshalden, 22.1.2026

View File

@@ -0,0 +1,2 @@
import { domAnimation } from "framer-motion"
export default domAnimation

View File

View File

View File

@@ -0,0 +1,40 @@
services:
app:
image: node:20-alpine
working_dir: /app
# Use pnpm since the project uses it, and run the next dev script directly
command: sh -c "corepack enable pnpm && pnpm i && pnpm dev:next"
volumes:
- .:/app
environment:
NODE_ENV: development
# Docker Internal Communication
DIRECTUS_URL: http://directus:8055
# Build / dependency installation
NPM_TOKEN: ${NPM_TOKEN}
CI: 'true'
ports:
- "3000:3000"
labels:
- "traefik.enable=true"
# Clear all production-related TLS/Middleware settings for the main routers
- "traefik.http.routers.${PROJECT_NAME:-mb-grid-solutions}.entrypoints=web"
- "traefik.http.routers.${PROJECT_NAME:-mb-grid-solutions}.rule=Host(`${TRAEFIK_HOST:-mb-grid-solutions.localhost}`)"
- "traefik.http.routers.${PROJECT_NAME:-mb-grid-solutions}.tls=false"
- "traefik.http.routers.${PROJECT_NAME:-mb-grid-solutions}.middlewares="
# Remove Gatekeeper for local dev simply by not defining it or overwriting?
# Actually, gatekeeper is a separate service. We can keep it or ignore it.
# But the app router normally points to gatekeeper middleware.
# By clearing middlewares above, we bypass gatekeeper for local dev.
directus:
labels:
- "traefik.enable=true"
- "traefik.http.routers.${PROJECT_NAME:-mb-grid-solutions}-directus.entrypoints=web"
- "traefik.http.routers.${PROJECT_NAME:-mb-grid-solutions}-directus.rule=Host(`${DIRECTUS_HOST:-cms.mb-grid-solutions.localhost}`)"
- "traefik.http.routers.${PROJECT_NAME:-mb-grid-solutions}-directus.tls=false"
- "traefik.http.routers.${PROJECT_NAME:-mb-grid-solutions}-directus.middlewares="
ports:
- "8055:8055"
environment:
PUBLIC_URL: http://${DIRECTUS_HOST:-cms.mb-grid-solutions.localhost}

View File

@@ -1,24 +1,121 @@
services:
app:
image: registry.infra.mintel.me/mintel/mb-grid-solutions:latest
image: registry.infra.mintel.me/mintel/mb-grid-solutions:${IMAGE_TAG:-latest}
restart: always
expose:
- "3000"
labels:
- "traefik.enable=true"
- "traefik.http.routers.mb-grid-solutions.rule=(Host(`mb-grid-solutions.com`) || Host(`www.mb-grid-solutions.com`))"
- "traefik.http.routers.mb-grid-solutions.entrypoints=websecure"
- "traefik.http.routers.mb-grid-solutions.tls.certresolver=le"
- "traefik.http.services.mb-grid-solutions.loadbalancer.server.port=3000"
- "traefik.http.routers.mb-grid-solutions.middlewares=auth@docker"
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: 5s
timeout: 2s
retries: 10
networks:
- infra
env_file:
- ${ENV_FILE:-.env}
labels:
- "traefik.enable=true"
- "traefik.http.routers.${PROJECT_NAME}.rule=${TRAEFIK_RULE:-Host(`${TRAEFIK_HOST:-mb-grid-solutions.localhost}`)}"
- "traefik.http.routers.${PROJECT_NAME}.entrypoints=websecure"
- "traefik.http.routers.${PROJECT_NAME}.tls.certresolver=le"
- "traefik.http.routers.${PROJECT_NAME}.tls=true"
- "traefik.http.services.${PROJECT_NAME}.loadbalancer.server.port=3000"
- "traefik.http.routers.${PROJECT_NAME}.middlewares=${TRAEFIK_MIDDLEWARES:-${PROJECT_NAME}-auth}"
- "traefik.docker.network=infra"
# Gatekeeper Router (Shared Host + dedicated Subdomain)
- "traefik.http.routers.${PROJECT_NAME}-gatekeeper.rule=${GATEKEEPER_RULE:-(Host(`${TRAEFIK_HOST:-mb-grid-solutions.localhost}`) && PathPrefix(`/gatekeeper`)) || Host(`gatekeeper.${TRAEFIK_HOST:-mb-grid-solutions.localhost}`)}"
- "traefik.http.routers.${PROJECT_NAME}-gatekeeper.entrypoints=websecure"
- "traefik.http.routers.${PROJECT_NAME}-gatekeeper.tls.certresolver=le"
- "traefik.http.routers.${PROJECT_NAME}-gatekeeper.tls=true"
- "traefik.http.routers.${PROJECT_NAME}-gatekeeper.service=${PROJECT_NAME}-gatekeeper"
- "traefik.http.middlewares.${PROJECT_NAME}-auth.forwardauth.address=http://${PROJECT_NAME}-gatekeeper:3000/api/verify"
- "traefik.http.middlewares.${PROJECT_NAME}-auth.forwardauth.trustForwardHeader=true"
- "traefik.http.middlewares.${PROJECT_NAME}-auth.forwardauth.authResponseHeaders=X-Auth-User"
- "traefik.docker.network=infra"
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
container_name: ${PROJECT_NAME:-mb-grid-solutions}-gatekeeper
restart: always
networks:
infra:
aliases:
- ${PROJECT_NAME:-mb-grid-solutions}-gatekeeper
env_file:
- ${ENV_FILE:-.env}
environment:
PORT: ${PORT:-3000}
PROJECT_NAME: ${PROJECT_NAME:-MB Grid Solutions}
PROJECT_COLOR: ${PROJECT_COLOR:-#82ed20}
COOKIE_DOMAIN: ${COOKIE_DOMAIN:-.mb-grid-solutions.com}
AUTH_COOKIE_NAME: ${AUTH_COOKIE_NAME:-mintel_gatekeeper_session}
GATEKEEPER_PASSWORD: ${GATEKEEPER_PASSWORD:-mintel}
# Dedicated Base URL for Gatekeeper subdomain to prevent redirect loops
NEXT_PUBLIC_BASE_URL: https://${GATEKEEPER_HOST:-gatekeeper.mb-grid-solutions.localhost}
labels:
- "traefik.enable=true"
- "traefik.http.services.${PROJECT_NAME}-gatekeeper.loadbalancer.server.port=3000"
- "traefik.docker.network=infra"
directus:
image: directus/directus:11
restart: always
networks:
- infra
- backend
env_file:
- ${ENV_FILE:-.env}
environment:
DB_CLIENT: 'pg'
DB_HOST: 'directus-db'
DB_PORT: '5432'
WEBSOCKETS_ENABLED: 'true'
PUBLIC_URL: ${DIRECTUS_URL}
KEY: ${DIRECTUS_KEY}
SECRET: ${DIRECTUS_SECRET}
ADMIN_EMAIL: ${DIRECTUS_ADMIN_EMAIL}
ADMIN_PASSWORD: ${DIRECTUS_ADMIN_PASSWORD}
DB_DATABASE: ${DIRECTUS_DB_NAME:-directus}
DB_USER: ${DIRECTUS_DB_USER:-directus}
DB_PASSWORD: ${DIRECTUS_DB_PASSWORD:-directus}
# Telemetry & Performance
LOGGER_LEVEL: ${LOG_LEVEL:-info}
SENTRY_DSN: ${SENTRY_DSN}
SENTRY_ENVIRONMENT: ${TARGET:-development}
volumes:
- ./directus/uploads:/directus/uploads
- ./directus/extensions:/directus/extensions
labels:
- "traefik.enable=true"
- "traefik.http.routers.${PROJECT_NAME}-directus.rule=Host(`${DIRECTUS_HOST:-cms.mb-grid-solutions.localhost}`)"
- "traefik.http.routers.${PROJECT_NAME}-directus.entrypoints=websecure"
- "traefik.http.routers.${PROJECT_NAME}-directus.tls.certresolver=le"
- "traefik.http.routers.${PROJECT_NAME}-directus.tls=true"
- "traefik.http.routers.${PROJECT_NAME}-directus.middlewares=${PROJECT_NAME}-forward,compress"
- "traefik.http.services.${PROJECT_NAME}-directus.loadbalancer.server.port=8055"
- "traefik.http.middlewares.${PROJECT_NAME}-forward.headers.customrequestheaders.X-Forwarded-Proto=https"
- "traefik.docker.network=infra"
directus-db:
image: postgres:15-alpine
restart: always
networks:
- backend
env_file:
- ${ENV_FILE:-.env}
environment:
POSTGRES_DB: ${DIRECTUS_DB_NAME:-directus}
POSTGRES_USER: ${DIRECTUS_DB_USER:-directus}
POSTGRES_PASSWORD: ${DIRECTUS_DB_PASSWORD:-directus}
volumes:
- directus-db-data:/var/lib/postgresql/data
networks:
infra:
external: true
external: true
backend:
internal: true
volumes:
directus-db-data:

9
i18n/request.ts Normal file
View File

@@ -0,0 +1,9 @@
import { getRequestConfig } from "next-intl/server";
export default getRequestConfig(async ({ locale }) => {
const baseLocale = locale || "de";
return {
locale: baseLocale,
messages: (await import(`../messages/${baseLocale}.json`)).default,
};
});

193
lib/config.ts Normal file
View File

@@ -0,0 +1,193 @@
/**
* Centralized configuration management for the application.
* This file provides a type-safe way to access environment variables.
*/
import { envSchema, getRawEnv } from "./env";
let memoizedConfig: ReturnType<typeof createConfig> | undefined;
/**
* Creates and validates the configuration object.
* Throws if validation fails.
*/
function createConfig() {
const env = envSchema.parse(getRawEnv());
const target = env.NEXT_PUBLIC_TARGET || env.TARGET;
return {
env: env.NODE_ENV,
target,
isProduction: target === "production" || !target,
isStaging: target === "staging",
isTesting: target === "testing",
isDevelopment: target === "development",
baseUrl: env.NEXT_PUBLIC_BASE_URL,
analytics: {
umami: {
websiteId: env.NEXT_PUBLIC_UMAMI_WEBSITE_ID || env.UMAMI_WEBSITE_ID,
apiEndpoint: env.UMAMI_API_ENDPOINT,
enabled: Boolean(
env.NEXT_PUBLIC_UMAMI_WEBSITE_ID || env.UMAMI_WEBSITE_ID,
),
},
},
errors: {
glitchtip: {
// Use SENTRY_DSN for both server and client (proxied)
dsn: env.SENTRY_DSN,
// The proxied origin used in the frontend
proxyPath: "/errors",
enabled: Boolean(env.SENTRY_DSN),
},
},
cache: {
enabled: false,
},
logging: {
level: env.LOG_LEVEL,
},
mail: {
host: env.MAIL_HOST,
port: env.MAIL_PORT,
user: env.MAIL_USERNAME,
pass: env.MAIL_PASSWORD,
from: env.MAIL_FROM,
recipients: env.MAIL_RECIPIENTS,
},
directus: {
url: env.DIRECTUS_URL,
adminEmail: env.DIRECTUS_ADMIN_EMAIL,
password: env.DIRECTUS_ADMIN_PASSWORD,
token: env.DIRECTUS_API_TOKEN,
internalUrl: env.INTERNAL_DIRECTUS_URL,
proxyPath: "/cms",
},
notifications: {
gotify: {
url: env.GOTIFY_URL,
token: env.GOTIFY_TOKEN,
enabled: Boolean(env.GOTIFY_URL && env.GOTIFY_TOKEN),
},
},
} as const;
}
/**
* Returns the validated configuration.
* Memoizes the result after the first call.
*/
export function getConfig() {
if (!memoizedConfig) {
memoizedConfig = createConfig();
}
return memoizedConfig;
}
/**
* Exported config object for convenience.
* Uses getters to ensure it's only initialized when accessed.
*/
export const config = {
get env() {
return getConfig().env;
},
get target() {
return getConfig().target;
},
get isProduction() {
return getConfig().isProduction;
},
get isStaging() {
return getConfig().isStaging;
},
get isTesting() {
return getConfig().isTesting;
},
get isDevelopment() {
return getConfig().isDevelopment;
},
get baseUrl() {
return getConfig().baseUrl;
},
get analytics() {
return getConfig().analytics;
},
get errors() {
return getConfig().errors;
},
get cache() {
return getConfig().cache;
},
get logging() {
return getConfig().logging;
},
get mail() {
return getConfig().mail;
},
get directus() {
return getConfig().directus;
},
get notifications() {
return getConfig().notifications;
},
};
/**
* Helper to get a masked version of the config for logging.
*/
export function getMaskedConfig() {
const c = getConfig();
const mask = (val: string | undefined) =>
val ? `***${val.slice(-4)}` : "not set";
return {
env: c.env,
baseUrl: c.baseUrl,
analytics: {
umami: {
websiteId: mask(c.analytics.umami.websiteId),
apiEndpoint: c.analytics.umami.apiEndpoint,
enabled: c.analytics.umami.enabled,
},
},
errors: {
glitchtip: {
dsn: mask(c.errors.glitchtip.dsn),
enabled: c.errors.glitchtip.enabled,
},
},
cache: {
enabled: c.cache.enabled,
},
logging: {
level: c.logging.level,
},
mail: {
host: c.mail.host,
port: c.mail.port,
user: mask(c.mail.user),
from: c.mail.from,
recipients: c.mail.recipients,
},
directus: {
url: c.directus.url,
adminEmail: mask(c.directus.adminEmail),
password: mask(c.directus.password),
token: mask(c.directus.token),
},
notifications: {
gotify: {
url: c.notifications.gotify.url,
token: mask(c.notifications.gotify.token),
enabled: c.notifications.gotify.enabled,
},
},
};
}

43
lib/directus.ts Normal file
View File

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

144
lib/env.ts Normal file
View File

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

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

@@ -0,0 +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 {
/**
* 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

@@ -0,0 +1,83 @@
/* 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 {
/**
* 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,150 @@
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 : "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
: undefined,
});
}
/**
* Track a pageview.
*/
trackPageview(url?: string) {
this.sendPayload("event", {
url:
url ||
(typeof window !== "undefined"
? window.location.pathname + window.location.search
: undefined),
});
}
}

View File

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

9
lib/services/cache/cache-service.ts vendored Normal file
View File

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

View File

@@ -0,0 +1,30 @@
import type { CacheService, CacheSetOptions } from "./cache-service";
type Entry = {
value: unknown;
expiresAt?: number;
};
export class MemoryCacheService implements CacheService {
private readonly store = new Map<string, Entry>();
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 entry.value as T;
}
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 del(key: string) {
this.store.delete(key);
}
}

View File

@@ -0,0 +1,86 @@
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,
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", {
environment: getMaskedConfig(),
timestamp: new Date().toISOString(),
});
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({
url: config.notifications.gotify.url!,
token: config.notifications.gotify.token!,
enabled: true,
})
: new NoopNotificationService();
if (config.notifications.gotify.enabled) {
logger.info("Gotify notification service initialized");
} else {
logger.info(
"Noop notification service initialized (notifications disabled)",
);
}
const errors = config.errors.glitchtip.enabled
? new GlitchtipErrorReportingService({ enabled: true }, 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);
logger.info("All application services initialized successfully");
return singleton;
}

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

@@ -0,0 +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>,
): 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

@@ -0,0 +1,77 @@
import * as Sentry from "@sentry/nextjs";
import type {
ErrorReportingLevel,
ErrorReportingService,
ErrorReportingUser,
} from "./error-reporting-service";
import type { NotificationService } from "../notifications/notification-service";
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 options: GlitchtipErrorReportingServiceOptions,
private readonly notifications?: NotificationService,
private readonly sentry: SentryLike = Sentry,
) {}
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) {
const errorMessage =
error instanceof Error ? error.message : String(error);
const contextStr = context
? `\nContext: ${JSON.stringify(context, null, 2)}`
: "";
await this.notifications.notify({
title: "🔥 Critical Error Captured",
message: `Error: ${errorMessage}${contextStr}`,
priority: 7,
});
}
return result;
}
captureMessage(message: string, level: ErrorReportingLevel = "error") {
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;
}
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) {
for (const [key, value] of Object.entries(context)) {
scope.setExtra(key, value);
}
}
return fn();
});
}
}

View File

@@ -0,0 +1,23 @@
/* eslint-disable @typescript-eslint/no-unused-vars */
import type {
ErrorReportingLevel,
ErrorReportingService,
ErrorReportingUser,
} from "./error-reporting-service";
export class NoopErrorReportingService implements ErrorReportingService {
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

@@ -0,0 +1,11 @@
export type LogLevel = "trace" | "debug" | "info" | "warn" | "error" | "fatal";
export interface 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

@@ -0,0 +1,70 @@
import pino, { Logger as PinoLogger } from "pino";
import type { LoggerService } from "./logger-service";
import { config } from "../../config";
export class PinoLoggerService implements LoggerService {
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";
this.logger = pino({
name: name || "app",
level: config.logging.level,
transport: useTransport
? {
target: "pino-pretty",
options: {
colorize: true,
},
}
: undefined,
});
}
}
trace(msg: string, ...args: unknown[]) {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
this.logger.trace(msg, ...(args as any));
}
debug(msg: string, ...args: unknown[]) {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
this.logger.debug(msg, ...(args as any));
}
info(msg: string, ...args: unknown[]) {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
this.logger.info(msg, ...(args as any));
}
warn(msg: string, ...args: unknown[]) {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
this.logger.warn(msg, ...(args as any));
}
error(msg: string, ...args: unknown[]) {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
this.logger.error(msg, ...(args as any));
}
fatal(msg: string, ...args: unknown[]) {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
this.logger.fatal(msg, ...(args as any));
}
child(bindings: Record<string, unknown>): LoggerService {
const childPino = this.logger.child(bindings);
const service = new PinoLoggerService();
// eslint-disable-next-line @typescript-eslint/no-explicit-any
(service as any).logger = childPino;
return service;
}
}

View File

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

View File

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

176
messages/de.json Normal file
View File

@@ -0,0 +1,176 @@
{
"Index": {
"hero": {
"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.",
"ctaPrimary": "Projekt anfragen",
"ctaSecondary": "Mehr erfahren"
},
"portfolio": {
"tag": "Portfolio",
"title": "Unsere Leistungen",
"description": "Beratung durch unabhängige Experten mit jahrzehntelanger Erfahrung aus Engineering, Normengremien, Planung und Produktion.",
"link": "Alle Details ansehen",
"items": {
"beratung": {
"title": "Technische Beratung",
"desc": "Individuelle Konzepte, Vergleiche, Risikobetrachtung und Empfehlungen für Ihre Kabelinfrastruktur."
},
"begleitung": {
"title": "Projektbegleitung",
"desc": "Wir begleiten Sie bei der Verlegung und Installation, um Herausforderungen proaktiv zu lösen."
},
"beschaffung": {
"title": "Produktbeschaffung",
"desc": "Herstellerneutrale Marktanalyse und Unterstützung bei der Komponentenwahl hinsichtlich Qualität und Preis."
}
}
},
"expertise": {
"tag": "Expertise",
"title": "Anwendungen & Zielgruppen",
"description": "Wir unterstützen Sie bei der Realisierung Ihrer Kabelprojekte.",
"groups": [
"Energieversorger",
"Ingenieurbüros",
"Tiefbauunternehmen",
"Industrie",
"Projektierer EE",
"Planungsbüros"
]
},
"specs": {
"tag": "Spezifikationen",
"title": "Technische Expertise",
"items": {
"kabel": {
"label": "Kabeltypen",
"value": "N2XS(FL)2Y, N2X(F)KLD2Y...",
"desc": "Umfassende Expertise im Design gängiger Hochspannungskabel."
},
"spannung": {
"label": "Spannungsebenen",
"value": "64/110 kV & Mittelspannung",
"desc": "Spezialisierte Beratung für komplexe Infrastrukturprojekte."
},
"technologie": {
"label": "Leitertechnologie",
"value": "Massiv-, Mehrdraht- & Milliken",
"desc": "Optimierung des Leiterdesigns hinsichtlich Stromtragfähigkeit."
}
}
},
"cta": {
"title": "Bereit für Ihr nächstes Projekt?",
"subtitle": "Lassen Sie uns gemeinsam die optimale Lösung für Ihre Energieinfrastruktur finden. Wir beraten Sie herstellerneutral und kompetent.",
"button": "Jetzt Kontakt aufnehmen"
}
},
"Layout": {
"nav": {
"home": "Startseite",
"about": "Über uns",
"contact": "Kontakt",
"cta": "Projekt anfragen"
},
"footer": {
"description": "Ihr spezialisierter Partner für herstellerneutrale technische Beratung und Projektbegleitung bei Energiekabelprojekten bis 110 kV.",
"navigation": "Navigation",
"legal": "Rechtliches",
"impressum": "Impressum",
"datenschutz": "Datenschutz",
"agb": "AGB",
"rights": "Alle Rechte vorbehalten.",
"madeWith": "Entwickelt mit",
"precision": "Präzision",
"inGermany": "in Deutschland"
}
},
"About": {
"hero": {
"tagline": "Über uns",
"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.",
"p2": "Wir verstehen die Herausforderungen der Energiewende und bieten herstellerneutrale Beratung, die auf Fakten, Normen und jahrzehntelanger Erfahrung basiert."
},
"team": {
"bodemer": "Geschäftsführung & Inhaber",
"mintel": "Geschäftsführung"
},
"manifest": {
"tagline": "Werte",
"title": "Unser Manifest",
"subtitle": "Werte, die unsere tägliche Arbeit leiten und den Erfolg Ihrer Projekte sichern.",
"items": [
{
"title": "Kompetenz",
"desc": "Jahrzehntelange Erfahrung kombiniert mit europaweitem Know-how in modernsten Anlagen."
},
{
"title": "Verfügbarkeit",
"desc": "Schnelle und verlässliche Unterstützung ohne unnötige Verzögerungen."
},
{
"title": "Lösungen",
"desc": "Wir stellen die richtigen Fragen, um die technisch und wirtschaftlich beste Lösung zu finden."
},
{
"title": "Logistik",
"desc": "Von der Fertigungsüberwachung bis zum termingerechten Fracht-Tracking."
},
{
"title": "Offenheit",
"desc": "Wir hören zu und passen unsere Prozesse kontinuierlich an Ihren Erfolg an."
},
{
"title": "Zuverlässigkeit",
"desc": "Wir halten, was wir versprechen ohne Ausnahme. Verbindlichkeit ist unser Fundament."
}
]
},
"cta": {
"title": "Bereit für Ihr nächstes Projekt?",
"subtitle": "Lassen Sie uns gemeinsam die optimale Lösung für Ihre Energieinfrastruktur finden. Wir beraten Sie herstellerneutral und kompetent.",
"button": "Jetzt Kontakt aufnehmen"
}
},
"Contact": {
"hero": {
"tagline": "Kontakt",
"title": "Lassen Sie uns sprechen",
"subtitle": "Haben Sie Fragen zu einem Projekt oder benötigen Sie technische Beratung? Wir freuen uns auf Ihre Nachricht."
},
"info": {
"email": "E-Mail",
"address": "Anschrift",
"company": "MB Grid Solutions & Services GmbH"
},
"form": {
"name": "Name *",
"namePlaceholder": "Ihr Name",
"company": "Firma",
"companyPlaceholder": "Ihr Unternehmen",
"email": "E-Mail *",
"emailPlaceholder": "ihre@email.de",
"message": "Nachricht *",
"messagePlaceholder": "Wie können wir Ihnen helfen?",
"submit": "Anfrage senden",
"submitting": "Übertragung läuft...",
"successTitle": "Anfrage erfolgreich übermittelt",
"successMessage": "Ihr Anliegen wurde erfasst. Wir werden die Informationen prüfen und sich zeitnah mit Ihnen in Verbindung setzen.",
"close": "Schließen",
"tryAgain": "Erneut versuchen",
"moreMessages": "Weitere Nachricht",
"privacyNote": "* Pflichtfelder. Mit dem Absenden erklären Sie sich mit unserer Datenschutzerklärung einverstanden.",
"errorTitle": "Systemfehler",
"errorMessage": "Die Anfrage konnte nicht übermittelt werden. Bitte prüfen Sie Ihre Verbindung oder versuchen Sie es später erneut.",
"message_too_short": "Ihre Nachricht ist zu kurz (mindestens 20 Zeichen). Bitte beschreiben Sie Ihr Anliegen etwas detaillierter.",
"message_too_long": "Ihre Nachricht ist zu lang (maximal 4000 Zeichen). Bitte fassen Sie sich etwas kürzer."
}
}
}

22
middleware.ts Normal file
View File

@@ -0,0 +1,22 @@
import createMiddleware from "next-intl/middleware";
export default createMiddleware({
// A list of all locales that are supported
locales: ["de"],
// Used when no locale matches
defaultLocale: "de",
// Use default locale without prefix
localePrefix: "as-needed",
});
export const config = {
// Matcher for all pages and internationalized pathnames
// excluding api, _next, static files, etc.
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.

36
next.config.mjs Normal file
View File

@@ -0,0 +1,36 @@
import withMintelConfig from "@mintel/next-config";
/** @type {import('next').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);

4574
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -2,26 +2,50 @@
"name": "mb-grid-solutions.com",
"version": "1.0.0",
"type": "module",
"packageManager": "pnpm@10.18.3",
"scripts": {
"dev": "next dev",
"dev": "docker network create infra 2>/dev/null || true && echo '\\n🚀 Development Environment Starting...\\n\\n📱 App: http://mb-grid-solutions.localhost\\n🗄 CMS: http://cms.mb-grid-solutions.localhost/admin\\n🚦 Traefik: http://localhost:8080\\n\\n(Press Ctrl+C to stop)\\n' && docker compose down --remove-orphans && docker compose up app directus directus-db",
"dev:next": "next dev",
"build": "next build",
"start": "next start",
"lint": "next lint",
"test": "vitest"
"lint": "eslint app components lib scripts",
"test": "vitest",
"prepare": "husky",
"cms:bootstrap": "DIRECTUS_URL=http://localhost:8055 npx tsx --env-file=.env scripts/setup-directus.ts",
"cms:push:staging": "./scripts/sync-directus.sh push staging",
"cms:pull:staging": "./scripts/sync-directus.sh pull staging",
"cms:push:testing": "./scripts/sync-directus.sh push testing",
"cms:pull:testing": "./scripts/sync-directus.sh pull testing",
"cms:push:prod": "./scripts/sync-directus.sh push production",
"cms:pull:prod": "./scripts/sync-directus.sh pull production",
"pagespeed:test": "mintel pagespeed test"
},
"keywords": [],
"author": "",
"license": "ISC",
"description": "",
"dependencies": {
"@mintel/next-config": "^1.1.13",
"@mintel/next-utils": "^1.1.13",
"@sentry/nextjs": "^10.38.0",
"framer-motion": "^12.29.2",
"lucide-react": "^0.562.0",
"next": "^16.1.6",
"next-intl": "^4.8.2",
"nodemailer": "^7.0.12",
"pino": "^10.3.0",
"react": "^19.2.4",
"react-dom": "^19.2.4"
"react-dom": "^19.2.4",
"zod": "^4.3.6"
},
"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",
"@tailwindcss/postcss": "^4.1.18",
"@testing-library/jest-dom": "^6.9.1",
"@testing-library/react": "^16.3.2",
@@ -31,8 +55,14 @@
"@types/react-dom": "^19.2.3",
"@vitejs/plugin-react": "^5.1.2",
"autoprefixer": "^10.4.23",
"eslint": "^8.57.1",
"eslint-config-next": "15.1.6",
"husky": "^9.1.7",
"jsdom": "^27.4.0",
"lint-staged": "^16.2.7",
"pino-pretty": "^13.1.3",
"postcss": "^8.5.6",
"prettier": "^3.5.0",
"tailwindcss": "^4.1.18",
"typescript": "^5.9.3",
"vitest": "^4.0.18"

10143
pnpm-lock.yaml generated Normal file

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.

Binary file not shown.

After

Width:  |  Height:  |  Size: 350 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 6.3 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 189 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.4 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 414 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.6 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 736 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 7.2 MiB

150
scripts/setup-directus.ts Normal file
View File

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

131
scripts/sync-directus.sh Executable file
View File

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

8
scripts/validate-env.ts Normal file
View File

@@ -0,0 +1,8 @@
import { validateMintelEnv } from "@mintel/next-utils";
try {
validateMintelEnv();
console.log("✅ Environment variables validated");
} catch {
process.exit(1);
}

0
tests/.gitkeep Normal file
View File

View File

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

View File

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

View File

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

126
tests_bak/contact.test.tsx Normal file
View File

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

27
tests_bak/home.test.tsx Normal file
View File

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

12
tests_bak/setup.ts Normal file
View File

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

View File

@@ -1,31 +1,9 @@
{
"extends": "@mintel/tsconfig/nextjs.json",
"compilerOptions": {
"target": "ESNext",
"lib": [
"dom",
"dom.iterable",
"esnext"
],
"allowJs": true,
"skipLibCheck": true,
"strict": true,
"noEmit": true,
"esModuleInterop": true,
"module": "esnext",
"moduleResolution": "bundler",
"resolveJsonModule": true,
"isolatedModules": true,
"jsx": "react-jsx",
"incremental": true,
"plugins": [
{
"name": "next"
}
],
"paths": {
"@/*": [
"./*"
]
"@/*": ["./*"]
}
},
"include": [
@@ -35,7 +13,5 @@
".next/types/**/*.ts",
".next/dev/types/**/*.ts"
],
"exclude": [
"node_modules"
]
"exclude": ["node_modules", "tests", "tests_bak"]
}

1
tsconfig.tsbuildinfo Normal file

File diff suppressed because one or more lines are too long