feat: Integrate Directus CMS, add i18n with next-intl, and configure project tooling with pnpm, husky, and commitlint.**

This commit is contained in:
2026-02-05 01:18:06 +01:00
parent 765cfd4c69
commit e80140f7cf
65 changed files with 12793 additions and 5879 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)
NEXT_PUBLIC_UMAMI_WEBSITE_ID=
NEXT_PUBLIC_UMAMI_SCRIPT_URL=https://analytics.infra.mintel.me/script.js

View File

@@ -1,280 +1,169 @@
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
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 }}
project_name: ${{ steps.determine.outputs.project_name }}
steps:
# ═══════════════════════════════════════════════════════════════════════════════
# LOGGING: Workflow Start - Full Transparency
# ═══════════════════════════════════════════════════════════════════════════════
- name: 📋 Log Workflow Start
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 ""
- 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
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
fi
echo ""
TAG="${{ github.ref_name }}"
SHORT_SHA=$(echo "${{ github.sha }}" | cut -c1-7)
DOMAIN_BASE="mb-grid-solutions.com"
PRJ_ID="mb-grid-solutions"
# ═══════════════════════════════════════════════════════════════════════════════
# LOGGING: Build Phase
# ═══════════════════════════════════════════════════════════════════════════════
- name: 🏗️ Build Docker image
if [[ "${{ github.ref_type }}" == "branch" && "$TAG" == "main" ]]; then
TARGET="staging"
IMAGE_TAG="staging-${SHORT_SHA}"
ENV_FILE=".env.staging"
TRAEFIK_HOST="\`staging.${DOMAIN_BASE}\`"
NEXT_PUBLIC_BASE_URL="https://staging.${DOMAIN_BASE}"
DIRECTUS_URL="https://cms.staging.${DOMAIN_BASE}"
DIRECTUS_HOST="\`cms.staging.${DOMAIN_BASE}\`"
elif [[ "${{ github.ref_type }}" == "tag" ]]; then
TARGET="production"
IMAGE_TAG="$TAG"
ENV_FILE=".env.prod"
TRAEFIK_HOST="\`${DOMAIN_BASE}\`, \`www.${DOMAIN_BASE}\`"
NEXT_PUBLIC_BASE_URL="https://${DOMAIN_BASE}"
DIRECTUS_URL="https://cms.${DOMAIN_BASE}"
DIRECTUS_HOST="\`cms.${DOMAIN_BASE}\`"
else
TARGET="skip"
fi
echo "target=$TARGET" >> "$GITHUB_OUTPUT"
echo "image_tag=$IMAGE_TAG" >> "$GITHUB_OUTPUT"
echo "env_file=$ENV_FILE" >> "$GITHUB_OUTPUT"
echo "traefik_host=$TRAEFIK_HOST" >> "$GITHUB_OUTPUT"
echo "next_public_base_url=$NEXT_PUBLIC_BASE_URL" >> "$GITHUB_OUTPUT"
echo "directus_url=$DIRECTUS_URL" >> "$GITHUB_OUTPUT"
echo "directus_host=$DIRECTUS_HOST" >> "$GITHUB_OUTPUT"
echo "project_name=$PRJ_ID-$TARGET" >> "$GITHUB_OUTPUT"
qa:
name: 🧪 QA
needs: prepare
if: needs.prepare.outputs.target != 'skip'
runs-on: docker
steps:
- uses: actions/checkout@v4
- uses: pnpm/action-setup@v4
- uses: actions/setup-node@v4
with:
node-version: 20
cache: 'pnpm'
- run: pnpm install --frozen-lockfile
env:
NPM_TOKEN: ${{ secrets.NPM_TOKEN }}
- run: pnpm lint
- 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
steps:
- uses: actions/checkout@v4
- name: 🔐 Registry Login
run: echo "${{ secrets.REGISTRY_PASSWORD }}" | docker login registry.infra.mintel.me -u "${{ secrets.REGISTRY_USERNAME }}" --password-stdin
- name: 🏗️ Build and Push
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 ""
# Execute build with detailed logging
set -e
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 \
--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 ""
docker build \
--build-arg NPM_TOKEN=${{ secrets.NPM_TOKEN }} \
--build-arg NEXT_PUBLIC_BASE_URL=${{ needs.prepare.outputs.next_public_base_url }} \
--build-arg DIRECTUS_URL=${{ needs.prepare.outputs.directus_url }} \
-t registry.infra.mintel.me/mintel/mb-grid-solutions:${{ needs.prepare.outputs.image_tag }} .
docker push registry.infra.mintel.me/mintel/mb-grid-solutions:${{ needs.prepare.outputs.image_tag }}
# ═══════════════════════════════════════════════════════════════════════════════
# LOGGING: Deployment Phase
# ═══════════════════════════════════════════════════════════════════════════════
- name: 🚀 Deploy to production server
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 ""
# 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 ""
# 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"
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 ""
# ═══════════════════════════════════════════════════════════════════════════════
# 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 "╚══════════════════════════════════════════════════════════════════════════════╝"
# ═══════════════════════════════════════════════════════════════════════════════
# 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.
deploy:
name: 🚀 Deploy
needs: [prepare, build, qa]
if: needs.prepare.outputs.target != 'skip'
runs-on: docker
steps:
- name: 🚀 Deploy via SSH
uses: appleboy/ssh-action@master
with:
host: ${{ secrets.SSH_HOST }}
username: root
key: ${{ secrets.SSH_PRIVATE_KEY }}
script: |
APP_DIR="/home/deploy/sites/mb-grid-solutions.com"
mkdir -p $APP_DIR
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
# Update Environment
cat > ${{ needs.prepare.outputs.env_file }} << EOF
IMAGE_TAG=${{ needs.prepare.outputs.image_tag }}
TRAEFIK_HOST=${{ needs.prepare.outputs.traefik_host }}
PROJECT_NAME=${{ needs.prepare.outputs.project_name }}
NEXT_PUBLIC_BASE_URL=${{ needs.prepare.outputs.next_public_base_url }}
# Directus
DIRECTUS_URL=${{ needs.prepare.outputs.directus_url }}
DIRECTUS_HOST=${{ needs.prepare.outputs.directus_host }}
DIRECTUS_API_TOKEN=${{ secrets.DIRECTUS_API_TOKEN }}
DIRECTUS_ADMIN_EMAIL=${{ secrets.DIRECTUS_ADMIN_EMAIL || 'admin@mintel.me' }}
DIRECTUS_ADMIN_PASSWORD=${{ secrets.DIRECTUS_ADMIN_PASSWORD }}
DIRECTUS_DB_NAME=${{ secrets.DIRECTUS_DB_NAME || 'directus' }}
DIRECTUS_DB_USER=${{ secrets.DIRECTUS_DB_USER || 'directus' }}
DIRECTUS_DB_PASSWORD=${{ secrets.DIRECTUS_DB_PASSWORD }}
DIRECTUS_KEY=${{ secrets.DIRECTUS_KEY }}
DIRECTUS_SECRET=${{ secrets.DIRECTUS_SECRET }}
EOF
# Sync docker-compose
echo "${{ secrets.REGISTRY_PASSWORD }}" | docker login registry.infra.mintel.me -u "${{ secrets.REGISTRY_USERNAME }}" --password-stdin
docker compose -p "${{ needs.prepare.outputs.project_name }}" --env-file ${{ needs.prepare.outputs.env_file }} pull
docker compose -p "${{ needs.prepare.outputs.project_name }}" --env-file ${{ needs.prepare.outputs.env_file }} up -d --remove-orphans
docker system prune -f --filter "until=24h"
- name: 🔔 Gotify Notification (Failure)
if: failure()
notifications:
name: 🔔 Notifications
needs: [prepare, deploy]
if: always()
runs-on: docker
steps:
- name: Notify Gotify
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

