feat: Integrate Directus CMS, add i18n with next-intl, and configure project tooling with pnpm, husky, and commitlint.**
This commit is contained in:
77
.env.example
77
.env.example
@@ -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
|
||||
|
||||
@@ -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
2
.gitignore
vendored
@@ -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
1
.husky/commit-msg
Normal file
@@ -0,0 +1 @@
|
||||
pnpm commitlint --edit "$1"
|
||||
1
.husky/pre-commit
Normal file
1
.husky/pre-commit
Normal file
@@ -0,0 +1 @@
|
||||
pnpm lint-staged
|
||||
11
.lintstagedrc.cjs
Normal file
11
.lintstagedrc.cjs
Normal 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
4
.npmrc
Normal 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
|
||||
41
Dockerfile
41
Dockerfile
@@ -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"]
|
||||
|
||||
@@ -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>
|
||||
66
app/[locale]/datenschutz/page.tsx
Normal file
66
app/[locale]/datenschutz/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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
123
app/[locale]/layout.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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"
|
||||
>
|
||||
175
app/[locale]/opengraph-image.tsx
Normal file
175
app/[locale]/opengraph-image.tsx
Normal 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,
|
||||
},
|
||||
);
|
||||
}
|
||||
@@ -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() {
|
||||
175
app/[locale]/twitter-image.tsx
Normal file
175
app/[locale]/twitter-image.tsx
Normal 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,
|
||||
},
|
||||
);
|
||||
}
|
||||
@@ -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() {
|
||||
@@ -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 },
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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,
|
||||
}
|
||||
);
|
||||
}
|
||||
@@ -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
1
commitlint.config.js
Normal file
@@ -0,0 +1 @@
|
||||
export { default } from "@mintel/husky-config/commitlint";
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
© {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>© {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
104
components/StatusModal.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
0
directus/extensions/.gitkeep
Normal file
0
directus/extensions/.gitkeep
Normal file
0
directus/uploads/.gitkeep
Normal file
0
directus/uploads/.gitkeep
Normal file
40
docker-compose.override.yml
Normal file
40
docker-compose.override.yml
Normal 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}
|
||||
@@ -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
5
eslint.config.mjs
Normal file
@@ -0,0 +1,5 @@
|
||||
import { nextConfig } from "@mintel/eslint-config/next";
|
||||
|
||||
export default [
|
||||
...nextConfig,
|
||||
];
|
||||
9
i18n/request.ts
Normal file
9
i18n/request.ts
Normal 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
193
lib/config.ts
Normal 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
37
lib/directus.ts
Normal 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
114
lib/env.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
3
lib/services/analytics/analytics-service.ts
Normal file
3
lib/services/analytics/analytics-service.ts
Normal file
@@ -0,0 +1,3 @@
|
||||
export interface AnalyticsService {
|
||||
trackEvent(name: string, properties?: Record<string, unknown>): void;
|
||||
}
|
||||
5
lib/services/analytics/noop-analytics-service.ts
Normal file
5
lib/services/analytics/noop-analytics-service.ts
Normal file
@@ -0,0 +1,5 @@
|
||||
import type { AnalyticsService } from "./analytics-service";
|
||||
|
||||
export class NoopAnalyticsService implements AnalyticsService {
|
||||
trackEvent() {}
|
||||
}
|
||||
15
lib/services/app-services.ts
Normal file
15
lib/services/app-services.ts
Normal 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
5
lib/services/cache/cache-service.ts
vendored
Normal 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>;
|
||||
}
|
||||
26
lib/services/cache/memory-cache-service.ts
vendored
Normal file
26
lib/services/cache/memory-cache-service.ts
vendored
Normal 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);
|
||||
}
|
||||
}
|
||||
44
lib/services/create-services.server.ts
Normal file
44
lib/services/create-services.server.ts
Normal 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;
|
||||
}
|
||||
4
lib/services/errors/error-reporting-service.ts
Normal file
4
lib/services/errors/error-reporting-service.ts
Normal file
@@ -0,0 +1,4 @@
|
||||
export interface ErrorReportingService {
|
||||
captureException(error: unknown, context?: Record<string, unknown>): void;
|
||||
captureMessage(message: string, context?: Record<string, unknown>): void;
|
||||
}
|
||||
48
lib/services/errors/glitchtip-error-reporting-service.ts
Normal file
48
lib/services/errors/glitchtip-error-reporting-service.ts
Normal 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);
|
||||
});
|
||||
}
|
||||
}
|
||||
6
lib/services/errors/noop-error-reporting-service.ts
Normal file
6
lib/services/errors/noop-error-reporting-service.ts
Normal file
@@ -0,0 +1,6 @@
|
||||
import type { ErrorReportingService } from "./error-reporting-service";
|
||||
|
||||
export class NoopErrorReportingService implements ErrorReportingService {
|
||||
captureException() {}
|
||||
captureMessage() {}
|
||||
}
|
||||
7
lib/services/logging/logger-service.ts
Normal file
7
lib/services/logging/logger-service.ts
Normal 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;
|
||||
}
|
||||
56
lib/services/logging/pino-logger-service.ts
Normal file
56
lib/services/logging/pino-logger-service.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
44
lib/services/notifications/gotify-notification-service.ts
Normal file
44
lib/services/notifications/gotify-notification-service.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
}
|
||||
5
lib/services/notifications/noop-notification-service.ts
Normal file
5
lib/services/notifications/noop-notification-service.ts
Normal file
@@ -0,0 +1,5 @@
|
||||
import type { NotificationService } from "./notification-service";
|
||||
|
||||
export class NoopNotificationService implements NotificationService {
|
||||
async notify() {}
|
||||
}
|
||||
9
lib/services/notifications/notification-service.ts
Normal file
9
lib/services/notifications/notification-service.ts
Normal 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
176
messages/de.json
Normal 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
18
middleware.ts
Normal 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
6
next.config.mjs
Normal 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
4574
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
33
package.json
33
package.json
@@ -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
9779
pnpm-lock.yaml
generated
Normal file
File diff suppressed because it is too large
Load Diff
108
scripts/setup-directus.ts
Normal file
108
scripts/setup-directus.ts
Normal 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
68
scripts/sync-directus.sh
Executable 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
8
scripts/validate-env.ts
Normal file
@@ -0,0 +1,8 @@
|
||||
import { validateMintelEnv } from "@mintel/next-utils";
|
||||
|
||||
try {
|
||||
validateMintelEnv();
|
||||
console.log("✅ Environment variables validated");
|
||||
} catch (error) {
|
||||
process.exit(1);
|
||||
}
|
||||
@@ -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"]
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user