11
.lintstagedrc.cjs Normal file
View File

@@ -0,0 +1,11 @@
const path = require('path');
const buildEslintCommand = (filenames) =>
`next lint --fix --file ${filenames
.map((f) => path.relative(process.cwd(), f))
.join(' --file ')}`;
module.exports = {
'*.{js,jsx,ts,tsx}': [buildEslintCommand, '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,36 @@
# 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
# Build-time environment variables for Next.js
ARG NEXT_PUBLIC_BASE_URL
ARG NEXT_PUBLIC_UMAMI_WEBSITE_ID
ARG NEXT_PUBLIC_UMAMI_SCRIPT_URL
ARG NEXT_PUBLIC_TARGET
ARG DIRECTUS_URL
ENV NEXT_PUBLIC_BASE_URL=$NEXT_PUBLIC_BASE_URL
ENV NEXT_PUBLIC_UMAMI_WEBSITE_ID=$NEXT_PUBLIC_UMAMI_WEBSITE_ID
ENV NEXT_PUBLIC_UMAMI_SCRIPT_URL=$NEXT_PUBLIC_UMAMI_SCRIPT_URL
ENV NEXT_PUBLIC_TARGET=$NEXT_PUBLIC_TARGET
ENV DIRECTUS_URL=$DIRECTUS_URL
# Copy local files
COPY . .
# Build Application
RUN npm run build
# Runtime Stage
FROM node:20-slim
# Build the specific application
RUN pnpm build
# Production runner image
FROM registry.infra.mintel.me/mintel/runtime:latest AS runner
WORKDIR /app
# 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
# 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
EXPOSE 3000
USER nextjs
CMD ["npm", "start"]
CMD ["node", "server.js"]

View File

@@ -1,44 +1,50 @@
import { Download } from 'lucide-react';
import fs from 'fs';
import path from 'path';
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');
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 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 !== '');
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(' ');
const remainingText = lines.slice(1).join(" ");
if (remainingText) currentSection.content.push(remainingText);
}
} else if (currentSection) {
// Continuation of current section
const blockText = lines.join(' ');
const blockText = lines.join(" ");
if (blockText) currentSection.content.push(blockText);
}
});
@@ -49,7 +55,7 @@ export default function AGB() {
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);
lastSection.content = lastSection.content.filter((c) => c !== footer);
if (sections[sections.length - 1].title === footer) {
sections.pop();
}
@@ -57,12 +63,14 @@ export default function AGB() {
}
return (
<div className="bg-slate-50 min-h-screen pt-28 pb-20">
<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>
<h1 className="text-4xl font-extrabold text-primary mb-2">
{title}
</h1>
<p className="text-slate-500 font-medium">{stand}</p>
</div>
<a
@@ -74,11 +82,13 @@ export default function AGB() {
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>
<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>

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() {

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

@@ -0,0 +1,123 @@
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";
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",
},
};
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}>
<Layout>{children}</Layout>
</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",
}}
>
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

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

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,56 +1,122 @@
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" });
try {
const { name, email, company, message, website } = await req.json();
// 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) {
logger.error("Failed to save to Directus", { error: directusError });
services.errors.captureException(directusError, {
phase: "directus_save",
});
// We still try to send the email even if Directus fails
}
// 2. Email sending
try {
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,
},
});
await transporter.sendMail({
from: process.env.SMTP_FROM,
to: process.env.CONTACT_RECIPIENT || "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}
`,
});
`,
});
return NextResponse.json({ message: 'Ok' });
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,
});
}
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,18 +1,38 @@
'use client';
"use client";
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 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">
<Image
src="/media/drums/about-hero.jpg"
@@ -30,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-4xl sm:text-5xl md:text-6xl font-extrabold text-primary mb-6 md:mb-8 leading-tight">
Wir gestalten die <span className="text-accent">Infrastruktur</span> der Zukunft
{t.rich("hero.title", {
accent: (chunks) => (
<span className="text-accent">{chunks}</span>
),
})}
</h1>
</Reveal>
<Reveal delay={0.3}>
<p className="text-slate-600 text-lg md:text-2xl leading-relaxed mb-8">
MB Grid Solution steht for technische Exzellenz in der Energiekabeltechnologie. Wir verstehen uns als Ihr technischer Lotse.
{t("hero.subtitle")}
</p>
</Reveal>
</div>
@@ -56,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>
))}
@@ -92,31 +131,37 @@ 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-3xl md:text-5xl font-bold text-white mb-6">Unser Manifest</h2>
<p className="text-slate-400 text-base md: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} />
{t.raw("manifest.items").map((item: any, 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>
<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>
))}
</Reveal>
);
})}
</div>
</div>
</section>
@@ -132,13 +177,18 @@ export default function About() {
<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-3xl md:text-5xl font-bold text-white mb-6 md:mb-8">
Bereit für Ihr nächstes Projekt?
{t("cta.title")}
</h2>
<p className="text-slate-400 text-lg md:text-xl mb-8 md:mb-12">
Lassen Sie uns gemeinsam die optimale Lösung für Ihre Energieinfrastruktur finden.
{t("cta.subtitle")}
</p>
<Button href="/kontakt" variant="accent" showArrow className="w-full sm:w-auto !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 { m, LazyMotion, domAnimation } from 'framer-motion';
import Link from 'next/link';
import { ArrowRight } from 'lucide-react';
import React, { useRef, useState } from "react";
import { m, LazyMotion, domAnimation } 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,23 +37,25 @@ export const Button = ({
});
};
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 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>
@@ -61,13 +63,13 @@ export const Button = ({
const spotlight = (
<LazyMotion features={domAnimation}>
<m.div
className="absolute inset-0 z-0 pointer-events-none transition-opacity duration-500"
style={{
opacity: isHovered ? 1 : 0,
background: `radial-gradient(600px circle at ${mousePosition.x}px ${mousePosition.y}px, rgba(255,255,255,0.15), transparent 40%)`,
}}
/>
<m.div
className="absolute inset-0 z-0 pointer-events-none transition-opacity duration-500"
style={{
opacity: isHovered ? 1 : 0,
background: `radial-gradient(600px circle at ${mousePosition.x}px ${mousePosition.y}px, rgba(255,255,255,0.15), transparent 40%)`,
}}
/>
</LazyMotion>
);

View File

@@ -1,37 +1,70 @@
'use client';
"use client";
import React, { useState } from 'react';
import Image from 'next/image';
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 { 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.');
setStatusModal({
isOpen: true,
type: "error",
title: t("form.errorTitle"),
message: t("form.errorMessage"),
buttonText: t("form.tryAgain") || "Erneut versuchen",
});
} finally {
setLoading(false);
}
@@ -40,7 +73,7 @@ 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">
<Image
src="/media/laying/contact-hero.jpg"
@@ -57,16 +90,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-4xl sm:text-5xl md:text-6xl font-extrabold text-primary mb-6 md:mb-8 leading-tight">
Lassen Sie uns <span className="text-accent">sprechen</span>
{t.rich("hero.title", {
accent: (chunks) => (
<span className="text-accent">{chunks}</span>
),
})}
</h1>
</Reveal>
<Reveal delay={0.3}>
<p className="text-slate-600 text-lg md:text-2xl leading-relaxed">
Haben Sie Fragen zu einem Projekt oder benötigen Sie technische Beratung? Wir freuen uns auf Ihre Nachricht.
{t("hero.subtitle")}
</p>
</Reveal>
</div>
@@ -85,8 +124,13 @@ export default function Contact() {
<Mail size={24} />
</div>
<div className="relative z-10">
<h4 className="text-slate-400 font-bold text-xs uppercase tracking-widest mb-1 md:mb-2">E-Mail</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">
<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>
@@ -99,10 +143,14 @@ export default function Contact() {
<MapPin size={24} />
</div>
<div className="relative z-10">
<h4 className="text-slate-400 font-bold text-xs uppercase tracking-widest mb-1 md:mb-2">Anschrift</h4>
<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">
MB Grid Solutions & Services GmbH<br />
Raiffeisenstraße 22<br />
{t("info.company")}
<br />
Raiffeisenstraße 22
<br />
73630 Remshalden
</p>
</div>
@@ -112,13 +160,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>
@@ -129,80 +177,116 @@ export default function Contact() {
<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"
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"
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"
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?"
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) => (
<a
href="/datenschutz"
className="text-accent hover:underline font-semibold"
>
{chunks}
</a>
),
})}
</p>
</form>
)}
@@ -211,6 +295,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,53 +1,62 @@
'use client';
"use client";
import { m, LazyMotion, domAnimation } from 'framer-motion';
import { BarChart3, CheckCircle2, ChevronRight, Shield, Zap } from 'lucide-react';
import Image from 'next/image';
import Link from 'next/link';
import { Button } from './Button';
import { Counter } from './Counter';
import { Reveal } from './Reveal';
import { TechBackground } from './TechBackground';
import { TileGrid } from './TileGrid';
import { m, LazyMotion, domAnimation } from "framer-motion";
import {
BarChart3,
CheckCircle2,
ChevronRight,
Shield,
Zap,
} from "lucide-react";
import Image from "next/image";
import Link from "next/link";
import { Button } from "./Button";
import { Counter } from "./Counter";
import { Reveal } from "./Reveal";
import { TechBackground } from "./TechBackground";
import { TileGrid } from "./TileGrid";
import { useTranslations } from "next-intl";
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 (
@@ -56,9 +65,9 @@ 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">
<Image
src="/media/business/hero-bg.jpg"
@@ -82,29 +91,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-4xl sm:text-5xl md:text-7xl font-extrabold text-primary mb-6 md:mb-8 leading-[1.1]">
Spezialisierter Partner für <span className="text-accent">Energiekabelprojekte</span>
{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-lg md:text-2xl leading-relaxed mb-8 md:mb-12 max-w-2xl">
Herstellerneutrale technische Beratung für Ihre Projekte in Mittel- und Hochspannungsnetzen bis zu 110 kV.
{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>
@@ -119,34 +136,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-3xl md:text-5xl font-bold text-white mb-6">Unsere Leistungen</h2>
<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">
Beratung durch unabhängige Experten mit jahrzehntelanger Erfahrung aus Engineering, Normengremien, Planung und Produktion.
{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">
@@ -154,7 +182,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>
@@ -187,21 +217,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-3xl md:text-5xl font-bold text-primary mb-6 md:mb-8">Anwendungen & Zielgruppen</h2>
<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">
Wir unterstützen Akteure der Energiewende bei der Realisierung komplexer Kabelprojekte mit höchster Präzision.
{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" />
@@ -230,7 +257,7 @@ export default function Home() {
<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 */}
@@ -238,15 +265,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-3xl 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-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">
@@ -257,9 +300,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>
))}
@@ -283,38 +324,73 @@ export default function Home() {
<div className="tech-corner top-8 right-8 border-t-2 border-r-2" />
<div className="tech-corner bottom-8 left-8 border-b-2 border-l-2" />
<div className="tech-corner bottom-8 right-8 border-b-2 border-r-2" />
<div className="absolute top-0 right-0 w-1/2 h-full opacity-10 pointer-events-none">
<LazyMotion features={domAnimation}>
<svg viewBox="0 0 400 400" fill="none" xmlns="http://www.w3.org/2000/svg">
<m.circle
animate={{ r: [400, 410, 400], opacity: [0.1, 0.2, 0.1] }}
transition={{ duration: 5, repeat: Infinity, ease: "easeInOut" }}
cx="400" cy="0" r="400" stroke="white" strokeWidth="2"
/>
<m.circle
animate={{ r: [300, 310, 300], opacity: [0.1, 0.2, 0.1] }}
transition={{ duration: 4, repeat: Infinity, ease: "easeInOut", delay: 0.5 }}
cx="400" cy="0" r="300" stroke="white" strokeWidth="2"
/>
<m.circle
animate={{ r: [200, 210, 200], opacity: [0.1, 0.2, 0.1] }}
transition={{ duration: 3, repeat: Infinity, ease: "easeInOut", delay: 1 }}
cx="400" cy="0" r="200" stroke="white" strokeWidth="2"
/>
</svg>
<svg
viewBox="0 0 400 400"
fill="none"
xmlns="http://www.w3.org/2000/svg"
>
<m.circle
animate={{ r: [400, 410, 400], opacity: [0.1, 0.2, 0.1] }}
transition={{
duration: 5,
repeat: Infinity,
ease: "easeInOut",
}}
cx="400"
cy="0"
r="400"
stroke="white"
strokeWidth="2"
/>
<m.circle
animate={{ r: [300, 310, 300], opacity: [0.1, 0.2, 0.1] }}
transition={{
duration: 4,
repeat: Infinity,
ease: "easeInOut",
delay: 0.5,
}}
cx="400"
cy="0"
r="300"
stroke="white"
strokeWidth="2"
/>
<m.circle
animate={{ r: [200, 210, 200], opacity: [0.1, 0.2, 0.1] }}
transition={{
duration: 3,
repeat: Infinity,
ease: "easeInOut",
delay: 1,
}}
cx="400"
cy="0"
r="200"
stroke="white"
strokeWidth="2"
/>
</svg>
</LazyMotion>
</div>
<div className="relative z-10">
<h2 className="text-3xl md:text-6xl font-bold text-white mb-6 md:mb-8 leading-tight">
Bereit für Ihr nächstes Projekt?
{t("cta.title")}
</h2>
<p className="text-slate-300 text-lg md:text-xl mb-8 md:mb-12 leading-relaxed">
Lassen Sie uns gemeinsam die optimale Lösung für Ihre Energieinfrastruktur finden. Wir beraten Sie herstellerneutral und kompetent.
{t("cta.subtitle")}
</p>
<Button href="/kontakt" variant="accent" showArrow className="w-full sm:w-auto !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,15 +1,17 @@
'use client';
"use client";
import { AnimatePresence, m, LazyMotion, domAnimation } 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 { AnimatePresence, m, LazyMotion, domAnimation } 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);
@@ -20,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(() => {
@@ -30,124 +32,131 @@ 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 trigger="mount" 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 ${
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"
: "bg-gradient-to-b from-white/80 via-white/40 to-transparent"
}`}
>
<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"
>
<div className={`relative transition-all duration-300 ${isScrolled ? 'h-[50px] md:h-[80px] w-[120px] md:w-[200px] mt-0 mb-[-10px]' : 'h-[80px] md:h-[140px] w-[180px] md:w-[320px] mt-2 md:mt-4 mb-[-20px] md:mb-[-40px]'}`}>
<Image
src="/assets/logo.png"
alt="MB Grid Solutions"
fill
className="object-contain"
priority
/>
</div>
</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-[80px] w-[120px] md:w-[200px] mt-0 mb-[-10px]" : "h-[80px] md:h-[140px] w-[180px] md:w-[320px] mt-2 md:mt-4 mb-[-20px] md:mb-[-40px]"}`}
>
<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 */}
<LazyMotion features={domAnimation}>
<AnimatePresence>
{isMobileMenuOpen && (
<m.div
initial={{ opacity: 0, y: -20 }}
animate={{ opacity: 1, y: 0 }}
exit={{ opacity: 0, y: -20 }}
className="fixed inset-0 z-[90] bg-white pt-32 px-6 md:hidden"
>
<nav className="flex flex-col gap-4">
{navLinks.map((link) => (
<Link
key={link.href}
href={link.href}
className={`flex items-center gap-4 p-4 rounded-xl text-lg font-semibold transition-all ${
isActive(link.href)
? 'text-accent bg-accent/5'
: 'text-slate-600 hover:text-primary hover:bg-slate-50'
}`}
>
<link.icon size={24} />
{link.label}
</Link>
))}
<Button
href="/kontakt"
className="mt-4 w-full"
>
Projekt anfragen
</Button>
</nav>
</m.div>
)}
</AnimatePresence>
<AnimatePresence>
{isMobileMenuOpen && (
<m.div
initial={{ opacity: 0, y: -20 }}
animate={{ opacity: 1, y: 0 }}
exit={{ opacity: 0, y: -20 }}
className="fixed inset-0 z-[90] bg-white pt-32 px-6 md:hidden"
>
<nav className="flex flex-col gap-4">
{navLinks.map((link) => (
<Link
key={link.href}
href={link.href}
className={`flex items-center gap-4 p-4 rounded-xl text-lg font-semibold transition-all ${
isActive(link.href)
? "text-accent bg-accent/5"
: "text-slate-600 hover:text-primary hover:bg-slate-50"
}`}
>
<link.icon size={24} />
{link.label}
</Link>
))}
<Button href="/kontakt" className="mt-4 w-full">
{t("nav.cta")}
</Button>
</nav>
</m.div>
)}
</AnimatePresence>
</LazyMotion>
<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"
>
@@ -157,87 +166,128 @@ 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 */}
<LazyMotion features={domAnimation}>
<m.div
animate={{ x: ['-100%', '100%'] }}
transition={{ duration: 15, repeat: Infinity, ease: "linear" }}
className="absolute top-0 left-0 w-full h-px bg-gradient-to-r from-transparent via-accent/30 to-transparent"
/>
<m.div
animate={{ x: ['100%', '-100%'] }}
transition={{ duration: 20, repeat: Infinity, ease: "linear" }}
className="absolute bottom-0 left-0 w-full h-px bg-gradient-to-r from-transparent via-accent/20 to-transparent"
/>
<m.div
animate={{ x: ["-100%", "100%"] }}
transition={{ duration: 15, repeat: Infinity, ease: "linear" }}
className="absolute top-0 left-0 w-full h-px bg-gradient-to-r from-transparent via-accent/30 to-transparent"
/>
<m.div
animate={{ x: ["100%", "-100%"] }}
transition={{ duration: 20, repeat: Infinity, ease: "linear" }}
className="absolute bottom-0 left-0 w-full h-px bg-gradient-to-r from-transparent via-accent/20 to-transparent"
/>
</LazyMotion>
{/* 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-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 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>
</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>
<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-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" /> 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 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>
</p>
</div>
</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

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,99 @@
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:-mb-grid-solutions}.rule=Host(`${TRAEFIK_HOST:-mb-grid-solutions.localhost}`)"
- "traefik.http.routers.${PROJECT_NAME:-mb-grid-solutions}.entrypoints=websecure"
- "traefik.http.routers.${PROJECT_NAME:-mb-grid-solutions}.tls.certresolver=le"
- "traefik.http.routers.${PROJECT_NAME:-mb-grid-solutions}.tls=true"
- "traefik.http.services.${PROJECT_NAME:-mb-grid-solutions}.loadbalancer.server.port=3000"
- "traefik.http.routers.${PROJECT_NAME:-mb-grid-solutions}.middlewares=${PROJECT_NAME:-mb-grid-solutions}-auth"
# Gatekeeper Router
- "traefik.http.routers.${PROJECT_NAME:-mb-grid-solutions}-gatekeeper.rule=Host(`${TRAEFIK_HOST:-mb-grid-solutions.localhost}`) && PathPrefix(`/gatekeeper`)"
- "traefik.http.routers.${PROJECT_NAME:-mb-grid-solutions}-gatekeeper.entrypoints=websecure"
- "traefik.http.routers.${PROJECT_NAME:-mb-grid-solutions}-gatekeeper.tls.certresolver=le"
- "traefik.http.routers.${PROJECT_NAME:-mb-grid-solutions}-gatekeeper.tls=true"
- "traefik.http.routers.${PROJECT_NAME:-mb-grid-solutions}-gatekeeper.service=${PROJECT_NAME:-mb-grid-solutions}-gatekeeper"
# Auth Middleware Definition
- "traefik.http.middlewares.${PROJECT_NAME:-mb-grid-solutions}-auth.forwardauth.address=http://gatekeeper:3000/api/verify"
- "traefik.http.middlewares.${PROJECT_NAME:-mb-grid-solutions}-auth.forwardauth.trustForwardHeader=true"
- "traefik.http.middlewares.${PROJECT_NAME:-mb-grid-solutions}-auth.forwardauth.authResponseHeaders=X-Auth-User"
gatekeeper:
image: registry.infra.mintel.me/mintel/gatekeeper:latest
restart: always
networks:
- infra
env_file:
- ${ENV_FILE:-.env}
environment:
PORT: 3000
PROJECT_NAME: "MB Grid Solutions"
PROJECT_COLOR: "#82ed20"
labels:
- "traefik.enable=true"
- "traefik.http.services.${PROJECT_NAME:-mb-grid-solutions}-gatekeeper.loadbalancer.server.port=3000"
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}
volumes:
- ./directus/uploads:/directus/uploads
- ./directus/extensions:/directus/extensions
labels:
- "traefik.enable=true"
- "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.entrypoints=websecure"
- "traefik.http.routers.${PROJECT_NAME:-mb-grid-solutions}-directus.tls.certresolver=le"
- "traefik.http.routers.${PROJECT_NAME:-mb-grid-solutions}-directus.tls=true"
- "traefik.http.routers.${PROJECT_NAME:-mb-grid-solutions}-directus.middlewares=${PROJECT_NAME:-mb-grid-solutions}-auth"
- "traefik.http.services.${PROJECT_NAME:-mb-grid-solutions}-directus.loadbalancer.server.port=8055"
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:

5
eslint.config.mjs Normal file
View File

@@ -0,0 +1,5 @@
import { nextConfig } from "@mintel/eslint-config/next";
export default [
...nextConfig,
];

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,
scriptUrl: env.NEXT_PUBLIC_UMAMI_SCRIPT_URL,
// The proxied path used in the frontend
proxyPath: "/stats/script.js",
enabled: Boolean(env.NEXT_PUBLIC_UMAMI_WEBSITE_ID),
},
},
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),
scriptUrl: c.analytics.umami.scriptUrl,
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,
},
},
};
}

37
lib/directus.ts Normal file
View File

@@ -0,0 +1,37 @@
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 });
} catch (e) {
if (typeof window === "undefined") {
getServerAppServices().errors.captureException(e, {
phase: "directus_auth",
});
}
console.error("Failed to authenticate with Directus login fallback:", e);
}
}
}
export default client;

114
lib/env.ts Normal file
View File

@@ -0,0 +1,114 @@
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
NEXT_PUBLIC_UMAMI_WEBSITE_ID: z.preprocess(
preprocessEmptyString,
z.string().optional(),
),
NEXT_PUBLIC_UMAMI_SCRIPT_URL: z.preprocess(
preprocessEmptyString,
z.string().url().default("https://analytics.infra.mintel.me/script.js"),
),
// Error Tracking
SENTRY_DSN: z.preprocess(preprocessEmptyString, z.string().optional()),
// Logging
LOG_LEVEL: z.enum(["debug", "info", "warn", "error"]).default("info"),
// Mail
MAIL_HOST: z.preprocess(preprocessEmptyString, z.string().optional()),
MAIL_PORT: z.preprocess(
preprocessEmptyString,
z.coerce.number().default(587),
),
MAIL_USERNAME: z.preprocess(preprocessEmptyString, z.string().optional()),
MAIL_PASSWORD: z.preprocess(preprocessEmptyString, z.string().optional()),
MAIL_FROM: z.preprocess(preprocessEmptyString, z.string().optional()),
MAIL_RECIPIENTS: z.preprocess(
(val) => (typeof val === "string" ? val.split(",").filter(Boolean) : val),
z.array(z.string()).default([]),
),
// Directus
DIRECTUS_URL: z.preprocess(
preprocessEmptyString,
z.string().url().default("http://localhost:8055"),
),
DIRECTUS_ADMIN_EMAIL: z.preprocess(
preprocessEmptyString,
z.string().optional(),
),
DIRECTUS_ADMIN_PASSWORD: z.preprocess(
preprocessEmptyString,
z.string().optional(),
),
DIRECTUS_API_TOKEN: z.preprocess(
preprocessEmptyString,
z.string().optional(),
),
INTERNAL_DIRECTUS_URL: z.preprocess(
preprocessEmptyString,
z.string().url().optional(),
),
// Deploy Target
TARGET: z
.enum(["development", "testing", "staging", "production"])
.optional(),
// Gotify
GOTIFY_URL: z.preprocess(preprocessEmptyString, z.string().url().optional()),
GOTIFY_TOKEN: z.preprocess(preprocessEmptyString, z.string().optional()),
});
export type Env = z.infer<typeof envSchema>;
/**
* 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,
NEXT_PUBLIC_UMAMI_WEBSITE_ID: process.env.NEXT_PUBLIC_UMAMI_WEBSITE_ID,
NEXT_PUBLIC_UMAMI_SCRIPT_URL: process.env.NEXT_PUBLIC_UMAMI_SCRIPT_URL,
SENTRY_DSN: process.env.SENTRY_DSN,
LOG_LEVEL: process.env.LOG_LEVEL,
MAIL_HOST: process.env.MAIL_HOST,
MAIL_PORT: process.env.MAIL_PORT,
MAIL_USERNAME: process.env.MAIL_USERNAME,
MAIL_PASSWORD: process.env.MAIL_PASSWORD,
MAIL_FROM: process.env.MAIL_FROM,
MAIL_RECIPIENTS: process.env.MAIL_RECIPIENTS,
DIRECTUS_URL: process.env.DIRECTUS_URL,
DIRECTUS_ADMIN_EMAIL: process.env.DIRECTUS_ADMIN_EMAIL,
DIRECTUS_ADMIN_PASSWORD: process.env.DIRECTUS_ADMIN_PASSWORD,
DIRECTUS_API_TOKEN: process.env.DIRECTUS_API_TOKEN,
INTERNAL_DIRECTUS_URL: process.env.INTERNAL_DIRECTUS_URL,
TARGET: process.env.TARGET,
GOTIFY_URL: process.env.GOTIFY_URL,
GOTIFY_TOKEN: process.env.GOTIFY_TOKEN,
};
}

View File

@@ -0,0 +1,3 @@
export interface AnalyticsService {
trackEvent(name: string, properties?: Record<string, unknown>): void;
}

View File

@@ -0,0 +1,5 @@
import type { AnalyticsService } from "./analytics-service";
export class NoopAnalyticsService implements AnalyticsService {
trackEvent() {}
}

View File

@@ -0,0 +1,15 @@
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";
export class AppServices {
constructor(
public readonly analytics: AnalyticsService,
public readonly errors: ErrorReportingService,
public readonly cache: CacheService,
public readonly logger: LoggerService,
public readonly notifications: NotificationService,
) {}
}

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

@@ -0,0 +1,5 @@
export interface CacheService {
get<T>(key: string): Promise<T | null>;
set<T>(key: string, value: T, ttlSeconds?: number): Promise<void>;
delete(key: string): Promise<void>;
}

View File

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

View File

@@ -0,0 +1,44 @@
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 { GotifyNotificationService } from "./notifications/gotify-notification-service";
import { NoopNotificationService } from "./notifications/noop-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;
const logger = new PinoLoggerService("server");
logger.info("Initializing server application services", {
environment: getMaskedConfig(),
timestamp: new Date().toISOString(),
});
const analytics = new NoopAnalyticsService();
const notifications = config.notifications.gotify.enabled
? new GotifyNotificationService({
url: config.notifications.gotify.url!,
token: config.notifications.gotify.token!,
enabled: true,
})
: new NoopNotificationService();
const errors = config.errors.glitchtip.enabled
? new GlitchtipErrorReportingService({ enabled: true }, notifications)
: new NoopErrorReportingService();
const cache = new MemoryCacheService();
singleton = new AppServices(analytics, errors, cache, logger, notifications);
logger.info("All application services initialized successfully");
return singleton;
}

View File

@@ -0,0 +1,4 @@
export interface ErrorReportingService {
captureException(error: unknown, context?: Record<string, unknown>): void;
captureMessage(message: string, context?: Record<string, unknown>): void;
}

View File

@@ -0,0 +1,48 @@
import * as Sentry from "@sentry/nextjs";
import type { ErrorReportingService } from "./error-reporting-service";
import type { NotificationService } from "../notifications/notification-service";
export interface GlitchtipConfig {
enabled: boolean;
}
export class GlitchtipErrorReportingService implements ErrorReportingService {
constructor(
private readonly config: GlitchtipConfig,
private readonly notifications?: NotificationService,
) {}
captureException(error: unknown, context?: Record<string, unknown>) {
if (!this.config.enabled) return;
Sentry.withScope((scope) => {
if (context) {
scope.setExtras(context);
}
Sentry.captureException(error);
});
if (this.notifications) {
this.notifications
.notify({
title: "🚨 Exception Captured",
message: error instanceof Error ? error.message : String(error),
priority: 10,
})
.catch((err) =>
console.error("Failed to send notification for exception", err),
);
}
}
captureMessage(message: string, context?: Record<string, unknown>) {
if (!this.config.enabled) return;
Sentry.withScope((scope) => {
if (context) {
scope.setExtras(context);
}
Sentry.captureMessage(message);
});
}
}

View File

@@ -0,0 +1,6 @@
import type { ErrorReportingService } from "./error-reporting-service";
export class NoopErrorReportingService implements ErrorReportingService {
captureException() {}
captureMessage() {}
}

View File

@@ -0,0 +1,7 @@
export interface LoggerService {
debug(message: string, context?: Record<string, unknown>): void;
info(message: string, context?: Record<string, unknown>): void;
warn(message: string, context?: Record<string, unknown>): void;
error(message: string, context?: Record<string, unknown>): void;
child(context: Record<string, unknown>): LoggerService;
}

View File

@@ -0,0 +1,56 @@
import { pino, type Logger as PinoLogger } from "pino";
import type { LoggerService } from "./logger-service";
import { config } from "../../config";
export class PinoLoggerService implements LoggerService {
private logger: PinoLogger;
constructor(name?: string, parent?: PinoLogger) {
if (parent) {
this.logger = parent.child({ name });
} else {
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,
});
}
}
debug(message: string, context?: Record<string, unknown>) {
if (context) this.logger.debug(context, message);
else this.logger.debug(message);
}
info(message: string, context?: Record<string, unknown>) {
if (context) this.logger.info(context, message);
else this.logger.info(message);
}
warn(message: string, context?: Record<string, unknown>) {
if (context) this.logger.warn(context, message);
else this.logger.warn(message);
}
error(message: string, context?: Record<string, unknown>) {
if (context) this.logger.error(context, message);
else this.logger.error(message);
}
child(context: Record<string, unknown>): LoggerService {
const childPino = this.logger.child(context);
const service = new PinoLoggerService();
service.logger = childPino;
return service;
}
}

View File

@@ -0,0 +1,44 @@
import type {
NotificationMessage,
NotificationService,
} from "./notification-service";
export interface GotifyConfig {
url: string;
token: string;
enabled: boolean;
}
export class GotifyNotificationService implements NotificationService {
constructor(private readonly config: GotifyConfig) {}
async notify(message: NotificationMessage): Promise<void> {
if (!this.config.enabled) return;
try {
const response = await fetch(
`${this.config.url}/message?token=${this.config.token}`,
{
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify({
title: message.title,
message: message.message,
priority: message.priority ?? 5,
}),
},
);
if (!response.ok) {
console.error(
"Failed to send Gotify notification",
await response.text(),
);
}
} catch (error) {
console.error("Error sending Gotify notification", error);
}
}
}

View File

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

View File

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

176
messages/de.json Normal file
View File

@@ -0,0 +1,176 @@
{
"Index": {
"hero": {
"tag": "Engineering Excellence",
"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 Akteure der Energiewende bei der Realisierung komplexer Kabelprojekte mit höchster Präzision.",
"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": "Made with",
"precision": "precision",
"inGermany": "in Germany"
}
},
"About": {
"hero": {
"tagline": "Über uns",
"title": "Wir gestalten die Infrastructure der Zukunft",
"subtitle": "MB Grid Solution steht für technische Exzellenz in der Energiekabeltechnologie. Wir verstehen uns als Ihr technischer Lotse."
},
"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."
}
}
}

18
middleware.ts Normal file
View File

@@ -0,0 +1,18 @@
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|_next|_vercel|.*\\..*).*)", "/", "/(de)/:path*"],
};

6
next.config.mjs Normal file
View File

@@ -0,0 +1,6 @@
import withMintelConfig from "@mintel/next-config";
/** @type {import('next').NextConfig} */
const nextConfig = {};
export default withMintelConfig(nextConfig);

4574
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -3,25 +3,46 @@
"version": "1.0.0",
"type": "module",
"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"
"test": "vitest",
"prepare": "husky",
"directus:bootstrap": "DIRECTUS_URL=http://localhost:8055 npx tsx --env-file=.env scripts/setup-directus.ts",
"directus:push:staging": "./scripts/sync-directus.sh push staging",
"directus:pull:staging": "./scripts/sync-directus.sh pull staging",
"directus:push:testing": "./scripts/sync-directus.sh push testing",
"directus:pull:testing": "./scripts/sync-directus.sh pull testing",
"directus:push:prod": "./scripts/sync-directus.sh push production",
"directus:pull:prod": "./scripts/sync-directus.sh pull production",
"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": {
"@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 +52,14 @@
"@types/react-dom": "^19.2.3",
"@vitejs/plugin-react": "^5.1.2",
"autoprefixer": "^10.4.23",
"eslint": "^9.39.2",
"eslint-config-next": "^16.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"

9779
pnpm-lock.yaml generated Normal file

File diff suppressed because it is too large Load Diff

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

@@ -0,0 +1,108 @@
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 || "Mintel Project";
const prjColor = process.env.PROJECT_COLOR || "#82ed20";
console.log(`🎨 Setup Directus Branding for ${prjName}...`);
await ensureDirectusAuthenticated(client);
const cssInjection = `
<style>
@import url('https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&display=swap');
body, .v-app { font-family: 'Inter', sans-serif !important; }
.public-view .v-card {
backdrop-filter: blur(20px);
border-radius: 32px !important;
box-shadow: 0 50px 100px -20px rgba(0, 0, 0, 0.4) !important;
}
.v-navigation-drawer { background: #000c24 !important; }
</style>
<div style="font-family: 'Inter', sans-serif; text-align: center; margin-top: 24px;">
<p style="color: rgba(255,255,255,0.7); font-size: 14px; margin-bottom: 4px; font-weight: 500;">MINTEL INFRASTRUCTURE ENGINE</p>
<h1 style="color: #ffffff; font-size: 18px; font-weight: 700; margin: 0;">${prjName.toUpperCase()} <span style="color: ${prjColor};">RELIABILITY.</span></h1>
</div>
`;
try {
await client.request(
updateSettings({
project_name: prjName,
project_color: prjColor,
public_note: cssInjection,
theme_light_overrides: {
primary: prjColor,
borderRadius: "16px",
navigationBackground: "#000c24",
navigationForeground: "#ffffff",
},
} as any),
);
console.log("✨ Branding applied!");
try {
await createCollectionAndFields();
console.log("🏗️ Schema alignment complete!");
} catch (error) {
console.error("❌ Error aligning schema:", error);
}
} catch (error) {
console.error("❌ Error setting up branding:", error);
}
}
async function createCollectionAndFields() {
const collectionName = "contact_submissions";
try {
await client.request(
createCollection({
collection: collectionName,
schema: {},
meta: {
icon: "contact_mail",
display_template: "{{name}} <{{email}}>",
},
} as any),
);
// 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 (e: any) {
console.log(` Collection ${collectionName} status: ${e.message}`);
}
const safeAddField = async (field: string, type: string, meta: any = {}) => {
try {
await client.request(createField(collectionName, { field, type, meta }));
console.log(`✅ Field ${field} added.`);
} catch (e: any) {
// Ignore if exists
}
};
await safeAddField("name", "string", { interface: "input" });
await safeAddField("email", "string", { interface: "input" });
await safeAddField("company", "string", { interface: "input" });
await safeAddField("message", "text", { interface: "textarea" });
await safeAddField("date_created", "timestamp", {
interface: "datetime",
special: ["date-created"],
});
}
setupBranding();

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

@@ -0,0 +1,68 @@
#!/bin/bash
# Mintel Directus Sync Engine
# Synchronizes Directus Data (Postgres + Uploads) between Local and Remote
REMOTE_HOST="${SSH_HOST:-root@alpha.mintel.me}"
ACTION=$1
ENV=$2
# Help
if [ -z "$ACTION" ] || [ -z "$ENV" ]; then
echo "Usage: mintel-sync [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
PRJ_ID=$(jq -r .name package.json | sed 's/@mintel\///')
case $ENV in
testing) PROJECT_NAME="${PRJ_ID}-testing"; ENV_FILE=".env.testing" ;;
staging) PROJECT_NAME="${PRJ_ID}-staging"; ENV_FILE=".env.staging" ;;
production) PROJECT_NAME="${PRJ_ID}-prod"; ENV_FILE=".env.prod" ;;
*) echo "❌ Invalid environment: $ENV"; exit 1 ;;
esac
REMOTE_DIR="/home/deploy/sites/${PRJ_ID}.com"
# DB Details
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
echo "❌ Local directus-db container not found. Running?"
exit 1
fi
if [ "$ACTION" == "push" ]; then
echo "🚀 Pushing LOCAL -> $ENV ($PROJECT_NAME)..."
docker exec "$LOCAL_DB_CONTAINER" pg_dump -U "$DB_USER" --clean --if-exists --no-owner --no-privileges "$DB_NAME" > dump.sql
scp dump.sql "$REMOTE_HOST:$REMOTE_DIR/dump.sql"
REMOTE_DB_CONTAINER=$(ssh "$REMOTE_HOST" "cd $REMOTE_DIR && docker compose -p $PROJECT_NAME ps -q directus-db")
ssh "$REMOTE_HOST" "docker exec -i $REMOTE_DB_CONTAINER psql -U $DB_USER $DB_NAME < $REMOTE_DIR/dump.sql"
rsync -avz --progress ./directus/uploads/ "$REMOTE_HOST:$REMOTE_DIR/directus/uploads/"
rm dump.sql
ssh "$REMOTE_HOST" "rm $REMOTE_DIR/dump.sql"
echo "✨ Push complete!"
elif [ "$ACTION" == "pull" ]; then
echo "📥 Pulling $ENV -> LOCAL..."
REMOTE_DB_CONTAINER=$(ssh "$REMOTE_HOST" "cd $REMOTE_DIR && docker compose -p $PROJECT_NAME ps -q directus-db")
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"
scp "$REMOTE_HOST:$REMOTE_DIR/dump.sql" dump.sql
docker exec -i "$LOCAL_DB_CONTAINER" psql -U "$DB_USER" "$DB_NAME" < dump.sql
rsync -avz --progress "$REMOTE_HOST:$REMOTE_DIR/directus/uploads/" ./directus/uploads/
rm dump.sql
ssh "$REMOTE_HOST" "rm $REMOTE_DIR/dump.sql"
echo "✨ Pull 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 (error) {
process.exit(1);
}

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"]
}