Compare commits
125 Commits
765cfd4c69
...
v1.1.5
| Author | SHA1 | Date | |
|---|---|---|---|
| afa586c833 | |||
| 9b55c42f35 | |||
| 554f958ba2 | |||
| c9f174e828 | |||
| 8dce4890c4 | |||
| 963e572291 | |||
| 9887324469 | |||
| 78da0fdea9 | |||
| 91db336c0e | |||
| cfbff88e45 | |||
| 90b41d2a15 | |||
| 3f45293c2e | |||
| 7e957d6fb4 | |||
| 4334d31445 | |||
| 1559037029 | |||
| b7438f2718 | |||
| a090373825 | |||
| 3c3d019924 | |||
| c6d20119c7 | |||
| 04a19742da | |||
| 39b96a51db | |||
| d27e1f91ad | |||
| 18cd576ee9 | |||
| 3d2f240cf6 | |||
| 6260b40b91 | |||
| 109c8389f3 | |||
| 55cb073a6d | |||
| fb87fd52f7 | |||
| da9b2fb9cf | |||
| 5032700c2c | |||
| d44838254c | |||
| 1742604a7a | |||
| ca59f32b99 | |||
| 0e98659506 | |||
| 744e1da716 | |||
| f2e38f9c29 | |||
| b85312c433 | |||
| 081ebec567 | |||
| d9dece37e5 | |||
| 9495772d1a | |||
| 248a0dc1f0 | |||
| eecc1b6108 | |||
| e0b38e617d | |||
| 8a9339f00f | |||
| f23fa4e2c8 | |||
| e177693aae | |||
| 39920bf432 | |||
| 04d3dac627 | |||
| bc0a6627c0 | |||
| 8030e45920 | |||
| fbc7b9bba0 | |||
| 05a90df512 | |||
| 817ee05710 | |||
| 5d01c2e963 | |||
| 1e32b8fbea | |||
| 1919d8bc2a | |||
| 67d47e3ec7 | |||
| 2f8d015823 | |||
| e18bd0b6f3 | |||
| 2ca79ee23a | |||
| e28c3c0f96 | |||
| 8f3f56a12c | |||
| 8d547c559e | |||
| 8ff4503270 | |||
| ad08c6c1f3 | |||
| 1f188c84b4 | |||
| e50cdade6c | |||
| 17bbb2f0e0 | |||
| ffb73e4b06 | |||
| 71b30ba8c5 | |||
| e9ea253021 | |||
| 237bd46593 | |||
| 40ebdb31d9 | |||
| 8f39ec3d35 | |||
| 7734440b90 | |||
| 42295c3c41 | |||
| 1e00690dd8 | |||
| 90e9f37849 | |||
| 9eaaa798a3 | |||
| f7685fdb2f | |||
| 609422b5b9 | |||
| 76cf6e7b62 | |||
| cc04b71327 | |||
| 1d5d86d07c | |||
| e2b7131adc | |||
| c2ced7185b | |||
| fd8f068594 | |||
| 00bafa761b | |||
| d0d66dd85f | |||
| 6f5c9bd613 | |||
| 9f6168592c | |||
| 29d474a102 | |||
| a31202f63b | |||
| 0afd6bbb60 | |||
| 2c647f0284 | |||
| d9ff6d640d | |||
| 8ab9ec7d1f | |||
| 0cc67d54ef | |||
| cbb95a38cf | |||
| 5b163d6d74 | |||
| f6e774b5c9 | |||
| 613c8b1645 | |||
| 9e1aae5d76 | |||
| f1e3ad1357 | |||
| 39b044c2c2 | |||
| c0c73315c8 | |||
| 72fbae0666 | |||
| 3ed32210ad | |||
| f2366b5a38 | |||
| dccf6ad2ce | |||
| 788c9ca7ac | |||
| 34474de163 | |||
| 12646e45e4 | |||
| b25299a3a8 | |||
| aa9b280f5c | |||
| 2ec9a29565 | |||
| 20cafce97d | |||
| 31f931f7ce | |||
| e415b5118b | |||
| 84aef6b860 | |||
| 195932dde4 | |||
| 977773fe94 | |||
| a5e2e5a2db | |||
| 5559a36de0 | |||
| e80140f7cf |
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_HOST=smtp.example.com
|
||||||
SMTP_PORT=587
|
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_SECURE=false
|
||||||
|
|
||||||
SMTP_USER=user@example.com
|
SMTP_USER=user@example.com
|
||||||
SMTP_PASS=your_password
|
SMTP_PASS=your_password
|
||||||
SMTP_FROM="MB Grid Solutions <noreply@mb-grid-solutions.com>"
|
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
|
CONTACT_RECIPIENT=info@mb-grid-solutions.com,admin@mb-grid-solutions.com
|
||||||
|
|
||||||
|
# ==============================================================================
|
||||||
|
# AUTHENTICATION (GATEKEEPER)
|
||||||
|
# ==============================================================================
|
||||||
|
GATEKEEPER_PASSWORD=lassmichrein
|
||||||
|
AUTH_COOKIE_NAME=mintel_gatekeeper_session
|
||||||
|
|
||||||
|
# ==============================================================================
|
||||||
|
# EXTERNAL SERVICES
|
||||||
|
# ==============================================================================
|
||||||
|
|
||||||
|
# Sentry / Glitchtip (Error Tracking)
|
||||||
|
SENTRY_DSN=
|
||||||
|
|
||||||
|
# Gotify (In-App Notifications)
|
||||||
|
# GOTIFY_URL=
|
||||||
|
# GOTIFY_TOKEN=
|
||||||
|
|
||||||
|
# Analytics (Umami)
|
||||||
|
UMAMI_WEBSITE_ID=
|
||||||
|
UMAMI_API_ENDPOINT=https://analytics.infra.mintel.me
|
||||||
|
|||||||
3
.eslintrc.json
Normal file
3
.eslintrc.json
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
{
|
||||||
|
"extends": ["next/core-web-vitals", "next/typescript"]
|
||||||
|
}
|
||||||
33
.gitea/workflows/ci.yml
Normal file
33
.gitea/workflows/ci.yml
Normal file
@@ -0,0 +1,33 @@
|
|||||||
|
name: CI - Quality Assurance
|
||||||
|
|
||||||
|
on:
|
||||||
|
pull_request:
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
qa:
|
||||||
|
name: 🧪 QA
|
||||||
|
runs-on: docker
|
||||||
|
container:
|
||||||
|
image: catthehacker/ubuntu:act-latest
|
||||||
|
steps:
|
||||||
|
- name: Checkout repository
|
||||||
|
uses: actions/checkout@v4
|
||||||
|
- name: Setup Node.js
|
||||||
|
uses: actions/setup-node@v4
|
||||||
|
with:
|
||||||
|
node-version: 20
|
||||||
|
- name: Setup pnpm
|
||||||
|
uses: pnpm/action-setup@v3
|
||||||
|
with:
|
||||||
|
version: 10
|
||||||
|
- name: 🔐 Registry Auth
|
||||||
|
run: |
|
||||||
|
echo "@mintel:registry=https://${{ vars.REGISTRY_HOST || 'npm.infra.mintel.me' }}" > .npmrc
|
||||||
|
echo "//${{ vars.REGISTRY_HOST || 'npm.infra.mintel.me' }}/:_authToken=${{ secrets.REGISTRY_PASS }}" >> .npmrc
|
||||||
|
- name: Install dependencies
|
||||||
|
run: pnpm install --frozen-lockfile
|
||||||
|
- name: 🧪 Parallel Checks
|
||||||
|
run: |
|
||||||
|
pnpm lint &
|
||||||
|
pnpm build &
|
||||||
|
wait
|
||||||
@@ -1,280 +1,503 @@
|
|||||||
name: Build & Deploy MB Grid Solutions
|
name: Build & Deploy
|
||||||
|
|
||||||
on:
|
on:
|
||||||
push:
|
push:
|
||||||
branches: [main]
|
branches:
|
||||||
|
- main
|
||||||
|
tags:
|
||||||
|
- 'v*'
|
||||||
|
workflow_dispatch:
|
||||||
|
inputs:
|
||||||
|
skip_checks:
|
||||||
|
description: 'Skip tests? (true/false)'
|
||||||
|
required: false
|
||||||
|
default: 'false'
|
||||||
|
|
||||||
|
concurrency:
|
||||||
|
group: ${{ github.workflow }}-${{ (github.ref_type == 'tag' && !contains(github.ref_name, '-')) && 'prod' || (github.ref_name == 'main' && 'testing' || github.ref_name) }}
|
||||||
|
cancel-in-progress: true
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
build-and-deploy:
|
# ──────────────────────────────────────────────────────────────────────────────
|
||||||
# ────────────────────────────────────────────────
|
# JOB 1: Prepare Environment
|
||||||
# WICHTIG: Kein "docker" mehr – sondern eines der neuen Labels
|
# ──────────────────────────────────────────────────────────────────────────────
|
||||||
|
prepare:
|
||||||
|
name: 🔍 Prepare
|
||||||
runs-on: docker
|
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 }}
|
||||||
|
traefik_rule: ${{ steps.determine.outputs.traefik_rule }}
|
||||||
|
next_public_url: ${{ steps.determine.outputs.next_public_url }}
|
||||||
|
project_name: ${{ steps.determine.outputs.project_name }}
|
||||||
|
short_sha: ${{ steps.determine.outputs.short_sha }}
|
||||||
|
container:
|
||||||
|
image: catthehacker/ubuntu:act-latest
|
||||||
steps:
|
steps:
|
||||||
# ═══════════════════════════════════════════════════════════════════════════════
|
- name: 🧹 Maintenance (High Density Cleanup)
|
||||||
# LOGGING: Workflow Start - Full Transparency
|
shell: bash
|
||||||
# ═══════════════════════════════════════════════════════════════════════════════
|
|
||||||
- name: 📋 Log Workflow Start
|
|
||||||
run: |
|
run: |
|
||||||
echo "╔══════════════════════════════════════════════════════════════════════════════╗"
|
echo "Purging old build layers and dangling images..."
|
||||||
echo "║ MB Grid Solutions Deployment Workflow Started ║"
|
docker image prune -f
|
||||||
echo "╚══════════════════════════════════════════════════════════════════════════════╝"
|
docker builder prune -f --filter "until=6h"
|
||||||
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
|
- name: Checkout repository
|
||||||
uses: actions/checkout@v4
|
uses: actions/checkout@v4
|
||||||
|
with:
|
||||||
|
fetch-depth: 2
|
||||||
|
|
||||||
# ═══════════════════════════════════════════════════════════════════════════════
|
- name: 🔍 Environment ermitteln
|
||||||
# LOGGING: Registry Login Phase
|
id: determine
|
||||||
# ═══════════════════════════════════════════════════════════════════════════════
|
shell: bash
|
||||||
- name: 🔐 Login to private registry
|
|
||||||
run: |
|
run: |
|
||||||
echo "╔══════════════════════════════════════════════════════════════════════════════╗"
|
REF="${{ github.ref_name }}"
|
||||||
echo "║ Step: Registry Login ║"
|
SHORT_SHA=$(echo "${{ github.sha }}" | cut -c1-7)
|
||||||
echo "╚══════════════════════════════════════════════════════════════════════════════╝"
|
DOMAIN="mb-grid-solutions.com"
|
||||||
echo ""
|
PRJ="mb-grid-solutions"
|
||||||
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 [[ "${{ github.ref_type }}" == "branch" && "$REF" == "main" ]]; then
|
||||||
if echo "${{ secrets.REGISTRY_PASS }}" | docker login registry.infra.mintel.me -u "${{ secrets.REGISTRY_USER }}" --password-stdin 2>&1; then
|
TARGET="testing"
|
||||||
echo "✅ Registry login successful"
|
IMAGE_TAG="main-${SHORT_SHA}"
|
||||||
|
ENV_FILE=".env.testing"
|
||||||
|
TRAEFIK_HOST="testing.${DOMAIN}"
|
||||||
|
elif [[ "${{ github.ref_type }}" == "tag" ]]; then
|
||||||
|
if [[ "$REF" =~ ^v[0-9]+\.[0-9]+(\.[0-9]+)?$ ]]; then
|
||||||
|
TARGET="production"
|
||||||
|
IMAGE_TAG="$REF"
|
||||||
|
ENV_FILE=".env.prod"
|
||||||
|
TRAEFIK_HOST="${DOMAIN}, www.${DOMAIN}"
|
||||||
|
else
|
||||||
|
TARGET="staging"
|
||||||
|
IMAGE_TAG="$REF"
|
||||||
|
ENV_FILE=".env.staging"
|
||||||
|
TRAEFIK_HOST="staging.${DOMAIN}"
|
||||||
|
fi
|
||||||
else
|
else
|
||||||
echo "❌ Registry login failed"
|
TARGET="skip"
|
||||||
exit 1
|
|
||||||
fi
|
fi
|
||||||
echo ""
|
|
||||||
|
|
||||||
# ═══════════════════════════════════════════════════════════════════════════════
|
if [[ "$TARGET" != "skip" ]]; then
|
||||||
# LOGGING: Build Phase
|
# Standardize Traefik Rule
|
||||||
# ═══════════════════════════════════════════════════════════════════════════════
|
if [[ "$TRAEFIK_HOST" == *","* ]]; then
|
||||||
- name: 🏗️ Build Docker image
|
TRAEFIK_RULE=$(echo "$TRAEFIK_HOST" | sed 's/,/ /g' | awk '{for(i=1;i<=NF;i++) printf "Host(\`%s\`)%s", $i, (i==NF?"":" || ")}')
|
||||||
run: |
|
PRIMARY_HOST=$(echo "$TRAEFIK_HOST" | cut -d',' -f1 | sed 's/ //g')
|
||||||
echo "╔══════════════════════════════════════════════════════════════════════════════╗"
|
else
|
||||||
echo "║ Step: Build Docker Image ║"
|
TRAEFIK_RULE="Host(\`$TRAEFIK_HOST\`)"
|
||||||
echo "╚══════════════════════════════════════════════════════════════════════════════╝"
|
PRIMARY_HOST="$TRAEFIK_HOST"
|
||||||
echo ""
|
fi
|
||||||
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
|
echo "target=$TARGET"
|
||||||
docker buildx build \
|
echo "image_tag=$IMAGE_TAG"
|
||||||
--pull \
|
echo "env_file=$ENV_FILE"
|
||||||
--platform linux/arm64 \
|
echo "traefik_host=$PRIMARY_HOST"
|
||||||
--build-arg NEXT_PUBLIC_BASE_URL="${{ secrets.NEXT_PUBLIC_BASE_URL }}" \
|
echo "traefik_rule=$TRAEFIK_RULE"
|
||||||
--build-arg NEXT_PUBLIC_UMAMI_WEBSITE_ID="${{ secrets.NEXT_PUBLIC_UMAMI_WEBSITE_ID }}" \
|
echo "next_public_url=https://$PRIMARY_HOST"
|
||||||
--build-arg NEXT_PUBLIC_UMAMI_SCRIPT_URL="${{ secrets.NEXT_PUBLIC_UMAMI_SCRIPT_URL }}" \
|
echo "project_name=$PRJ-$TARGET"
|
||||||
-t registry.infra.mintel.me/mintel/mb-grid-solutions:latest \
|
echo "short_sha=$SHORT_SHA"
|
||||||
--push .
|
} >> "$GITHUB_OUTPUT"
|
||||||
|
|
||||||
BUILD_EXIT_CODE=$?
|
# ⏳ Wait for Upstream Packages/Images if Tagged
|
||||||
if [ $BUILD_EXIT_CODE -eq 0 ]; then
|
if [[ "${{ github.ref_type }}" == "tag" ]]; then
|
||||||
echo ""
|
echo "🔎 Checking for @mintel dependencies in package.json..."
|
||||||
echo "✅ Build completed successfully at: $(date -u +'%Y-%m-%d %H:%M:%S UTC')"
|
# Extract any @mintel/ version (they should be synced in monorepo)
|
||||||
echo ""
|
UPSTREAM_VERSION=$(grep -o '"@mintel/.*": "[^"]*"' package.json | grep -v "next-utils" | cut -d'"' -f4 | sed 's/\^//; s/\~//' | sort -V | tail -1)
|
||||||
echo "📊 Image Details:"
|
TAG_TO_WAIT="v$UPSTREAM_VERSION"
|
||||||
IMAGE_SIZE=$(docker inspect registry.infra.mintel.me/mintel/mb-grid-solutions:latest --format='{{.Size}}')
|
|
||||||
IMAGE_SIZE_MB=$((IMAGE_SIZE / 1024 / 1024))
|
if [[ -n "$UPSTREAM_VERSION" && "$UPSTREAM_VERSION" != "workspace:"* ]]; then
|
||||||
echo " • Size: ${IMAGE_SIZE_MB}MB"
|
# 1. Discovery (Works without token for public repositories)
|
||||||
docker inspect registry.infra.mintel.me/mintel/mb-grid-solutions:latest --format=' • Created: {{.Created}}'
|
UPSTREAM_SHA=$(git ls-remote --tags https://git.infra.mintel.me/mmintel/at-mintel.git "$TAG_TO_WAIT" | grep "$TAG_TO_WAIT" | tail -n1 | awk '{print $1}')
|
||||||
docker inspect registry.infra.mintel.me/mintel/mb-grid-solutions:latest --format=' • Architecture: {{.Architecture}}'
|
|
||||||
|
if [[ -z "$UPSTREAM_SHA" ]]; then
|
||||||
|
echo "❌ Error: Tag $TAG_TO_WAIT not found in mmintel/at-mintel."
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
echo "✅ Tag verified: Found upstream SHA $UPSTREAM_SHA for $TAG_TO_WAIT"
|
||||||
|
|
||||||
|
# 2. Status Check (Requires PAT for cross-repo API access)
|
||||||
|
POLL_TOKEN="${{ secrets.GITEA_PAT || secrets.MINTEL_PRIVATE_TOKEN }}"
|
||||||
|
|
||||||
|
if [[ -n "$POLL_TOKEN" ]]; then
|
||||||
|
echo "⏳ POLL_TOKEN found. Checking upstream build status..."
|
||||||
|
curl -s -H "Authorization: token ${{ secrets.GITHUB_TOKEN }}" \
|
||||||
|
"https://git.infra.mintel.me/mmintel/at-mintel/raw/branch/main/packages/infra/scripts/wait-for-upstream.sh" > wait-for-upstream.sh
|
||||||
|
chmod +x wait-for-upstream.sh
|
||||||
|
GITEA_TOKEN="$POLL_TOKEN" ./wait-for-upstream.sh "mmintel/at-mintel" "$TAG_TO_WAIT"
|
||||||
|
else
|
||||||
|
echo "ℹ️ No PAT secret found. Skipping build status wait (Actions API is restricted)."
|
||||||
|
echo " If this build fails, ensure that mmintel/at-mintel $TAG_TO_WAIT has finished its Docker build."
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
fi
|
||||||
else
|
else
|
||||||
echo ""
|
echo "target=skip" >> "$GITHUB_OUTPUT"
|
||||||
echo "❌ Build failed with exit code: $BUILD_EXIT_CODE"
|
|
||||||
exit $BUILD_EXIT_CODE
|
|
||||||
fi
|
fi
|
||||||
echo ""
|
|
||||||
|
|
||||||
# ═══════════════════════════════════════════════════════════════════════════════
|
# ──────────────────────────────────────────────────────────────────────────────
|
||||||
# LOGGING: Deployment Phase
|
# JOB 2: QA (Lint, Build Test)
|
||||||
# ═══════════════════════════════════════════════════════════════════════════════
|
# ──────────────────────────────────────────────────────────────────────────────
|
||||||
- name: 🚀 Deploy to production server
|
qa:
|
||||||
|
name: 🧪 QA
|
||||||
|
needs: prepare
|
||||||
|
if: needs.prepare.outputs.target != 'skip'
|
||||||
|
runs-on: docker
|
||||||
|
container:
|
||||||
|
image: catthehacker/ubuntu:act-latest
|
||||||
|
steps:
|
||||||
|
- name: Checkout repository
|
||||||
|
uses: actions/checkout@v4
|
||||||
|
- name: Setup Node.js
|
||||||
|
uses: actions/setup-node@v4
|
||||||
|
with:
|
||||||
|
node-version: 20
|
||||||
|
- name: Setup pnpm
|
||||||
|
uses: pnpm/action-setup@v3
|
||||||
|
with:
|
||||||
|
version: 10
|
||||||
|
- name: 🔐 Registry Auth
|
||||||
run: |
|
run: |
|
||||||
echo "╔══════════════════════════════════════════════════════════════════════════════╗"
|
echo "@mintel:registry=https://${{ vars.REGISTRY_HOST || 'npm.infra.mintel.me' }}" > .npmrc
|
||||||
echo "║ Step: Deploy to Production Server ║"
|
echo "//${{ vars.REGISTRY_HOST || 'npm.infra.mintel.me' }}/:_authToken=${{ secrets.REGISTRY_PASS }}" >> .npmrc
|
||||||
echo "╚══════════════════════════════════════════════════════════════════════════════╝"
|
- name: Install dependencies
|
||||||
echo ""
|
run: |
|
||||||
echo "🚀 Starting deployment process..."
|
pnpm store prune
|
||||||
echo " Target Server: alpha.mintel.me"
|
pnpm install --no-frozen-lockfile
|
||||||
echo " Deploy User: deploy (via sudo from root)"
|
- name: 🧪 QA Checks
|
||||||
echo " Target Path: /home/deploy/sites/mb-grid-solutions.com"
|
if: github.event.inputs.skip_checks != 'true'
|
||||||
echo ""
|
run: |
|
||||||
|
pnpm lint
|
||||||
|
pnpm exec tsc --noEmit
|
||||||
|
pnpm test run
|
||||||
|
- name: 🏗️ Build Test
|
||||||
|
if: github.event.inputs.skip_checks != 'true'
|
||||||
|
run: pnpm build
|
||||||
|
|
||||||
# Setup SSH with logging
|
# ──────────────────────────────────────────────────────────────────────────────
|
||||||
echo "🔐 Setting up SSH connection..."
|
# JOB 3: Build & Push
|
||||||
|
# ──────────────────────────────────────────────────────────────────────────────
|
||||||
|
build:
|
||||||
|
name: 🏗️ Build
|
||||||
|
needs: prepare
|
||||||
|
if: needs.prepare.outputs.target != 'skip'
|
||||||
|
runs-on: docker
|
||||||
|
container:
|
||||||
|
image: catthehacker/ubuntu:act-latest
|
||||||
|
steps:
|
||||||
|
- name: Checkout repository
|
||||||
|
uses: actions/checkout@v4
|
||||||
|
- name: 🐳 Set up Docker Buildx
|
||||||
|
uses: docker/setup-buildx-action@v3
|
||||||
|
- name: 🔐 Registry Login
|
||||||
|
run: echo "${{ secrets.REGISTRY_PASS }}" | docker login registry.infra.mintel.me -u "${{ secrets.REGISTRY_USER }}" --password-stdin
|
||||||
|
- name: 🏗️ Build and Push
|
||||||
|
uses: docker/build-push-action@v5
|
||||||
|
with:
|
||||||
|
context: .
|
||||||
|
push: true
|
||||||
|
platforms: linux/amd64
|
||||||
|
build-args: |
|
||||||
|
NEXT_PUBLIC_BASE_URL=${{ needs.prepare.outputs.next_public_url }}
|
||||||
|
NEXT_PUBLIC_TARGET=${{ needs.prepare.outputs.target }}
|
||||||
|
NPM_TOKEN=${{ secrets.REGISTRY_PASS }}
|
||||||
|
tags: registry.infra.mintel.me/mintel/mb-grid-solutions:${{ needs.prepare.outputs.image_tag }}
|
||||||
|
cache-from: type=registry,ref=registry.infra.mintel.me/mintel/mb-grid-solutions:buildcache
|
||||||
|
cache-to: type=registry,ref=registry.infra.mintel.me/mintel/mb-grid-solutions:buildcache,mode=max
|
||||||
|
secrets: |
|
||||||
|
"NPM_TOKEN=${{ secrets.REGISTRY_PASS }}"
|
||||||
|
|
||||||
|
# ──────────────────────────────────────────────────────────────────────────────
|
||||||
|
# JOB 4: Deploy
|
||||||
|
# ──────────────────────────────────────────────────────────────────────────────
|
||||||
|
deploy:
|
||||||
|
name: 🚀 Deploy
|
||||||
|
needs: [prepare, build, qa]
|
||||||
|
runs-on: docker
|
||||||
|
container:
|
||||||
|
image: catthehacker/ubuntu:act-latest
|
||||||
|
env:
|
||||||
|
TARGET: ${{ needs.prepare.outputs.target }}
|
||||||
|
IMAGE_TAG: ${{ needs.prepare.outputs.image_tag }}
|
||||||
|
PROJECT_NAME: ${{ needs.prepare.outputs.project_name }}
|
||||||
|
NEXT_PUBLIC_BASE_URL: ${{ needs.prepare.outputs.next_public_url }}
|
||||||
|
|
||||||
|
# Secrets mapping (Database & CMS)
|
||||||
|
PAYLOAD_SECRET: ${{ (needs.prepare.outputs.target == 'testing' && secrets.TESTING_PAYLOAD_SECRET) || (needs.prepare.outputs.target == 'staging' && secrets.STAGING_PAYLOAD_SECRET) || secrets.PAYLOAD_SECRET || secrets.DIRECTUS_SECRET || vars.PAYLOAD_SECRET || 'you-need-to-set-a-payload-secret' }}
|
||||||
|
DATABASE_URI: ${{ (needs.prepare.outputs.target == 'testing' && secrets.TESTING_DATABASE_URI) || (needs.prepare.outputs.target == 'staging' && secrets.STAGING_DATABASE_URI) || secrets.DATABASE_URI || vars.DATABASE_URI }}
|
||||||
|
DIRECTUS_DB_NAME: ${{ secrets.DIRECTUS_DB_NAME || vars.DIRECTUS_DB_NAME || 'directus' }}
|
||||||
|
DIRECTUS_DB_USER: ${{ secrets.DIRECTUS_DB_USER || vars.DIRECTUS_DB_USER || 'directus' }}
|
||||||
|
DIRECTUS_DB_PASSWORD: ${{ (needs.prepare.outputs.target == 'testing' && secrets.TESTING_DIRECTUS_DB_PASSWORD) || (needs.prepare.outputs.target == 'staging' && secrets.STAGING_DIRECTUS_DB_PASSWORD) || secrets.DIRECTUS_DB_PASSWORD || vars.DIRECTUS_DB_PASSWORD || 'directus' }}
|
||||||
|
|
||||||
|
|
||||||
|
# Secrets mapping (Mail)
|
||||||
|
MAIL_HOST: ${{ secrets.SMTP_HOST || vars.SMTP_HOST }}
|
||||||
|
MAIL_PORT: ${{ secrets.SMTP_PORT || vars.SMTP_PORT || '587' }}
|
||||||
|
MAIL_USERNAME: ${{ secrets.SMTP_USER || vars.SMTP_USER }}
|
||||||
|
MAIL_PASSWORD: ${{ secrets.SMTP_PASS || vars.SMTP_PASS }}
|
||||||
|
MAIL_FROM: ${{ secrets.SMTP_FROM || vars.SMTP_FROM }}
|
||||||
|
MAIL_RECIPIENTS: ${{ secrets.CONTACT_RECIPIENT || vars.CONTACT_RECIPIENT }}
|
||||||
|
|
||||||
|
# Authentication
|
||||||
|
GATEKEEPER_PASSWORD: ${{ secrets.GATEKEEPER_PASSWORD || vars.GATEKEEPER_PASSWORD }}
|
||||||
|
AUTH_COOKIE_NAME: ${{ secrets.AUTH_COOKIE_NAME || vars.AUTH_COOKIE_NAME || 'mintel_gatekeeper_session' }}
|
||||||
|
COOKIE_DOMAIN: ${{ secrets.COOKIE_DOMAIN || vars.COOKIE_DOMAIN || '.mb-grid-solutions.com' }}
|
||||||
|
|
||||||
|
# Monitoring & Services
|
||||||
|
SENTRY_DSN: ${{ secrets.SENTRY_DSN || vars.SENTRY_DSN }}
|
||||||
|
UMAMI_WEBSITE_ID: ${{ secrets.UMAMI_WEBSITE_ID || secrets.NEXT_PUBLIC_UMAMI_WEBSITE_ID || vars.UMAMI_WEBSITE_ID || vars.NEXT_PUBLIC_UMAMI_WEBSITE_ID }}
|
||||||
|
UMAMI_API_ENDPOINT: ${{ secrets.UMAMI_API_ENDPOINT || secrets.NEXT_PUBLIC_UMAMI_SCRIPT_URL || vars.UMAMI_API_ENDPOINT || 'https://analytics.infra.mintel.me' }}
|
||||||
|
PROJECT_COLOR: ${{ secrets.PROJECT_COLOR || vars.PROJECT_COLOR || '#82ed20' }}
|
||||||
|
steps:
|
||||||
|
- name: Checkout repository
|
||||||
|
uses: actions/checkout@v4
|
||||||
|
- name: 📝 Generate Environment
|
||||||
|
shell: bash
|
||||||
|
env:
|
||||||
|
TRAEFIK_HOST: ${{ needs.prepare.outputs.traefik_host }}
|
||||||
|
ENV_FILE: ${{ needs.prepare.outputs.env_file }}
|
||||||
|
run: |
|
||||||
|
# Middleware & Auth Logic
|
||||||
|
LOG_LEVEL=$( [[ "$TARGET" == "testing" || "$TARGET" == "development" ]] && echo "debug" || echo "info" )
|
||||||
|
STD_MW="${PROJECT_NAME}-forward,compress"
|
||||||
|
|
||||||
|
if [[ "$TARGET" == "production" ]]; then
|
||||||
|
AUTH_MIDDLEWARE="$STD_MW"
|
||||||
|
COMPOSE_PROFILES=""
|
||||||
|
else
|
||||||
|
# Order: Forward (Proto) -> Auth -> Compression
|
||||||
|
AUTH_MIDDLEWARE="${PROJECT_NAME}-forward,${PROJECT_NAME}-auth,compress"
|
||||||
|
COMPOSE_PROFILES="gatekeeper"
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Gatekeeper Origin
|
||||||
|
COOKIE_DOMAIN=.$(echo $NEXT_PUBLIC_BASE_URL | sed 's|https://||')
|
||||||
|
GATEKEEPER_ORIGIN="$NEXT_PUBLIC_BASE_URL/gatekeeper"
|
||||||
|
|
||||||
|
# Generate Environment File
|
||||||
|
cat > .env.deploy << EOF
|
||||||
|
# Generated by CI - $TARGET
|
||||||
|
IMAGE_TAG=$IMAGE_TAG
|
||||||
|
NEXT_PUBLIC_BASE_URL=$NEXT_PUBLIC_BASE_URL
|
||||||
|
GATEKEEPER_ORIGIN=$GATEKEEPER_ORIGIN
|
||||||
|
SENTRY_DSN=$SENTRY_DSN
|
||||||
|
PROJECT_COLOR=$PROJECT_COLOR
|
||||||
|
LOG_LEVEL=$LOG_LEVEL
|
||||||
|
|
||||||
|
# Database & Payload
|
||||||
|
DATABASE_URI=\${DATABASE_URI:-postgresql://$DIRECTUS_DB_USER:$DIRECTUS_DB_PASSWORD@mb-grid-db:5432/$DIRECTUS_DB_NAME}
|
||||||
|
PAYLOAD_SECRET=${PAYLOAD_SECRET:-you-need-to-set-a-payload-secret}
|
||||||
|
DIRECTUS_DB_NAME=$DIRECTUS_DB_NAME
|
||||||
|
DIRECTUS_DB_USER=$DIRECTUS_DB_USER
|
||||||
|
DIRECTUS_DB_PASSWORD=$DIRECTUS_DB_PASSWORD
|
||||||
|
|
||||||
|
# Mail
|
||||||
|
MAIL_HOST=$MAIL_HOST
|
||||||
|
MAIL_PORT=$MAIL_PORT
|
||||||
|
MAIL_USERNAME=$MAIL_USERNAME
|
||||||
|
MAIL_PASSWORD=$MAIL_PASSWORD
|
||||||
|
MAIL_FROM=$MAIL_FROM
|
||||||
|
MAIL_RECIPIENTS=$MAIL_RECIPIENTS
|
||||||
|
|
||||||
|
# Authentication
|
||||||
|
GATEKEEPER_PASSWORD=$GATEKEEPER_PASSWORD
|
||||||
|
AUTH_COOKIE_NAME=$AUTH_COOKIE_NAME
|
||||||
|
COOKIE_DOMAIN=$COOKIE_DOMAIN
|
||||||
|
|
||||||
|
# Analytics
|
||||||
|
UMAMI_WEBSITE_ID=$UMAMI_WEBSITE_ID
|
||||||
|
NEXT_PUBLIC_UMAMI_WEBSITE_ID=$UMAMI_WEBSITE_ID
|
||||||
|
UMAMI_API_ENDPOINT=$UMAMI_API_ENDPOINT
|
||||||
|
|
||||||
|
TARGET=$TARGET
|
||||||
|
SENTRY_ENVIRONMENT=$TARGET
|
||||||
|
PROJECT_NAME=$PROJECT_NAME
|
||||||
|
ENV_FILE=$ENV_FILE
|
||||||
|
TRAEFIK_HOST=$TRAEFIK_HOST
|
||||||
|
COMPOSE_PROFILES=$COMPOSE_PROFILES
|
||||||
|
TRAEFIK_MIDDLEWARES=$AUTH_MIDDLEWARE
|
||||||
|
EOF
|
||||||
|
|
||||||
|
- name: 🚀 SSH Deploy
|
||||||
|
shell: bash
|
||||||
|
env:
|
||||||
|
ENV_FILE: ${{ needs.prepare.outputs.env_file }}
|
||||||
|
run: |
|
||||||
mkdir -p ~/.ssh
|
mkdir -p ~/.ssh
|
||||||
echo "${{ secrets.ALPHA_SSH_KEY }}" > ~/.ssh/id_ed25519
|
echo "${{ secrets.ALPHA_SSH_KEY }}" > ~/.ssh/id_ed25519
|
||||||
chmod 600 ~/.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
|
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
|
# Transfer and Restart
|
||||||
echo "📦 Syncing docker-compose.yaml..."
|
SITE_DIR="/home/deploy/sites/mb-grid-solutions.com"
|
||||||
tar czf - docker-compose.yaml | \
|
ssh root@alpha.mintel.me "mkdir -p $SITE_DIR"
|
||||||
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
|
scp .env.deploy root@alpha.mintel.me:$SITE_DIR/$ENV_FILE
|
||||||
echo "✅ Files synced successfully"
|
scp docker-compose.yaml root@alpha.mintel.me:$SITE_DIR/docker-compose.yaml
|
||||||
else
|
|
||||||
echo "❌ File sync failed"
|
|
||||||
exit 1
|
|
||||||
fi
|
|
||||||
echo ""
|
|
||||||
|
|
||||||
# Execute deployment commands with detailed logging
|
ssh root@alpha.mintel.me "cd $SITE_DIR && echo '${{ secrets.REGISTRY_PASS }}' | docker login registry.infra.mintel.me -u '${{ secrets.REGISTRY_USER }}' --password-stdin"
|
||||||
echo "📡 Connecting to server and executing deployment commands..."
|
ssh root@alpha.mintel.me "cd $SITE_DIR && docker compose -p '${{ needs.prepare.outputs.project_name }}' --env-file '$ENV_FILE' pull"
|
||||||
echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"
|
ssh root@alpha.mintel.me "cd $SITE_DIR && docker compose -p '${{ needs.prepare.outputs.project_name }}' --env-file '$ENV_FILE' up -d --remove-orphans"
|
||||||
echo ""
|
|
||||||
|
|
||||||
# SSH as root and use sudo to run deployment script as deploy user
|
# Apply Payload Migrations using the target app container's programmatic endpoint
|
||||||
# This works around the broken SSH output issue with deploy user
|
ssh root@alpha.mintel.me "cd $SITE_DIR && echo '→ Waiting for DB and Running Payload Migrations...' && \
|
||||||
ssh -o StrictHostKeyChecking=accept-new \
|
for i in {1..5}; do \
|
||||||
-o ServerAliveInterval=30 \
|
echo \"Attempt \$i...\"; \
|
||||||
-o ServerAliveCountMax=3 \
|
docker compose -p '${{ needs.prepare.outputs.project_name }}' --env-file '$ENV_FILE' exec -T mb-grid-app sh -c 'curl -s -f -X POST -H \"Authorization: Bearer \$PAYLOAD_SECRET\" http://localhost:3000/api/payload/migrate \
|
||||||
-o ConnectTimeout=10 \
|
|| { echo \"HTTP error or DB not ready.\"; exit 1; }' && { echo '✅ Migrations successful!'; break; } \
|
||||||
root@alpha.mintel.me \
|
|| { if [ \$i -eq 5 ]; then echo '❌ Migration failed after 5 attempts!'; exit 1; else echo '⏳ Retrying in 5s...'; sleep 5; fi; }; \
|
||||||
"CONTACT_RECIPIENT='${{ secrets.CONTACT_RECIPIENT }}' \
|
done"
|
||||||
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=$?
|
ssh root@alpha.mintel.me "docker system prune -f --filter 'until=24h'"
|
||||||
echo ""
|
|
||||||
|
|
||||||
if [ $DEPLOY_EXIT_CODE -eq 0 ]; then
|
- name: 🧹 Post-Deploy Cleanup (Runner)
|
||||||
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()
|
if: always()
|
||||||
|
run: docker builder prune -f --filter "until=1h"
|
||||||
|
|
||||||
|
# ──────────────────────────────────────────────────────────────────────────────
|
||||||
|
# JOB 5: Post-Deploy Verification (Smoke Tests + Quality Gates)
|
||||||
|
# ──────────────────────────────────────────────────────────────────────────────
|
||||||
|
post_deploy_checks:
|
||||||
|
name: 🧪 Post-Deploy Verification
|
||||||
|
needs: [prepare, deploy]
|
||||||
|
if: needs.deploy.result == 'success' && needs.prepare.outputs.target != 'branch'
|
||||||
|
runs-on: docker
|
||||||
|
container:
|
||||||
|
image: catthehacker/ubuntu:act-latest
|
||||||
|
steps:
|
||||||
|
- name: Checkout repository
|
||||||
|
uses: actions/checkout@v4
|
||||||
|
- name: Setup pnpm
|
||||||
|
uses: pnpm/action-setup@v3
|
||||||
|
with:
|
||||||
|
version: 10
|
||||||
|
- name: Setup Node.js
|
||||||
|
uses: actions/setup-node@v4
|
||||||
|
with:
|
||||||
|
node-version: 20
|
||||||
|
- name: 🔐 Registry Auth
|
||||||
run: |
|
run: |
|
||||||
echo "╔══════════════════════════════════════════════════════════════════════════════╗"
|
echo "@mintel:registry=https://${{ vars.REGISTRY_HOST || 'npm.infra.mintel.me' }}" > .npmrc
|
||||||
echo "║ Workflow Summary ║"
|
echo "//${{ vars.REGISTRY_HOST || 'npm.infra.mintel.me' }}/:_authToken=${{ secrets.REGISTRY_PASS }}" >> .npmrc
|
||||||
echo "╚══════════════════════════════════════════════════════════════════════════════╝"
|
- name: Install dependencies
|
||||||
echo ""
|
id: deps
|
||||||
echo "📊 Final Status:"
|
run: |
|
||||||
echo " • Workflow: ${{ job.status }}"
|
pnpm store prune
|
||||||
echo " • Completed: $(date -u +'%Y-%m-%d %H:%M:%S UTC')"
|
pnpm install --no-frozen-lockfile
|
||||||
echo ""
|
- name: 📦 Cache APT Packages
|
||||||
echo "🎯 Deployment Target:"
|
uses: actions/cache@v4
|
||||||
echo " • Image: registry.infra.mintel.me/mintel/mb-grid-solutions:latest"
|
with:
|
||||||
echo " • Server: alpha.mintel.me"
|
path: /var/cache/apt/archives
|
||||||
echo " • Service: mb-grid-solutions.com"
|
key: apt-cache-${{ runner.os }}-${{ runner.arch }}-chromium
|
||||||
echo ""
|
|
||||||
echo "🔐 Security Notes:"
|
- name: 💾 Cache Chromium
|
||||||
echo " • All secrets are masked (*** ) in logs"
|
id: cache-chromium
|
||||||
echo " • SSH keys are created with 600 permissions"
|
uses: actions/cache@v4
|
||||||
echo " • Passwords are never displayed in plain text"
|
with:
|
||||||
echo ""
|
path: /usr/bin/chromium
|
||||||
echo "╔══════════════════════════════════════════════════════════════════════════════╗"
|
key: ${{ runner.os }}-chromium-native-${{ hashFiles('package.json') }}
|
||||||
if [ "${{ job.status }}" == "success" ]; then
|
|
||||||
echo "║ ✅ DEPLOYMENT SUCCESSFUL ║"
|
- name: 🔍 Install Chromium (Native & ARM64)
|
||||||
|
if: steps.cache-chromium.outputs.cache-hit != 'true'
|
||||||
|
run: |
|
||||||
|
rm -f /etc/apt/apt.conf.d/docker-clean
|
||||||
|
apt-get update
|
||||||
|
apt-get install -y gnupg wget ca-certificates
|
||||||
|
OS_ID=$(. /etc/os-release && echo $ID)
|
||||||
|
CODENAME=$(. /etc/os-release && echo $VERSION_CODENAME)
|
||||||
|
if [ "$OS_ID" = "debian" ]; then
|
||||||
|
apt-get install -y chromium
|
||||||
else
|
else
|
||||||
echo "║ ❌ DEPLOYMENT FAILED ║"
|
mkdir -p /etc/apt/keyrings
|
||||||
|
KEY_ID="82BB6851C64F6880"
|
||||||
|
wget -qO- "https://keyserver.ubuntu.com/pks/lookup?op=get&search=0x$KEY_ID" | gpg --dearmor > /etc/apt/keyrings/xtradeb.gpg
|
||||||
|
echo "deb [signed-by=/etc/apt/keyrings/xtradeb.gpg] http://ppa.launchpad.net/xtradeb/apps/ubuntu $CODENAME main" > /etc/apt/sources.list.d/xtradeb-ppa.list
|
||||||
|
printf "Package: *\nPin: release o=LP-PPA-xtradeb-apps\nPin-Priority: 1001\n" > /etc/apt/preferences.d/xtradeb
|
||||||
|
apt-get update
|
||||||
|
apt-get install -y --allow-downgrades chromium
|
||||||
fi
|
fi
|
||||||
echo "╚══════════════════════════════════════════════════════════════════════════════╝"
|
[ -f /usr/bin/chromium ] && ln -sf /usr/bin/chromium /usr/bin/google-chrome
|
||||||
|
[ -f /usr/bin/chromium ] && ln -sf /usr/bin/chromium /usr/bin/chromium-browser
|
||||||
|
|
||||||
# ═══════════════════════════════════════════════════════════════════════════════
|
# ── Critical Smoke Tests (MUST pass) ──────────────────────────────────
|
||||||
# NOTIFICATION: Gotify
|
- name: 🏥 CMS Deep Health Check
|
||||||
# ═══════════════════════════════════════════════════════════════════════════════
|
env:
|
||||||
- name: 🔔 Gotify Notification (Success)
|
DEPLOY_URL: ${{ needs.prepare.outputs.next_public_url }}
|
||||||
if: success()
|
GK_PASS: ${{ secrets.GATEKEEPER_PASSWORD || vars.GATEKEEPER_PASSWORD }}
|
||||||
run: |
|
run: |
|
||||||
echo "Sending success notification to Gotify..."
|
echo "Waiting 10s for app to fully start..."
|
||||||
RESPONSE=$(curl -k -s -w "\n%{http_code}" -X POST "${{ secrets.GOTIFY_URL }}/message?token=${{ secrets.GOTIFY_TOKEN }}" \
|
sleep 10
|
||||||
-F "title=✅ Deployment Success: ${{ github.repository }}" \
|
echo "Checking basic health..."
|
||||||
-F "message=The deployment of ${{ github.repository }} (branch: ${{ github.ref }}) was successful.
|
curl -sf "$DEPLOY_URL/api/health" || { echo "❌ Basic health check failed"; exit 1; }
|
||||||
|
echo "✅ Basic health OK"
|
||||||
|
|
||||||
Commit: ${{ github.sha }}
|
- name: 🌐 Core Smoke Tests (HTTP, API, Locale)
|
||||||
Actor: ${{ github.actor }}
|
if: always() && steps.deps.outcome == 'success'
|
||||||
Run ID: ${{ github.run_id }}" \
|
uses: https://git.infra.mintel.me/mmintel/at-mintel/.gitea/actions/core-smoke-tests@main
|
||||||
-F "priority=5")
|
with:
|
||||||
|
TARGET_URL: ${{ needs.prepare.outputs.next_public_url }}
|
||||||
|
GATEKEEPER_PASSWORD: ${{ secrets.GATEKEEPER_PASSWORD || vars.GATEKEEPER_PASSWORD }}
|
||||||
|
UMAMI_API_ENDPOINT: ${{ secrets.UMAMI_API_ENDPOINT || secrets.NEXT_PUBLIC_UMAMI_SCRIPT_URL || vars.UMAMI_API_ENDPOINT || 'https://analytics.infra.mintel.me' }}
|
||||||
|
SENTRY_DSN: ${{ secrets.SENTRY_DSN || vars.SENTRY_DSN }}
|
||||||
|
|
||||||
HTTP_CODE=$(echo "$RESPONSE" | tail -n1)
|
- name: 📝 E2E Form Submission Test
|
||||||
BODY=$(echo "$RESPONSE" | sed '$d')
|
if: always() && steps.deps.outcome == 'success'
|
||||||
|
env:
|
||||||
|
NEXT_PUBLIC_BASE_URL: ${{ needs.prepare.outputs.next_public_url }}
|
||||||
|
GATEKEEPER_PASSWORD: ${{ secrets.GATEKEEPER_PASSWORD || vars.GATEKEEPER_PASSWORD }}
|
||||||
|
PUPPETEER_EXECUTABLE_PATH: /usr/bin/chromium
|
||||||
|
run: pnpm test run
|
||||||
|
|
||||||
echo "HTTP Status: $HTTP_CODE"
|
# ──────────────────────────────────────────────────────────────────────────────
|
||||||
echo "Response Body: $BODY"
|
# JOB 6: Notifications
|
||||||
|
# ──────────────────────────────────────────────────────────────────────────────
|
||||||
if [ "$HTTP_CODE" -lt 200 ] || [ "$HTTP_CODE" -ge 300 ]; then
|
notifications:
|
||||||
echo "Failed to send Gotify notification"
|
name: 🔔 Notify
|
||||||
exit 0 # Don't fail the workflow because of notification failure
|
needs: [prepare, deploy, post_deploy_checks]
|
||||||
fi
|
if: always()
|
||||||
|
runs-on: docker
|
||||||
- name: 🔔 Gotify Notification (Failure)
|
container:
|
||||||
if: failure()
|
image: catthehacker/ubuntu:act-latest
|
||||||
|
steps:
|
||||||
|
- name: 🔔 Gotify
|
||||||
|
shell: bash
|
||||||
run: |
|
run: |
|
||||||
echo "Sending failure notification to Gotify..."
|
DEPLOY="${{ needs.deploy.result }}"
|
||||||
RESPONSE=$(curl -k -s -w "\n%{http_code}" -X POST "${{ secrets.GOTIFY_URL }}/message?token=${{ secrets.GOTIFY_TOKEN }}" \
|
SMOKE="${{ needs.post_deploy_checks.result }}"
|
||||||
-F "title=❌ Deployment Failed: ${{ github.repository }}" \
|
PERF="${{ needs.post_deploy_checks.result }}"
|
||||||
-F "message=The deployment of ${{ github.repository }} (branch: ${{ github.ref }}) failed!
|
TARGET="${{ needs.prepare.outputs.target }}"
|
||||||
|
VERSION="${{ needs.prepare.outputs.image_tag }}"
|
||||||
|
URL="${{ needs.prepare.outputs.next_public_url }}"
|
||||||
|
|
||||||
Commit: ${{ github.sha }}
|
# Gotify priority scale:
|
||||||
Actor: ${{ github.actor }}
|
# 1-3 = low (silent/info)
|
||||||
Run ID: ${{ github.run_id }}
|
# 4-5 = normal
|
||||||
|
# 6-7 = high (warning)
|
||||||
Please check the logs for details." \
|
# 8-10 = critical (alarm)
|
||||||
-F "priority=8")
|
if [[ "$DEPLOY" != "success" ]]; then
|
||||||
|
PRIORITY=10
|
||||||
HTTP_CODE=$(echo "$RESPONSE" | tail -n1)
|
EMOJI="🚨"
|
||||||
BODY=$(echo "$RESPONSE" | sed '$d')
|
STATUS_LINE="DEPLOY FAILED"
|
||||||
|
elif [[ "$SMOKE" != "success" ]]; then
|
||||||
echo "HTTP Status: $HTTP_CODE"
|
PRIORITY=8
|
||||||
echo "Response Body: $BODY"
|
EMOJI="⚠️"
|
||||||
|
STATUS_LINE="Smoke tests failed"
|
||||||
if [ "$HTTP_CODE" -lt 200 ] || [ "$HTTP_CODE" -ge 300 ]; then
|
elif [[ "$PERF" != "success" ]]; then
|
||||||
echo "Failed to send Gotify notification"
|
PRIORITY=5
|
||||||
exit 0 # Don't fail the workflow because of notification failure
|
EMOJI="📉"
|
||||||
|
STATUS_LINE="Performance degraded"
|
||||||
|
else
|
||||||
|
PRIORITY=2
|
||||||
|
EMOJI="✅"
|
||||||
|
STATUS_LINE="All checks passed"
|
||||||
fi
|
fi
|
||||||
|
|
||||||
|
TITLE="$EMOJI mb-grid-solutions.com $VERSION → $TARGET"
|
||||||
|
MESSAGE="$STATUS_LINE
|
||||||
|
Deploy: $DEPLOY | Smoke: $SMOKE | Perf: $PERF
|
||||||
|
$URL"
|
||||||
|
|
||||||
|
curl -s -k -X POST "${{ secrets.GOTIFY_URL }}/message?token=${{ secrets.GOTIFY_TOKEN }}" \
|
||||||
|
-F "title=$TITLE" \
|
||||||
|
-F "message=$MESSAGE" \
|
||||||
|
-F "priority=$PRIORITY" || true
|
||||||
|
|||||||
17
.gitea/workflows/qa.yml
Normal file
17
.gitea/workflows/qa.yml
Normal file
@@ -0,0 +1,17 @@
|
|||||||
|
name: Nightly QA
|
||||||
|
|
||||||
|
on:
|
||||||
|
schedule:
|
||||||
|
- cron: '0 4 * * *'
|
||||||
|
workflow_dispatch:
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
call-qa-workflow:
|
||||||
|
uses: mmintel/at-mintel/.gitea/workflows/quality-assurance-template.yml@main
|
||||||
|
with:
|
||||||
|
TARGET_URL: 'https://testing.mb-grid-solutions.com'
|
||||||
|
PROJECT_NAME: 'mb-grid-solutions'
|
||||||
|
secrets:
|
||||||
|
GOTIFY_URL: ${{ secrets.GOTIFY_URL }}
|
||||||
|
GOTIFY_TOKEN: ${{ secrets.GOTIFY_TOKEN }}
|
||||||
|
GATEKEEPER_PASSWORD: ${{ secrets.GATEKEEPER_PASSWORD || vars.GATEKEEPER_PASSWORD }}
|
||||||
2
.gitignore
vendored
2
.gitignore
vendored
@@ -7,6 +7,8 @@ yarn-error.log*
|
|||||||
pnpm-debug.log*
|
pnpm-debug.log*
|
||||||
lerna-debug.log*
|
lerna-debug.log*
|
||||||
|
|
||||||
|
.pnpm-store
|
||||||
|
|
||||||
node_modules
|
node_modules
|
||||||
dist
|
dist
|
||||||
dist-ssr
|
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
|
||||||
4
.lintstagedrc.cjs
Normal file
4
.lintstagedrc.cjs
Normal file
@@ -0,0 +1,4 @@
|
|||||||
|
module.exports = {
|
||||||
|
'*.{js,jsx,ts,tsx}': [/* 'eslint --fix', */ 'prettier --write'],
|
||||||
|
'*.{json,md,css,scss}': ['prettier --write'],
|
||||||
|
};
|
||||||
4
.npmrc
Normal file
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
|
||||||
76
Dockerfile
76
Dockerfile
@@ -1,27 +1,69 @@
|
|||||||
# Build Stage
|
# Stage 1: Builder
|
||||||
FROM node:20-slim AS build
|
FROM node:20-alpine AS builder
|
||||||
|
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
|
|
||||||
COPY package*.json ./
|
# Clean the workspace
|
||||||
RUN npm ci
|
RUN rm -rf ./*
|
||||||
|
|
||||||
|
# Arguments for build-time configuration
|
||||||
|
ARG NEXT_PUBLIC_BASE_URL
|
||||||
|
ARG NEXT_PUBLIC_TARGET
|
||||||
|
ARG DIRECTUS_URL
|
||||||
|
ARG UMAMI_API_ENDPOINT
|
||||||
|
ARG NEXT_PUBLIC_UMAMI_WEBSITE_ID
|
||||||
|
ARG NPM_TOKEN
|
||||||
|
|
||||||
|
# Environment variables for Next.js build
|
||||||
|
ENV NEXT_PUBLIC_BASE_URL=$NEXT_PUBLIC_BASE_URL
|
||||||
|
ENV NEXT_PUBLIC_TARGET=$NEXT_PUBLIC_TARGET
|
||||||
|
ENV DIRECTUS_URL=$DIRECTUS_URL
|
||||||
|
ENV UMAMI_API_ENDPOINT=$UMAMI_API_ENDPOINT
|
||||||
|
ENV NEXT_PUBLIC_UMAMI_WEBSITE_ID=$NEXT_PUBLIC_UMAMI_WEBSITE_ID
|
||||||
|
ENV SKIP_RUNTIME_ENV_VALIDATION=true
|
||||||
|
ENV CI=true
|
||||||
|
|
||||||
|
# Enable pnpm
|
||||||
|
RUN corepack enable && corepack prepare pnpm@10.3.0 --activate
|
||||||
|
|
||||||
|
# Copy lockfile and manifest for dependency installation caching
|
||||||
|
COPY pnpm-lock.yaml package.json .npmrc* ./
|
||||||
|
|
||||||
|
# Install dependencies with cache mount
|
||||||
|
RUN --mount=type=cache,id=pnpm,target=/pnpm/store \
|
||||||
|
--mount=type=secret,id=NPM_TOKEN \
|
||||||
|
export NPM_TOKEN=$(cat /run/secrets/NPM_TOKEN 2>/dev/null || echo $NPM_TOKEN) && \
|
||||||
|
pnpm store prune && \
|
||||||
|
pnpm install --no-frozen-lockfile
|
||||||
|
|
||||||
|
# Copy source code
|
||||||
COPY . .
|
COPY . .
|
||||||
|
|
||||||
# Build Application
|
# Build application
|
||||||
RUN npm run build
|
RUN pnpm build
|
||||||
|
|
||||||
# Runtime Stage
|
|
||||||
FROM node:20-slim
|
|
||||||
|
|
||||||
|
# Stage 2: Runner
|
||||||
|
FROM node:20-alpine AS runner
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
|
|
||||||
# Copy necessary files for production
|
# Install curl for health checks
|
||||||
COPY --from=build /app/package*.json ./
|
RUN apk add --no-cache curl
|
||||||
COPY --from=build /app/.next ./.next
|
|
||||||
COPY --from=build /app/public ./public
|
|
||||||
COPY --from=build /app/node_modules ./node_modules
|
|
||||||
|
|
||||||
EXPOSE 3000
|
# Create nextjs user and group for security
|
||||||
|
RUN addgroup --system --gid 1001 nodejs && \
|
||||||
|
adduser --system --uid 1001 nextjs && \
|
||||||
|
chown -R nextjs:nodejs /app
|
||||||
|
|
||||||
CMD ["npm", "start"]
|
ENV HOSTNAME="0.0.0.0"
|
||||||
|
ENV PORT=3000
|
||||||
|
ENV NODE_ENV=production
|
||||||
|
ENV NEXT_TELEMETRY_DISABLED=1
|
||||||
|
|
||||||
|
# Copy standalone output and static files
|
||||||
|
COPY --from=builder --chown=nextjs:nodejs /app/public ./public
|
||||||
|
COPY --from=builder --chown=nextjs:nodejs /app/.next/standalone ./
|
||||||
|
COPY --from=builder --chown=nextjs:nodejs /app/.next/static ./.next/static
|
||||||
|
COPY --from=builder --chown=nextjs:nodejs /app/.next/cache ./.next/cache
|
||||||
|
|
||||||
|
USER nextjs
|
||||||
|
|
||||||
|
CMD ["node", "server.js"]
|
||||||
|
|||||||
17
app/(payload)/admin/[[...segments]]/page.tsx
Normal file
17
app/(payload)/admin/[[...segments]]/page.tsx
Normal file
@@ -0,0 +1,17 @@
|
|||||||
|
import configPromise from "@payload-config";
|
||||||
|
import { RootPage } from "@payloadcms/next/views";
|
||||||
|
import { importMap } from "../importMap";
|
||||||
|
|
||||||
|
type Args = {
|
||||||
|
params: Promise<{
|
||||||
|
segments: string[];
|
||||||
|
}>;
|
||||||
|
searchParams: Promise<{
|
||||||
|
[key: string]: string | string[];
|
||||||
|
}>;
|
||||||
|
};
|
||||||
|
|
||||||
|
const Page = ({ params, searchParams }: Args) =>
|
||||||
|
RootPage({ config: configPromise, importMap, params, searchParams });
|
||||||
|
|
||||||
|
export default Page;
|
||||||
78
app/(payload)/admin/importMap.js
Normal file
78
app/(payload)/admin/importMap.js
Normal file
@@ -0,0 +1,78 @@
|
|||||||
|
import { RscEntryLexicalCell as RscEntryLexicalCell_44fe37237e0ebf4470c9990d8cb7b07e } from "@payloadcms/richtext-lexical/rsc";
|
||||||
|
import { RscEntryLexicalField as RscEntryLexicalField_44fe37237e0ebf4470c9990d8cb7b07e } from "@payloadcms/richtext-lexical/rsc";
|
||||||
|
import { LexicalDiffComponent as LexicalDiffComponent_44fe37237e0ebf4470c9990d8cb7b07e } from "@payloadcms/richtext-lexical/rsc";
|
||||||
|
import { BlocksFeatureClient as BlocksFeatureClient_e70f5e05f09f93e00b997edb1ef0c864 } from "@payloadcms/richtext-lexical/client";
|
||||||
|
import { InlineToolbarFeatureClient as InlineToolbarFeatureClient_e70f5e05f09f93e00b997edb1ef0c864 } from "@payloadcms/richtext-lexical/client";
|
||||||
|
import { HorizontalRuleFeatureClient as HorizontalRuleFeatureClient_e70f5e05f09f93e00b997edb1ef0c864 } from "@payloadcms/richtext-lexical/client";
|
||||||
|
import { UploadFeatureClient as UploadFeatureClient_e70f5e05f09f93e00b997edb1ef0c864 } from "@payloadcms/richtext-lexical/client";
|
||||||
|
import { BlockquoteFeatureClient as BlockquoteFeatureClient_e70f5e05f09f93e00b997edb1ef0c864 } from "@payloadcms/richtext-lexical/client";
|
||||||
|
import { RelationshipFeatureClient as RelationshipFeatureClient_e70f5e05f09f93e00b997edb1ef0c864 } from "@payloadcms/richtext-lexical/client";
|
||||||
|
import { LinkFeatureClient as LinkFeatureClient_e70f5e05f09f93e00b997edb1ef0c864 } from "@payloadcms/richtext-lexical/client";
|
||||||
|
import { ChecklistFeatureClient as ChecklistFeatureClient_e70f5e05f09f93e00b997edb1ef0c864 } from "@payloadcms/richtext-lexical/client";
|
||||||
|
import { OrderedListFeatureClient as OrderedListFeatureClient_e70f5e05f09f93e00b997edb1ef0c864 } from "@payloadcms/richtext-lexical/client";
|
||||||
|
import { UnorderedListFeatureClient as UnorderedListFeatureClient_e70f5e05f09f93e00b997edb1ef0c864 } from "@payloadcms/richtext-lexical/client";
|
||||||
|
import { IndentFeatureClient as IndentFeatureClient_e70f5e05f09f93e00b997edb1ef0c864 } from "@payloadcms/richtext-lexical/client";
|
||||||
|
import { AlignFeatureClient as AlignFeatureClient_e70f5e05f09f93e00b997edb1ef0c864 } from "@payloadcms/richtext-lexical/client";
|
||||||
|
import { HeadingFeatureClient as HeadingFeatureClient_e70f5e05f09f93e00b997edb1ef0c864 } from "@payloadcms/richtext-lexical/client";
|
||||||
|
import { ParagraphFeatureClient as ParagraphFeatureClient_e70f5e05f09f93e00b997edb1ef0c864 } from "@payloadcms/richtext-lexical/client";
|
||||||
|
import { InlineCodeFeatureClient as InlineCodeFeatureClient_e70f5e05f09f93e00b997edb1ef0c864 } from "@payloadcms/richtext-lexical/client";
|
||||||
|
import { SuperscriptFeatureClient as SuperscriptFeatureClient_e70f5e05f09f93e00b997edb1ef0c864 } from "@payloadcms/richtext-lexical/client";
|
||||||
|
import { SubscriptFeatureClient as SubscriptFeatureClient_e70f5e05f09f93e00b997edb1ef0c864 } from "@payloadcms/richtext-lexical/client";
|
||||||
|
import { StrikethroughFeatureClient as StrikethroughFeatureClient_e70f5e05f09f93e00b997edb1ef0c864 } from "@payloadcms/richtext-lexical/client";
|
||||||
|
import { UnderlineFeatureClient as UnderlineFeatureClient_e70f5e05f09f93e00b997edb1ef0c864 } from "@payloadcms/richtext-lexical/client";
|
||||||
|
import { BoldFeatureClient as BoldFeatureClient_e70f5e05f09f93e00b997edb1ef0c864 } from "@payloadcms/richtext-lexical/client";
|
||||||
|
import { ItalicFeatureClient as ItalicFeatureClient_e70f5e05f09f93e00b997edb1ef0c864 } from "@payloadcms/richtext-lexical/client";
|
||||||
|
import { CollectionCards as CollectionCards_f9c02e79a4aed9a3924487c0cd4cafb1 } from "@payloadcms/next/rsc";
|
||||||
|
|
||||||
|
export const importMap = {
|
||||||
|
"@payloadcms/richtext-lexical/rsc#RscEntryLexicalCell":
|
||||||
|
RscEntryLexicalCell_44fe37237e0ebf4470c9990d8cb7b07e,
|
||||||
|
"@payloadcms/richtext-lexical/rsc#RscEntryLexicalField":
|
||||||
|
RscEntryLexicalField_44fe37237e0ebf4470c9990d8cb7b07e,
|
||||||
|
"@payloadcms/richtext-lexical/rsc#LexicalDiffComponent":
|
||||||
|
LexicalDiffComponent_44fe37237e0ebf4470c9990d8cb7b07e,
|
||||||
|
"@payloadcms/richtext-lexical/client#BlocksFeatureClient":
|
||||||
|
BlocksFeatureClient_e70f5e05f09f93e00b997edb1ef0c864,
|
||||||
|
"@payloadcms/richtext-lexical/client#InlineToolbarFeatureClient":
|
||||||
|
InlineToolbarFeatureClient_e70f5e05f09f93e00b997edb1ef0c864,
|
||||||
|
"@payloadcms/richtext-lexical/client#HorizontalRuleFeatureClient":
|
||||||
|
HorizontalRuleFeatureClient_e70f5e05f09f93e00b997edb1ef0c864,
|
||||||
|
"@payloadcms/richtext-lexical/client#UploadFeatureClient":
|
||||||
|
UploadFeatureClient_e70f5e05f09f93e00b997edb1ef0c864,
|
||||||
|
"@payloadcms/richtext-lexical/client#BlockquoteFeatureClient":
|
||||||
|
BlockquoteFeatureClient_e70f5e05f09f93e00b997edb1ef0c864,
|
||||||
|
"@payloadcms/richtext-lexical/client#RelationshipFeatureClient":
|
||||||
|
RelationshipFeatureClient_e70f5e05f09f93e00b997edb1ef0c864,
|
||||||
|
"@payloadcms/richtext-lexical/client#LinkFeatureClient":
|
||||||
|
LinkFeatureClient_e70f5e05f09f93e00b997edb1ef0c864,
|
||||||
|
"@payloadcms/richtext-lexical/client#ChecklistFeatureClient":
|
||||||
|
ChecklistFeatureClient_e70f5e05f09f93e00b997edb1ef0c864,
|
||||||
|
"@payloadcms/richtext-lexical/client#OrderedListFeatureClient":
|
||||||
|
OrderedListFeatureClient_e70f5e05f09f93e00b997edb1ef0c864,
|
||||||
|
"@payloadcms/richtext-lexical/client#UnorderedListFeatureClient":
|
||||||
|
UnorderedListFeatureClient_e70f5e05f09f93e00b997edb1ef0c864,
|
||||||
|
"@payloadcms/richtext-lexical/client#IndentFeatureClient":
|
||||||
|
IndentFeatureClient_e70f5e05f09f93e00b997edb1ef0c864,
|
||||||
|
"@payloadcms/richtext-lexical/client#AlignFeatureClient":
|
||||||
|
AlignFeatureClient_e70f5e05f09f93e00b997edb1ef0c864,
|
||||||
|
"@payloadcms/richtext-lexical/client#HeadingFeatureClient":
|
||||||
|
HeadingFeatureClient_e70f5e05f09f93e00b997edb1ef0c864,
|
||||||
|
"@payloadcms/richtext-lexical/client#ParagraphFeatureClient":
|
||||||
|
ParagraphFeatureClient_e70f5e05f09f93e00b997edb1ef0c864,
|
||||||
|
"@payloadcms/richtext-lexical/client#InlineCodeFeatureClient":
|
||||||
|
InlineCodeFeatureClient_e70f5e05f09f93e00b997edb1ef0c864,
|
||||||
|
"@payloadcms/richtext-lexical/client#SuperscriptFeatureClient":
|
||||||
|
SuperscriptFeatureClient_e70f5e05f09f93e00b997edb1ef0c864,
|
||||||
|
"@payloadcms/richtext-lexical/client#SubscriptFeatureClient":
|
||||||
|
SubscriptFeatureClient_e70f5e05f09f93e00b997edb1ef0c864,
|
||||||
|
"@payloadcms/richtext-lexical/client#StrikethroughFeatureClient":
|
||||||
|
StrikethroughFeatureClient_e70f5e05f09f93e00b997edb1ef0c864,
|
||||||
|
"@payloadcms/richtext-lexical/client#UnderlineFeatureClient":
|
||||||
|
UnderlineFeatureClient_e70f5e05f09f93e00b997edb1ef0c864,
|
||||||
|
"@payloadcms/richtext-lexical/client#BoldFeatureClient":
|
||||||
|
BoldFeatureClient_e70f5e05f09f93e00b997edb1ef0c864,
|
||||||
|
"@payloadcms/richtext-lexical/client#ItalicFeatureClient":
|
||||||
|
ItalicFeatureClient_e70f5e05f09f93e00b997edb1ef0c864,
|
||||||
|
"@payloadcms/next/rsc#CollectionCards":
|
||||||
|
CollectionCards_f9c02e79a4aed9a3924487c0cd4cafb1,
|
||||||
|
};
|
||||||
1
app/(payload)/admin/importMap.ts
Normal file
1
app/(payload)/admin/importMap.ts
Normal file
@@ -0,0 +1 @@
|
|||||||
|
export const importMap = {};
|
||||||
14
app/(payload)/api/[...slug]/route.ts
Normal file
14
app/(payload)/api/[...slug]/route.ts
Normal file
@@ -0,0 +1,14 @@
|
|||||||
|
import config from "@payload-config";
|
||||||
|
import {
|
||||||
|
REST_GET,
|
||||||
|
REST_OPTIONS,
|
||||||
|
REST_PATCH,
|
||||||
|
REST_POST,
|
||||||
|
REST_DELETE,
|
||||||
|
} from "@payloadcms/next/routes";
|
||||||
|
|
||||||
|
export const GET = REST_GET(config);
|
||||||
|
export const POST = REST_POST(config);
|
||||||
|
export const DELETE = REST_DELETE(config);
|
||||||
|
export const PATCH = REST_PATCH(config);
|
||||||
|
export const OPTIONS = REST_OPTIONS(config);
|
||||||
1
app/(payload)/custom.scss
Normal file
1
app/(payload)/custom.scss
Normal file
@@ -0,0 +1 @@
|
|||||||
|
/* Custom SCSS for Payload Admin Panel */
|
||||||
36
app/(payload)/layout.tsx
Normal file
36
app/(payload)/layout.tsx
Normal file
@@ -0,0 +1,36 @@
|
|||||||
|
import configPromise from "@payload-config";
|
||||||
|
import { RootLayout } from "@payloadcms/next/layouts";
|
||||||
|
import React from "react";
|
||||||
|
|
||||||
|
import "@payloadcms/next/css";
|
||||||
|
import "./custom.scss";
|
||||||
|
import { handleServerFunctions } from "@payloadcms/next/layouts";
|
||||||
|
import { importMap } from "./admin/importMap";
|
||||||
|
|
||||||
|
type Args = {
|
||||||
|
children: React.ReactNode;
|
||||||
|
};
|
||||||
|
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
|
const serverFunction: any = async function (args: any) {
|
||||||
|
"use server";
|
||||||
|
return handleServerFunctions({
|
||||||
|
...args,
|
||||||
|
config: configPromise,
|
||||||
|
importMap,
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const Layout = ({ children }: Args) => {
|
||||||
|
return (
|
||||||
|
<RootLayout
|
||||||
|
config={configPromise}
|
||||||
|
importMap={importMap}
|
||||||
|
serverFunction={serverFunction}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</RootLayout>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default Layout;
|
||||||
@@ -1,23 +1,29 @@
|
|||||||
import { Download } from 'lucide-react';
|
import { Download } from "lucide-react";
|
||||||
import fs from 'fs';
|
import fs from "fs";
|
||||||
import path from 'path';
|
import path from "path";
|
||||||
|
|
||||||
export default function AGB() {
|
export default function AGB() {
|
||||||
const filePath = path.join(process.cwd(), 'context/agbs.md');
|
const filePath = path.join(process.cwd(), "context/agbs.md");
|
||||||
const fileContent = fs.readFileSync(filePath, 'utf8');
|
const fileContent = fs.readFileSync(filePath, "utf8");
|
||||||
|
|
||||||
// Split by double newlines to get major blocks (headers + their first paragraphs, or subsequent paragraphs)
|
// 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 blocks = fileContent
|
||||||
|
.split(/\n\s*\n/)
|
||||||
|
.map((b) => b.trim())
|
||||||
|
.filter((b) => b !== "");
|
||||||
|
|
||||||
const title = blocks[0] || 'Liefer- und Zahlungsbedingungen';
|
const title = blocks[0] || "Liefer- und Zahlungsbedingungen";
|
||||||
const stand = blocks[1] || 'Stand Januar 2026';
|
const stand = blocks[1] || "Stand Januar 2026";
|
||||||
|
|
||||||
const sections: { title: string; content: string[] }[] = [];
|
const sections: { title: string; content: string[] }[] = [];
|
||||||
let currentSection: { title: string; content: string[] } | null = null;
|
let currentSection: { title: string; content: string[] } | null = null;
|
||||||
|
|
||||||
// Skip title and stand
|
// Skip title and stand
|
||||||
blocks.slice(2).forEach(block => {
|
blocks.slice(2).forEach((block) => {
|
||||||
const lines = block.split('\n').map(l => l.trim()).filter(l => l !== '');
|
const lines = block
|
||||||
|
.split("\n")
|
||||||
|
.map((l) => l.trim())
|
||||||
|
.filter((l) => l !== "");
|
||||||
if (lines.length === 0) return;
|
if (lines.length === 0) return;
|
||||||
|
|
||||||
const firstLine = lines[0];
|
const firstLine = lines[0];
|
||||||
@@ -33,12 +39,12 @@ export default function AGB() {
|
|||||||
// Join subsequent lines as they might be part of the same paragraph
|
// 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
|
// 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)
|
// 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);
|
if (remainingText) currentSection.content.push(remainingText);
|
||||||
}
|
}
|
||||||
} else if (currentSection) {
|
} else if (currentSection) {
|
||||||
// Continuation of current section
|
// Continuation of current section
|
||||||
const blockText = lines.join(' ');
|
const blockText = lines.join(" ");
|
||||||
if (blockText) currentSection.content.push(blockText);
|
if (blockText) currentSection.content.push(blockText);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
@@ -49,7 +55,7 @@ export default function AGB() {
|
|||||||
if (sections.length > 0) {
|
if (sections.length > 0) {
|
||||||
const lastSection = sections[sections.length - 1];
|
const lastSection = sections[sections.length - 1];
|
||||||
if (lastSection.content.includes(footer) || lastSection.title === footer) {
|
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) {
|
if (sections[sections.length - 1].title === footer) {
|
||||||
sections.pop();
|
sections.pop();
|
||||||
}
|
}
|
||||||
@@ -57,12 +63,14 @@ export default function AGB() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
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="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="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 className="flex flex-col md:flex-row md:items-center justify-between gap-6 mb-8">
|
||||||
<div>
|
<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>
|
<p className="text-slate-500 font-medium">{stand}</p>
|
||||||
</div>
|
</div>
|
||||||
<a
|
<a
|
||||||
@@ -78,7 +86,9 @@ export default function AGB() {
|
|||||||
<div className="space-y-8 text-slate-600 leading-relaxed">
|
<div className="space-y-8 text-slate-600 leading-relaxed">
|
||||||
{sections.map((section, index) => (
|
{sections.map((section, index) => (
|
||||||
<div key={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">
|
<div className="space-y-4">
|
||||||
{section.content.map((paragraph, pIndex) => (
|
{section.content.map((paragraph, pIndex) => (
|
||||||
<p key={pIndex}>{paragraph}</p>
|
<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 { useEffect } from "react";
|
||||||
import { motion } from 'framer-motion';
|
import { motion } from "framer-motion";
|
||||||
import { RefreshCcw, Home } from 'lucide-react';
|
import { RefreshCcw, Home } from "lucide-react";
|
||||||
import Link from 'next/link';
|
import Link from "next/link";
|
||||||
|
|
||||||
export default function Error({
|
export default function Error({
|
||||||
error,
|
error,
|
||||||
@@ -33,7 +33,9 @@ export default function Error({
|
|||||||
animate={{ opacity: 1, y: 0 }}
|
animate={{ opacity: 1, y: 0 }}
|
||||||
transition={{ delay: 0.2, duration: 0.5 }}
|
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">
|
<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.
|
Es gab ein technisches Problem. Wir arbeiten bereits an der Lösung.
|
||||||
</p>
|
</p>
|
||||||
@@ -46,7 +48,10 @@ export default function Error({
|
|||||||
<RefreshCcw size={18} />
|
<RefreshCcw size={18} />
|
||||||
Erneut versuchen
|
Erneut versuchen
|
||||||
</button>
|
</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} />
|
<Home size={18} />
|
||||||
Zur Startseite
|
Zur Startseite
|
||||||
</Link>
|
</Link>
|
||||||
@@ -1,11 +1,11 @@
|
|||||||
'use client';
|
"use client";
|
||||||
|
|
||||||
import { motion } from 'framer-motion';
|
import { motion } from "framer-motion";
|
||||||
import { TechBackground } from '@/components/TechBackground';
|
import { TechBackground } from "@/components/TechBackground";
|
||||||
|
|
||||||
export default function Legal() {
|
export default function Legal() {
|
||||||
return (
|
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 />
|
<TechBackground />
|
||||||
<div className="container-custom relative z-10">
|
<div className="container-custom relative z-10">
|
||||||
<motion.div
|
<motion.div
|
||||||
@@ -17,22 +17,31 @@ export default function Legal() {
|
|||||||
<div className="tech-corner top-8 left-8 border-t-2 border-l-2 opacity-20" />
|
<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" />
|
<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 className="space-y-8 text-slate-600 leading-relaxed relative z-10">
|
||||||
<div>
|
<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>
|
<p>
|
||||||
MB Grid Solutions & Services GmbH<br />
|
MB Grid Solutions & Services GmbH
|
||||||
Raiffeisenstraße 22<br />
|
<br />
|
||||||
|
Raiffeisenstraße 22
|
||||||
|
<br />
|
||||||
73630 Remshalden
|
73630 Remshalden
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<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>
|
<p>
|
||||||
Michael Bodemer<br />
|
Michael Bodemer
|
||||||
|
<br />
|
||||||
Klaus Mintel
|
Klaus Mintel
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
@@ -40,24 +49,48 @@ export default function Legal() {
|
|||||||
<div>
|
<div>
|
||||||
<h2 className="text-xl font-bold text-primary mb-4">Kontakt</h2>
|
<h2 className="text-xl font-bold text-primary mb-4">Kontakt</h2>
|
||||||
<p>
|
<p>
|
||||||
E-Mail: <a href="mailto:info@mb-grid-solutions.com" className="text-accent hover:underline">info@mb-grid-solutions.com</a><br />
|
E-Mail:{" "}
|
||||||
Web: <a href="https://www.mb-grid-solutions.com" className="text-accent hover:underline">www.mb-grid-solutions.com</a>
|
<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>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<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>
|
<p>
|
||||||
Eintragung im Handelsregister.<br />
|
Eintragung im Handelsregister.
|
||||||
Registergericht: Amtsgericht Stuttgart<br />
|
<br />
|
||||||
|
Registergericht: Amtsgericht Stuttgart
|
||||||
|
<br />
|
||||||
Registernummer: HRB 803379
|
Registernummer: HRB 803379
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<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>
|
<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>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -3,7 +3,8 @@ import ContactContent from "@/components/ContactContent";
|
|||||||
|
|
||||||
export const metadata: Metadata = {
|
export const metadata: Metadata = {
|
||||||
title: "Kontakt",
|
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() {
|
export default function Page() {
|
||||||
153
app/[locale]/layout.tsx
Normal file
153
app/[locale]/layout.tsx
Normal file
@@ -0,0 +1,153 @@
|
|||||||
|
import Layout from "@/components/Layout";
|
||||||
|
import type { Metadata } from "next";
|
||||||
|
import { Inter } from "next/font/google";
|
||||||
|
import "../globals.css";
|
||||||
|
import { NextIntlClientProvider } from "next-intl";
|
||||||
|
import { getMessages } from "next-intl/server";
|
||||||
|
import { notFound } from "next/navigation";
|
||||||
|
import { LazyMotion, domAnimation } from "framer-motion";
|
||||||
|
import AnalyticsProvider from "@/components/analytics/AnalyticsProvider";
|
||||||
|
import { config } from "@/lib/config";
|
||||||
|
|
||||||
|
const inter = Inter({
|
||||||
|
subsets: ["latin"],
|
||||||
|
display: "swap",
|
||||||
|
variable: "--font-inter",
|
||||||
|
weight: ["400", "700", "800"], // Explicit weights to optimize download
|
||||||
|
});
|
||||||
|
|
||||||
|
export const metadata: Metadata = {
|
||||||
|
metadataBase: new URL("https://www.mb-grid-solutions.com"),
|
||||||
|
title: {
|
||||||
|
default: "MB Grid Solutions | Energiekabelprojekte & Technische Beratung",
|
||||||
|
template: "%s | MB Grid Solutions",
|
||||||
|
},
|
||||||
|
description:
|
||||||
|
"Ihr spezialisierter Partner für herstellerneutrale technische Beratung und Projektbegleitung bei Energiekabelprojekten bis 110 kV. Expertise in Mittel- und Hochspannungsnetzen.",
|
||||||
|
keywords: [
|
||||||
|
"Energiekabel",
|
||||||
|
"Hochspannung",
|
||||||
|
"Mittelspannung",
|
||||||
|
"Kabelprojekte",
|
||||||
|
"Technische Beratung",
|
||||||
|
"Engineering",
|
||||||
|
"Energiewende",
|
||||||
|
"110 kV",
|
||||||
|
],
|
||||||
|
authors: [{ name: "MB Grid Solutions & Services GmbH" }],
|
||||||
|
creator: "MB Grid Solutions & Services GmbH",
|
||||||
|
publisher: "MB Grid Solutions & Services GmbH",
|
||||||
|
formatDetection: {
|
||||||
|
email: false,
|
||||||
|
address: false,
|
||||||
|
telephone: false,
|
||||||
|
},
|
||||||
|
openGraph: {
|
||||||
|
type: "website",
|
||||||
|
locale: "de_DE",
|
||||||
|
url: "https://www.mb-grid-solutions.com",
|
||||||
|
siteName: "MB Grid Solutions",
|
||||||
|
title: "MB Grid Solutions | Energiekabelprojekte & Technische Beratung",
|
||||||
|
description:
|
||||||
|
"Spezialisierter Partner für Energiekabelprojekte bis 110 kV. Herstellerneutrale technische Beratung und Projektbegleitung.",
|
||||||
|
},
|
||||||
|
twitter: {
|
||||||
|
card: "summary_large_image",
|
||||||
|
title: "MB Grid Solutions | Energiekabelprojekte & Technische Beratung",
|
||||||
|
description: "Spezialisierter Partner für Energiekabelprojekte bis 110 kV.",
|
||||||
|
},
|
||||||
|
robots: {
|
||||||
|
index: true,
|
||||||
|
follow: true,
|
||||||
|
googleBot: {
|
||||||
|
index: true,
|
||||||
|
follow: true,
|
||||||
|
"max-video-preview": -1,
|
||||||
|
"max-image-preview": "large",
|
||||||
|
"max-snippet": -1,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
export default async function RootLayout({
|
||||||
|
children,
|
||||||
|
params,
|
||||||
|
}: {
|
||||||
|
children: React.ReactNode;
|
||||||
|
params: Promise<{ locale: string }>;
|
||||||
|
}) {
|
||||||
|
const { locale } = await params;
|
||||||
|
|
||||||
|
// Validate that the incoming `locale` is supported
|
||||||
|
if (locale !== "de") {
|
||||||
|
notFound();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Providing all messages to the client
|
||||||
|
// side is the easiest way to get started
|
||||||
|
const messages = await getMessages();
|
||||||
|
|
||||||
|
const jsonLd = {
|
||||||
|
"@context": "https://schema.org",
|
||||||
|
"@type": "Organization",
|
||||||
|
name: "MB Grid Solutions & Services GmbH",
|
||||||
|
url: "https://www.mb-grid-solutions.com",
|
||||||
|
logo: "https://www.mb-grid-solutions.com/assets/logo.png",
|
||||||
|
description:
|
||||||
|
"Ihr spezialisierter Partner für herstellerneutrale technische Beratung und Projektbegleitung bei Energiekabelprojekten bis 110 kV.",
|
||||||
|
address: {
|
||||||
|
"@type": "PostalAddress",
|
||||||
|
streetAddress: "Raiffeisenstraße 22",
|
||||||
|
addressLocality: "Remshalden",
|
||||||
|
postalCode: "73630",
|
||||||
|
addressCountry: "DE",
|
||||||
|
},
|
||||||
|
contactPoint: {
|
||||||
|
"@type": "ContactPoint",
|
||||||
|
email: "info@mb-grid-solutions.com",
|
||||||
|
contactType: "customer service",
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
// Track pageview on the server
|
||||||
|
// This is safe to call here because layout is a Server Component
|
||||||
|
const serverServices = (
|
||||||
|
await import("@/lib/services/create-services.server")
|
||||||
|
).getServerAppServices();
|
||||||
|
|
||||||
|
// Populate analytics context with headers for high-fidelity server-side tracking
|
||||||
|
const { headers } = await import("next/headers");
|
||||||
|
const requestHeaders = await headers();
|
||||||
|
|
||||||
|
if (serverServices.analytics.setServerContext) {
|
||||||
|
serverServices.analytics.setServerContext({
|
||||||
|
userAgent: requestHeaders.get("user-agent") || undefined,
|
||||||
|
language:
|
||||||
|
requestHeaders.get("accept-language")?.split(",")[0] || undefined,
|
||||||
|
referrer: requestHeaders.get("referer") || undefined,
|
||||||
|
ip: requestHeaders.get("x-forwarded-for")?.split(",")[0] || undefined,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Track server-side (initial load)
|
||||||
|
// serverServices.analytics.trackPageview("/"); // Removed to avoid double-tracking and incorrect path reporting
|
||||||
|
|
||||||
|
return (
|
||||||
|
<html lang={locale} className={`${inter.variable}`}>
|
||||||
|
<head>
|
||||||
|
<script
|
||||||
|
type="application/ld+json"
|
||||||
|
dangerouslySetInnerHTML={{ __html: JSON.stringify(jsonLd) }}
|
||||||
|
/>
|
||||||
|
</head>
|
||||||
|
<body className="antialiased">
|
||||||
|
<NextIntlClientProvider messages={messages}>
|
||||||
|
<AnalyticsProvider websiteId={config.analytics.umami.websiteId} />
|
||||||
|
<LazyMotion features={domAnimation}>
|
||||||
|
<Layout>{children}</Layout>
|
||||||
|
</LazyMotion>
|
||||||
|
</NextIntlClientProvider>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,8 +1,8 @@
|
|||||||
'use client';
|
"use client";
|
||||||
|
|
||||||
import Link from 'next/link';
|
import Link from "next/link";
|
||||||
import { motion } from 'framer-motion';
|
import { motion } from "framer-motion";
|
||||||
import { Home, ArrowLeft } from 'lucide-react';
|
import { Home, ArrowLeft } from "lucide-react";
|
||||||
|
|
||||||
export default function NotFound() {
|
export default function NotFound() {
|
||||||
return (
|
return (
|
||||||
@@ -22,9 +22,12 @@ export default function NotFound() {
|
|||||||
animate={{ opacity: 1, y: 0 }}
|
animate={{ opacity: 1, y: 0 }}
|
||||||
transition={{ delay: 0.2, duration: 0.5 }}
|
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">
|
<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>
|
</p>
|
||||||
|
|
||||||
<div className="flex flex-col sm:flex-row items-center justify-center gap-4">
|
<div className="flex flex-col sm:flex-row items-center justify-center gap-4">
|
||||||
183
app/[locale]/opengraph-image.tsx
Normal file
183
app/[locale]/opengraph-image.tsx
Normal file
@@ -0,0 +1,183 @@
|
|||||||
|
import { ImageResponse } from "next/og";
|
||||||
|
|
||||||
|
export const runtime = "edge";
|
||||||
|
|
||||||
|
export const alt =
|
||||||
|
"MB Grid Solutions | Energiekabelprojekte & Technische Beratung";
|
||||||
|
export const size = {
|
||||||
|
width: 1200,
|
||||||
|
height: 630,
|
||||||
|
};
|
||||||
|
|
||||||
|
export const contentType = "image/png";
|
||||||
|
|
||||||
|
export default async function Image() {
|
||||||
|
return new ImageResponse(
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
background: "#ffffff",
|
||||||
|
width: "100%",
|
||||||
|
height: "100%",
|
||||||
|
display: "flex",
|
||||||
|
flexDirection: "column",
|
||||||
|
alignItems: "center",
|
||||||
|
justifyContent: "center",
|
||||||
|
position: "relative",
|
||||||
|
fontFamily: "sans-serif",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{/* Grid Pattern Background - matching .grid-pattern in globals.css */}
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
position: "absolute",
|
||||||
|
top: 0,
|
||||||
|
left: 0,
|
||||||
|
right: 0,
|
||||||
|
bottom: 0,
|
||||||
|
backgroundImage:
|
||||||
|
"radial-gradient(circle, #e2e8f0 1.5px, transparent 1.5px)",
|
||||||
|
backgroundSize: "40px 40px",
|
||||||
|
zIndex: 0,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* Content Container - matching .card-modern / .glass-panel style */}
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
display: "flex",
|
||||||
|
flexDirection: "column",
|
||||||
|
alignItems: "center",
|
||||||
|
justifyContent: "center",
|
||||||
|
backgroundColor: "rgba(255, 255, 255, 0.95)",
|
||||||
|
padding: "60px 80px",
|
||||||
|
borderRadius: "48px",
|
||||||
|
border: "1px solid #e2e8f0",
|
||||||
|
boxShadow: "0 25px 50px -12px rgba(0, 0, 0, 0.1)",
|
||||||
|
zIndex: 1,
|
||||||
|
position: "relative",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{/* Engineering Excellence Badge */}
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
display: "flex",
|
||||||
|
alignItems: "center",
|
||||||
|
gap: "12px",
|
||||||
|
padding: "8px 20px",
|
||||||
|
backgroundColor: "rgba(16, 185, 129, 0.1)",
|
||||||
|
borderRadius: "100px",
|
||||||
|
marginBottom: "32px",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
width: "10px",
|
||||||
|
height: "10px",
|
||||||
|
backgroundColor: "#10b981",
|
||||||
|
borderRadius: "50%",
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
fontSize: "14px",
|
||||||
|
fontWeight: "bold",
|
||||||
|
color: "#10b981",
|
||||||
|
textTransform: "uppercase",
|
||||||
|
letterSpacing: "0.1em",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Technische Beratung
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Brand Mark */}
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
display: "flex",
|
||||||
|
alignItems: "center",
|
||||||
|
justifyContent: "center",
|
||||||
|
width: "100px",
|
||||||
|
height: "100px",
|
||||||
|
backgroundColor: "#0f172a",
|
||||||
|
borderRadius: "24px",
|
||||||
|
marginBottom: "32px",
|
||||||
|
boxShadow: "0 10px 15px -3px rgba(15, 23, 42, 0.3)",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
fontSize: "48px",
|
||||||
|
fontWeight: "bold",
|
||||||
|
color: "#10b981",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
MB
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Title */}
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
display: "flex",
|
||||||
|
flexDirection: "row",
|
||||||
|
fontSize: "72px",
|
||||||
|
fontWeight: "900",
|
||||||
|
color: "#0f172a",
|
||||||
|
marginBottom: "16px",
|
||||||
|
textAlign: "center",
|
||||||
|
letterSpacing: "-0.02em",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
MB Grid{" "}
|
||||||
|
<span
|
||||||
|
style={{ color: "#10b981", display: "flex", marginLeft: "16px" }}
|
||||||
|
>
|
||||||
|
Solutions
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Subtitle */}
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
display: "flex",
|
||||||
|
flexDirection: "column",
|
||||||
|
fontSize: "32px",
|
||||||
|
fontWeight: "500",
|
||||||
|
color: "#64748b",
|
||||||
|
textAlign: "center",
|
||||||
|
maxWidth: "800px",
|
||||||
|
lineHeight: 1.4,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<span>Energiekabelprojekte & Technische Beratung</span>
|
||||||
|
<span>bis 110 kV</span>
|
||||||
|
</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 = {
|
export const metadata: Metadata = {
|
||||||
title: "MB Grid Solutions | Energiekabelprojekte & Technische Beratung",
|
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() {
|
export default function Page() {
|
||||||
183
app/[locale]/twitter-image.tsx
Normal file
183
app/[locale]/twitter-image.tsx
Normal file
@@ -0,0 +1,183 @@
|
|||||||
|
import { ImageResponse } from "next/og";
|
||||||
|
|
||||||
|
export const runtime = "edge";
|
||||||
|
|
||||||
|
export const alt =
|
||||||
|
"MB Grid Solutions | Energiekabelprojekte & Technische Beratung";
|
||||||
|
export const size = {
|
||||||
|
width: 1200,
|
||||||
|
height: 630,
|
||||||
|
};
|
||||||
|
|
||||||
|
export const contentType = "image/png";
|
||||||
|
|
||||||
|
export default async function Image() {
|
||||||
|
return new ImageResponse(
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
background: "#ffffff",
|
||||||
|
width: "100%",
|
||||||
|
height: "100%",
|
||||||
|
display: "flex",
|
||||||
|
flexDirection: "column",
|
||||||
|
alignItems: "center",
|
||||||
|
justifyContent: "center",
|
||||||
|
position: "relative",
|
||||||
|
fontFamily: "sans-serif",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{/* Grid Pattern Background */}
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
position: "absolute",
|
||||||
|
top: 0,
|
||||||
|
left: 0,
|
||||||
|
right: 0,
|
||||||
|
bottom: 0,
|
||||||
|
backgroundImage:
|
||||||
|
"radial-gradient(circle, #e2e8f0 1.5px, transparent 1.5px)",
|
||||||
|
backgroundSize: "40px 40px",
|
||||||
|
zIndex: 0,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* Content Container */}
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
display: "flex",
|
||||||
|
flexDirection: "column",
|
||||||
|
alignItems: "center",
|
||||||
|
justifyContent: "center",
|
||||||
|
backgroundColor: "rgba(255, 255, 255, 0.95)",
|
||||||
|
padding: "60px 80px",
|
||||||
|
borderRadius: "48px",
|
||||||
|
border: "1px solid #e2e8f0",
|
||||||
|
boxShadow: "0 25px 50px -12px rgba(0, 0, 0, 0.1)",
|
||||||
|
zIndex: 1,
|
||||||
|
position: "relative",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{/* Engineering Excellence Badge */}
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
display: "flex",
|
||||||
|
alignItems: "center",
|
||||||
|
gap: "12px",
|
||||||
|
padding: "8px 20px",
|
||||||
|
backgroundColor: "rgba(16, 185, 129, 0.1)",
|
||||||
|
borderRadius: "100px",
|
||||||
|
marginBottom: "32px",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
width: "10px",
|
||||||
|
height: "10px",
|
||||||
|
backgroundColor: "#10b981",
|
||||||
|
borderRadius: "50%",
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
fontSize: "14px",
|
||||||
|
fontWeight: "bold",
|
||||||
|
color: "#10b981",
|
||||||
|
textTransform: "uppercase",
|
||||||
|
letterSpacing: "0.1em",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Technische Beratung
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Brand Mark */}
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
display: "flex",
|
||||||
|
alignItems: "center",
|
||||||
|
justifyContent: "center",
|
||||||
|
width: "100px",
|
||||||
|
height: "100px",
|
||||||
|
backgroundColor: "#0f172a",
|
||||||
|
borderRadius: "24px",
|
||||||
|
marginBottom: "32px",
|
||||||
|
boxShadow: "0 10px 15px -3px rgba(15, 23, 42, 0.3)",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
fontSize: "48px",
|
||||||
|
fontWeight: "bold",
|
||||||
|
color: "#10b981",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
MB
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Title */}
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
display: "flex",
|
||||||
|
flexDirection: "row",
|
||||||
|
fontSize: "72px",
|
||||||
|
fontWeight: "900",
|
||||||
|
color: "#0f172a",
|
||||||
|
marginBottom: "16px",
|
||||||
|
textAlign: "center",
|
||||||
|
letterSpacing: "-0.02em",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
MB Grid{" "}
|
||||||
|
<span
|
||||||
|
style={{ color: "#10b981", display: "flex", marginLeft: "16px" }}
|
||||||
|
>
|
||||||
|
Solutions
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Subtitle */}
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
display: "flex",
|
||||||
|
flexDirection: "column",
|
||||||
|
fontSize: "32px",
|
||||||
|
fontWeight: "500",
|
||||||
|
color: "#64748b",
|
||||||
|
textAlign: "center",
|
||||||
|
maxWidth: "800px",
|
||||||
|
lineHeight: 1.4,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<span>Energiekabelprojekte & Technische Beratung</span>
|
||||||
|
<span>bis 110 kV</span>
|
||||||
|
</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 = {
|
export const metadata: Metadata = {
|
||||||
title: "Über uns",
|
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() {
|
export default function Page() {
|
||||||
@@ -1,56 +1,168 @@
|
|||||||
import { NextResponse } from 'next/server';
|
import { NextResponse } from "next/server";
|
||||||
import * as nodemailer from 'nodemailer';
|
import { getPayload } from "payload";
|
||||||
|
import configPromise from "@payload-config";
|
||||||
|
import { getServerAppServices } from "@/lib/services/create-services.server";
|
||||||
|
import {
|
||||||
|
render,
|
||||||
|
ContactFormNotification,
|
||||||
|
ConfirmationMessage,
|
||||||
|
} from "@mintel/mail";
|
||||||
|
import React from "react";
|
||||||
|
|
||||||
export async function POST(req: Request) {
|
export async function POST(req: Request) {
|
||||||
|
const services = getServerAppServices();
|
||||||
|
const logger = services.logger.child({ action: "contact_submission" });
|
||||||
|
|
||||||
|
// Set analytics context from request headers for high-fidelity server-side tracking
|
||||||
|
if (services.analytics.setServerContext) {
|
||||||
|
services.analytics.setServerContext({
|
||||||
|
userAgent: req.headers.get("user-agent") || undefined,
|
||||||
|
language: req.headers.get("accept-language")?.split(",")[0] || undefined,
|
||||||
|
referrer: req.headers.get("referer") || undefined,
|
||||||
|
ip: req.headers.get("x-forwarded-for")?.split(",")[0] || undefined,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const { name, email, company, message, website } = await req.json();
|
const { name, email, company, message, website } = await req.json();
|
||||||
|
|
||||||
|
// Track attempt
|
||||||
|
services.analytics.track("contact-form-attempt");
|
||||||
|
|
||||||
// Honeypot check
|
// Honeypot check
|
||||||
if (website) {
|
if (website) {
|
||||||
console.log('Spam detected (honeypot)');
|
logger.info("Spam detected (honeypot)");
|
||||||
return NextResponse.json({ message: 'Ok' });
|
return NextResponse.json({ message: "Ok" });
|
||||||
}
|
}
|
||||||
|
|
||||||
// Validation
|
// Validation
|
||||||
if (!name || name.length < 2 || name.length > 100) {
|
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)) {
|
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) {
|
if (!message || message.length < 20) {
|
||||||
return NextResponse.json({ error: 'Nachricht zu kurz oder zu lang' }, { status: 400 });
|
return NextResponse.json({ error: "message_too_short" }, { status: 400 });
|
||||||
|
}
|
||||||
|
if (message.length > 4000) {
|
||||||
|
return NextResponse.json({ error: "message_too_long" }, { status: 400 });
|
||||||
}
|
}
|
||||||
|
|
||||||
const transporter = nodemailer.createTransport({
|
const payload = await getPayload({ config: configPromise });
|
||||||
host: process.env.SMTP_HOST,
|
|
||||||
port: parseInt(process.env.SMTP_PORT || '587'),
|
// 1. Payload save
|
||||||
secure: process.env.SMTP_SECURE === 'true',
|
let payloadSaved = false;
|
||||||
auth: {
|
try {
|
||||||
user: process.env.SMTP_USER,
|
await payload.create({
|
||||||
pass: process.env.SMTP_PASS,
|
collection: "form-submissions",
|
||||||
},
|
data: {
|
||||||
|
name,
|
||||||
|
email,
|
||||||
|
company: company || "Nicht angegeben",
|
||||||
|
message,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
logger.info("Contact submission saved to PayloadCMS");
|
||||||
|
payloadSaved = true;
|
||||||
|
} catch (payloadError) {
|
||||||
|
const errorMessage =
|
||||||
|
payloadError instanceof Error
|
||||||
|
? payloadError.message
|
||||||
|
: String(payloadError);
|
||||||
|
logger.error("Failed to save to Payload", {
|
||||||
|
error: errorMessage,
|
||||||
|
details: payloadError,
|
||||||
|
});
|
||||||
|
services.errors.captureException(payloadError, { phase: "payload_save" });
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. Email sending via Payload (which uses configured nodemailer)
|
||||||
|
try {
|
||||||
|
const { config } = await import("@/lib/config");
|
||||||
|
const clientName = "MB Grid Solutions";
|
||||||
|
|
||||||
|
// 2a. Notification to MB Grid
|
||||||
|
const notificationHtml = await render(
|
||||||
|
React.createElement(ContactFormNotification, {
|
||||||
|
name,
|
||||||
|
email,
|
||||||
|
message,
|
||||||
|
company,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
await payload.sendEmail({
|
||||||
|
from: config.mail.from,
|
||||||
|
to:
|
||||||
|
config.mail.recipients.join(",") ||
|
||||||
|
process.env.CONTACT_RECIPIENT ||
|
||||||
|
"info@mb-grid-solutions.com",
|
||||||
|
replyTo: email,
|
||||||
|
subject: `Kontaktanfrage von ${name}`,
|
||||||
|
html: notificationHtml,
|
||||||
|
});
|
||||||
|
|
||||||
|
// 2b. Confirmation to the User
|
||||||
|
try {
|
||||||
|
const confirmationHtml = await render(
|
||||||
|
React.createElement(ConfirmationMessage, {
|
||||||
|
name,
|
||||||
|
clientName,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
await payload.sendEmail({
|
||||||
|
from: config.mail.from,
|
||||||
|
to: email,
|
||||||
|
subject: `Ihre Kontaktanfrage bei ${clientName}`,
|
||||||
|
html: confirmationHtml,
|
||||||
|
});
|
||||||
|
} catch (confirmError) {
|
||||||
|
logger.warn(
|
||||||
|
"Failed to send confirmation email, but notification was sent",
|
||||||
|
{ error: confirmError },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
logger.info("Emails sent successfully");
|
||||||
|
|
||||||
|
// Notify success for important leads
|
||||||
|
await services.notifications.notify({
|
||||||
|
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 (!payloadSaved) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: "Systemfehler (Speicherung und Versand fehlgeschlagen)" },
|
||||||
|
{ status: 500 },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
await services.notifications.notify({
|
||||||
|
title: "🚨 SMTP Fehler (Kontaktformular)",
|
||||||
|
message: `Anfrage von ${name} (${email}) in Payload gespeichert, aber E-Mail-Versand fehlgeschlagen: ${smtpError instanceof Error ? smtpError.message : String(smtpError)}`,
|
||||||
|
priority: 8,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Track success
|
||||||
|
services.analytics.track("contact-form-success", {
|
||||||
|
has_company: Boolean(company),
|
||||||
});
|
});
|
||||||
|
|
||||||
await transporter.sendMail({
|
return NextResponse.json({ message: "Ok" });
|
||||||
from: process.env.SMTP_FROM,
|
|
||||||
to: process.env.CONTACT_RECIPIENT,
|
|
||||||
replyTo: email,
|
|
||||||
subject: `Kontaktanfrage von ${name}`,
|
|
||||||
text: `
|
|
||||||
Name: ${name}
|
|
||||||
Firma: ${company || 'Nicht angegeben'}
|
|
||||||
E-Mail: ${email}
|
|
||||||
Zeitpunkt: ${new Date().toISOString()}
|
|
||||||
|
|
||||||
Nachricht:
|
|
||||||
${message}
|
|
||||||
`,
|
|
||||||
});
|
|
||||||
|
|
||||||
return NextResponse.json({ message: 'Ok' });
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('SMTP Error:', error);
|
logger.error("Global API Error", { error });
|
||||||
return NextResponse.json({ error: 'Interner Serverfehler' }, { status: 500 });
|
services.errors.captureException(error, { phase: "api_global" });
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: "Interner Serverfehler" },
|
||||||
|
{ status: 500 },
|
||||||
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
35
app/api/payload/migrate/route.ts
Normal file
35
app/api/payload/migrate/route.ts
Normal file
@@ -0,0 +1,35 @@
|
|||||||
|
import { NextResponse } from "next/server";
|
||||||
|
import { getPayload } from "payload";
|
||||||
|
import configPromise from "@payload-config";
|
||||||
|
import { getServerAppServices } from "@/lib/services/create-services.server";
|
||||||
|
|
||||||
|
export async function POST(req: Request) {
|
||||||
|
const authHeader = req.headers.get("authorization");
|
||||||
|
if (authHeader !== `Bearer ${process.env.PAYLOAD_SECRET}`) {
|
||||||
|
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const { logger } = getServerAppServices();
|
||||||
|
|
||||||
|
try {
|
||||||
|
logger.info("Starting programmatic Payload migrations...");
|
||||||
|
const payload = await getPayload({ config: configPromise });
|
||||||
|
|
||||||
|
await payload.db.migrate();
|
||||||
|
|
||||||
|
logger.info("Successfully executed Payload migrations.");
|
||||||
|
return NextResponse.json({
|
||||||
|
success: true,
|
||||||
|
message: "Migrations executed successfully.",
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
logger.error("Failed to run migrations remotely", { error });
|
||||||
|
return NextResponse.json(
|
||||||
|
{
|
||||||
|
error:
|
||||||
|
error instanceof Error ? error.message : "Unknown error occurred",
|
||||||
|
},
|
||||||
|
{ status: 500 },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -11,13 +11,17 @@
|
|||||||
--color-text-muted: #64748b;
|
--color-text-muted: #64748b;
|
||||||
--color-border: #e2e8f0;
|
--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-xl: 1rem;
|
||||||
--radius-2xl: 1.5rem;
|
--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-soft:
|
||||||
--shadow-card: 0 10px 15px -3px rgb(0 0 0 / 0.03), 0 4px 6px -4px rgb(0 0 0 / 0.03);
|
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 {
|
:root {
|
||||||
@@ -43,7 +47,11 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.grid-pattern {
|
.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;
|
background-size: 40px 40px;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -56,7 +64,11 @@
|
|||||||
background-image:
|
background-image:
|
||||||
radial-gradient(at 0% 0%, rgba(16, 185, 129, 0.05) 0px, transparent 50%),
|
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% 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%);
|
radial-gradient(at 0% 100%, rgba(15, 23, 42, 0.05) 0px, transparent 50%);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -78,7 +90,7 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.tech-card-border::before {
|
.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;
|
@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;
|
@apply opacity-100;
|
||||||
}
|
}
|
||||||
|
|
||||||
h1, h2, h3, h4, h5, h6 {
|
h1,
|
||||||
|
h2,
|
||||||
|
h3,
|
||||||
|
h4,
|
||||||
|
h5,
|
||||||
|
h6 {
|
||||||
@apply font-bold tracking-tight text-primary;
|
@apply font-bold tracking-tight text-primary;
|
||||||
text-wrap: balance;
|
text-wrap: balance;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
button {
|
||||||
|
@apply cursor-pointer;
|
||||||
|
}
|
||||||
|
|
||||||
section {
|
section {
|
||||||
@apply py-20 md:py-32;
|
@apply py-20 md:py-32;
|
||||||
}
|
}
|
||||||
@@ -102,11 +123,11 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.btn-primary {
|
.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 {
|
.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 {
|
.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 React from "react";
|
||||||
import Image from 'next/image';
|
import Image from "next/image";
|
||||||
import { Award, Clock, Lightbulb, Linkedin, MessageSquare, ShieldCheck, Truck } from 'lucide-react';
|
import {
|
||||||
import { Reveal } from './Reveal';
|
Award,
|
||||||
import { TechBackground } from './TechBackground';
|
Clock,
|
||||||
import { Counter } from './Counter';
|
Lightbulb,
|
||||||
import { Button } from './Button';
|
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() {
|
export default function About() {
|
||||||
|
const t = useTranslations("About");
|
||||||
|
|
||||||
|
const manifestIcons = [
|
||||||
|
Award,
|
||||||
|
Clock,
|
||||||
|
Lightbulb,
|
||||||
|
Truck,
|
||||||
|
MessageSquare,
|
||||||
|
ShieldCheck,
|
||||||
|
];
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="overflow-hidden relative">
|
<div className="overflow-hidden relative">
|
||||||
{/* Hero Section */}
|
{/* Hero Section */}
|
||||||
<section className="relative min-h-[60vh] flex items-center pt-32 pb-20 overflow-hidden">
|
<section className="relative min-h-[60vh] flex items-center pt-44 pb-20 overflow-hidden">
|
||||||
<div className="absolute inset-0 z-0">
|
<div className="absolute inset-0 z-0">
|
||||||
<Image
|
<Image
|
||||||
src="/media/drums/about-hero.jpg"
|
src="/media/drums/about-hero.jpg"
|
||||||
@@ -30,17 +50,21 @@ export default function About() {
|
|||||||
<Counter value={1} className="section-number" />
|
<Counter value={1} className="section-number" />
|
||||||
<Reveal delay={0.1}>
|
<Reveal delay={0.1}>
|
||||||
<span className="text-accent font-bold uppercase tracking-widest text-sm mb-4 block">
|
<span className="text-accent font-bold uppercase tracking-widest text-sm mb-4 block">
|
||||||
Über uns
|
{t("hero.tagline")}
|
||||||
</span>
|
</span>
|
||||||
</Reveal>
|
</Reveal>
|
||||||
<Reveal delay={0.2}>
|
<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">
|
<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>
|
</h1>
|
||||||
</Reveal>
|
</Reveal>
|
||||||
<Reveal delay={0.3}>
|
<Reveal delay={0.3}>
|
||||||
<p className="text-slate-600 text-lg md:text-2xl leading-relaxed mb-8">
|
<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>
|
</p>
|
||||||
</Reveal>
|
</Reveal>
|
||||||
</div>
|
</div>
|
||||||
@@ -56,28 +80,43 @@ export default function About() {
|
|||||||
<Reveal direction="right">
|
<Reveal direction="right">
|
||||||
<div className="space-y-6 text-lg text-slate-600 leading-relaxed relative">
|
<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" />
|
<div className="absolute -left-4 top-0 w-1 h-full bg-accent/10" />
|
||||||
<p>
|
<p>{t("intro.p1")}</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>{t("intro.p2")}</p>
|
||||||
</p>
|
|
||||||
<p>
|
|
||||||
Wir verstehen die Herausforderungen der Energiewende und bieten herstellerneutrale Beratung, die auf Fakten, Normen und jahrzehntelanger Erfahrung basiert.
|
|
||||||
</p>
|
|
||||||
</div>
|
</div>
|
||||||
</Reveal>
|
</Reveal>
|
||||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-6">
|
<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) => (
|
].map((person, i) => (
|
||||||
<Reveal key={i} delay={i * 0.1}>
|
<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="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">
|
<div className="flex justify-between items-start mb-4 relative z-10">
|
||||||
<h3 className="text-xl font-bold text-primary">{person.name}</h3>
|
<h3 className="text-xl font-bold text-primary">
|
||||||
<a href={person.linkedin} target="_blank" rel="noopener noreferrer" className="text-[#0077b5] hover:scale-110 transition-transform">
|
{person.name}
|
||||||
|
</h3>
|
||||||
|
<a
|
||||||
|
href={person.linkedin}
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
className="text-[#0077b5] hover:scale-110 transition-transform"
|
||||||
|
>
|
||||||
<Linkedin size={20} />
|
<Linkedin size={20} />
|
||||||
</a>
|
</a>
|
||||||
</div>
|
</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>
|
</div>
|
||||||
</Reveal>
|
</Reveal>
|
||||||
))}
|
))}
|
||||||
@@ -92,31 +131,39 @@ export default function About() {
|
|||||||
<div className="container-custom relative z-10">
|
<div className="container-custom relative z-10">
|
||||||
<Counter value={3} className="section-number !text-white/5" />
|
<Counter value={3} className="section-number !text-white/5" />
|
||||||
<Reveal className="mb-20">
|
<Reveal className="mb-20">
|
||||||
<span className="text-accent font-bold uppercase tracking-widest text-sm mb-4 block">Werte</span>
|
<span className="text-accent font-bold uppercase tracking-widest text-sm mb-4 block">
|
||||||
<h2 className="text-3xl md:text-5xl font-bold text-white mb-6">Unser Manifest</h2>
|
{t("manifest.tagline")}
|
||||||
<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>
|
||||||
|
<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>
|
</Reveal>
|
||||||
|
|
||||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-8">
|
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-8">
|
||||||
{[
|
{t
|
||||||
{ icon: Award, title: 'Kompetenz', desc: 'Jahrzehntelange Erfahrung kombiniert mit europaweitem Know-how in modernsten Anlagen.' },
|
.raw("manifest.items")
|
||||||
{ icon: Clock, title: 'Verfügbarkeit', desc: 'Schnelle und verlässliche Unterstützung ohne unnötige Verzögerungen.' },
|
.map((item: { title: string; desc: string }, i: number) => {
|
||||||
{ icon: Lightbulb, title: 'Lösungen', desc: 'Wir stellen die richtigen Fragen, um die technisch und wirtschaftlich beste Lösung zu finden.' },
|
const Icon = manifestIcons[i];
|
||||||
{ icon: Truck, title: 'Logistik', desc: 'Von der Fertigungsüberwachung bis zum termingerechten Fracht-Tracking.' },
|
return (
|
||||||
{ icon: MessageSquare, title: 'Offenheit', desc: 'Wir hören zu und passen unsere Prozesse kontinuierlich an Ihren Erfolg an.' },
|
<Reveal key={i} delay={i * 0.1}>
|
||||||
{ icon: ShieldCheck, title: 'Zuverlässigkeit', desc: 'Wir halten, was wir versprechen – ohne Ausnahme. Verbindlichkeit ist unser Fundament.' }
|
<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">
|
||||||
].map((item, i) => (
|
<div className="absolute top-0 left-0 w-full h-1 bg-accent/0 group-hover:bg-accent/50 transition-all duration-500" />
|
||||||
<Reveal key={i} delay={i * 0.1}>
|
<div className="text-accent mb-6">
|
||||||
<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">
|
<Icon size={32} />
|
||||||
<div className="absolute top-0 left-0 w-full h-1 bg-accent/0 group-hover:bg-accent/50 transition-all duration-500" />
|
</div>
|
||||||
<div className="text-accent mb-6">
|
<h4 className="text-xl font-bold text-white mb-4">
|
||||||
<item.icon size={32} />
|
{i + 1}. {item.title}
|
||||||
</div>
|
</h4>
|
||||||
<h4 className="text-xl font-bold text-white mb-4">{i + 1}. {item.title}</h4>
|
<p className="text-slate-400 leading-relaxed">
|
||||||
<p className="text-slate-400 leading-relaxed">{item.desc}</p>
|
{item.desc}
|
||||||
</div>
|
</p>
|
||||||
</Reveal>
|
</div>
|
||||||
))}
|
</Reveal>
|
||||||
|
);
|
||||||
|
})}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
@@ -132,13 +179,18 @@ export default function About() {
|
|||||||
<div className="tech-corner bottom-8 right-8 border-b-2 border-r-2" />
|
<div className="tech-corner bottom-8 right-8 border-b-2 border-r-2" />
|
||||||
<div className="relative z-10 max-w-2xl">
|
<div className="relative z-10 max-w-2xl">
|
||||||
<h2 className="text-3xl md:text-5xl font-bold text-white mb-6 md:mb-8">
|
<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>
|
</h2>
|
||||||
<p className="text-slate-400 text-lg md:text-xl mb-8 md:mb-12">
|
<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>
|
</p>
|
||||||
<Button href="/kontakt" variant="accent" showArrow className="w-full sm:w-auto !px-10 !py-5 text-lg">
|
<Button
|
||||||
Jetzt Kontakt aufnehmen
|
href="/kontakt"
|
||||||
|
variant="accent"
|
||||||
|
showArrow
|
||||||
|
className="w-full sm:w-auto !px-10 !py-5 text-lg"
|
||||||
|
>
|
||||||
|
{t("cta.button")}
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,18 +1,18 @@
|
|||||||
'use client';
|
"use client";
|
||||||
|
|
||||||
import React, { useRef, useState } from 'react';
|
import React, { useState } from "react";
|
||||||
import { m, LazyMotion, domAnimation } from 'framer-motion';
|
import { m } from "framer-motion";
|
||||||
import Link from 'next/link';
|
import Link from "next/link";
|
||||||
import { ArrowRight } from 'lucide-react';
|
import { ArrowRight } from "lucide-react";
|
||||||
|
|
||||||
interface ButtonProps {
|
interface ButtonProps {
|
||||||
children: React.ReactNode;
|
children: React.ReactNode;
|
||||||
href?: string;
|
href?: string;
|
||||||
onClick?: () => void;
|
onClick?: () => void;
|
||||||
variant?: 'primary' | 'accent' | 'outline' | 'ghost';
|
variant?: "primary" | "accent" | "outline" | "ghost";
|
||||||
className?: string;
|
className?: string;
|
||||||
showArrow?: boolean;
|
showArrow?: boolean;
|
||||||
type?: 'button' | 'submit' | 'reset';
|
type?: "button" | "submit" | "reset";
|
||||||
disabled?: boolean;
|
disabled?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -20,11 +20,11 @@ export const Button = ({
|
|||||||
children,
|
children,
|
||||||
href,
|
href,
|
||||||
onClick,
|
onClick,
|
||||||
variant = 'primary',
|
variant = "primary",
|
||||||
className = '',
|
className = "",
|
||||||
showArrow = false,
|
showArrow = false,
|
||||||
type = 'button',
|
type = "button",
|
||||||
disabled = false
|
disabled = false,
|
||||||
}: ButtonProps) => {
|
}: ButtonProps) => {
|
||||||
const [mousePosition, setMousePosition] = useState({ x: 0, y: 0 });
|
const [mousePosition, setMousePosition] = useState({ x: 0, y: 0 });
|
||||||
const [isHovered, setIsHovered] = useState(false);
|
const [isHovered, setIsHovered] = useState(false);
|
||||||
@@ -37,13 +37,15 @@ 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 = {
|
const variants = {
|
||||||
primary: "bg-primary text-white shadow-lg",
|
primary: "bg-primary text-white shadow-lg",
|
||||||
accent: "bg-accent text-white shadow-lg",
|
accent: "bg-accent text-white shadow-lg",
|
||||||
outline: "border-2 border-primary text-primary hover:bg-primary hover:text-white",
|
outline:
|
||||||
ghost: "bg-slate-100 text-primary hover:bg-slate-200"
|
"border-2 border-primary text-primary hover:bg-primary hover:text-white",
|
||||||
|
ghost: "bg-slate-100 text-primary hover:bg-slate-200",
|
||||||
};
|
};
|
||||||
|
|
||||||
const content = (
|
const content = (
|
||||||
@@ -60,7 +62,6 @@ export const Button = ({
|
|||||||
);
|
);
|
||||||
|
|
||||||
const spotlight = (
|
const spotlight = (
|
||||||
<LazyMotion features={domAnimation}>
|
|
||||||
<m.div
|
<m.div
|
||||||
className="absolute inset-0 z-0 pointer-events-none transition-opacity duration-500"
|
className="absolute inset-0 z-0 pointer-events-none transition-opacity duration-500"
|
||||||
style={{
|
style={{
|
||||||
@@ -68,7 +69,6 @@ export const Button = ({
|
|||||||
background: `radial-gradient(600px circle at ${mousePosition.x}px ${mousePosition.y}px, rgba(255,255,255,0.15), transparent 40%)`,
|
background: `radial-gradient(600px circle at ${mousePosition.x}px ${mousePosition.y}px, rgba(255,255,255,0.15), transparent 40%)`,
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
</LazyMotion>
|
|
||||||
);
|
);
|
||||||
|
|
||||||
const buttonProps = {
|
const buttonProps = {
|
||||||
|
|||||||
@@ -1,16 +1,27 @@
|
|||||||
'use client';
|
"use client";
|
||||||
|
|
||||||
import React, { useState } from 'react';
|
import React, { useState } from "react";
|
||||||
import Image from 'next/image';
|
import Image from "next/image";
|
||||||
import { Mail, MapPin, CheckCircle } from 'lucide-react';
|
import Link from "next/link";
|
||||||
import { Button } from './Button';
|
import { Mail, MapPin, CheckCircle } from "lucide-react";
|
||||||
import { Counter } from './Counter';
|
import { Button } from "./Button";
|
||||||
import { Reveal } from './Reveal';
|
import { Counter } from "./Counter";
|
||||||
import { TechBackground } from './TechBackground';
|
import { Reveal } from "./Reveal";
|
||||||
|
import { TechBackground } from "./TechBackground";
|
||||||
|
import { StatusModal } from "./StatusModal";
|
||||||
|
import { useTranslations } from "next-intl";
|
||||||
|
|
||||||
export default function Contact() {
|
export default function Contact() {
|
||||||
|
const t = useTranslations("Contact");
|
||||||
const [submitted, setSubmitted] = useState(false);
|
const [submitted, setSubmitted] = useState(false);
|
||||||
const [loading, setLoading] = 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>) => {
|
const handleSubmit = async (e: React.FormEvent<HTMLFormElement>) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
@@ -19,19 +30,42 @@ export default function Contact() {
|
|||||||
const data = Object.fromEntries(formData.entries());
|
const data = Object.fromEntries(formData.entries());
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const response = await fetch('/api/contact', {
|
const response = await fetch("/api/contact", {
|
||||||
method: 'POST',
|
method: "POST",
|
||||||
headers: { 'Content-Type': 'application/json' },
|
headers: { "Content-Type": "application/json" },
|
||||||
body: JSON.stringify(data),
|
body: JSON.stringify(data),
|
||||||
});
|
});
|
||||||
if (response.ok) {
|
if (response.ok) {
|
||||||
setSubmitted(true);
|
setSubmitted(true);
|
||||||
|
setStatusModal({
|
||||||
|
isOpen: true,
|
||||||
|
type: "success",
|
||||||
|
title: t("form.successTitle"),
|
||||||
|
message: t("form.successMessage"),
|
||||||
|
buttonText: t("form.close") || "Schließen",
|
||||||
|
});
|
||||||
} else {
|
} else {
|
||||||
const err = await response.json();
|
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) {
|
} catch {
|
||||||
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 {
|
} finally {
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
}
|
}
|
||||||
@@ -40,7 +74,7 @@ export default function Contact() {
|
|||||||
return (
|
return (
|
||||||
<div className="overflow-hidden relative">
|
<div className="overflow-hidden relative">
|
||||||
{/* Hero Section */}
|
{/* Hero Section */}
|
||||||
<section className="relative min-h-[40vh] flex items-center pt-32 pb-20 overflow-hidden">
|
<section className="relative min-h-[40vh] flex items-center pt-44 pb-20 overflow-hidden">
|
||||||
<div className="absolute inset-0 z-0">
|
<div className="absolute inset-0 z-0">
|
||||||
<Image
|
<Image
|
||||||
src="/media/laying/contact-hero.jpg"
|
src="/media/laying/contact-hero.jpg"
|
||||||
@@ -57,16 +91,22 @@ export default function Contact() {
|
|||||||
<div className="text-left relative">
|
<div className="text-left relative">
|
||||||
<div className="section-number">01</div>
|
<div className="section-number">01</div>
|
||||||
<Reveal delay={0.1}>
|
<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>
|
||||||
<Reveal delay={0.2}>
|
<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">
|
<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>
|
</h1>
|
||||||
</Reveal>
|
</Reveal>
|
||||||
<Reveal delay={0.3}>
|
<Reveal delay={0.3}>
|
||||||
<p className="text-slate-600 text-lg md:text-2xl leading-relaxed">
|
<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>
|
</p>
|
||||||
</Reveal>
|
</Reveal>
|
||||||
</div>
|
</div>
|
||||||
@@ -85,8 +125,13 @@ export default function Contact() {
|
|||||||
<Mail size={24} />
|
<Mail size={24} />
|
||||||
</div>
|
</div>
|
||||||
<div className="relative z-10">
|
<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>
|
<h4 className="text-slate-400 font-bold text-xs uppercase tracking-widest mb-1 md:mb-2">
|
||||||
<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">
|
{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
|
info@mb-grid-solutions.com
|
||||||
</a>
|
</a>
|
||||||
</div>
|
</div>
|
||||||
@@ -99,10 +144,14 @@ export default function Contact() {
|
|||||||
<MapPin size={24} />
|
<MapPin size={24} />
|
||||||
</div>
|
</div>
|
||||||
<div className="relative z-10">
|
<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">
|
<p className="text-white text-lg md:text-xl font-bold leading-relaxed">
|
||||||
MB Grid Solutions & Services GmbH<br />
|
{t("info.company")}
|
||||||
Raiffeisenstraße 22<br />
|
<br />
|
||||||
|
Raiffeisenstraße 22
|
||||||
|
<br />
|
||||||
73630 Remshalden
|
73630 Remshalden
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
@@ -135,74 +184,111 @@ export default function Contact() {
|
|||||||
<div className="w-20 h-20 rounded-full bg-accent/10 text-accent flex items-center justify-center mx-auto mb-8">
|
<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} />
|
<CheckCircle size={40} />
|
||||||
</div>
|
</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">
|
<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>
|
</p>
|
||||||
<Button onClick={() => setSubmitted(false)}>
|
<Button onClick={() => setSubmitted(false)}>
|
||||||
Weitere Nachricht
|
{t("form.moreMessages")}
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<form onSubmit={handleSubmit} className="space-y-6 relative z-10">
|
<form
|
||||||
|
onSubmit={handleSubmit}
|
||||||
|
aria-label={t("form.submit")}
|
||||||
|
className="space-y-6 relative z-10"
|
||||||
|
>
|
||||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
|
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
|
||||||
<div className="space-y-2">
|
<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
|
<input
|
||||||
type="text"
|
type="text"
|
||||||
id="name"
|
id="name"
|
||||||
name="name"
|
name="name"
|
||||||
required
|
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"
|
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">
|
<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
|
<input
|
||||||
type="text"
|
type="text"
|
||||||
id="company"
|
id="company"
|
||||||
name="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"
|
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>
|
</div>
|
||||||
|
|
||||||
<div className="space-y-2">
|
<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
|
<input
|
||||||
type="email"
|
type="email"
|
||||||
id="email"
|
id="email"
|
||||||
name="email"
|
name="email"
|
||||||
required
|
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"
|
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">
|
<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
|
<textarea
|
||||||
id="message"
|
id="message"
|
||||||
name="message"
|
name="message"
|
||||||
required
|
required
|
||||||
rows={5}
|
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"
|
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>
|
></textarea>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<Button type="submit" variant="accent" disabled={loading} className="w-full py-5 text-lg" showArrow>
|
<Button
|
||||||
{loading ? 'Wird gesendet...' : 'Nachricht senden'}
|
type="submit"
|
||||||
|
variant="accent"
|
||||||
|
disabled={loading}
|
||||||
|
className="w-full py-5 text-lg"
|
||||||
|
showArrow
|
||||||
|
>
|
||||||
|
{loading ? t("form.submitting") : t("form.submit")}
|
||||||
</Button>
|
</Button>
|
||||||
|
|
||||||
<p className="text-xs text-slate-400 text-center">
|
<p className="text-xs text-slate-400 text-center">
|
||||||
* Pflichtfelder. Mit dem Absenden erklären Sie sich mit unserer{' '}
|
{t.rich("form.privacyNote", {
|
||||||
<a href="/datenschutz" className="text-accent hover:underline font-semibold">
|
link: (chunks) => (
|
||||||
Datenschutzerklärung
|
<Link
|
||||||
</a>{' '}
|
href="/datenschutz"
|
||||||
einverstanden.
|
className="text-accent hover:underline font-semibold"
|
||||||
|
>
|
||||||
|
{chunks}
|
||||||
|
</Link>
|
||||||
|
),
|
||||||
|
})}
|
||||||
</p>
|
</p>
|
||||||
</form>
|
</form>
|
||||||
)}
|
)}
|
||||||
@@ -211,6 +297,15 @@ export default function Contact() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
|
<StatusModal
|
||||||
|
isOpen={statusModal.isOpen}
|
||||||
|
onClose={() => setStatusModal({ ...statusModal, isOpen: false })}
|
||||||
|
type={statusModal.type}
|
||||||
|
title={statusModal.title}
|
||||||
|
message={statusModal.message}
|
||||||
|
buttonText={statusModal.buttonText}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,53 +1,69 @@
|
|||||||
'use client';
|
"use client";
|
||||||
|
|
||||||
import { m, LazyMotion, domAnimation } from 'framer-motion';
|
import Image from "next/image";
|
||||||
import { BarChart3, CheckCircle2, ChevronRight, Shield, Zap } from 'lucide-react';
|
import { Button } from "./Button";
|
||||||
import Image from 'next/image';
|
import { Counter } from "./Counter";
|
||||||
import Link from 'next/link';
|
import { Reveal } from "./Reveal";
|
||||||
import { Button } from './Button';
|
import { TechBackground } from "./TechBackground";
|
||||||
import { Counter } from './Counter';
|
import { useTranslations } from "next-intl";
|
||||||
import { Reveal } from './Reveal';
|
|
||||||
import { TechBackground } from './TechBackground';
|
import dynamic from "next/dynamic";
|
||||||
import { TileGrid } from './TileGrid';
|
|
||||||
|
const PortfolioSection = dynamic(() =>
|
||||||
|
import("./sections/PortfolioSection").then((mod) => mod.PortfolioSection),
|
||||||
|
);
|
||||||
|
const ExpertiseSection = dynamic(() =>
|
||||||
|
import("./sections/ExpertiseSection").then((mod) => mod.ExpertiseSection),
|
||||||
|
);
|
||||||
|
const TechnicalSpecsSection = dynamic(() =>
|
||||||
|
import("./sections/TechnicalSpecsSection").then(
|
||||||
|
(mod) => mod.TechnicalSpecsSection,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
const CTASection = dynamic(() =>
|
||||||
|
import("./sections/CTASection").then((mod) => mod.CTASection),
|
||||||
|
);
|
||||||
|
|
||||||
export default function Home() {
|
export default function Home() {
|
||||||
|
const t = useTranslations("Index");
|
||||||
|
|
||||||
const serviceJsonLd = {
|
const serviceJsonLd = {
|
||||||
"@context": "https://schema.org",
|
"@context": "https://schema.org",
|
||||||
"@type": "Service",
|
"@type": "Service",
|
||||||
"name": "Technische Beratung für Energiekabelprojekte",
|
name: t("portfolio.items.beratung.title"),
|
||||||
"provider": {
|
provider: {
|
||||||
"@type": "Organization",
|
"@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.",
|
description: t("portfolio.description"),
|
||||||
"areaServed": "Europe",
|
areaServed: "Europe",
|
||||||
"hasOfferCatalog": {
|
hasOfferCatalog: {
|
||||||
"@type": "OfferCatalog",
|
"@type": "OfferCatalog",
|
||||||
"name": "Dienstleistungen",
|
name: t("portfolio.title"),
|
||||||
"itemListElement": [
|
itemListElement: [
|
||||||
{
|
{
|
||||||
"@type": "Offer",
|
"@type": "Offer",
|
||||||
"itemOffered": {
|
itemOffered: {
|
||||||
"@type": "Service",
|
"@type": "Service",
|
||||||
"name": "Technische Beratung"
|
name: t("portfolio.items.beratung.title"),
|
||||||
}
|
},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"@type": "Offer",
|
"@type": "Offer",
|
||||||
"itemOffered": {
|
itemOffered: {
|
||||||
"@type": "Service",
|
"@type": "Service",
|
||||||
"name": "Projektbegleitung"
|
name: t("portfolio.items.begleitung.title"),
|
||||||
}
|
},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"@type": "Offer",
|
"@type": "Offer",
|
||||||
"itemOffered": {
|
itemOffered: {
|
||||||
"@type": "Service",
|
"@type": "Service",
|
||||||
"name": "Produktbeschaffung"
|
name: t("portfolio.items.beschaffung.title"),
|
||||||
}
|
},
|
||||||
}
|
},
|
||||||
]
|
],
|
||||||
}
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -58,7 +74,7 @@ export default function Home() {
|
|||||||
/>
|
/>
|
||||||
|
|
||||||
{/* Hero Section */}
|
{/* Hero Section */}
|
||||||
<section className="relative min-h-[90vh] flex items-center pt-32 pb-20 overflow-hidden">
|
<section className="relative min-h-[90vh] flex items-center pt-44 pb-20 overflow-hidden">
|
||||||
<div className="absolute inset-0 z-0">
|
<div className="absolute inset-0 z-0">
|
||||||
<Image
|
<Image
|
||||||
src="/media/business/hero-bg.jpg"
|
src="/media/business/hero-bg.jpg"
|
||||||
@@ -66,12 +82,11 @@ export default function Home() {
|
|||||||
fill
|
fill
|
||||||
className="object-cover"
|
className="object-cover"
|
||||||
priority
|
priority
|
||||||
quality={90}
|
quality={75}
|
||||||
/>
|
/>
|
||||||
<div className="absolute inset-0 bg-gradient-to-r from-slate-100/80 via-white/90 to-white/40 md:to-transparent" />
|
<div className="absolute inset-0 bg-gradient-to-r from-slate-100/80 via-white/90 to-white/40 md:to-transparent" />
|
||||||
<TechBackground />
|
<TechBackground />
|
||||||
</div>
|
</div>
|
||||||
<TileGrid />
|
|
||||||
|
|
||||||
<div className="container-custom relative z-10">
|
<div className="container-custom relative z-10">
|
||||||
<div className="text-left relative">
|
<div className="text-left relative">
|
||||||
@@ -82,29 +97,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="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 className="relative inline-flex rounded-full h-2 w-2 bg-accent"></span>
|
||||||
</span>
|
</span>
|
||||||
Engineering Excellence
|
{t("hero.tag")}
|
||||||
</span>
|
</span>
|
||||||
</Reveal>
|
</Reveal>
|
||||||
|
|
||||||
<Reveal delay={0.2}>
|
<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]">
|
<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>
|
</h1>
|
||||||
</Reveal>
|
</Reveal>
|
||||||
|
|
||||||
<Reveal delay={0.3}>
|
<Reveal delay={0.3}>
|
||||||
<p className="text-slate-600 text-lg md:text-2xl leading-relaxed mb-8 md:mb-12 max-w-2xl">
|
<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>
|
</p>
|
||||||
</Reveal>
|
</Reveal>
|
||||||
|
|
||||||
<Reveal delay={0.4}>
|
<Reveal delay={0.4}>
|
||||||
<div className="flex flex-wrap gap-4">
|
<div className="flex flex-wrap gap-4">
|
||||||
<Button href="/kontakt" variant="accent" showArrow>
|
<Button href="/kontakt" variant="accent" showArrow>
|
||||||
Projekt anfragen
|
{t("hero.ctaPrimary")}
|
||||||
</Button>
|
</Button>
|
||||||
<Button href="/ueber-uns" variant="ghost">
|
<Button href="/ueber-uns" variant="ghost">
|
||||||
Mehr erfahren
|
{t("hero.ctaSecondary")}
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</Reveal>
|
</Reveal>
|
||||||
@@ -112,215 +135,11 @@ export default function Home() {
|
|||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
{/* Portfolio Section */}
|
{/* Dynamic Sections */}
|
||||||
<section className="bg-slate-950 text-accent relative overflow-hidden">
|
<PortfolioSection />
|
||||||
<TechBackground />
|
<ExpertiseSection />
|
||||||
<div className="container-custom relative z-10">
|
<TechnicalSpecsSection />
|
||||||
<Counter value={2} className="section-number !text-white/5" />
|
<CTASection />
|
||||||
<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>
|
|
||||||
<p className="text-slate-400 text-base md:text-xl">
|
|
||||||
Beratung durch unabhängige Experten mit jahrzehntelanger Erfahrung aus Engineering, Normengremien, Planung und Produktion.
|
|
||||||
</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>
|
|
||||||
</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: <Shield size={32} />,
|
|
||||||
title: 'Projektbegleitung',
|
|
||||||
desc: 'Wir begleiten Sie bei der Verlegung und Installation, um Herausforderungen proaktiv zu lösen.'
|
|
||||||
},
|
|
||||||
{
|
|
||||||
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">
|
|
||||||
<div className="absolute top-0 right-0 w-16 h-16 bg-accent/5 -mr-8 -mt-8 rounded-full group-hover:bg-accent/10 transition-colors" />
|
|
||||||
<div className="w-16 h-16 rounded-2xl bg-accent/10 text-accent flex items-center justify-center mb-8 group-hover:bg-accent group-hover:text-white transition-colors relative z-10">
|
|
||||||
{item.icon}
|
|
||||||
</div>
|
|
||||||
<h3 className="text-2xl font-bold text-white mb-4 relative z-10">{item.title}</h3>
|
|
||||||
<p className="text-slate-400 leading-relaxed relative z-10">
|
|
||||||
{item.desc}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</Reveal>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
|
|
||||||
{/* Expertise Section */}
|
|
||||||
<section className="bg-white relative overflow-hidden">
|
|
||||||
<TechBackground />
|
|
||||||
<div className="container-custom relative z-10">
|
|
||||||
<Counter value={3} className="section-number" />
|
|
||||||
<div className="grid grid-cols-1 lg:grid-cols-2 items-center gap-16 md:gap-24">
|
|
||||||
<Reveal direction="right">
|
|
||||||
<div className="relative overflow-hidden rounded-2xl shadow-lg group">
|
|
||||||
<div className="absolute inset-0 bg-accent/10 opacity-0 group-hover:opacity-100 transition-opacity duration-500 z-10 pointer-events-none" />
|
|
||||||
<Image
|
|
||||||
src="/media/cables/hs-kabel.png"
|
|
||||||
alt="Technical Engineering"
|
|
||||||
width={800}
|
|
||||||
height={600}
|
|
||||||
className="w-full h-[400px] md:h-[500px] object-cover hover:scale-105 transition-transform duration-700"
|
|
||||||
/>
|
|
||||||
<div className="tech-corner top-4 left-4 border-t-2 border-l-2 z-20" />
|
|
||||||
<div className="tech-corner bottom-4 right-4 border-b-2 border-r-2 z-20" />
|
|
||||||
</div>
|
|
||||||
</Reveal>
|
|
||||||
<div>
|
|
||||||
<Reveal>
|
|
||||||
<span className="text-accent font-bold uppercase tracking-widest text-sm mb-4 block">Expertise</span>
|
|
||||||
<h2 className="text-3xl md:text-5xl font-bold text-primary mb-6 md:mb-8">Anwendungen & Zielgruppen</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.
|
|
||||||
</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) => (
|
|
||||||
<Reveal key={i} delay={i * 0.05}>
|
|
||||||
<div className="flex items-center gap-4 p-4 bg-slate-50 rounded-xl border border-slate-100 hover:border-accent/30 transition-colors group relative overflow-hidden">
|
|
||||||
<div className="absolute top-0 left-0 w-1 h-full bg-accent/0 group-hover:bg-accent/100 transition-all duration-300" />
|
|
||||||
<div className="w-8 h-8 rounded-full bg-white flex items-center justify-center shadow-sm group-hover:bg-accent group-hover:text-white transition-colors">
|
|
||||||
<CheckCircle2 size={16} />
|
|
||||||
</div>
|
|
||||||
<span className="text-primary font-semibold">{item}</span>
|
|
||||||
</div>
|
|
||||||
</Reveal>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
|
|
||||||
{/* Technical Specs Section */}
|
|
||||||
<section className="relative py-24 md:py-32 text-white overflow-hidden bg-slate-900">
|
|
||||||
<div className="absolute inset-0 opacity-20">
|
|
||||||
<Image
|
|
||||||
src="/media/drums/about-hero.jpg"
|
|
||||||
alt="Background"
|
|
||||||
fill
|
|
||||||
className="object-cover"
|
|
||||||
/>
|
|
||||||
<div className="absolute inset-0 bg-gradient-to-b from-slate-900 via-slate-900/80 to-slate-900" />
|
|
||||||
</div>
|
|
||||||
<TechBackground />
|
|
||||||
|
|
||||||
<div className="container-custom relative z-10">
|
|
||||||
<Counter value={4} className="section-number !text-white/5" />
|
|
||||||
{/* Data Stream Effect */}
|
|
||||||
<div className="absolute -top-10 right-0 w-px h-64 bg-gradient-to-b from-transparent via-accent/50 to-transparent animate-pulse" />
|
|
||||||
<div className="absolute -bottom-10 left-10 w-px h-64 bg-gradient-to-b from-transparent via-accent/30 to-transparent animate-pulse delay-700" />
|
|
||||||
|
|
||||||
<Reveal className="mb-20">
|
|
||||||
<span className="text-accent font-bold uppercase tracking-widest text-sm mb-4 block">Spezifikationen</span>
|
|
||||||
<h2 className="text-3xl md:text-5xl font-bold text-white mb-6">Technische Expertise</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.' }
|
|
||||||
].map((item, i) => (
|
|
||||||
<Reveal key={i} delay={i * 0.1}>
|
|
||||||
<div className="p-10 rounded-3xl bg-white/5 border border-white/10 backdrop-blur-sm hover:bg-white/10 transition-colors h-full relative group overflow-hidden">
|
|
||||||
<div className="absolute top-0 left-0 w-full h-1 bg-accent/0 group-hover:bg-accent/50 transition-all duration-500" />
|
|
||||||
<h4 className="text-accent font-bold text-xs uppercase tracking-widest mb-6">
|
|
||||||
{item.label}
|
|
||||||
</h4>
|
|
||||||
<p className="text-2xl font-bold text-white mb-4 leading-tight">
|
|
||||||
{item.value}
|
|
||||||
</p>
|
|
||||||
<p className="text-slate-400 leading-relaxed">
|
|
||||||
{item.desc}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</Reveal>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
|
|
||||||
{/* CTA Section */}
|
|
||||||
<section className="bg-white relative overflow-hidden">
|
|
||||||
<TechBackground />
|
|
||||||
{/* Decorative Background Elements */}
|
|
||||||
<div className="absolute top-0 left-0 w-64 h-64 bg-accent/5 rounded-full blur-3xl -translate-x-1/2 -translate-y-1/2" />
|
|
||||||
<div className="absolute bottom-0 right-0 w-96 h-96 bg-primary/5 rounded-full blur-3xl translate-x-1/2 translate-y-1/2" />
|
|
||||||
|
|
||||||
<div className="container-custom relative z-10">
|
|
||||||
<Counter value={5} className="section-number" />
|
|
||||||
<Reveal>
|
|
||||||
<div className="relative rounded-3xl md:rounded-[2.5rem] bg-primary p-8 md:p-24 overflow-hidden group">
|
|
||||||
{/* Corner Accents */}
|
|
||||||
<div className="tech-corner top-8 left-8 border-t-2 border-l-2" />
|
|
||||||
<div className="tech-corner top-8 right-8 border-t-2 border-r-2" />
|
|
||||||
<div className="tech-corner bottom-8 left-8 border-b-2 border-l-2" />
|
|
||||||
<div className="tech-corner bottom-8 right-8 border-b-2 border-r-2" />
|
|
||||||
|
|
||||||
<div className="absolute top-0 right-0 w-1/2 h-full opacity-10 pointer-events-none">
|
|
||||||
<LazyMotion features={domAnimation}>
|
|
||||||
<svg viewBox="0 0 400 400" fill="none" xmlns="http://www.w3.org/2000/svg">
|
|
||||||
<m.circle
|
|
||||||
animate={{ r: [400, 410, 400], opacity: [0.1, 0.2, 0.1] }}
|
|
||||||
transition={{ duration: 5, repeat: Infinity, ease: "easeInOut" }}
|
|
||||||
cx="400" cy="0" r="400" stroke="white" strokeWidth="2"
|
|
||||||
/>
|
|
||||||
<m.circle
|
|
||||||
animate={{ r: [300, 310, 300], opacity: [0.1, 0.2, 0.1] }}
|
|
||||||
transition={{ duration: 4, repeat: Infinity, ease: "easeInOut", delay: 0.5 }}
|
|
||||||
cx="400" cy="0" r="300" stroke="white" strokeWidth="2"
|
|
||||||
/>
|
|
||||||
<m.circle
|
|
||||||
animate={{ r: [200, 210, 200], opacity: [0.1, 0.2, 0.1] }}
|
|
||||||
transition={{ duration: 3, repeat: Infinity, ease: "easeInOut", delay: 1 }}
|
|
||||||
cx="400" cy="0" r="200" stroke="white" strokeWidth="2"
|
|
||||||
/>
|
|
||||||
</svg>
|
|
||||||
</LazyMotion>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="relative z-10">
|
|
||||||
<h2 className="text-3xl md:text-6xl font-bold text-white mb-6 md:mb-8 leading-tight">
|
|
||||||
Bereit für Ihr nächstes Projekt?
|
|
||||||
</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.
|
|
||||||
</p>
|
|
||||||
<Button href="/kontakt" variant="accent" showArrow className="w-full sm:w-auto !px-10 !py-5 text-lg">
|
|
||||||
Jetzt Kontakt aufnehmen
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</Reveal>
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,15 +1,17 @@
|
|||||||
'use client';
|
"use client";
|
||||||
|
|
||||||
import { AnimatePresence, m, LazyMotion, domAnimation } from 'framer-motion';
|
import { AnimatePresence, m } from "framer-motion";
|
||||||
import { ArrowUp, Home, Info, Menu, X } from 'lucide-react';
|
import { ArrowUp, Home, Info, Menu, X } from "lucide-react";
|
||||||
import Image from 'next/image';
|
import Image from "next/image";
|
||||||
import Link from 'next/link';
|
import Link from "next/link";
|
||||||
import { usePathname } from 'next/navigation';
|
import { usePathname } from "next/navigation";
|
||||||
import React, { useEffect, useState } from 'react';
|
import React, { useEffect, useState } from "react";
|
||||||
import { Button } from './Button';
|
import { Button } from "./Button";
|
||||||
import { Reveal } from './Reveal';
|
import { Reveal } from "./Reveal";
|
||||||
|
import { useTranslations } from "next-intl";
|
||||||
|
|
||||||
const Layout = ({ children }: { children: React.ReactNode }) => {
|
const Layout = ({ children }: { children: React.ReactNode }) => {
|
||||||
|
const t = useTranslations("Layout");
|
||||||
const pathname = usePathname();
|
const pathname = usePathname();
|
||||||
const [showScrollTop, setShowScrollTop] = useState(false);
|
const [showScrollTop, setShowScrollTop] = useState(false);
|
||||||
const [isScrolled, setIsScrolled] = useState(false);
|
const [isScrolled, setIsScrolled] = useState(false);
|
||||||
@@ -21,8 +23,8 @@ const Layout = ({ children }: { children: React.ReactNode }) => {
|
|||||||
setIsScrolled(window.scrollY > 20);
|
setIsScrolled(window.scrollY > 20);
|
||||||
};
|
};
|
||||||
|
|
||||||
window.addEventListener('scroll', handleScroll, { passive: true });
|
window.addEventListener("scroll", handleScroll, { passive: true });
|
||||||
return () => window.removeEventListener('scroll', handleScroll);
|
return () => window.removeEventListener("scroll", handleScroll);
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -30,81 +32,90 @@ const Layout = ({ children }: { children: React.ReactNode }) => {
|
|||||||
}, [pathname]);
|
}, [pathname]);
|
||||||
|
|
||||||
const scrollToTop = () => {
|
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 = [
|
const navLinks = [
|
||||||
{ href: '/', label: 'Startseite', icon: Home },
|
{ href: "/", label: t("nav.home"), icon: Home },
|
||||||
{ href: '/ueber-uns', label: 'Über uns', icon: Info },
|
{ href: "/ueber-uns", label: t("nav.about"), icon: Info },
|
||||||
];
|
];
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="min-h-screen flex flex-col font-sans">
|
<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
|
<header
|
||||||
className={`transition-all duration-300 flex items-center py-1 ${
|
className={`transition-all duration-300 flex items-center ${
|
||||||
isScrolled
|
isScrolled
|
||||||
? 'bg-white/90 backdrop-blur-lg border-b border-slate-200 shadow-sm'
|
? "bg-white/90 backdrop-blur-lg border-b border-slate-200 shadow-sm py-2"
|
||||||
: 'bg-gradient-to-b from-white/80 via-white/40 to-transparent'
|
: "bg-gradient-to-b from-white/80 via-white/40 to-transparent py-4"
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
<div className="container-custom flex justify-between items-center w-full relative z-10">
|
<div className="container-custom flex justify-between items-center w-full relative z-10">
|
||||||
<Link
|
<Link
|
||||||
href="/"
|
href="/"
|
||||||
className="relative z-10 flex items-center group"
|
className="relative z-10 flex items-center group"
|
||||||
aria-label="MB Grid Solutions - Zur Startseite"
|
aria-label={`${t("nav.home")} - 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]"
|
|
||||||
>
|
>
|
||||||
Projekt anfragen
|
<div
|
||||||
</Button>
|
className={`relative transition-all duration-300 ${isScrolled ? "h-[50px] md:h-[60px] w-[120px] md:w-[150px]" : "h-[70px] md:h-[100px] w-[160px] md:w-[240px]"}`}
|
||||||
</nav>
|
>
|
||||||
|
<Image
|
||||||
|
src="/assets/logo.png"
|
||||||
|
alt="MB Grid Solutions"
|
||||||
|
fill
|
||||||
|
className="object-contain"
|
||||||
|
priority
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</Link>
|
||||||
|
|
||||||
{/* Mobile Menu Toggle */}
|
{/* Desktop Navigation */}
|
||||||
<button
|
<nav
|
||||||
className="md:hidden p-2 text-slate-600 hover:text-primary transition-colors"
|
className="hidden md:flex items-center gap-8"
|
||||||
onClick={() => setIsMobileMenuOpen(!isMobileMenuOpen)}
|
aria-label="Hauptnavigation"
|
||||||
aria-label="Menü öffnen"
|
>
|
||||||
>
|
{navLinks.map((link) => (
|
||||||
{isMobileMenuOpen ? <X size={24} /> : <Menu size={24} />}
|
<Link
|
||||||
</button>
|
key={link.href}
|
||||||
</div>
|
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>
|
</header>
|
||||||
</Reveal>
|
</Reveal>
|
||||||
|
|
||||||
{/* Mobile Menu Overlay */}
|
{/* Mobile Menu Overlay */}
|
||||||
<LazyMotion features={domAnimation}>
|
|
||||||
<AnimatePresence>
|
<AnimatePresence>
|
||||||
{isMobileMenuOpen && (
|
{isMobileMenuOpen && (
|
||||||
<m.div
|
<m.div
|
||||||
@@ -120,34 +131,30 @@ const Layout = ({ children }: { children: React.ReactNode }) => {
|
|||||||
href={link.href}
|
href={link.href}
|
||||||
className={`flex items-center gap-4 p-4 rounded-xl text-lg font-semibold transition-all ${
|
className={`flex items-center gap-4 p-4 rounded-xl text-lg font-semibold transition-all ${
|
||||||
isActive(link.href)
|
isActive(link.href)
|
||||||
? 'text-accent bg-accent/5'
|
? "text-accent bg-accent/5"
|
||||||
: 'text-slate-600 hover:text-primary hover:bg-slate-50'
|
: "text-slate-600 hover:text-primary hover:bg-slate-50"
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
<link.icon size={24} />
|
<link.icon size={24} />
|
||||||
{link.label}
|
{link.label}
|
||||||
</Link>
|
</Link>
|
||||||
))}
|
))}
|
||||||
<Button
|
<Button href="/kontakt" className="mt-4 w-full">
|
||||||
href="/kontakt"
|
{t("nav.cta")}
|
||||||
className="mt-4 w-full"
|
|
||||||
>
|
|
||||||
Projekt anfragen
|
|
||||||
</Button>
|
</Button>
|
||||||
</nav>
|
</nav>
|
||||||
</m.div>
|
</m.div>
|
||||||
)}
|
)}
|
||||||
</AnimatePresence>
|
</AnimatePresence>
|
||||||
</LazyMotion>
|
|
||||||
|
|
||||||
<main className="flex-grow">
|
<main className="flex-grow">{children}</main>
|
||||||
{children}
|
|
||||||
</main>
|
|
||||||
|
|
||||||
<button
|
<button
|
||||||
onClick={scrollToTop}
|
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 ${
|
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"
|
aria-label="Nach oben scrollen"
|
||||||
>
|
>
|
||||||
@@ -159,85 +166,124 @@ const Layout = ({ children }: { children: React.ReactNode }) => {
|
|||||||
<div className="absolute inset-0 grid-pattern opacity-[0.08] pointer-events-none" />
|
<div className="absolute inset-0 grid-pattern opacity-[0.08] pointer-events-none" />
|
||||||
|
|
||||||
{/* Animated Tech Lines */}
|
{/* Animated Tech Lines */}
|
||||||
<LazyMotion features={domAnimation}>
|
|
||||||
<m.div
|
<m.div
|
||||||
animate={{ x: ['-100%', '100%'] }}
|
animate={{ x: ["-100%", "100%"] }}
|
||||||
transition={{ duration: 15, repeat: Infinity, ease: "linear" }}
|
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"
|
className="absolute top-0 left-0 w-full h-px bg-gradient-to-r from-transparent via-accent/30 to-transparent"
|
||||||
/>
|
/>
|
||||||
<m.div
|
<m.div
|
||||||
animate={{ x: ['100%', '-100%'] }}
|
animate={{ x: ["100%", "-100%"] }}
|
||||||
transition={{ duration: 20, repeat: Infinity, ease: "linear" }}
|
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"
|
className="absolute bottom-0 left-0 w-full h-px bg-gradient-to-r from-transparent via-accent/20 to-transparent"
|
||||||
/>
|
/>
|
||||||
</LazyMotion>
|
|
||||||
|
|
||||||
{/* Corner Accents */}
|
{/* 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 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="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="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="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">
|
<div className="lg:col-span-2">
|
||||||
<Link href="/" className="inline-block mb-6 md:mb-8 group">
|
<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">
|
<div className="relative h-16 md:h-20 w-48 brightness-0 invert opacity-80 group-hover:opacity-100 transition-opacity">
|
||||||
<Image
|
<Image
|
||||||
src="/assets/logo.png"
|
src="/assets/logo.png"
|
||||||
alt="MB Grid Solutions"
|
alt="MB Grid Solutions"
|
||||||
fill
|
fill
|
||||||
className="object-contain object-left"
|
className="object-contain object-left"
|
||||||
/>
|
/>
|
||||||
|
</div>
|
||||||
|
</Link>
|
||||||
|
<p className="text-slate-400 max-w-md leading-relaxed mb-8">
|
||||||
|
{t("footer.description")}
|
||||||
|
</p>
|
||||||
|
<div className="flex gap-4">
|
||||||
|
{/* Social links could go here */}
|
||||||
</div>
|
</div>
|
||||||
</Link>
|
</div>
|
||||||
<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.
|
<div>
|
||||||
</p>
|
<h4 className="text-white font-bold mb-6">
|
||||||
<div className="flex gap-4">
|
{t("footer.navigation")}
|
||||||
{/* Social links could go here */}
|
</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>
|
</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">
|
||||||
<h4 className="text-white font-bold mb-6">Navigation</h4>
|
<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" />
|
||||||
<nav className="flex flex-col gap-4">
|
<p>
|
||||||
{navLinks.map((link) => (
|
© {new Date().getFullYear()} MB Grid Solutions & Services
|
||||||
<Link key={link.href} href={link.href} className="hover:text-accent transition-colors">
|
GmbH. <br className="md:hidden" /> {t("footer.rights")}
|
||||||
{link.label}
|
</p>
|
||||||
</Link>
|
<p className="flex items-center gap-2">
|
||||||
))}
|
<span className="relative flex h-2 w-2">
|
||||||
<Link href="/kontakt" className="hover:text-accent transition-colors">Kontakt</Link>
|
<span className="animate-ping absolute inline-flex h-full w-full rounded-full bg-accent opacity-75"></span>
|
||||||
</nav>
|
<span className="relative inline-flex rounded-full h-2 w-2 bg-accent"></span>
|
||||||
</div>
|
</span>
|
||||||
|
{t("footer.madeWith")}{" "}
|
||||||
<div>
|
<span className="text-accent">{t("footer.precision")}</span>{" "}
|
||||||
<h4 className="text-white font-bold mb-6">Rechtliches</h4>
|
{t("footer.inGermany")}
|
||||||
<nav className="flex flex-col gap-4">
|
</p>
|
||||||
<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>
|
</div>
|
||||||
</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>
|
</footer>
|
||||||
</Reveal>
|
</Reveal>
|
||||||
|
|
||||||
<div className="bg-slate-950 py-6 border-t border-white/5">
|
<div className="bg-slate-950 py-6 border-t border-white/5">
|
||||||
<div className="container-custom">
|
<div className="container-custom">
|
||||||
<p className="text-[10px] uppercase tracking-[0.2em] text-slate-600 text-center md:text-left">
|
<p className="text-[10px] uppercase tracking-[0.2em] text-slate-600 text-center md:text-left">
|
||||||
Website developed by <a href="https://mintel.me" target="_blank" rel="noopener noreferrer" className="text-slate-500 hover:text-accent transition-colors duration-300">mintel.me</a>
|
Website entwickelt von{" "}
|
||||||
|
<a
|
||||||
|
href="https://mintel.me"
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
className="text-slate-500 hover:text-accent transition-colors duration-300"
|
||||||
|
>
|
||||||
|
mintel.me
|
||||||
|
</a>
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,26 +1,26 @@
|
|||||||
'use client';
|
"use client";
|
||||||
|
|
||||||
import React from 'react';
|
import React from "react";
|
||||||
import { m, LazyMotion, domAnimation } from 'framer-motion';
|
import { m } from "framer-motion";
|
||||||
|
|
||||||
interface RevealProps {
|
interface RevealProps {
|
||||||
children: React.ReactNode;
|
children: React.ReactNode;
|
||||||
className?: string;
|
className?: string;
|
||||||
delay?: number;
|
delay?: number;
|
||||||
direction?: 'up' | 'down' | 'left' | 'right';
|
direction?: "up" | "down" | "left" | "right";
|
||||||
fullWidth?: boolean;
|
fullWidth?: boolean;
|
||||||
viewportMargin?: string;
|
viewportMargin?: string;
|
||||||
trigger?: 'inView' | 'mount';
|
trigger?: "inView" | "mount";
|
||||||
}
|
}
|
||||||
|
|
||||||
export const Reveal = ({
|
export const Reveal = ({
|
||||||
children,
|
children,
|
||||||
className = '',
|
className = "",
|
||||||
delay = 0,
|
delay = 0,
|
||||||
direction = 'up',
|
direction = "up",
|
||||||
fullWidth = false,
|
fullWidth = false,
|
||||||
viewportMargin = "-50px",
|
viewportMargin = "-50px",
|
||||||
trigger = 'inView'
|
trigger = "inView",
|
||||||
}: RevealProps) => {
|
}: RevealProps) => {
|
||||||
const directions = {
|
const directions = {
|
||||||
up: { y: 30 },
|
up: { y: 30 },
|
||||||
@@ -30,35 +30,45 @@ export const Reveal = ({
|
|||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<LazyMotion features={domAnimation}>
|
|
||||||
<m.div
|
<m.div
|
||||||
initial={{
|
initial={{
|
||||||
opacity: 0,
|
opacity: 0,
|
||||||
...directions[direction]
|
...directions[direction],
|
||||||
}}
|
}}
|
||||||
animate={trigger === 'mount' ? {
|
animate={
|
||||||
opacity: 1,
|
trigger === "mount"
|
||||||
x: 0,
|
? {
|
||||||
y: 0
|
opacity: 1,
|
||||||
} : undefined}
|
x: 0,
|
||||||
whileInView={trigger === 'inView' ? {
|
y: 0,
|
||||||
opacity: 1,
|
}
|
||||||
x: 0,
|
: undefined
|
||||||
y: 0
|
}
|
||||||
} : undefined}
|
whileInView={
|
||||||
viewport={trigger === 'inView' ? { once: true, margin: viewportMargin } : undefined}
|
trigger === "inView"
|
||||||
|
? {
|
||||||
|
opacity: 1,
|
||||||
|
x: 0,
|
||||||
|
y: 0,
|
||||||
|
}
|
||||||
|
: undefined
|
||||||
|
}
|
||||||
|
viewport={
|
||||||
|
trigger === "inView"
|
||||||
|
? { once: true, margin: viewportMargin }
|
||||||
|
: undefined
|
||||||
|
}
|
||||||
transition={{
|
transition={{
|
||||||
type: "spring",
|
type: "spring",
|
||||||
stiffness: 50,
|
stiffness: 50,
|
||||||
damping: 20,
|
damping: 20,
|
||||||
mass: 1,
|
mass: 1,
|
||||||
delay: delay
|
delay: delay,
|
||||||
}}
|
}}
|
||||||
className={`${fullWidth ? 'w-full' : ''} ${className} motion-fix`}
|
className={`${fullWidth ? "w-full" : ""} ${className} motion-fix will-change-[transform,opacity]`}
|
||||||
>
|
>
|
||||||
{children}
|
{children}
|
||||||
</m.div>
|
</m.div>
|
||||||
</LazyMotion>
|
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -70,11 +80,10 @@ interface StaggerProps {
|
|||||||
|
|
||||||
export const Stagger = ({
|
export const Stagger = ({
|
||||||
children,
|
children,
|
||||||
className = '',
|
className = "",
|
||||||
staggerDelay = 0.1
|
staggerDelay = 0.1,
|
||||||
}: StaggerProps) => {
|
}: StaggerProps) => {
|
||||||
return (
|
return (
|
||||||
<LazyMotion features={domAnimation}>
|
|
||||||
<m.div
|
<m.div
|
||||||
initial="initial"
|
initial="initial"
|
||||||
whileInView="animate"
|
whileInView="animate"
|
||||||
@@ -90,6 +99,5 @@ export const Stagger = ({
|
|||||||
>
|
>
|
||||||
{children}
|
{children}
|
||||||
</m.div>
|
</m.div>
|
||||||
</LazyMotion>
|
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
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>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -1,53 +0,0 @@
|
|||||||
'use client';
|
|
||||||
|
|
||||||
import React, { useEffect, useState } from 'react';
|
|
||||||
import { m, LazyMotion, domAnimation } from 'framer-motion';
|
|
||||||
|
|
||||||
export const TileGrid = () => {
|
|
||||||
const [mounted, setMounted] = useState(false);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
setMounted(true);
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
if (!mounted) return null;
|
|
||||||
|
|
||||||
const rows = 15;
|
|
||||||
const cols = 20;
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="absolute inset-0 pointer-events-none overflow-hidden z-[1]">
|
|
||||||
<LazyMotion features={domAnimation}>
|
|
||||||
<div className="flex flex-col gap-3 min-w-[120%] min-h-[120%] -left-[10%] -top-[10%] absolute">
|
|
||||||
{[...Array(rows)].map((_, rowIndex) => (
|
|
||||||
<div
|
|
||||||
key={rowIndex}
|
|
||||||
className="flex gap-3 justify-center"
|
|
||||||
style={{
|
|
||||||
transform: rowIndex % 2 === 0 ? 'translateX(0)' : 'translateX(80px)',
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{[...Array(cols)].map((_, colIndex) => (
|
|
||||||
<m.div
|
|
||||||
key={`${rowIndex}-${colIndex}`}
|
|
||||||
initial={{ opacity: 0.05 }}
|
|
||||||
animate={{
|
|
||||||
opacity: [0.05, Math.random() > 0.9 ? 0.25 : 0.05, 0.05],
|
|
||||||
scale: [1, Math.random() > 0.9 ? 1.05 : 1, 1]
|
|
||||||
}}
|
|
||||||
transition={{
|
|
||||||
duration: 5 + Math.random() * 5,
|
|
||||||
repeat: Infinity,
|
|
||||||
delay: Math.random() * 20,
|
|
||||||
ease: "easeInOut"
|
|
||||||
}}
|
|
||||||
className="w-24 h-24 md:w-40 md:h-40 bg-white/10 backdrop-blur-[2px] rounded-2xl md:rounded-3xl border border-white/20 shadow-[0_8px_32px_0_rgba(31,38,135,0.07)] shrink-0"
|
|
||||||
/>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</LazyMotion>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
48
components/analytics/AnalyticsProvider.tsx
Normal file
48
components/analytics/AnalyticsProvider.tsx
Normal file
@@ -0,0 +1,48 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useEffect } from "react";
|
||||||
|
import { usePathname, useSearchParams } from "next/navigation";
|
||||||
|
import { getAppServices } from "@/lib/services/create-services";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* AnalyticsProvider Component
|
||||||
|
*
|
||||||
|
* Automatically tracks pageviews on client-side route changes.
|
||||||
|
* This component should be placed inside your layout to handle navigation events.
|
||||||
|
*
|
||||||
|
* @param {Object} props - Component props
|
||||||
|
* @param {string} [props.websiteId] - The Umami website ID (passed from server config)
|
||||||
|
*
|
||||||
|
* @example
|
||||||
|
* ```tsx
|
||||||
|
* // In your layout.tsx
|
||||||
|
* const { websiteId } = config.analytics.umami;
|
||||||
|
* <AnalyticsProvider websiteId={websiteId} />
|
||||||
|
* ```
|
||||||
|
*/
|
||||||
|
export default function AnalyticsProvider({
|
||||||
|
websiteId,
|
||||||
|
}: {
|
||||||
|
websiteId?: string;
|
||||||
|
}) {
|
||||||
|
const pathname = usePathname();
|
||||||
|
const searchParams = useSearchParams();
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!pathname) return;
|
||||||
|
|
||||||
|
const services = getAppServices();
|
||||||
|
const url = `${pathname}${searchParams?.size ? `?${searchParams.toString()}` : ""}`;
|
||||||
|
|
||||||
|
// Track pageview with the full URL
|
||||||
|
services.analytics.trackPageview(url);
|
||||||
|
|
||||||
|
if (process.env.NODE_ENV === "development") {
|
||||||
|
console.log("[Umami] Tracked pageview:", url);
|
||||||
|
}
|
||||||
|
}, [pathname, searchParams]);
|
||||||
|
|
||||||
|
if (!websiteId) return null;
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
102
components/sections/CTASection.tsx
Normal file
102
components/sections/CTASection.tsx
Normal file
@@ -0,0 +1,102 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import React from "react";
|
||||||
|
import { m } from "framer-motion";
|
||||||
|
import { Reveal } from "../Reveal";
|
||||||
|
import { Counter } from "../Counter";
|
||||||
|
import { TechBackground } from "../TechBackground";
|
||||||
|
import { Button } from "../Button";
|
||||||
|
import { useTranslations } from "next-intl";
|
||||||
|
|
||||||
|
export const CTASection = () => {
|
||||||
|
const t = useTranslations("Index");
|
||||||
|
|
||||||
|
return (
|
||||||
|
<section className="bg-white relative overflow-hidden">
|
||||||
|
<TechBackground />
|
||||||
|
{/* Decorative Background Elements */}
|
||||||
|
<div className="absolute top-0 left-0 w-64 h-64 bg-accent/5 rounded-full blur-3xl -translate-x-1/2 -translate-y-1/2" />
|
||||||
|
<div className="absolute bottom-0 right-0 w-96 h-96 bg-primary/5 rounded-full blur-3xl translate-x-1/2 translate-y-1/2" />
|
||||||
|
|
||||||
|
<div className="container-custom relative z-10">
|
||||||
|
<Counter value={5} className="section-number" />
|
||||||
|
<Reveal>
|
||||||
|
<div className="relative rounded-3xl md:rounded-[2.5rem] bg-primary p-8 md:p-24 overflow-hidden group">
|
||||||
|
{/* Corner Accents */}
|
||||||
|
<div className="tech-corner top-8 left-8 border-t-2 border-l-2" />
|
||||||
|
<div className="tech-corner top-8 right-8 border-t-2 border-r-2" />
|
||||||
|
<div className="tech-corner bottom-8 left-8 border-b-2 border-l-2" />
|
||||||
|
<div className="tech-corner bottom-8 right-8 border-b-2 border-r-2" />
|
||||||
|
|
||||||
|
<div className="absolute top-0 right-0 w-1/2 h-full opacity-10 pointer-events-none">
|
||||||
|
<svg
|
||||||
|
viewBox="0 0 400 400"
|
||||||
|
fill="none"
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
>
|
||||||
|
<m.circle
|
||||||
|
animate={{ r: [400, 410, 400], opacity: [0.1, 0.2, 0.1] }}
|
||||||
|
transition={{
|
||||||
|
duration: 5,
|
||||||
|
repeat: Infinity,
|
||||||
|
ease: "easeInOut",
|
||||||
|
}}
|
||||||
|
cx="400"
|
||||||
|
cy="0"
|
||||||
|
r="400"
|
||||||
|
stroke="white"
|
||||||
|
strokeWidth="2"
|
||||||
|
/>
|
||||||
|
<m.circle
|
||||||
|
animate={{ r: [300, 310, 300], opacity: [0.1, 0.2, 0.1] }}
|
||||||
|
transition={{
|
||||||
|
duration: 4,
|
||||||
|
repeat: Infinity,
|
||||||
|
ease: "easeInOut",
|
||||||
|
delay: 0.5,
|
||||||
|
}}
|
||||||
|
cx="400"
|
||||||
|
cy="0"
|
||||||
|
r="300"
|
||||||
|
stroke="white"
|
||||||
|
strokeWidth="2"
|
||||||
|
/>
|
||||||
|
<m.circle
|
||||||
|
animate={{ r: [200, 210, 200], opacity: [0.1, 0.2, 0.1] }}
|
||||||
|
transition={{
|
||||||
|
duration: 3,
|
||||||
|
repeat: Infinity,
|
||||||
|
ease: "easeInOut",
|
||||||
|
delay: 1,
|
||||||
|
}}
|
||||||
|
cx="400"
|
||||||
|
cy="0"
|
||||||
|
r="200"
|
||||||
|
stroke="white"
|
||||||
|
strokeWidth="2"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="relative z-10">
|
||||||
|
<h2 className="text-3xl md:text-6xl font-bold text-white mb-6 md:mb-8 leading-tight">
|
||||||
|
{t("cta.title")}
|
||||||
|
</h2>
|
||||||
|
<p className="text-slate-300 text-lg md:text-xl mb-8 md:mb-12 leading-relaxed">
|
||||||
|
{t("cta.subtitle")}
|
||||||
|
</p>
|
||||||
|
<Button
|
||||||
|
href="/kontakt"
|
||||||
|
variant="accent"
|
||||||
|
showArrow
|
||||||
|
className="w-full sm:w-auto !px-10 !py-5 text-lg"
|
||||||
|
>
|
||||||
|
{t("cta.button")}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Reveal>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
};
|
||||||
64
components/sections/ExpertiseSection.tsx
Normal file
64
components/sections/ExpertiseSection.tsx
Normal file
@@ -0,0 +1,64 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import React from "react";
|
||||||
|
import Image from "next/image";
|
||||||
|
import { CheckCircle2 } from "lucide-react";
|
||||||
|
import { Reveal } from "../Reveal";
|
||||||
|
import { Counter } from "../Counter";
|
||||||
|
import { TechBackground } from "../TechBackground";
|
||||||
|
import { useTranslations } from "next-intl";
|
||||||
|
|
||||||
|
export const ExpertiseSection = () => {
|
||||||
|
const t = useTranslations("Index");
|
||||||
|
|
||||||
|
return (
|
||||||
|
<section className="bg-white relative overflow-hidden">
|
||||||
|
<TechBackground />
|
||||||
|
<div className="container-custom relative z-10">
|
||||||
|
<Counter value={3} className="section-number" />
|
||||||
|
<div className="grid grid-cols-1 lg:grid-cols-2 items-center gap-16 md:gap-24">
|
||||||
|
<Reveal direction="right">
|
||||||
|
<div className="relative overflow-hidden rounded-2xl shadow-lg group">
|
||||||
|
<div className="absolute inset-0 bg-accent/10 opacity-0 group-hover:opacity-100 transition-opacity duration-500 z-10 pointer-events-none" />
|
||||||
|
<Image
|
||||||
|
src="/media/cables/hs-kabel.png"
|
||||||
|
alt="Technische Beratung"
|
||||||
|
width={800}
|
||||||
|
height={600}
|
||||||
|
className="w-full h-[400px] md:h-[500px] object-cover hover:scale-105 transition-transform duration-700"
|
||||||
|
/>
|
||||||
|
<div className="tech-corner top-4 left-4 border-t-2 border-l-2 z-20" />
|
||||||
|
<div className="tech-corner bottom-4 right-4 border-b-2 border-r-2 z-20" />
|
||||||
|
</div>
|
||||||
|
</Reveal>
|
||||||
|
<div>
|
||||||
|
<Reveal>
|
||||||
|
<span className="text-accent font-bold uppercase tracking-widest text-sm mb-4 block">
|
||||||
|
{t("expertise.tag")}
|
||||||
|
</span>
|
||||||
|
<h2 className="text-3xl md:text-5xl font-bold text-primary mb-6 md:mb-8">
|
||||||
|
{t("expertise.title")}
|
||||||
|
</h2>
|
||||||
|
<p className="text-slate-600 text-base md:text-xl mb-8 md:mb-12">
|
||||||
|
{t("expertise.description")}
|
||||||
|
</p>
|
||||||
|
</Reveal>
|
||||||
|
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
|
||||||
|
{t.raw("expertise.groups").map((item: string, i: number) => (
|
||||||
|
<Reveal key={i} delay={i * 0.05}>
|
||||||
|
<div className="flex items-center gap-4 p-4 bg-slate-50 rounded-xl border border-slate-100 hover:border-accent/30 transition-colors group relative overflow-hidden">
|
||||||
|
<div className="absolute top-0 left-0 w-1 h-full bg-accent/0 group-hover:bg-accent/100 transition-all duration-300" />
|
||||||
|
<div className="w-8 h-8 rounded-full bg-white flex items-center justify-center shadow-sm group-hover:bg-accent group-hover:text-white transition-colors">
|
||||||
|
<CheckCircle2 size={16} />
|
||||||
|
</div>
|
||||||
|
<span className="text-primary font-semibold">{item}</span>
|
||||||
|
</div>
|
||||||
|
</Reveal>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
};
|
||||||
80
components/sections/PortfolioSection.tsx
Normal file
80
components/sections/PortfolioSection.tsx
Normal file
@@ -0,0 +1,80 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import React from "react";
|
||||||
|
import Link from "next/link";
|
||||||
|
import { ChevronRight, Zap, Shield, BarChart3 } from "lucide-react";
|
||||||
|
import { Reveal } from "../Reveal";
|
||||||
|
import { Counter } from "../Counter";
|
||||||
|
import { TechBackground } from "../TechBackground";
|
||||||
|
import { useTranslations } from "next-intl";
|
||||||
|
|
||||||
|
export const PortfolioSection = () => {
|
||||||
|
const t = useTranslations("Index");
|
||||||
|
|
||||||
|
return (
|
||||||
|
<section className="bg-slate-950 text-accent relative overflow-hidden">
|
||||||
|
<TechBackground />
|
||||||
|
<div className="container-custom relative z-10">
|
||||||
|
<Counter value={2} className="section-number !text-white/5" />
|
||||||
|
<Reveal className="flex flex-col md:flex-row md:items-end justify-between gap-8 mb-16">
|
||||||
|
<div>
|
||||||
|
<span className="text-accent font-bold uppercase tracking-widest text-sm mb-4 block">
|
||||||
|
{t("portfolio.tag")}
|
||||||
|
</span>
|
||||||
|
<h2 className="text-3xl md:text-5xl font-bold text-white mb-6">
|
||||||
|
{t("portfolio.title")}
|
||||||
|
</h2>
|
||||||
|
<p className="text-slate-400 text-base md:text-xl">
|
||||||
|
{t("portfolio.description")}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<Link
|
||||||
|
href="/ueber-uns"
|
||||||
|
className="text-accent font-bold flex items-center gap-2 hover:text-white transition-colors group"
|
||||||
|
>
|
||||||
|
{t("portfolio.link")}{" "}
|
||||||
|
<ChevronRight
|
||||||
|
className="transition-transform group-hover:translate-x-1"
|
||||||
|
size={20}
|
||||||
|
/>
|
||||||
|
</Link>
|
||||||
|
</Reveal>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-3 gap-8">
|
||||||
|
{[
|
||||||
|
{
|
||||||
|
icon: <Zap size={32} />,
|
||||||
|
title: t("portfolio.items.beratung.title"),
|
||||||
|
desc: t("portfolio.items.beratung.desc"),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
icon: <Shield size={32} />,
|
||||||
|
title: t("portfolio.items.begleitung.title"),
|
||||||
|
desc: t("portfolio.items.begleitung.desc"),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
icon: <BarChart3 size={32} />,
|
||||||
|
title: t("portfolio.items.beschaffung.title"),
|
||||||
|
desc: t("portfolio.items.beschaffung.desc"),
|
||||||
|
},
|
||||||
|
].map((item, i) => (
|
||||||
|
<Reveal key={i} delay={i * 0.1}>
|
||||||
|
<div className="bg-white/5 p-8 rounded-2xl border border-white/10 group hover:-translate-y-2 transition-[box-shadow,transform] duration-300 h-full relative overflow-hidden">
|
||||||
|
<div className="absolute top-0 right-0 w-16 h-16 bg-accent/5 -mr-8 -mt-8 rounded-full group-hover:bg-accent/10 transition-colors" />
|
||||||
|
<div className="w-16 h-16 rounded-2xl bg-accent/10 text-accent flex items-center justify-center mb-8 group-hover:bg-accent group-hover:text-white transition-colors relative z-10">
|
||||||
|
{item.icon}
|
||||||
|
</div>
|
||||||
|
<h3 className="text-2xl font-bold text-white mb-4 relative z-10">
|
||||||
|
{item.title}
|
||||||
|
</h3>
|
||||||
|
<p className="text-slate-400 leading-relaxed relative z-10">
|
||||||
|
{item.desc}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</Reveal>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
};
|
||||||
76
components/sections/TechnicalSpecsSection.tsx
Normal file
76
components/sections/TechnicalSpecsSection.tsx
Normal file
@@ -0,0 +1,76 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import React from "react";
|
||||||
|
import Image from "next/image";
|
||||||
|
import { Reveal } from "../Reveal";
|
||||||
|
import { Counter } from "../Counter";
|
||||||
|
import { TechBackground } from "../TechBackground";
|
||||||
|
import { useTranslations } from "next-intl";
|
||||||
|
|
||||||
|
export const TechnicalSpecsSection = () => {
|
||||||
|
const t = useTranslations("Index");
|
||||||
|
|
||||||
|
return (
|
||||||
|
<section className="relative py-24 md:py-32 text-white overflow-hidden bg-slate-900">
|
||||||
|
<div className="absolute inset-0 opacity-20">
|
||||||
|
<Image
|
||||||
|
src="/media/drums/about-hero.jpg"
|
||||||
|
alt="Background"
|
||||||
|
fill
|
||||||
|
className="object-cover"
|
||||||
|
/>
|
||||||
|
<div className="absolute inset-0 bg-gradient-to-b from-slate-900 via-slate-900/80 to-slate-900" />
|
||||||
|
</div>
|
||||||
|
<TechBackground />
|
||||||
|
|
||||||
|
<div className="container-custom relative z-10">
|
||||||
|
<Counter value={4} className="section-number !text-white/5" />
|
||||||
|
{/* Data Stream Effect */}
|
||||||
|
<div className="absolute -top-10 right-0 w-px h-64 bg-gradient-to-b from-transparent via-accent/50 to-transparent animate-pulse" />
|
||||||
|
<div className="absolute -bottom-10 left-10 w-px h-64 bg-gradient-to-b from-transparent via-accent/30 to-transparent animate-pulse delay-700" />
|
||||||
|
|
||||||
|
<Reveal className="mb-20">
|
||||||
|
<span className="text-accent font-bold uppercase tracking-widest text-sm mb-4 block">
|
||||||
|
{t("specs.tag")}
|
||||||
|
</span>
|
||||||
|
<h2 className="text-3xl md:text-5xl font-bold text-white mb-6">
|
||||||
|
{t("specs.title")}
|
||||||
|
</h2>
|
||||||
|
</Reveal>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-3 gap-6 md:gap-8">
|
||||||
|
{[
|
||||||
|
{
|
||||||
|
label: t("specs.items.kabel.label"),
|
||||||
|
value: t("specs.items.kabel.value"),
|
||||||
|
desc: t("specs.items.kabel.desc"),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: t("specs.items.spannung.label"),
|
||||||
|
value: t("specs.items.spannung.value"),
|
||||||
|
desc: t("specs.items.spannung.desc"),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: t("specs.items.technologie.label"),
|
||||||
|
value: t("specs.items.technologie.value"),
|
||||||
|
desc: t("specs.items.technologie.desc"),
|
||||||
|
},
|
||||||
|
].map((item, i) => (
|
||||||
|
<Reveal key={i} delay={i * 0.1}>
|
||||||
|
<div className="p-10 rounded-3xl bg-white/5 border border-white/10 backdrop-blur-sm hover:bg-white/10 transition-colors h-full relative group overflow-hidden">
|
||||||
|
<div className="absolute top-0 left-0 w-full h-1 bg-accent/0 group-hover:bg-accent/50 transition-all duration-500" />
|
||||||
|
<h4 className="text-accent font-bold text-xs uppercase tracking-widest mb-6">
|
||||||
|
{item.label}
|
||||||
|
</h4>
|
||||||
|
<p className="text-2xl font-bold text-white mb-4 leading-tight">
|
||||||
|
{item.value}
|
||||||
|
</p>
|
||||||
|
<p className="text-slate-400 leading-relaxed">{item.desc}</p>
|
||||||
|
</div>
|
||||||
|
</Reveal>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
};
|
||||||
0
directus/extensions/.gitkeep
Normal file
0
directus/extensions/.gitkeep
Normal file
0
directus/uploads/.gitkeep
Normal file
0
directus/uploads/.gitkeep
Normal file
27
docker-compose.override.yml
Normal file
27
docker-compose.override.yml
Normal file
@@ -0,0 +1,27 @@
|
|||||||
|
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
|
||||||
|
DATABASE_URI: postgresql://directus:directus@mb-grid-db:5432/directus
|
||||||
|
# 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.
|
||||||
@@ -1,24 +1,105 @@
|
|||||||
services:
|
services:
|
||||||
app:
|
mb-grid-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
|
restart: always
|
||||||
expose:
|
networks:
|
||||||
- "3000"
|
- default
|
||||||
|
- infra
|
||||||
|
env_file:
|
||||||
|
- ${ENV_FILE:-.env}
|
||||||
labels:
|
labels:
|
||||||
- "traefik.enable=true"
|
- "traefik.enable=true"
|
||||||
- "traefik.http.routers.mb-grid-solutions.rule=(Host(`mb-grid-solutions.com`) || Host(`www.mb-grid-solutions.com`))"
|
- "traefik.http.routers.${PROJECT_NAME:-mb-grid}.rule=Host(`${TRAEFIK_HOST:-mb-grid-solutions.localhost}`)"
|
||||||
- "traefik.http.routers.mb-grid-solutions.entrypoints=websecure"
|
- "traefik.http.routers.${PROJECT_NAME:-mb-grid}.entrypoints=websecure"
|
||||||
- "traefik.http.routers.mb-grid-solutions.tls.certresolver=le"
|
- "traefik.http.routers.${PROJECT_NAME:-mb-grid}.tls.certresolver=le"
|
||||||
- "traefik.http.services.mb-grid-solutions.loadbalancer.server.port=3000"
|
- "traefik.http.routers.${PROJECT_NAME:-mb-grid}.tls=true"
|
||||||
- "traefik.http.routers.mb-grid-solutions.middlewares=auth@docker"
|
- "traefik.http.routers.${PROJECT_NAME:-mb-grid}.priority=1000"
|
||||||
|
- "traefik.http.routers.${PROJECT_NAME:-mb-grid}.service=${PROJECT_NAME:-mb-grid}-app-svc"
|
||||||
|
- "traefik.http.services.${PROJECT_NAME:-mb-grid}-app-svc.loadbalancer.server.port=3000"
|
||||||
|
- "traefik.http.routers.${PROJECT_NAME:-mb-grid}.middlewares=${TRAEFIK_MIDDLEWARES:-mb-grid-auth,mb-grid-forward,compress}"
|
||||||
|
- "traefik.docker.network=infra"
|
||||||
|
|
||||||
|
# Public Router – paths that bypass Gatekeeper auth
|
||||||
|
- "traefik.http.routers.${PROJECT_NAME:-mb-grid}-public.rule=Host(`${TRAEFIK_HOST:-mb-grid-solutions.localhost}`) && PathRegexp(`^/([a-z]{2}/)?(health|login|gatekeeper|uploads|media|robots\\.txt|manifest\\.webmanifest|sitemap(-[0-9]+)?\\.xml|(.*/)?api/og(/.*)?|(.*/)?opengraph-image.*)`)"
|
||||||
|
- "traefik.http.routers.${PROJECT_NAME:-mb-grid}-public.entrypoints=websecure"
|
||||||
|
- "traefik.http.routers.${PROJECT_NAME:-mb-grid}-public.tls.certresolver=le"
|
||||||
|
- "traefik.http.routers.${PROJECT_NAME:-mb-grid}-public.tls=true"
|
||||||
|
- "traefik.http.routers.${PROJECT_NAME:-mb-grid}-public.service=${PROJECT_NAME:-mb-grid}-app-svc"
|
||||||
|
- "traefik.http.routers.${PROJECT_NAME:-mb-grid}-public.priority=2000"
|
||||||
|
|
||||||
|
# Forwarded Headers (Protocol Normalization)
|
||||||
|
- "traefik.http.middlewares.${PROJECT_NAME:-mb-grid}-forward.headers.customrequestheaders.X-Forwarded-Proto=https"
|
||||||
|
- "traefik.http.middlewares.${PROJECT_NAME:-mb-grid}-forward.headers.customrequestheaders.X-Forwarded-Ssl=on"
|
||||||
|
- "traefik.http.middlewares.compress.compress=true"
|
||||||
healthcheck:
|
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))"]
|
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
|
interval: 10s
|
||||||
timeout: 2s
|
timeout: 5s
|
||||||
retries: 10
|
retries: 5
|
||||||
|
start_period: 30s
|
||||||
|
|
||||||
|
mb-grid-gatekeeper:
|
||||||
|
image: registry.infra.mintel.me/mintel/gatekeeper:testing
|
||||||
|
container_name: ${PROJECT_NAME:-mb-grid-solutions}-gatekeeper
|
||||||
|
profiles: [ "gatekeeper" ]
|
||||||
|
restart: unless-stopped
|
||||||
networks:
|
networks:
|
||||||
- infra
|
infra:
|
||||||
|
aliases:
|
||||||
|
- mb-grid-gatekeeper
|
||||||
|
env_file:
|
||||||
|
- ${ENV_FILE:-.env}
|
||||||
|
environment:
|
||||||
|
PORT: ${PORT:-3000}
|
||||||
|
PROJECT_NAME: ${PROJECT_NAME:-MB Grid Solutions}
|
||||||
|
PROJECT_COLOR: ${PROJECT_COLOR:-#82ed20}
|
||||||
|
COOKIE_DOMAIN: ${COOKIE_DOMAIN}
|
||||||
|
AUTH_COOKIE_NAME: ${AUTH_COOKIE_NAME}
|
||||||
|
GATEKEEPER_PASSWORD: ${GATEKEEPER_PASSWORD}
|
||||||
|
NEXT_PUBLIC_BASE_URL: ${GATEKEEPER_ORIGIN}
|
||||||
|
healthcheck:
|
||||||
|
test: [ "CMD", "node", "-e", "fetch('http://127.0.0.1:3000/gatekeeper/login').then(r => r.ok ? process.exit(0) : process.exit(1)).catch(() => process.exit(1))" ]
|
||||||
|
interval: 10s
|
||||||
|
timeout: 5s
|
||||||
|
retries: 5
|
||||||
|
labels:
|
||||||
|
- "traefik.enable=true"
|
||||||
|
- "traefik.http.services.mb-grid-gatekeeper-svc.loadbalancer.server.port=3000"
|
||||||
|
|
||||||
|
# Gatekeeper Verification Middleware
|
||||||
|
- "traefik.http.middlewares.${PROJECT_NAME:-mb-grid}-auth.forwardauth.address=http://${PROJECT_NAME:-mb-grid}-gatekeeper:3000/gatekeeper/api/verify"
|
||||||
|
- "traefik.http.middlewares.${PROJECT_NAME:-mb-grid}-auth.forwardauth.trustForwardHeader=true"
|
||||||
|
- "traefik.http.middlewares.${PROJECT_NAME:-mb-grid}-auth.forwardauth.authRequestHeaders=X-Forwarded-Host,X-Forwarded-Proto,X-Forwarded-For,Cookie"
|
||||||
|
- "traefik.http.middlewares.${PROJECT_NAME:-mb-grid}-auth.forwardauth.authResponseHeaders=X-Auth-User"
|
||||||
|
|
||||||
|
# Gatekeeper Public Router (Login/Auth UI)
|
||||||
|
- "traefik.http.routers.${PROJECT_NAME:-mb-grid}-gatekeeper.rule=(Host(`${TRAEFIK_HOST:-mb-grid-solutions.localhost}`) && PathPrefix(`/gatekeeper`))"
|
||||||
|
- "traefik.http.routers.${PROJECT_NAME:-mb-grid}-gatekeeper.entrypoints=websecure"
|
||||||
|
- "traefik.http.routers.${PROJECT_NAME:-mb-grid}-gatekeeper.tls.certresolver=le"
|
||||||
|
- "traefik.http.routers.${PROJECT_NAME:-mb-grid}-gatekeeper.tls=true"
|
||||||
|
- "traefik.http.routers.${PROJECT_NAME:-mb-grid}-gatekeeper.priority=2000"
|
||||||
|
- "traefik.http.routers.${PROJECT_NAME:-mb-grid}-gatekeeper.service=${PROJECT_NAME:-mb-grid}-gatekeeper-svc"
|
||||||
|
- "traefik.docker.network=infra"
|
||||||
|
|
||||||
|
mb-grid-db:
|
||||||
|
image: postgres:15-alpine
|
||||||
|
restart: always
|
||||||
|
networks:
|
||||||
|
- default
|
||||||
|
env_file:
|
||||||
|
- ${ENV_FILE:-.env}
|
||||||
|
environment:
|
||||||
|
POSTGRES_DB: ${DIRECTUS_DB_NAME:-directus}
|
||||||
|
POSTGRES_USER: ${DIRECTUS_DB_USER:-directus}
|
||||||
|
POSTGRES_PASSWORD: ${DIRECTUS_DB_PASSWORD:-directus}
|
||||||
|
volumes:
|
||||||
|
- mb-grid-db-data:/var/lib/postgresql/data
|
||||||
|
|
||||||
networks:
|
networks:
|
||||||
|
default:
|
||||||
|
name: mb-grid-solutions-internal
|
||||||
infra:
|
infra:
|
||||||
external: true
|
external: true
|
||||||
|
|
||||||
|
volumes:
|
||||||
|
mb-grid-db-data:
|
||||||
|
|||||||
@@ -20,7 +20,7 @@ CI (Woodpecker)
|
|||||||
https://ci.infra.mintel.me
|
https://ci.infra.mintel.me
|
||||||
|
|
||||||
Container Registry
|
Container Registry
|
||||||
https://registry.infra.mintel.me
|
https://git.infra.mintel.me
|
||||||
|
|
||||||
Errors (GlitchTip)
|
Errors (GlitchTip)
|
||||||
https://errors.infra.mintel.me
|
https://errors.infra.mintel.me
|
||||||
@@ -76,13 +76,13 @@ This directory contains:
|
|||||||
All production images must be built by CI and pushed to the Mintel Registry.
|
All production images must be built by CI and pushed to the Mintel Registry.
|
||||||
|
|
||||||
Registry:
|
Registry:
|
||||||
registry.infra.mintel.me
|
git.infra.mintel.me
|
||||||
|
|
||||||
Image naming:
|
Image naming:
|
||||||
registry.infra.mintel.me/ORG/APP_NAME:TAG
|
git.infra.mintel.me/mmintel/APP_NAME:TAG
|
||||||
|
|
||||||
Example:
|
Example:
|
||||||
registry.infra.mintel.me/mintel/mb-grid-solutions:latest
|
git.infra.mintel.me/mmintel/mb-grid-solutions:latest
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -204,8 +204,8 @@ steps:
|
|||||||
build:
|
build:
|
||||||
image: woodpeckerci/plugin-docker
|
image: woodpeckerci/plugin-docker
|
||||||
settings:
|
settings:
|
||||||
registry: registry.infra.mintel.me
|
registry: git.infra.mintel.me
|
||||||
repo: registry.infra.mintel.me/mintel/mb-grid-solutions
|
repo: git.infra.mintel.me/mmintel/mb-grid-solutions
|
||||||
username:
|
username:
|
||||||
from_secret: REGISTRY_USER
|
from_secret: REGISTRY_USER
|
||||||
password:
|
password:
|
||||||
|
|||||||
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,
|
||||||
|
};
|
||||||
|
});
|
||||||
176
lib/config.ts
Normal file
176
lib/config.ts
Normal file
@@ -0,0 +1,176 @@
|
|||||||
|
/**
|
||||||
|
* Centralized configuration management for the application.
|
||||||
|
* This file provides a type-safe way to access environment variables.
|
||||||
|
*/
|
||||||
|
import { envSchema, getRawEnv } from "./env";
|
||||||
|
|
||||||
|
let memoizedConfig: ReturnType<typeof createConfig> | undefined;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Creates and validates the configuration object.
|
||||||
|
* Throws if validation fails.
|
||||||
|
*/
|
||||||
|
function createConfig() {
|
||||||
|
const env = envSchema.parse(getRawEnv());
|
||||||
|
|
||||||
|
const target = env.NEXT_PUBLIC_TARGET || env.TARGET;
|
||||||
|
|
||||||
|
return {
|
||||||
|
env: env.NODE_ENV,
|
||||||
|
target,
|
||||||
|
isProduction: target === "production" || !target,
|
||||||
|
isStaging: target === "staging",
|
||||||
|
isTesting: target === "testing",
|
||||||
|
isDevelopment: target === "development",
|
||||||
|
|
||||||
|
baseUrl: env.NEXT_PUBLIC_BASE_URL,
|
||||||
|
|
||||||
|
analytics: {
|
||||||
|
umami: {
|
||||||
|
websiteId: env.NEXT_PUBLIC_UMAMI_WEBSITE_ID || env.UMAMI_WEBSITE_ID,
|
||||||
|
apiEndpoint: env.UMAMI_API_ENDPOINT,
|
||||||
|
enabled: Boolean(
|
||||||
|
env.NEXT_PUBLIC_UMAMI_WEBSITE_ID || env.UMAMI_WEBSITE_ID,
|
||||||
|
),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
|
||||||
|
errors: {
|
||||||
|
glitchtip: {
|
||||||
|
// Use SENTRY_DSN for both server and client (proxied)
|
||||||
|
dsn: env.SENTRY_DSN,
|
||||||
|
// The proxied origin used in the frontend
|
||||||
|
proxyPath: "/errors",
|
||||||
|
enabled: Boolean(env.SENTRY_DSN),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
|
||||||
|
cache: {
|
||||||
|
enabled: false,
|
||||||
|
},
|
||||||
|
|
||||||
|
logging: {
|
||||||
|
level: env.LOG_LEVEL,
|
||||||
|
},
|
||||||
|
|
||||||
|
mail: {
|
||||||
|
host: env.MAIL_HOST,
|
||||||
|
port: env.MAIL_PORT,
|
||||||
|
user: env.MAIL_USERNAME,
|
||||||
|
pass: env.MAIL_PASSWORD,
|
||||||
|
from: env.MAIL_FROM,
|
||||||
|
recipients: env.MAIL_RECIPIENTS,
|
||||||
|
},
|
||||||
|
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 notifications() {
|
||||||
|
return getConfig().notifications;
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Helper to get a masked version of the config for logging.
|
||||||
|
*/
|
||||||
|
export function getMaskedConfig() {
|
||||||
|
const c = getConfig();
|
||||||
|
const mask = (val: string | undefined) =>
|
||||||
|
val ? `***${val.slice(-4)}` : "not set";
|
||||||
|
|
||||||
|
return {
|
||||||
|
env: c.env,
|
||||||
|
baseUrl: c.baseUrl,
|
||||||
|
analytics: {
|
||||||
|
umami: {
|
||||||
|
websiteId: mask(c.analytics.umami.websiteId),
|
||||||
|
apiEndpoint: c.analytics.umami.apiEndpoint,
|
||||||
|
enabled: c.analytics.umami.enabled,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
errors: {
|
||||||
|
glitchtip: {
|
||||||
|
dsn: mask(c.errors.glitchtip.dsn),
|
||||||
|
enabled: c.errors.glitchtip.enabled,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
cache: {
|
||||||
|
enabled: c.cache.enabled,
|
||||||
|
},
|
||||||
|
logging: {
|
||||||
|
level: c.logging.level,
|
||||||
|
},
|
||||||
|
mail: {
|
||||||
|
host: c.mail.host,
|
||||||
|
port: c.mail.port,
|
||||||
|
user: mask(c.mail.user),
|
||||||
|
from: c.mail.from,
|
||||||
|
recipients: c.mail.recipients,
|
||||||
|
},
|
||||||
|
notifications: {
|
||||||
|
gotify: {
|
||||||
|
url: c.notifications.gotify.url,
|
||||||
|
token: mask(c.notifications.gotify.token),
|
||||||
|
enabled: c.notifications.gotify.enabled,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
70
lib/env.test.ts
Normal file
70
lib/env.test.ts
Normal file
@@ -0,0 +1,70 @@
|
|||||||
|
import { describe, it, expect } from "vitest";
|
||||||
|
import { envSchema } from "./env";
|
||||||
|
|
||||||
|
describe("envSchema", () => {
|
||||||
|
it("should allow missing MAIL_HOST in development", () => {
|
||||||
|
const result = envSchema.safeParse({
|
||||||
|
NEXT_PUBLIC_BASE_URL: "http://localhost:3000",
|
||||||
|
TARGET: "development",
|
||||||
|
});
|
||||||
|
expect(result.success).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should require MAIL_HOST in production", () => {
|
||||||
|
const result = envSchema.safeParse({
|
||||||
|
NEXT_PUBLIC_BASE_URL: "https://example.com",
|
||||||
|
TARGET: "production",
|
||||||
|
});
|
||||||
|
expect(result.success).toBe(false);
|
||||||
|
if (!result.success) {
|
||||||
|
expect(result.error.issues[0].message).toBe(
|
||||||
|
"MAIL_HOST is required in non-development environments",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should require MAIL_HOST in testing", () => {
|
||||||
|
const result = envSchema.safeParse({
|
||||||
|
NEXT_PUBLIC_BASE_URL: "https://testing.example.com",
|
||||||
|
TARGET: "testing",
|
||||||
|
});
|
||||||
|
expect(result.success).toBe(false);
|
||||||
|
if (!result.success) {
|
||||||
|
expect(result.error.issues[0].message).toBe(
|
||||||
|
"MAIL_HOST is required in non-development environments",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should require MAIL_HOST in staging", () => {
|
||||||
|
const result = envSchema.safeParse({
|
||||||
|
NEXT_PUBLIC_BASE_URL: "https://staging.example.com",
|
||||||
|
TARGET: "staging",
|
||||||
|
});
|
||||||
|
expect(result.success).toBe(false);
|
||||||
|
if (!result.success) {
|
||||||
|
expect(result.error.issues[0].message).toBe(
|
||||||
|
"MAIL_HOST is required in non-development environments",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should pass if MAIL_HOST is provided in production", () => {
|
||||||
|
const result = envSchema.safeParse({
|
||||||
|
NEXT_PUBLIC_BASE_URL: "https://example.com",
|
||||||
|
TARGET: "production",
|
||||||
|
MAIL_HOST: "smtp.example.com",
|
||||||
|
});
|
||||||
|
expect(result.success).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should skip MAIL_HOST requirement if SKIP_RUNTIME_ENV_VALIDATION is true", () => {
|
||||||
|
process.env.SKIP_RUNTIME_ENV_VALIDATION = "true";
|
||||||
|
const result = envSchema.safeParse({
|
||||||
|
NEXT_PUBLIC_BASE_URL: "https://example.com",
|
||||||
|
TARGET: "production",
|
||||||
|
});
|
||||||
|
expect(result.success).toBe(true);
|
||||||
|
delete process.env.SKIP_RUNTIME_ENV_VALIDATION;
|
||||||
|
});
|
||||||
|
});
|
||||||
42
lib/env.ts
Normal file
42
lib/env.ts
Normal file
@@ -0,0 +1,42 @@
|
|||||||
|
import { z } from "zod";
|
||||||
|
import {
|
||||||
|
validateMintelEnv,
|
||||||
|
mintelEnvSchema,
|
||||||
|
withMintelRefinements,
|
||||||
|
} from "@mintel/next-utils";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Environment variable schema.
|
||||||
|
* Extends the default Mintel environment schema which already includes:
|
||||||
|
* - Directus (URL, TOKEN, INTERNAL_URL, etc.)
|
||||||
|
* - Mail (HOST, PORT, etc.)
|
||||||
|
* - Gotify
|
||||||
|
* - Logging
|
||||||
|
* - Analytics
|
||||||
|
*/
|
||||||
|
const envExtension = {
|
||||||
|
// Project specific overrides or additions
|
||||||
|
AUTH_COOKIE_NAME: z.string().default("mb_gatekeeper_session"),
|
||||||
|
|
||||||
|
INFRA_DIRECTUS_URL: z.string().url().optional(),
|
||||||
|
INFRA_DIRECTUS_TOKEN: z.string().optional(),
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Full schema including Mintel base and refinements
|
||||||
|
*/
|
||||||
|
export const envSchema = withMintelRefinements(
|
||||||
|
z.object(mintelEnvSchema).extend(envExtension),
|
||||||
|
);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Validated environment object.
|
||||||
|
*/
|
||||||
|
export const env = validateMintelEnv(envExtension);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* For legacy compatibility with existing code.
|
||||||
|
*/
|
||||||
|
export function getRawEnv() {
|
||||||
|
return env;
|
||||||
|
}
|
||||||
445
lib/services/analytics/README.md
Normal file
445
lib/services/analytics/README.md
Normal file
@@ -0,0 +1,445 @@
|
|||||||
|
# Analytics Service Layer
|
||||||
|
|
||||||
|
This directory contains the service layer implementation for analytics tracking in the KLZ Cables application.
|
||||||
|
|
||||||
|
## Overview
|
||||||
|
|
||||||
|
The analytics service layer provides a clean abstraction over different analytics implementations (Umami, Google Analytics, etc.) while maintaining a consistent API.
|
||||||
|
|
||||||
|
## Architecture
|
||||||
|
|
||||||
|
```
|
||||||
|
lib/services/analytics/
|
||||||
|
├── analytics-service.ts # Interface definition
|
||||||
|
├── umami-analytics-service.ts # Umami implementation
|
||||||
|
├── noop-analytics-service.ts # No-op fallback implementation
|
||||||
|
└── README.md # This file
|
||||||
|
```
|
||||||
|
|
||||||
|
## Components
|
||||||
|
|
||||||
|
### 1. AnalyticsService Interface (`analytics-service.ts`)
|
||||||
|
|
||||||
|
Defines the contract for all analytics services:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
export interface AnalyticsService {
|
||||||
|
track(eventName: string, props?: AnalyticsEventProperties): void;
|
||||||
|
trackPageview(url?: string): void;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Key Features:**
|
||||||
|
|
||||||
|
- Type-safe event properties
|
||||||
|
- Consistent API across implementations
|
||||||
|
- Well-documented with JSDoc comments
|
||||||
|
|
||||||
|
### 2. UmamiAnalyticsService (`umami-analytics-service.ts`)
|
||||||
|
|
||||||
|
Implements the `AnalyticsService` interface for Umami analytics.
|
||||||
|
|
||||||
|
**Features:**
|
||||||
|
|
||||||
|
- Type-safe event tracking
|
||||||
|
- Automatic pageview tracking
|
||||||
|
- Browser environment detection
|
||||||
|
- Graceful error handling
|
||||||
|
- Comprehensive JSDoc documentation
|
||||||
|
|
||||||
|
**Usage:**
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
import { UmamiAnalyticsService } from "@/lib/services/analytics/umami-analytics-service";
|
||||||
|
|
||||||
|
const service = new UmamiAnalyticsService({ enabled: true });
|
||||||
|
service.track("button_click", { button_id: "cta" });
|
||||||
|
service.trackPageview("/products/123");
|
||||||
|
```
|
||||||
|
|
||||||
|
### 3. NoopAnalyticsService (`noop-analytics-service.ts`)
|
||||||
|
|
||||||
|
A no-op implementation used as a fallback when analytics are disabled.
|
||||||
|
|
||||||
|
**Features:**
|
||||||
|
|
||||||
|
- Maintains the same API as other services
|
||||||
|
- Safe to call even when analytics are disabled
|
||||||
|
- No performance impact
|
||||||
|
- Comprehensive JSDoc documentation
|
||||||
|
|
||||||
|
**Usage:**
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
import { NoopAnalyticsService } from "@/lib/services/analytics/noop-analytics-service";
|
||||||
|
|
||||||
|
const service = new NoopAnalyticsService();
|
||||||
|
service.track("button_click", { button_id: "cta" }); // Does nothing
|
||||||
|
service.trackPageview("/products/123"); // Does nothing
|
||||||
|
```
|
||||||
|
|
||||||
|
## Service Selection
|
||||||
|
|
||||||
|
The service layer automatically selects the appropriate implementation based on environment variables:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// In lib/services/create-services.ts
|
||||||
|
const umamiEnabled = Boolean(process.env.NEXT_PUBLIC_UMAMI_WEBSITE_ID);
|
||||||
|
|
||||||
|
const analytics = umamiEnabled
|
||||||
|
? new UmamiAnalyticsService({ enabled: true })
|
||||||
|
: new NoopAnalyticsService();
|
||||||
|
```
|
||||||
|
|
||||||
|
## Environment Variables
|
||||||
|
|
||||||
|
### Required for Umami
|
||||||
|
|
||||||
|
```bash
|
||||||
|
NEXT_PUBLIC_UMAMI_WEBSITE_ID=59a7db94-0100-4c7e-98ef-99f45b17f9c3
|
||||||
|
```
|
||||||
|
|
||||||
|
### Optional (defaults provided)
|
||||||
|
|
||||||
|
```bash
|
||||||
|
NEXT_PUBLIC_UMAMI_SCRIPT_URL=https://analytics.infra.mintel.me/script.js
|
||||||
|
```
|
||||||
|
|
||||||
|
## API Reference
|
||||||
|
|
||||||
|
### AnalyticsService Interface
|
||||||
|
|
||||||
|
#### `track(eventName: string, props?: AnalyticsEventProperties): void`
|
||||||
|
|
||||||
|
Track a custom event with optional properties.
|
||||||
|
|
||||||
|
**Parameters:**
|
||||||
|
|
||||||
|
- `eventName` - The name of the event to track
|
||||||
|
- `props` - Optional event properties (metadata)
|
||||||
|
|
||||||
|
**Example:**
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
service.track("product_add_to_cart", {
|
||||||
|
product_id: "123",
|
||||||
|
product_name: "Cable",
|
||||||
|
price: 99.99,
|
||||||
|
quantity: 1,
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
#### `trackPageview(url?: string): void`
|
||||||
|
|
||||||
|
Track a pageview.
|
||||||
|
|
||||||
|
**Parameters:**
|
||||||
|
|
||||||
|
- `url` - The URL to track (defaults to current location)
|
||||||
|
|
||||||
|
**Example:**
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// Track current page
|
||||||
|
service.trackPageview();
|
||||||
|
|
||||||
|
// Track custom URL
|
||||||
|
service.trackPageview("/products/123?category=cables");
|
||||||
|
```
|
||||||
|
|
||||||
|
### UmamiAnalyticsService
|
||||||
|
|
||||||
|
#### Constructor
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
new UmamiAnalyticsService(options: UmamiAnalyticsServiceOptions)
|
||||||
|
```
|
||||||
|
|
||||||
|
**Options:**
|
||||||
|
|
||||||
|
- `enabled: boolean` - Whether analytics are enabled
|
||||||
|
|
||||||
|
**Example:**
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
const service = new UmamiAnalyticsService({ enabled: true });
|
||||||
|
```
|
||||||
|
|
||||||
|
### NoopAnalyticsService
|
||||||
|
|
||||||
|
#### Constructor
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
new NoopAnalyticsService();
|
||||||
|
```
|
||||||
|
|
||||||
|
**Example:**
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
const service = new NoopAnalyticsService();
|
||||||
|
```
|
||||||
|
|
||||||
|
## Type Definitions
|
||||||
|
|
||||||
|
### AnalyticsEventProperties
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
type AnalyticsEventProperties = Record<
|
||||||
|
string,
|
||||||
|
string | number | boolean | null | undefined
|
||||||
|
>;
|
||||||
|
```
|
||||||
|
|
||||||
|
**Example:**
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
const properties: AnalyticsEventProperties = {
|
||||||
|
product_id: "123",
|
||||||
|
product_name: "Cable",
|
||||||
|
price: 99.99,
|
||||||
|
quantity: 1,
|
||||||
|
in_stock: true,
|
||||||
|
discount: null,
|
||||||
|
};
|
||||||
|
```
|
||||||
|
|
||||||
|
### UmamiAnalyticsServiceOptions
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
type UmamiAnalyticsServiceOptions = {
|
||||||
|
enabled: boolean;
|
||||||
|
};
|
||||||
|
```
|
||||||
|
|
||||||
|
## Best Practices
|
||||||
|
|
||||||
|
### 1. Use the Service Layer
|
||||||
|
|
||||||
|
Always use the service layer instead of calling Umami directly:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// ✅ Good
|
||||||
|
import { getAppServices } from "@/lib/services/create-services";
|
||||||
|
|
||||||
|
const services = getAppServices();
|
||||||
|
services.analytics.track("button_click", { button_id: "cta" });
|
||||||
|
|
||||||
|
// ❌ Avoid
|
||||||
|
(window as any).umami?.track("button_click", { button_id: "cta" });
|
||||||
|
```
|
||||||
|
|
||||||
|
### 2. Check Environment
|
||||||
|
|
||||||
|
The service layer automatically handles environment detection:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// ✅ Safe - works in both server and client
|
||||||
|
const services = getAppServices();
|
||||||
|
services.analytics.track("event", { prop: "value" });
|
||||||
|
|
||||||
|
// ❌ Unsafe - may fail in server environment
|
||||||
|
if (typeof window !== "undefined") {
|
||||||
|
window.umami?.track("event", { prop: "value" });
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 3. Use Type-Safe Events
|
||||||
|
|
||||||
|
Import events from the centralized definitions:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
import { AnalyticsEvents } from "@/components/analytics/analytics-events";
|
||||||
|
|
||||||
|
// ✅ Type-safe
|
||||||
|
services.analytics.track(AnalyticsEvents.BUTTON_CLICK, {
|
||||||
|
button_id: "cta",
|
||||||
|
});
|
||||||
|
|
||||||
|
// ❌ Prone to typos
|
||||||
|
services.analytics.track("button_click", {
|
||||||
|
button_id: "cta",
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
### 4. Handle Disabled Analytics
|
||||||
|
|
||||||
|
The service layer gracefully handles disabled analytics:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// When NEXT_PUBLIC_UMAMI_WEBSITE_ID is not set:
|
||||||
|
// - NoopAnalyticsService is used
|
||||||
|
// - All calls are safe (no-op)
|
||||||
|
// - No errors are thrown
|
||||||
|
|
||||||
|
const services = getAppServices();
|
||||||
|
services.analytics.track("event", { prop: "value" }); // Safe, does nothing
|
||||||
|
```
|
||||||
|
|
||||||
|
## Testing
|
||||||
|
|
||||||
|
### Mocking for Tests
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// __tests__/analytics-mock.ts
|
||||||
|
export const mockAnalytics = {
|
||||||
|
track: jest.fn(),
|
||||||
|
trackPageview: jest.fn(),
|
||||||
|
};
|
||||||
|
|
||||||
|
jest.mock("@/lib/services/create-services", () => ({
|
||||||
|
getAppServices: () => ({
|
||||||
|
analytics: mockAnalytics,
|
||||||
|
}),
|
||||||
|
}));
|
||||||
|
|
||||||
|
// Usage in tests
|
||||||
|
import { mockAnalytics } from "./analytics-mock";
|
||||||
|
|
||||||
|
test("tracks button click", () => {
|
||||||
|
// ... test code ...
|
||||||
|
expect(mockAnalytics.track).toHaveBeenCalledWith("button_click", {
|
||||||
|
button_id: "cta",
|
||||||
|
});
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
### Development Mode
|
||||||
|
|
||||||
|
In development, the service layer logs to console:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Console output:
|
||||||
|
[Umami] Tracked event: button_click { button_id: 'cta' }
|
||||||
|
[Umami] Tracked pageview: /products/123
|
||||||
|
```
|
||||||
|
|
||||||
|
## Error Handling
|
||||||
|
|
||||||
|
The service layer includes built-in error handling:
|
||||||
|
|
||||||
|
1. **Environment Detection** - Checks for browser environment
|
||||||
|
2. **Service Availability** - Checks if Umami is loaded
|
||||||
|
3. **Graceful Degradation** - Falls back to NoopAnalyticsService if needed
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// These are all safe:
|
||||||
|
const services = getAppServices();
|
||||||
|
services.analytics.track("event", { prop: "value" }); // Works or does nothing
|
||||||
|
services.analytics.trackPageview("/path"); // Works or does nothing
|
||||||
|
```
|
||||||
|
|
||||||
|
## Performance
|
||||||
|
|
||||||
|
### Singleton Pattern
|
||||||
|
|
||||||
|
The service layer uses a singleton pattern for performance:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// First call creates the singleton
|
||||||
|
const services1 = getAppServices();
|
||||||
|
|
||||||
|
// Subsequent calls return the cached singleton
|
||||||
|
const services2 = getAppServices();
|
||||||
|
|
||||||
|
// services1 === services2 (same instance)
|
||||||
|
```
|
||||||
|
|
||||||
|
### Lazy Initialization
|
||||||
|
|
||||||
|
Services are only created when first accessed:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// Services are not created until getAppServices() is called
|
||||||
|
// This keeps initial bundle size minimal
|
||||||
|
```
|
||||||
|
|
||||||
|
## Integration with Components
|
||||||
|
|
||||||
|
### Client Components
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
'use client';
|
||||||
|
|
||||||
|
import { getAppServices } from '@/lib/services/create-services';
|
||||||
|
|
||||||
|
function MyComponent() {
|
||||||
|
const handleClick = () => {
|
||||||
|
const services = getAppServices();
|
||||||
|
services.analytics.track('button_click', { button_id: 'my-button' });
|
||||||
|
};
|
||||||
|
|
||||||
|
return <button onClick={handleClick}>Click Me</button>;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Server Components
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
import { getAppServices } from '@/lib/services/create-services';
|
||||||
|
|
||||||
|
async function MyServerComponent() {
|
||||||
|
const services = getAppServices();
|
||||||
|
|
||||||
|
// Note: Analytics won't work in server components
|
||||||
|
// Use client components for analytics tracking
|
||||||
|
// But you can still access other services like cache
|
||||||
|
|
||||||
|
const data = await services.cache.get('key');
|
||||||
|
|
||||||
|
return <div>{data}</div>;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Troubleshooting
|
||||||
|
|
||||||
|
### Analytics Not Working
|
||||||
|
|
||||||
|
1. **Check environment variables:**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
echo $NEXT_PUBLIC_UMAMI_WEBSITE_ID
|
||||||
|
```
|
||||||
|
|
||||||
|
2. **Verify service selection:**
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
import { getAppServices } from "@/lib/services/create-services";
|
||||||
|
|
||||||
|
const services = getAppServices();
|
||||||
|
console.log(services.analytics); // Should be UmamiAnalyticsService
|
||||||
|
```
|
||||||
|
|
||||||
|
3. **Check Umami dashboard:**
|
||||||
|
- Log into Umami
|
||||||
|
- Verify website ID matches
|
||||||
|
- Check if data is being received
|
||||||
|
|
||||||
|
### Common Issues
|
||||||
|
|
||||||
|
| Issue | Solution |
|
||||||
|
| ------------------- | ----------------------------------- |
|
||||||
|
| No data in Umami | Check website ID and script URL |
|
||||||
|
| Events not tracking | Verify service is being used |
|
||||||
|
| Script not loading | Check network connection, CORS |
|
||||||
|
| Wrong data | Verify event properties are correct |
|
||||||
|
|
||||||
|
## Related Files
|
||||||
|
|
||||||
|
- [`components/analytics/useAnalytics.ts`](../components/analytics/useAnalytics.ts) - Custom hook for easy event tracking
|
||||||
|
- [`components/analytics/analytics-events.ts`](../components/analytics/analytics-events.ts) - Event definitions
|
||||||
|
- [`components/analytics/UmamiScript.tsx`](../components/analytics/UmamiScript.tsx) - Script loader component
|
||||||
|
- [`components/analytics/AnalyticsProvider.tsx`](../components/analytics/AnalyticsProvider.tsx) - Route change tracker
|
||||||
|
- [`lib/services/create-services.ts`](../lib/services/create-services.ts) - Service factory
|
||||||
|
|
||||||
|
## Summary
|
||||||
|
|
||||||
|
The analytics service layer provides:
|
||||||
|
|
||||||
|
- ✅ **Type-safe API** - TypeScript throughout
|
||||||
|
- ✅ **Clean abstraction** - Easy to switch analytics providers
|
||||||
|
- ✅ **Graceful degradation** - Safe no-op fallback
|
||||||
|
- ✅ **Comprehensive documentation** - JSDoc comments and examples
|
||||||
|
- ✅ **Performance optimized** - Singleton pattern, lazy initialization
|
||||||
|
- ✅ **Error handling** - Safe in all environments
|
||||||
|
|
||||||
|
This layer is the foundation for all analytics tracking in the application.
|
||||||
87
lib/services/analytics/analytics-service.ts
Normal file
87
lib/services/analytics/analytics-service.ts
Normal file
@@ -0,0 +1,87 @@
|
|||||||
|
/**
|
||||||
|
* Type definition for analytics event properties.
|
||||||
|
*
|
||||||
|
* @example
|
||||||
|
* ```typescript
|
||||||
|
* const properties: AnalyticsEventProperties = {
|
||||||
|
* product_id: '123',
|
||||||
|
* product_name: 'Cable',
|
||||||
|
* price: 99.99,
|
||||||
|
* quantity: 1,
|
||||||
|
* in_stock: true,
|
||||||
|
* };
|
||||||
|
* ```
|
||||||
|
*/
|
||||||
|
export type AnalyticsEventProperties = Record<
|
||||||
|
string,
|
||||||
|
string | number | boolean | null | undefined
|
||||||
|
>;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Interface for analytics service implementations.
|
||||||
|
*
|
||||||
|
* This interface defines the contract for all analytics services,
|
||||||
|
* allowing for different implementations (Umami, Google Analytics, etc.)
|
||||||
|
* while maintaining a consistent API.
|
||||||
|
*
|
||||||
|
* @example
|
||||||
|
* ```typescript
|
||||||
|
* // Using the service directly
|
||||||
|
* const service = new UmamiAnalyticsService({ enabled: true });
|
||||||
|
* service.track('button_click', { button_id: 'cta' });
|
||||||
|
* service.trackPageview('/products/123');
|
||||||
|
* ```
|
||||||
|
*
|
||||||
|
* @example
|
||||||
|
* ```typescript
|
||||||
|
* // Using the useAnalytics hook (recommended)
|
||||||
|
* const { trackEvent, trackPageview } = useAnalytics();
|
||||||
|
* trackEvent('button_click', { button_id: 'cta' });
|
||||||
|
* trackPageview('/products/123');
|
||||||
|
* ```
|
||||||
|
*/
|
||||||
|
export interface AnalyticsService {
|
||||||
|
/**
|
||||||
|
* Track a custom event with optional properties.
|
||||||
|
*
|
||||||
|
* @param eventName - The name of the event to track
|
||||||
|
* @param props - Optional event properties (metadata)
|
||||||
|
*
|
||||||
|
* @example
|
||||||
|
* ```typescript
|
||||||
|
* track('product_add_to_cart', {
|
||||||
|
* product_id: '123',
|
||||||
|
* product_name: 'Cable',
|
||||||
|
* price: 99.99,
|
||||||
|
* });
|
||||||
|
* ```
|
||||||
|
*/
|
||||||
|
track(eventName: string, props?: AnalyticsEventProperties): void;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Track a pageview.
|
||||||
|
*
|
||||||
|
* @param url - The URL to track (defaults to current location)
|
||||||
|
*
|
||||||
|
* @example
|
||||||
|
* ```typescript
|
||||||
|
* // Track current page
|
||||||
|
* trackPageview();
|
||||||
|
*
|
||||||
|
* // Track custom URL
|
||||||
|
* trackPageview('/products/123?category=cables');
|
||||||
|
* ```
|
||||||
|
*/
|
||||||
|
trackPageview(url?: string): void;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Set the server-side context for the current request.
|
||||||
|
* This is used for server-side tracking (e.g. from Next.js proxy).
|
||||||
|
*/
|
||||||
|
setServerContext?(context: {
|
||||||
|
userAgent?: string;
|
||||||
|
language?: string;
|
||||||
|
referrer?: string;
|
||||||
|
ip?: string;
|
||||||
|
}): void;
|
||||||
|
}
|
||||||
83
lib/services/analytics/noop-analytics-service.ts
Normal file
83
lib/services/analytics/noop-analytics-service.ts
Normal file
@@ -0,0 +1,83 @@
|
|||||||
|
/* eslint-disable @typescript-eslint/no-unused-vars */
|
||||||
|
import type {
|
||||||
|
AnalyticsEventProperties,
|
||||||
|
AnalyticsService,
|
||||||
|
} from "./analytics-service";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* No-op Analytics Service Implementation.
|
||||||
|
*
|
||||||
|
* This service implements the AnalyticsService interface but does nothing.
|
||||||
|
* It's used as a fallback when analytics are disabled or not configured.
|
||||||
|
*
|
||||||
|
* @example
|
||||||
|
* ```typescript
|
||||||
|
* // Service creation (usually done by create-services.ts)
|
||||||
|
* const service = new NoopAnalyticsService();
|
||||||
|
*
|
||||||
|
* // These calls do nothing but are safe to execute
|
||||||
|
* service.track('button_click', { button_id: 'cta' });
|
||||||
|
* service.trackPageview('/products/123');
|
||||||
|
* ```
|
||||||
|
*
|
||||||
|
* @example
|
||||||
|
* ```typescript
|
||||||
|
* // Automatic fallback in create-services.ts
|
||||||
|
* const umamiEnabled = Boolean(process.env.NEXT_PUBLIC_UMAMI_WEBSITE_ID);
|
||||||
|
* const analytics = umamiEnabled
|
||||||
|
* ? new UmamiAnalyticsService({ enabled: true })
|
||||||
|
* : new NoopAnalyticsService(); // Fallback when no website ID
|
||||||
|
* ```
|
||||||
|
*/
|
||||||
|
export class NoopAnalyticsService implements AnalyticsService {
|
||||||
|
/**
|
||||||
|
* No-op implementation of track.
|
||||||
|
*
|
||||||
|
* This method does nothing but maintains the same signature as other
|
||||||
|
* analytics services for consistency.
|
||||||
|
*
|
||||||
|
* @param _eventName - Event name (ignored)
|
||||||
|
* @param _props - Event properties (ignored)
|
||||||
|
*
|
||||||
|
* @example
|
||||||
|
* ```typescript
|
||||||
|
* // Safe to call even when analytics are disabled
|
||||||
|
* service.track('button_click', { button_id: 'cta' });
|
||||||
|
* // No error, no action taken
|
||||||
|
* ```
|
||||||
|
*/
|
||||||
|
track(_eventName: string, _props?: AnalyticsEventProperties) {
|
||||||
|
// intentionally noop - analytics are disabled
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* No-op implementation of trackPageview.
|
||||||
|
*
|
||||||
|
* This method does nothing but maintains the same signature as other
|
||||||
|
* analytics services for consistency.
|
||||||
|
*
|
||||||
|
* @param _url - URL to track (ignored)
|
||||||
|
*
|
||||||
|
* @example
|
||||||
|
* ```typescript
|
||||||
|
* // Safe to call even when analytics are disabled
|
||||||
|
* service.trackPageview('/products/123');
|
||||||
|
* // No error, no action taken
|
||||||
|
* ```
|
||||||
|
*/
|
||||||
|
trackPageview(_url?: string) {
|
||||||
|
// intentionally noop - analytics are disabled
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* No-op implementation of setServerContext.
|
||||||
|
*/
|
||||||
|
setServerContext(_context: {
|
||||||
|
userAgent?: string;
|
||||||
|
language?: string;
|
||||||
|
referrer?: string;
|
||||||
|
ip?: string;
|
||||||
|
}) {
|
||||||
|
// intentionally noop - analytics are disabled
|
||||||
|
}
|
||||||
|
}
|
||||||
158
lib/services/analytics/umami-analytics-service.ts
Normal file
158
lib/services/analytics/umami-analytics-service.ts
Normal file
@@ -0,0 +1,158 @@
|
|||||||
|
import type {
|
||||||
|
AnalyticsEventProperties,
|
||||||
|
AnalyticsService,
|
||||||
|
} from "./analytics-service";
|
||||||
|
import { config } from "../../config";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Configuration options for UmamiAnalyticsService.
|
||||||
|
*
|
||||||
|
* @property enabled - Whether analytics are enabled
|
||||||
|
*/
|
||||||
|
export type UmamiAnalyticsServiceOptions = {
|
||||||
|
enabled: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Umami Analytics Service Implementation (Script-less/Proxy edition).
|
||||||
|
*
|
||||||
|
* This version implements the Umami tracking protocol directly via fetch,
|
||||||
|
* eliminating the need to load an external script.js file.
|
||||||
|
*
|
||||||
|
* In the browser, it gathers standard metadata (screen, language, referrer)
|
||||||
|
* and sends it to the proxied '/stats/api/send' endpoint.
|
||||||
|
*/
|
||||||
|
export class UmamiAnalyticsService implements AnalyticsService {
|
||||||
|
private websiteId?: string;
|
||||||
|
private endpoint: string;
|
||||||
|
private serverContext?: {
|
||||||
|
userAgent?: string;
|
||||||
|
language?: string;
|
||||||
|
referrer?: string;
|
||||||
|
ip?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
constructor(private readonly options: UmamiAnalyticsServiceOptions) {
|
||||||
|
this.websiteId = config.analytics.umami.websiteId;
|
||||||
|
|
||||||
|
// On server, use the full internal URL; on client, use the proxied path
|
||||||
|
this.endpoint =
|
||||||
|
typeof window === "undefined"
|
||||||
|
? config.analytics.umami.apiEndpoint
|
||||||
|
: "/stats";
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Set the server-side context for the current request.
|
||||||
|
* This allows the service to use real request headers for tracking.
|
||||||
|
*/
|
||||||
|
setServerContext(context: {
|
||||||
|
userAgent?: string;
|
||||||
|
language?: string;
|
||||||
|
referrer?: string;
|
||||||
|
ip?: string;
|
||||||
|
}) {
|
||||||
|
this.serverContext = context;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Internal method to send the payload to Umami API.
|
||||||
|
*/
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
|
private async sendPayload(type: "event", data: Record<string, unknown>) {
|
||||||
|
if (!this.options.enabled || !this.websiteId) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const payload = {
|
||||||
|
website: this.websiteId,
|
||||||
|
hostname:
|
||||||
|
typeof window !== "undefined"
|
||||||
|
? window.location.hostname
|
||||||
|
: this.serverContext?.referrer
|
||||||
|
? new URL(this.serverContext.referrer).hostname
|
||||||
|
: "server",
|
||||||
|
screen:
|
||||||
|
typeof window !== "undefined"
|
||||||
|
? `${window.screen.width}x${window.screen.height}`
|
||||||
|
: undefined,
|
||||||
|
language:
|
||||||
|
typeof window !== "undefined"
|
||||||
|
? navigator.language
|
||||||
|
: this.serverContext?.language,
|
||||||
|
referrer:
|
||||||
|
typeof window !== "undefined"
|
||||||
|
? document.referrer
|
||||||
|
: this.serverContext?.referrer,
|
||||||
|
...data,
|
||||||
|
};
|
||||||
|
|
||||||
|
const headers: Record<string, string> = {
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
};
|
||||||
|
|
||||||
|
// Set User-Agent
|
||||||
|
if (typeof window !== "undefined") {
|
||||||
|
headers["User-Agent"] = navigator.userAgent;
|
||||||
|
} else if (this.serverContext?.userAgent) {
|
||||||
|
headers["User-Agent"] = this.serverContext.userAgent;
|
||||||
|
} else {
|
||||||
|
headers["User-Agent"] = "Mintel-Server-Proxy";
|
||||||
|
}
|
||||||
|
|
||||||
|
// Forward client IP if available (Umami must be configured to trust this)
|
||||||
|
if (this.serverContext?.ip) {
|
||||||
|
headers["X-Forwarded-For"] = this.serverContext.ip;
|
||||||
|
}
|
||||||
|
|
||||||
|
const response = await fetch(`${this.endpoint}/api/send`, {
|
||||||
|
method: "POST",
|
||||||
|
headers,
|
||||||
|
body: JSON.stringify({ type, payload }),
|
||||||
|
keepalive: true,
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
|
} as any);
|
||||||
|
|
||||||
|
if (!response.ok && process.env.NODE_ENV === "development") {
|
||||||
|
const errorText = await response.text();
|
||||||
|
console.warn(
|
||||||
|
`[Umami] API responded with ${response.status}: ${errorText}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
if (process.env.NODE_ENV === "development") {
|
||||||
|
console.error("[Umami] Failed to send analytics:", error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Track a custom event.
|
||||||
|
*/
|
||||||
|
track(eventName: string, props?: AnalyticsEventProperties) {
|
||||||
|
this.sendPayload("event", {
|
||||||
|
name: eventName,
|
||||||
|
data: props,
|
||||||
|
url:
|
||||||
|
typeof window !== "undefined"
|
||||||
|
? window.location.pathname + window.location.search
|
||||||
|
: this.serverContext?.referrer
|
||||||
|
? new URL(this.serverContext.referrer).pathname
|
||||||
|
: "/",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Track a pageview.
|
||||||
|
*/
|
||||||
|
trackPageview(url?: string) {
|
||||||
|
this.sendPayload("event", {
|
||||||
|
url:
|
||||||
|
url ||
|
||||||
|
(typeof window !== "undefined"
|
||||||
|
? window.location.pathname + window.location.search
|
||||||
|
: this.serverContext?.referrer
|
||||||
|
? new URL(this.serverContext.referrer).pathname
|
||||||
|
: "/"),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
16
lib/services/app-services.ts
Normal file
16
lib/services/app-services.ts
Normal file
@@ -0,0 +1,16 @@
|
|||||||
|
import type { AnalyticsService } from "./analytics/analytics-service";
|
||||||
|
import type { CacheService } from "./cache/cache-service";
|
||||||
|
import type { ErrorReportingService } from "./errors/error-reporting-service";
|
||||||
|
import type { LoggerService } from "./logging/logger-service";
|
||||||
|
import type { NotificationService } from "./notifications/notification-service";
|
||||||
|
|
||||||
|
// Simple constructor-based DI container.
|
||||||
|
export class AppServices {
|
||||||
|
constructor(
|
||||||
|
public readonly analytics: AnalyticsService,
|
||||||
|
public readonly errors: ErrorReportingService,
|
||||||
|
public readonly cache: CacheService,
|
||||||
|
public readonly logger: LoggerService,
|
||||||
|
public readonly notifications: NotificationService,
|
||||||
|
) {}
|
||||||
|
}
|
||||||
9
lib/services/cache/cache-service.ts
vendored
Normal file
9
lib/services/cache/cache-service.ts
vendored
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
export type CacheSetOptions = {
|
||||||
|
ttlSeconds?: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
export interface CacheService {
|
||||||
|
get<T>(key: string): Promise<T | undefined>;
|
||||||
|
set<T>(key: string, value: T, options?: CacheSetOptions): Promise<void>;
|
||||||
|
del(key: string): Promise<void>;
|
||||||
|
}
|
||||||
30
lib/services/cache/memory-cache-service.ts
vendored
Normal file
30
lib/services/cache/memory-cache-service.ts
vendored
Normal file
@@ -0,0 +1,30 @@
|
|||||||
|
import type { CacheService, CacheSetOptions } from "./cache-service";
|
||||||
|
|
||||||
|
type Entry = {
|
||||||
|
value: unknown;
|
||||||
|
expiresAt?: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
export class MemoryCacheService implements CacheService {
|
||||||
|
private readonly store = new Map<string, Entry>();
|
||||||
|
|
||||||
|
async get<T>(key: string) {
|
||||||
|
const entry = this.store.get(key);
|
||||||
|
if (!entry) return undefined;
|
||||||
|
if (entry.expiresAt && Date.now() > entry.expiresAt) {
|
||||||
|
this.store.delete(key);
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
return entry.value as T;
|
||||||
|
}
|
||||||
|
|
||||||
|
async set<T>(key: string, value: T, options?: CacheSetOptions) {
|
||||||
|
const ttl = options?.ttlSeconds;
|
||||||
|
const expiresAt = ttl ? Date.now() + ttl * 1000 : undefined;
|
||||||
|
this.store.set(key, { value, expiresAt });
|
||||||
|
}
|
||||||
|
|
||||||
|
async del(key: string) {
|
||||||
|
this.store.delete(key);
|
||||||
|
}
|
||||||
|
}
|
||||||
86
lib/services/create-services.server.ts
Normal file
86
lib/services/create-services.server.ts
Normal file
@@ -0,0 +1,86 @@
|
|||||||
|
import { AppServices } from "./app-services";
|
||||||
|
import { NoopAnalyticsService } from "./analytics/noop-analytics-service";
|
||||||
|
import { UmamiAnalyticsService } from "./analytics/umami-analytics-service";
|
||||||
|
import { MemoryCacheService } from "./cache/memory-cache-service";
|
||||||
|
import { GlitchtipErrorReportingService } from "./errors/glitchtip-error-reporting-service";
|
||||||
|
import { NoopErrorReportingService } from "./errors/noop-error-reporting-service";
|
||||||
|
import {
|
||||||
|
GotifyNotificationService,
|
||||||
|
NoopNotificationService,
|
||||||
|
} from "./notifications/gotify-notification-service";
|
||||||
|
import { PinoLoggerService } from "./logging/pino-logger-service";
|
||||||
|
import { config, getMaskedConfig } from "../config";
|
||||||
|
|
||||||
|
let singleton: AppServices | undefined;
|
||||||
|
export function getServerAppServices(): AppServices {
|
||||||
|
if (singleton) return singleton;
|
||||||
|
|
||||||
|
// Create logger first to log initialization
|
||||||
|
const logger = new PinoLoggerService("server");
|
||||||
|
|
||||||
|
logger.info("Initializing server application services", {
|
||||||
|
environment: getMaskedConfig(),
|
||||||
|
timestamp: new Date().toISOString(),
|
||||||
|
});
|
||||||
|
|
||||||
|
logger.info("Service configuration", {
|
||||||
|
umamiEnabled: config.analytics.umami.enabled,
|
||||||
|
sentryEnabled: config.errors.glitchtip.enabled,
|
||||||
|
mailEnabled: Boolean(config.mail.host && config.mail.user),
|
||||||
|
gotifyEnabled: config.notifications.gotify.enabled,
|
||||||
|
});
|
||||||
|
|
||||||
|
const analytics = config.analytics.umami.enabled
|
||||||
|
? new UmamiAnalyticsService({ enabled: true })
|
||||||
|
: new NoopAnalyticsService();
|
||||||
|
|
||||||
|
if (config.analytics.umami.enabled) {
|
||||||
|
logger.info("Umami analytics service initialized");
|
||||||
|
} else {
|
||||||
|
logger.info("Noop analytics service initialized (analytics disabled)");
|
||||||
|
}
|
||||||
|
|
||||||
|
const notifications = config.notifications.gotify.enabled
|
||||||
|
? new GotifyNotificationService({
|
||||||
|
url: config.notifications.gotify.url!,
|
||||||
|
token: config.notifications.gotify.token!,
|
||||||
|
enabled: true,
|
||||||
|
})
|
||||||
|
: new NoopNotificationService();
|
||||||
|
|
||||||
|
if (config.notifications.gotify.enabled) {
|
||||||
|
logger.info("Gotify notification service initialized");
|
||||||
|
} else {
|
||||||
|
logger.info(
|
||||||
|
"Noop notification service initialized (notifications disabled)",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const errors = config.errors.glitchtip.enabled
|
||||||
|
? new GlitchtipErrorReportingService({ enabled: true }, notifications)
|
||||||
|
: new NoopErrorReportingService();
|
||||||
|
|
||||||
|
if (config.errors.glitchtip.enabled) {
|
||||||
|
logger.info("GlitchTip error reporting service initialized", {
|
||||||
|
dsnPresent: Boolean(config.errors.glitchtip.dsn),
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
logger.info(
|
||||||
|
"Noop error reporting service initialized (error reporting disabled)",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const cache = new MemoryCacheService();
|
||||||
|
logger.info("Memory cache service initialized");
|
||||||
|
|
||||||
|
logger.info("Pino logger service initialized", {
|
||||||
|
name: "server",
|
||||||
|
level: config.logging.level,
|
||||||
|
});
|
||||||
|
|
||||||
|
singleton = new AppServices(analytics, errors, cache, logger, notifications);
|
||||||
|
|
||||||
|
logger.info("All application services initialized successfully");
|
||||||
|
|
||||||
|
return singleton;
|
||||||
|
}
|
||||||
154
lib/services/create-services.ts
Normal file
154
lib/services/create-services.ts
Normal file
@@ -0,0 +1,154 @@
|
|||||||
|
import { AppServices } from "./app-services";
|
||||||
|
import { NoopAnalyticsService } from "./analytics/noop-analytics-service";
|
||||||
|
import { MemoryCacheService } from "./cache/memory-cache-service";
|
||||||
|
import { GlitchtipErrorReportingService } from "./errors/glitchtip-error-reporting-service";
|
||||||
|
import { NoopErrorReportingService } from "./errors/noop-error-reporting-service";
|
||||||
|
import { NoopLoggerService } from "./logging/noop-logger-service";
|
||||||
|
import { PinoLoggerService } from "./logging/pino-logger-service";
|
||||||
|
import { NoopNotificationService } from "./notifications/gotify-notification-service";
|
||||||
|
import { config, getMaskedConfig } from "../config";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Singleton instance of AppServices.
|
||||||
|
*
|
||||||
|
* In Next.js, module singletons are per-process (server) and per-tab (client).
|
||||||
|
* This is sufficient for a small service layer and provides better performance
|
||||||
|
* than creating new instances on every request.
|
||||||
|
*
|
||||||
|
* @private
|
||||||
|
*/
|
||||||
|
let singleton: AppServices | undefined;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get the application services singleton.
|
||||||
|
*
|
||||||
|
* This function creates and caches the application services, including:
|
||||||
|
* - Analytics service (Umami or no-op)
|
||||||
|
* - Error reporting service (GlitchTip/Sentry or no-op)
|
||||||
|
* - Cache service (in-memory)
|
||||||
|
*
|
||||||
|
* The services are configured based on environment variables:
|
||||||
|
* - `UMAMI_WEBSITE_ID` - Enables Umami analytics
|
||||||
|
* - `NEXT_PUBLIC_SENTRY_DSN` - Enables client-side error reporting
|
||||||
|
* - `SENTRY_DSN` - Enables server-side error reporting
|
||||||
|
*
|
||||||
|
* @returns {AppServices} The application services singleton
|
||||||
|
*
|
||||||
|
* @example
|
||||||
|
* ```typescript
|
||||||
|
* // Get services in a client component
|
||||||
|
* import { getAppServices } from '@/lib/services/create-services';
|
||||||
|
*
|
||||||
|
* const services = getAppServices();
|
||||||
|
* services.analytics.track('button_click', { button_id: 'cta' });
|
||||||
|
* ```
|
||||||
|
*
|
||||||
|
* @example
|
||||||
|
* ```typescript
|
||||||
|
* // Get services in a server component or API route
|
||||||
|
* import { getAppServices } from '@/lib/services/create-services';
|
||||||
|
*
|
||||||
|
* const services = getAppServices();
|
||||||
|
* await services.cache.set('key', 'value');
|
||||||
|
* ```
|
||||||
|
*
|
||||||
|
* @example
|
||||||
|
* ```typescript
|
||||||
|
* // Automatic service selection based on environment
|
||||||
|
* // If NEXT_PUBLIC_UMAMI_WEBSITE_ID is set:
|
||||||
|
* // services.analytics = UmamiAnalyticsService
|
||||||
|
* // If not set:
|
||||||
|
* // services.analytics = NoopAnalyticsService (safe no-op)
|
||||||
|
* ```
|
||||||
|
*
|
||||||
|
* @see {@link UmamiAnalyticsService} for analytics implementation
|
||||||
|
* @see {@link NoopAnalyticsService} for no-op fallback
|
||||||
|
* @see {@link GlitchtipErrorReportingService} for error reporting
|
||||||
|
* @see {@link MemoryCacheService} for caching
|
||||||
|
*/
|
||||||
|
export function getAppServices(): AppServices {
|
||||||
|
// Return cached instance if available
|
||||||
|
if (singleton) return singleton;
|
||||||
|
|
||||||
|
// Create logger first to log initialization
|
||||||
|
const logger =
|
||||||
|
typeof window === "undefined"
|
||||||
|
? new PinoLoggerService("server")
|
||||||
|
: new NoopLoggerService();
|
||||||
|
|
||||||
|
// Log initialization
|
||||||
|
if (typeof window === "undefined") {
|
||||||
|
// Server-side
|
||||||
|
logger.info("Initializing server application services", {
|
||||||
|
environment: getMaskedConfig(),
|
||||||
|
timestamp: new Date().toISOString(),
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
// Client-side
|
||||||
|
logger.info("Initializing client application services", {
|
||||||
|
environment: getMaskedConfig(),
|
||||||
|
timestamp: new Date().toISOString(),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Determine which services to enable based on environment variables
|
||||||
|
const umamiEnabled = config.analytics.umami.enabled;
|
||||||
|
const sentryEnabled = config.errors.glitchtip.enabled;
|
||||||
|
|
||||||
|
logger.info("Service configuration", {
|
||||||
|
umamiEnabled,
|
||||||
|
sentryEnabled,
|
||||||
|
isServer: typeof window === "undefined",
|
||||||
|
});
|
||||||
|
|
||||||
|
// Create analytics service (Umami or no-op)
|
||||||
|
// Use dynamic import to avoid importing server-only code in client components
|
||||||
|
const analytics = umamiEnabled
|
||||||
|
? (() => {
|
||||||
|
const { UmamiAnalyticsService } =
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-require-imports
|
||||||
|
require("./analytics/umami-analytics-service");
|
||||||
|
return new UmamiAnalyticsService({ enabled: true });
|
||||||
|
})()
|
||||||
|
: new NoopAnalyticsService();
|
||||||
|
|
||||||
|
if (umamiEnabled) {
|
||||||
|
logger.info("Umami analytics service initialized");
|
||||||
|
} else {
|
||||||
|
logger.info("Noop analytics service initialized (analytics disabled)");
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create error reporting service (GlitchTip/Sentry or no-op)
|
||||||
|
const errors = sentryEnabled
|
||||||
|
? new GlitchtipErrorReportingService({ enabled: true })
|
||||||
|
: new NoopErrorReportingService();
|
||||||
|
|
||||||
|
if (sentryEnabled) {
|
||||||
|
logger.info(
|
||||||
|
`GlitchTip error reporting service initialized (${typeof window === "undefined" ? "server" : "client"})`,
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
logger.info(
|
||||||
|
"Noop error reporting service initialized (error reporting disabled)",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// IMPORTANT: This module is imported by client components.
|
||||||
|
// Do not import Node-only modules (like the `redis` client) here.
|
||||||
|
// Use [`getServerAppServices()`](lib/services/create-services.server.ts:1) on the server.
|
||||||
|
const cache = new MemoryCacheService();
|
||||||
|
logger.info("Memory cache service initialized");
|
||||||
|
|
||||||
|
logger.info("Pino logger service initialized", {
|
||||||
|
name: typeof window === "undefined" ? "server" : "client",
|
||||||
|
level: config.logging.level,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Create and cache the singleton
|
||||||
|
const notifications = new NoopNotificationService();
|
||||||
|
singleton = new AppServices(analytics, errors, cache, logger, notifications);
|
||||||
|
|
||||||
|
logger.info("All application services initialized successfully");
|
||||||
|
|
||||||
|
return singleton;
|
||||||
|
}
|
||||||
27
lib/services/errors/error-reporting-service.ts
Normal file
27
lib/services/errors/error-reporting-service.ts
Normal file
@@ -0,0 +1,27 @@
|
|||||||
|
export type ErrorReportingUser = {
|
||||||
|
id?: string;
|
||||||
|
email?: string;
|
||||||
|
username?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type ErrorReportingLevel =
|
||||||
|
| "fatal"
|
||||||
|
| "error"
|
||||||
|
| "warning"
|
||||||
|
| "info"
|
||||||
|
| "debug"
|
||||||
|
| "log";
|
||||||
|
|
||||||
|
export interface ErrorReportingService {
|
||||||
|
captureException(
|
||||||
|
error: unknown,
|
||||||
|
context?: Record<string, unknown>,
|
||||||
|
): Promise<string | undefined> | string | undefined;
|
||||||
|
captureMessage(
|
||||||
|
message: string,
|
||||||
|
level?: ErrorReportingLevel,
|
||||||
|
): Promise<string | undefined> | string | undefined;
|
||||||
|
setUser(user: ErrorReportingUser | null): void;
|
||||||
|
setTag(key: string, value: string): void;
|
||||||
|
withScope<T>(fn: () => T, context?: Record<string, unknown>): T;
|
||||||
|
}
|
||||||
77
lib/services/errors/glitchtip-error-reporting-service.ts
Normal file
77
lib/services/errors/glitchtip-error-reporting-service.ts
Normal file
@@ -0,0 +1,77 @@
|
|||||||
|
import * as Sentry from "@sentry/nextjs";
|
||||||
|
import type {
|
||||||
|
ErrorReportingLevel,
|
||||||
|
ErrorReportingService,
|
||||||
|
ErrorReportingUser,
|
||||||
|
} from "./error-reporting-service";
|
||||||
|
import type { NotificationService } from "../notifications/notification-service";
|
||||||
|
|
||||||
|
type SentryLike = typeof Sentry;
|
||||||
|
|
||||||
|
export type GlitchtipErrorReportingServiceOptions = {
|
||||||
|
enabled: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
// GlitchTip speaks the Sentry protocol; @sentry/nextjs can send to GlitchTip via DSN.
|
||||||
|
export class GlitchtipErrorReportingService implements ErrorReportingService {
|
||||||
|
constructor(
|
||||||
|
private readonly options: GlitchtipErrorReportingServiceOptions,
|
||||||
|
private readonly notifications?: NotificationService,
|
||||||
|
private readonly sentry: SentryLike = Sentry,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
async captureException(error: unknown, context?: Record<string, unknown>) {
|
||||||
|
if (!this.options.enabled) return undefined;
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
|
const result = this.sentry.captureException(error, context as any) as any;
|
||||||
|
|
||||||
|
// Send to Gotify if it's considered critical or if we just want all exceptions there
|
||||||
|
// For now, let's send all exceptions to Gotify as requested "notify me via gotify about critical error messages"
|
||||||
|
// We'll treat all captureException calls as potentially critical or at least noteworthy
|
||||||
|
if (this.notifications) {
|
||||||
|
const errorMessage =
|
||||||
|
error instanceof Error ? error.message : String(error);
|
||||||
|
const contextStr = context
|
||||||
|
? `\nContext: ${JSON.stringify(context, null, 2)}`
|
||||||
|
: "";
|
||||||
|
|
||||||
|
await this.notifications.notify({
|
||||||
|
title: "🔥 Critical Error Captured",
|
||||||
|
message: `Error: ${errorMessage}${contextStr}`,
|
||||||
|
priority: 7,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
captureMessage(message: string, level: ErrorReportingLevel = "error") {
|
||||||
|
if (!this.options.enabled) return undefined;
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
|
return this.sentry.captureMessage(message, level as any) as any;
|
||||||
|
}
|
||||||
|
|
||||||
|
setUser(user: ErrorReportingUser | null) {
|
||||||
|
if (!this.options.enabled) return;
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
|
this.sentry.setUser(user as any);
|
||||||
|
}
|
||||||
|
|
||||||
|
setTag(key: string, value: string) {
|
||||||
|
if (!this.options.enabled) return;
|
||||||
|
this.sentry.setTag(key, value);
|
||||||
|
}
|
||||||
|
|
||||||
|
withScope<T>(fn: () => T, context?: Record<string, unknown>) {
|
||||||
|
if (!this.options.enabled) return fn();
|
||||||
|
|
||||||
|
return this.sentry.withScope((scope) => {
|
||||||
|
if (context) {
|
||||||
|
for (const [key, value] of Object.entries(context)) {
|
||||||
|
scope.setExtra(key, value);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return fn();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
23
lib/services/errors/noop-error-reporting-service.ts
Normal file
23
lib/services/errors/noop-error-reporting-service.ts
Normal file
@@ -0,0 +1,23 @@
|
|||||||
|
/* eslint-disable @typescript-eslint/no-unused-vars */
|
||||||
|
import type {
|
||||||
|
ErrorReportingLevel,
|
||||||
|
ErrorReportingService,
|
||||||
|
ErrorReportingUser,
|
||||||
|
} from "./error-reporting-service";
|
||||||
|
|
||||||
|
export class NoopErrorReportingService implements ErrorReportingService {
|
||||||
|
async captureException(_error: unknown, _context?: Record<string, unknown>) {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
async captureMessage(_message: string, _level?: ErrorReportingLevel) {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
setUser(_user: ErrorReportingUser | null) {}
|
||||||
|
setTag(_key: string, _value: string) {}
|
||||||
|
|
||||||
|
withScope<T>(fn: () => T, _context?: Record<string, unknown>) {
|
||||||
|
return fn();
|
||||||
|
}
|
||||||
|
}
|
||||||
11
lib/services/logging/logger-service.ts
Normal file
11
lib/services/logging/logger-service.ts
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
export type LogLevel = "trace" | "debug" | "info" | "warn" | "error" | "fatal";
|
||||||
|
|
||||||
|
export interface LoggerService {
|
||||||
|
trace(msg: string, ...args: unknown[]): void;
|
||||||
|
debug(msg: string, ...args: unknown[]): void;
|
||||||
|
info(msg: string, ...args: unknown[]): void;
|
||||||
|
warn(msg: string, ...args: unknown[]): void;
|
||||||
|
error(msg: string, ...args: unknown[]): void;
|
||||||
|
fatal(msg: string, ...args: unknown[]): void;
|
||||||
|
child(bindings: Record<string, unknown>): LoggerService;
|
||||||
|
}
|
||||||
13
lib/services/logging/noop-logger-service.ts
Normal file
13
lib/services/logging/noop-logger-service.ts
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
import type { LoggerService } from "./logger-service";
|
||||||
|
|
||||||
|
export class NoopLoggerService implements LoggerService {
|
||||||
|
trace() {}
|
||||||
|
debug() {}
|
||||||
|
info() {}
|
||||||
|
warn() {}
|
||||||
|
error() {}
|
||||||
|
fatal() {}
|
||||||
|
child() {
|
||||||
|
return this;
|
||||||
|
}
|
||||||
|
}
|
||||||
100
lib/services/logging/pino-logger-service.ts
Normal file
100
lib/services/logging/pino-logger-service.ts
Normal file
@@ -0,0 +1,100 @@
|
|||||||
|
import pino, { Logger as PinoLogger } from "pino";
|
||||||
|
import type { LoggerService } from "./logger-service";
|
||||||
|
import { config } from "../../config";
|
||||||
|
|
||||||
|
export class PinoLoggerService implements LoggerService {
|
||||||
|
private readonly logger: PinoLogger;
|
||||||
|
|
||||||
|
constructor(name?: string, parent?: PinoLogger) {
|
||||||
|
if (parent) {
|
||||||
|
this.logger = parent.child({ name });
|
||||||
|
} else {
|
||||||
|
// In Next.js, especially in the Edge runtime or during instrumentation,
|
||||||
|
// pino transports (which use worker threads) can cause issues.
|
||||||
|
// We disable transport in production and during instrumentation.
|
||||||
|
const useTransport =
|
||||||
|
config.isDevelopment && typeof window === "undefined";
|
||||||
|
|
||||||
|
this.logger = pino({
|
||||||
|
name: name || "app",
|
||||||
|
level: config.logging.level,
|
||||||
|
transport: useTransport
|
||||||
|
? {
|
||||||
|
target: "pino-pretty",
|
||||||
|
options: {
|
||||||
|
colorize: true,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
: undefined,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
trace(msg: string, ...args: unknown[]) {
|
||||||
|
if (args.length > 0 && typeof args[0] === "object" && args[0] !== null) {
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
|
(this.logger as any).trace(args[0] as object, msg, ...args.slice(1));
|
||||||
|
} else {
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
|
(this.logger as any).trace(msg, ...args);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
debug(msg: string, ...args: unknown[]) {
|
||||||
|
if (args.length > 0 && typeof args[0] === "object" && args[0] !== null) {
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
|
(this.logger as any).debug(args[0] as object, msg, ...args.slice(1));
|
||||||
|
} else {
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
|
(this.logger as any).debug(msg, ...args);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
info(msg: string, ...args: unknown[]) {
|
||||||
|
if (args.length > 0 && typeof args[0] === "object" && args[0] !== null) {
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
|
(this.logger as any).info(args[0] as object, msg, ...args.slice(1));
|
||||||
|
} else {
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
|
(this.logger as any).info(msg, ...args);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
warn(msg: string, ...args: unknown[]) {
|
||||||
|
if (args.length > 0 && typeof args[0] === "object" && args[0] !== null) {
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
|
(this.logger as any).warn(args[0] as object, msg, ...args.slice(1));
|
||||||
|
} else {
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
|
(this.logger as any).warn(msg, ...args);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
error(msg: string, ...args: unknown[]) {
|
||||||
|
if (args.length > 0 && typeof args[0] === "object" && args[0] !== null) {
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
|
(this.logger as any).error(args[0] as object, msg, ...args.slice(1));
|
||||||
|
} else {
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
|
(this.logger as any).error(msg, ...args);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fatal(msg: string, ...args: unknown[]) {
|
||||||
|
if (args.length > 0 && typeof args[0] === "object" && args[0] !== null) {
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
|
(this.logger as any).fatal(args[0] as object, msg, ...args.slice(1));
|
||||||
|
} else {
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
|
(this.logger as any).fatal(msg, ...args);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
child(bindings: Record<string, unknown>): LoggerService {
|
||||||
|
const childPino = this.logger.child(bindings);
|
||||||
|
const service = new PinoLoggerService();
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
|
(service as any).logger = childPino;
|
||||||
|
return service;
|
||||||
|
}
|
||||||
|
}
|
||||||
53
lib/services/notifications/gotify-notification-service.ts
Normal file
53
lib/services/notifications/gotify-notification-service.ts
Normal file
@@ -0,0 +1,53 @@
|
|||||||
|
import {
|
||||||
|
NotificationOptions,
|
||||||
|
NotificationService,
|
||||||
|
} from "./notification-service";
|
||||||
|
|
||||||
|
export interface GotifyConfig {
|
||||||
|
url: string;
|
||||||
|
token: string;
|
||||||
|
enabled: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class GotifyNotificationService implements NotificationService {
|
||||||
|
constructor(private config: GotifyConfig) {}
|
||||||
|
|
||||||
|
async notify(options: NotificationOptions): Promise<void> {
|
||||||
|
if (!this.config.enabled) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const { title, message, priority = 4 } = options;
|
||||||
|
const url = new URL("message", this.config.url);
|
||||||
|
url.searchParams.set("token", this.config.token);
|
||||||
|
|
||||||
|
const response = await fetch(url.toString(), {
|
||||||
|
method: "POST",
|
||||||
|
headers: {
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
},
|
||||||
|
body: JSON.stringify({
|
||||||
|
title,
|
||||||
|
message,
|
||||||
|
priority,
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
const errorText = await response.text();
|
||||||
|
console.error("Gotify notification failed:", {
|
||||||
|
status: response.status,
|
||||||
|
error: errorText,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Gotify notification error:", error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export class NoopNotificationService implements NotificationService {
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
||||||
|
async notify(_options: NotificationOptions): Promise<void> {
|
||||||
|
// Do nothing
|
||||||
|
}
|
||||||
|
}
|
||||||
9
lib/services/notifications/notification-service.ts
Normal file
9
lib/services/notifications/notification-service.ts
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
export interface NotificationOptions {
|
||||||
|
title: string;
|
||||||
|
message: string;
|
||||||
|
priority?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface NotificationService {
|
||||||
|
notify(options: NotificationOptions): Promise<void>;
|
||||||
|
}
|
||||||
176
messages/de.json
Normal file
176
messages/de.json
Normal file
@@ -0,0 +1,176 @@
|
|||||||
|
{
|
||||||
|
"Index": {
|
||||||
|
"hero": {
|
||||||
|
"tag": "Technische Beratung",
|
||||||
|
"title": "Spezialisierter Partner für Energiekabelprojekte",
|
||||||
|
"titleHighlight": "Energiekabelprojekte",
|
||||||
|
"subtitle": "Herstellerneutrale technische Beratung für Ihre Projekte in Mittel- und Hochspannungsnetzen bis zu 110 kV.",
|
||||||
|
"ctaPrimary": "Projekt anfragen",
|
||||||
|
"ctaSecondary": "Mehr erfahren"
|
||||||
|
},
|
||||||
|
"portfolio": {
|
||||||
|
"tag": "Portfolio",
|
||||||
|
"title": "Unsere Leistungen",
|
||||||
|
"description": "Beratung durch unabhängige Experten mit jahrzehntelanger Erfahrung aus Engineering, Normengremien, Planung und Produktion.",
|
||||||
|
"link": "Alle Details ansehen",
|
||||||
|
"items": {
|
||||||
|
"beratung": {
|
||||||
|
"title": "Technische Beratung",
|
||||||
|
"desc": "Individuelle Konzepte, Vergleiche, Risikobetrachtung und Empfehlungen für Ihre Kabelinfrastruktur."
|
||||||
|
},
|
||||||
|
"begleitung": {
|
||||||
|
"title": "Projektbegleitung",
|
||||||
|
"desc": "Wir begleiten Sie bei der Verlegung und Installation, um Herausforderungen proaktiv zu lösen."
|
||||||
|
},
|
||||||
|
"beschaffung": {
|
||||||
|
"title": "Produktbeschaffung",
|
||||||
|
"desc": "Herstellerneutrale Marktanalyse und Unterstützung bei der Komponentenwahl hinsichtlich Qualität und Preis."
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"expertise": {
|
||||||
|
"tag": "Expertise",
|
||||||
|
"title": "Anwendungen & Zielgruppen",
|
||||||
|
"description": "Wir unterstützen Sie bei der Realisierung Ihrer Kabelprojekte.",
|
||||||
|
"groups": [
|
||||||
|
"Energieversorger",
|
||||||
|
"Ingenieurbüros",
|
||||||
|
"Tiefbauunternehmen",
|
||||||
|
"Industrie",
|
||||||
|
"Projektierer EE",
|
||||||
|
"Planungsbüros"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"specs": {
|
||||||
|
"tag": "Spezifikationen",
|
||||||
|
"title": "Technische Expertise",
|
||||||
|
"items": {
|
||||||
|
"kabel": {
|
||||||
|
"label": "Kabeltypen",
|
||||||
|
"value": "N2XS(FL)2Y, N2X(F)KLD2Y...",
|
||||||
|
"desc": "Umfassende Expertise im Design gängiger Hochspannungskabel."
|
||||||
|
},
|
||||||
|
"spannung": {
|
||||||
|
"label": "Spannungsebenen",
|
||||||
|
"value": "64/110 kV & Mittelspannung",
|
||||||
|
"desc": "Spezialisierte Beratung für komplexe Infrastrukturprojekte."
|
||||||
|
},
|
||||||
|
"technologie": {
|
||||||
|
"label": "Leitertechnologie",
|
||||||
|
"value": "Massiv-, Mehrdraht- & Milliken",
|
||||||
|
"desc": "Optimierung des Leiterdesigns hinsichtlich Stromtragfähigkeit."
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"cta": {
|
||||||
|
"title": "Bereit für Ihr nächstes Projekt?",
|
||||||
|
"subtitle": "Lassen Sie uns gemeinsam die optimale Lösung für Ihre Energieinfrastruktur finden. Wir beraten Sie herstellerneutral und kompetent.",
|
||||||
|
"button": "Jetzt Kontakt aufnehmen"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"Layout": {
|
||||||
|
"nav": {
|
||||||
|
"home": "Startseite",
|
||||||
|
"about": "Über uns",
|
||||||
|
"contact": "Kontakt",
|
||||||
|
"cta": "Projekt anfragen"
|
||||||
|
},
|
||||||
|
"footer": {
|
||||||
|
"description": "Ihr spezialisierter Partner für herstellerneutrale technische Beratung und Projektbegleitung bei Energiekabelprojekten bis 110 kV.",
|
||||||
|
"navigation": "Navigation",
|
||||||
|
"legal": "Rechtliches",
|
||||||
|
"impressum": "Impressum",
|
||||||
|
"datenschutz": "Datenschutz",
|
||||||
|
"agb": "AGB",
|
||||||
|
"rights": "Alle Rechte vorbehalten.",
|
||||||
|
"madeWith": "Entwickelt mit",
|
||||||
|
"precision": "Präzision",
|
||||||
|
"inGermany": "in Deutschland"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"About": {
|
||||||
|
"hero": {
|
||||||
|
"tagline": "Über uns",
|
||||||
|
"title": "Zuverlässige Begleitung für Ihre Netzinfrastruktur",
|
||||||
|
"subtitle": "Herstellerneutrale Beratung in der Energiekabeltechnologie. Wir verstehen uns als Ihr technischer Lotse."
|
||||||
|
},
|
||||||
|
"intro": {
|
||||||
|
"p1": "Unsere Wurzeln liegen in der tiefen praktischen Erfahrung unserer technischen Berater und unserer Netzwerke im globalem Kabelmarkt. Wir vereinen Tradition mit modernster Innovation, um zuverlässige Energielösungen für Projekte bis 110 kV zu realisieren.",
|
||||||
|
"p2": "Wir verstehen die Herausforderungen der Energiewende und bieten herstellerneutrale Beratung, die auf Fakten, Normen und jahrzehntelanger Erfahrung basiert."
|
||||||
|
},
|
||||||
|
"team": {
|
||||||
|
"bodemer": "Geschäftsführung & Inhaber",
|
||||||
|
"mintel": "Geschäftsführung"
|
||||||
|
},
|
||||||
|
"manifest": {
|
||||||
|
"tagline": "Werte",
|
||||||
|
"title": "Unser Manifest",
|
||||||
|
"subtitle": "Werte, die unsere tägliche Arbeit leiten und den Erfolg Ihrer Projekte sichern.",
|
||||||
|
"items": [
|
||||||
|
{
|
||||||
|
"title": "Kompetenz",
|
||||||
|
"desc": "Jahrzehntelange Erfahrung kombiniert mit europaweitem Know-how in modernsten Anlagen."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"title": "Verfügbarkeit",
|
||||||
|
"desc": "Schnelle und verlässliche Unterstützung ohne unnötige Verzögerungen."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"title": "Lösungen",
|
||||||
|
"desc": "Wir stellen die richtigen Fragen, um die technisch und wirtschaftlich beste Lösung zu finden."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"title": "Logistik",
|
||||||
|
"desc": "Von der Fertigungsüberwachung bis zum termingerechten Fracht-Tracking."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"title": "Offenheit",
|
||||||
|
"desc": "Wir hören zu und passen unsere Prozesse kontinuierlich an Ihren Erfolg an."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"title": "Zuverlässigkeit",
|
||||||
|
"desc": "Wir halten, was wir versprechen – ohne Ausnahme. Verbindlichkeit ist unser Fundament."
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"cta": {
|
||||||
|
"title": "Bereit für Ihr nächstes Projekt?",
|
||||||
|
"subtitle": "Lassen Sie uns gemeinsam die optimale Lösung für Ihre Energieinfrastruktur finden. Wir beraten Sie herstellerneutral und kompetent.",
|
||||||
|
"button": "Jetzt Kontakt aufnehmen"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"Contact": {
|
||||||
|
"hero": {
|
||||||
|
"tagline": "Kontakt",
|
||||||
|
"title": "Lassen Sie uns sprechen",
|
||||||
|
"subtitle": "Haben Sie Fragen zu einem Projekt oder benötigen Sie technische Beratung? Wir freuen uns auf Ihre Nachricht."
|
||||||
|
},
|
||||||
|
"info": {
|
||||||
|
"email": "E-Mail",
|
||||||
|
"address": "Anschrift",
|
||||||
|
"company": "MB Grid Solutions & Services GmbH"
|
||||||
|
},
|
||||||
|
"form": {
|
||||||
|
"name": "Name *",
|
||||||
|
"namePlaceholder": "Ihr Name",
|
||||||
|
"company": "Firma",
|
||||||
|
"companyPlaceholder": "Ihr Unternehmen",
|
||||||
|
"email": "E-Mail *",
|
||||||
|
"emailPlaceholder": "ihre@email.de",
|
||||||
|
"message": "Nachricht *",
|
||||||
|
"messagePlaceholder": "Wie können wir Ihnen helfen?",
|
||||||
|
"submit": "Anfrage senden",
|
||||||
|
"submitting": "Übertragung läuft...",
|
||||||
|
"successTitle": "Anfrage erfolgreich übermittelt",
|
||||||
|
"successMessage": "Ihr Anliegen wurde erfasst. Wir werden die Informationen prüfen und sich zeitnah mit Ihnen in Verbindung setzen.",
|
||||||
|
"close": "Schließen",
|
||||||
|
"tryAgain": "Erneut versuchen",
|
||||||
|
"moreMessages": "Weitere Nachricht",
|
||||||
|
"privacyNote": "* Pflichtfelder. Mit dem Absenden erklären Sie sich mit unserer Datenschutzerklärung einverstanden.",
|
||||||
|
"errorTitle": "Systemfehler",
|
||||||
|
"errorMessage": "Die Anfrage konnte nicht übermittelt werden. Bitte prüfen Sie Ihre Verbindung oder versuchen Sie es später erneut.",
|
||||||
|
"message_too_short": "Ihre Nachricht ist zu kurz (mindestens 20 Zeichen). Bitte beschreiben Sie Ihr Anliegen etwas detaillierter.",
|
||||||
|
"message_too_long": "Ihre Nachricht ist zu lang (maximal 4000 Zeichen). Bitte fassen Sie sich etwas kürzer."
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
22
middleware.ts
Normal file
22
middleware.ts
Normal file
@@ -0,0 +1,22 @@
|
|||||||
|
import createMiddleware from "next-intl/middleware";
|
||||||
|
|
||||||
|
export default createMiddleware({
|
||||||
|
// A list of all locales that are supported
|
||||||
|
locales: ["de"],
|
||||||
|
|
||||||
|
// Used when no locale matches
|
||||||
|
defaultLocale: "de",
|
||||||
|
|
||||||
|
// Use default locale without prefix
|
||||||
|
localePrefix: "as-needed",
|
||||||
|
});
|
||||||
|
|
||||||
|
export const config = {
|
||||||
|
// Matcher for all pages and internationalized pathnames
|
||||||
|
// excluding api, _next, static files, etc.
|
||||||
|
matcher: [
|
||||||
|
"/((?!api|admin|stats|errors|_next|_vercel|.*\\..*).*)",
|
||||||
|
"/",
|
||||||
|
"/(de)/:path*",
|
||||||
|
],
|
||||||
|
};
|
||||||
1278
migrations/20260227_113637_v1_initial.json
Normal file
1278
migrations/20260227_113637_v1_initial.json
Normal file
File diff suppressed because it is too large
Load Diff
183
migrations/20260227_113637_v1_initial.ts
Normal file
183
migrations/20260227_113637_v1_initial.ts
Normal file
@@ -0,0 +1,183 @@
|
|||||||
|
import { MigrateUpArgs, MigrateDownArgs, sql } from "@payloadcms/db-postgres";
|
||||||
|
|
||||||
|
export async function up({ db, payload, req }: MigrateUpArgs): Promise<void> {
|
||||||
|
await db.execute(sql`
|
||||||
|
CREATE TABLE "users_sessions" (
|
||||||
|
"_order" integer NOT NULL,
|
||||||
|
"_parent_id" integer NOT NULL,
|
||||||
|
"id" varchar PRIMARY KEY NOT NULL,
|
||||||
|
"created_at" timestamp(3) with time zone,
|
||||||
|
"expires_at" timestamp(3) with time zone NOT NULL
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE "users" (
|
||||||
|
"id" serial PRIMARY KEY NOT NULL,
|
||||||
|
"updated_at" timestamp(3) with time zone DEFAULT now() NOT NULL,
|
||||||
|
"created_at" timestamp(3) with time zone DEFAULT now() NOT NULL,
|
||||||
|
"email" varchar NOT NULL,
|
||||||
|
"reset_password_token" varchar,
|
||||||
|
"reset_password_expiration" timestamp(3) with time zone,
|
||||||
|
"salt" varchar,
|
||||||
|
"hash" varchar,
|
||||||
|
"login_attempts" numeric DEFAULT 0,
|
||||||
|
"lock_until" timestamp(3) with time zone
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE "media" (
|
||||||
|
"id" serial PRIMARY KEY NOT NULL,
|
||||||
|
"alt" varchar NOT NULL,
|
||||||
|
"prefix" varchar DEFAULT 'mb-grid-solutions/media',
|
||||||
|
"updated_at" timestamp(3) with time zone DEFAULT now() NOT NULL,
|
||||||
|
"created_at" timestamp(3) with time zone DEFAULT now() NOT NULL,
|
||||||
|
"url" varchar,
|
||||||
|
"thumbnail_u_r_l" varchar,
|
||||||
|
"filename" varchar,
|
||||||
|
"mime_type" varchar,
|
||||||
|
"filesize" numeric,
|
||||||
|
"width" numeric,
|
||||||
|
"height" numeric,
|
||||||
|
"focal_x" numeric,
|
||||||
|
"focal_y" numeric,
|
||||||
|
"sizes_thumbnail_url" varchar,
|
||||||
|
"sizes_thumbnail_width" numeric,
|
||||||
|
"sizes_thumbnail_height" numeric,
|
||||||
|
"sizes_thumbnail_mime_type" varchar,
|
||||||
|
"sizes_thumbnail_filesize" numeric,
|
||||||
|
"sizes_thumbnail_filename" varchar,
|
||||||
|
"sizes_card_url" varchar,
|
||||||
|
"sizes_card_width" numeric,
|
||||||
|
"sizes_card_height" numeric,
|
||||||
|
"sizes_card_mime_type" varchar,
|
||||||
|
"sizes_card_filesize" numeric,
|
||||||
|
"sizes_card_filename" varchar
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE "form_submissions" (
|
||||||
|
"id" serial PRIMARY KEY NOT NULL,
|
||||||
|
"name" varchar NOT NULL,
|
||||||
|
"email" varchar NOT NULL,
|
||||||
|
"company" varchar,
|
||||||
|
"message" varchar NOT NULL,
|
||||||
|
"updated_at" timestamp(3) with time zone DEFAULT now() NOT NULL,
|
||||||
|
"created_at" timestamp(3) with time zone DEFAULT now() NOT NULL
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE "pages" (
|
||||||
|
"id" serial PRIMARY KEY NOT NULL,
|
||||||
|
"title" varchar NOT NULL,
|
||||||
|
"slug" varchar NOT NULL,
|
||||||
|
"content" jsonb NOT NULL,
|
||||||
|
"updated_at" timestamp(3) with time zone DEFAULT now() NOT NULL,
|
||||||
|
"created_at" timestamp(3) with time zone DEFAULT now() NOT NULL
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE "payload_kv" (
|
||||||
|
"id" serial PRIMARY KEY NOT NULL,
|
||||||
|
"key" varchar NOT NULL,
|
||||||
|
"data" jsonb NOT NULL
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE "payload_locked_documents" (
|
||||||
|
"id" serial PRIMARY KEY NOT NULL,
|
||||||
|
"global_slug" varchar,
|
||||||
|
"updated_at" timestamp(3) with time zone DEFAULT now() NOT NULL,
|
||||||
|
"created_at" timestamp(3) with time zone DEFAULT now() NOT NULL
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE "payload_locked_documents_rels" (
|
||||||
|
"id" serial PRIMARY KEY NOT NULL,
|
||||||
|
"order" integer,
|
||||||
|
"parent_id" integer NOT NULL,
|
||||||
|
"path" varchar NOT NULL,
|
||||||
|
"users_id" integer,
|
||||||
|
"media_id" integer,
|
||||||
|
"form_submissions_id" integer,
|
||||||
|
"pages_id" integer
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE "payload_preferences" (
|
||||||
|
"id" serial PRIMARY KEY NOT NULL,
|
||||||
|
"key" varchar,
|
||||||
|
"value" jsonb,
|
||||||
|
"updated_at" timestamp(3) with time zone DEFAULT now() NOT NULL,
|
||||||
|
"created_at" timestamp(3) with time zone DEFAULT now() NOT NULL
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE "payload_preferences_rels" (
|
||||||
|
"id" serial PRIMARY KEY NOT NULL,
|
||||||
|
"order" integer,
|
||||||
|
"parent_id" integer NOT NULL,
|
||||||
|
"path" varchar NOT NULL,
|
||||||
|
"users_id" integer
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE "payload_migrations" (
|
||||||
|
"id" serial PRIMARY KEY NOT NULL,
|
||||||
|
"name" varchar,
|
||||||
|
"batch" numeric,
|
||||||
|
"updated_at" timestamp(3) with time zone DEFAULT now() NOT NULL,
|
||||||
|
"created_at" timestamp(3) with time zone DEFAULT now() NOT NULL
|
||||||
|
);
|
||||||
|
|
||||||
|
ALTER TABLE "users_sessions" ADD CONSTRAINT "users_sessions_parent_id_fk" FOREIGN KEY ("_parent_id") REFERENCES "public"."users"("id") ON DELETE cascade ON UPDATE no action;
|
||||||
|
ALTER TABLE "payload_locked_documents_rels" ADD CONSTRAINT "payload_locked_documents_rels_parent_fk" FOREIGN KEY ("parent_id") REFERENCES "public"."payload_locked_documents"("id") ON DELETE cascade ON UPDATE no action;
|
||||||
|
ALTER TABLE "payload_locked_documents_rels" ADD CONSTRAINT "payload_locked_documents_rels_users_fk" FOREIGN KEY ("users_id") REFERENCES "public"."users"("id") ON DELETE cascade ON UPDATE no action;
|
||||||
|
ALTER TABLE "payload_locked_documents_rels" ADD CONSTRAINT "payload_locked_documents_rels_media_fk" FOREIGN KEY ("media_id") REFERENCES "public"."media"("id") ON DELETE cascade ON UPDATE no action;
|
||||||
|
ALTER TABLE "payload_locked_documents_rels" ADD CONSTRAINT "payload_locked_documents_rels_form_submissions_fk" FOREIGN KEY ("form_submissions_id") REFERENCES "public"."form_submissions"("id") ON DELETE cascade ON UPDATE no action;
|
||||||
|
ALTER TABLE "payload_locked_documents_rels" ADD CONSTRAINT "payload_locked_documents_rels_pages_fk" FOREIGN KEY ("pages_id") REFERENCES "public"."pages"("id") ON DELETE cascade ON UPDATE no action;
|
||||||
|
ALTER TABLE "payload_preferences_rels" ADD CONSTRAINT "payload_preferences_rels_parent_fk" FOREIGN KEY ("parent_id") REFERENCES "public"."payload_preferences"("id") ON DELETE cascade ON UPDATE no action;
|
||||||
|
ALTER TABLE "payload_preferences_rels" ADD CONSTRAINT "payload_preferences_rels_users_fk" FOREIGN KEY ("users_id") REFERENCES "public"."users"("id") ON DELETE cascade ON UPDATE no action;
|
||||||
|
CREATE INDEX "users_sessions_order_idx" ON "users_sessions" USING btree ("_order");
|
||||||
|
CREATE INDEX "users_sessions_parent_id_idx" ON "users_sessions" USING btree ("_parent_id");
|
||||||
|
CREATE INDEX "users_updated_at_idx" ON "users" USING btree ("updated_at");
|
||||||
|
CREATE INDEX "users_created_at_idx" ON "users" USING btree ("created_at");
|
||||||
|
CREATE UNIQUE INDEX "users_email_idx" ON "users" USING btree ("email");
|
||||||
|
CREATE INDEX "media_updated_at_idx" ON "media" USING btree ("updated_at");
|
||||||
|
CREATE INDEX "media_created_at_idx" ON "media" USING btree ("created_at");
|
||||||
|
CREATE UNIQUE INDEX "media_filename_idx" ON "media" USING btree ("filename");
|
||||||
|
CREATE INDEX "media_sizes_thumbnail_sizes_thumbnail_filename_idx" ON "media" USING btree ("sizes_thumbnail_filename");
|
||||||
|
CREATE INDEX "media_sizes_card_sizes_card_filename_idx" ON "media" USING btree ("sizes_card_filename");
|
||||||
|
CREATE INDEX "form_submissions_updated_at_idx" ON "form_submissions" USING btree ("updated_at");
|
||||||
|
CREATE INDEX "form_submissions_created_at_idx" ON "form_submissions" USING btree ("created_at");
|
||||||
|
CREATE INDEX "pages_updated_at_idx" ON "pages" USING btree ("updated_at");
|
||||||
|
CREATE INDEX "pages_created_at_idx" ON "pages" USING btree ("created_at");
|
||||||
|
CREATE UNIQUE INDEX "payload_kv_key_idx" ON "payload_kv" USING btree ("key");
|
||||||
|
CREATE INDEX "payload_locked_documents_global_slug_idx" ON "payload_locked_documents" USING btree ("global_slug");
|
||||||
|
CREATE INDEX "payload_locked_documents_updated_at_idx" ON "payload_locked_documents" USING btree ("updated_at");
|
||||||
|
CREATE INDEX "payload_locked_documents_created_at_idx" ON "payload_locked_documents" USING btree ("created_at");
|
||||||
|
CREATE INDEX "payload_locked_documents_rels_order_idx" ON "payload_locked_documents_rels" USING btree ("order");
|
||||||
|
CREATE INDEX "payload_locked_documents_rels_parent_idx" ON "payload_locked_documents_rels" USING btree ("parent_id");
|
||||||
|
CREATE INDEX "payload_locked_documents_rels_path_idx" ON "payload_locked_documents_rels" USING btree ("path");
|
||||||
|
CREATE INDEX "payload_locked_documents_rels_users_id_idx" ON "payload_locked_documents_rels" USING btree ("users_id");
|
||||||
|
CREATE INDEX "payload_locked_documents_rels_media_id_idx" ON "payload_locked_documents_rels" USING btree ("media_id");
|
||||||
|
CREATE INDEX "payload_locked_documents_rels_form_submissions_id_idx" ON "payload_locked_documents_rels" USING btree ("form_submissions_id");
|
||||||
|
CREATE INDEX "payload_locked_documents_rels_pages_id_idx" ON "payload_locked_documents_rels" USING btree ("pages_id");
|
||||||
|
CREATE INDEX "payload_preferences_key_idx" ON "payload_preferences" USING btree ("key");
|
||||||
|
CREATE INDEX "payload_preferences_updated_at_idx" ON "payload_preferences" USING btree ("updated_at");
|
||||||
|
CREATE INDEX "payload_preferences_created_at_idx" ON "payload_preferences" USING btree ("created_at");
|
||||||
|
CREATE INDEX "payload_preferences_rels_order_idx" ON "payload_preferences_rels" USING btree ("order");
|
||||||
|
CREATE INDEX "payload_preferences_rels_parent_idx" ON "payload_preferences_rels" USING btree ("parent_id");
|
||||||
|
CREATE INDEX "payload_preferences_rels_path_idx" ON "payload_preferences_rels" USING btree ("path");
|
||||||
|
CREATE INDEX "payload_preferences_rels_users_id_idx" ON "payload_preferences_rels" USING btree ("users_id");
|
||||||
|
CREATE INDEX "payload_migrations_updated_at_idx" ON "payload_migrations" USING btree ("updated_at");
|
||||||
|
CREATE INDEX "payload_migrations_created_at_idx" ON "payload_migrations" USING btree ("created_at");`);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function down({
|
||||||
|
db,
|
||||||
|
payload,
|
||||||
|
req,
|
||||||
|
}: MigrateDownArgs): Promise<void> {
|
||||||
|
await db.execute(sql`
|
||||||
|
DROP TABLE "users_sessions" CASCADE;
|
||||||
|
DROP TABLE "users" CASCADE;
|
||||||
|
DROP TABLE "media" CASCADE;
|
||||||
|
DROP TABLE "form_submissions" CASCADE;
|
||||||
|
DROP TABLE "pages" CASCADE;
|
||||||
|
DROP TABLE "payload_kv" CASCADE;
|
||||||
|
DROP TABLE "payload_locked_documents" CASCADE;
|
||||||
|
DROP TABLE "payload_locked_documents_rels" CASCADE;
|
||||||
|
DROP TABLE "payload_preferences" CASCADE;
|
||||||
|
DROP TABLE "payload_preferences_rels" CASCADE;
|
||||||
|
DROP TABLE "payload_migrations" CASCADE;`);
|
||||||
|
}
|
||||||
9
migrations/index.ts
Normal file
9
migrations/index.ts
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
import * as migration_20260227_113637_v1_initial from "./20260227_113637_v1_initial";
|
||||||
|
|
||||||
|
export const migrations = [
|
||||||
|
{
|
||||||
|
up: migration_20260227_113637_v1_initial.up,
|
||||||
|
down: migration_20260227_113637_v1_initial.down,
|
||||||
|
name: "20260227_113637_v1_initial",
|
||||||
|
},
|
||||||
|
];
|
||||||
37
next.config.mjs
Normal file
37
next.config.mjs
Normal file
@@ -0,0 +1,37 @@
|
|||||||
|
import { withPayload } from "@payloadcms/next/withPayload";
|
||||||
|
import withMintelConfig from "@mintel/next-config";
|
||||||
|
|
||||||
|
/** @type {import('next').NextConfig} */
|
||||||
|
const nextConfig = {
|
||||||
|
async rewrites() {
|
||||||
|
const umamiUrl =
|
||||||
|
process.env.UMAMI_API_ENDPOINT ||
|
||||||
|
process.env.UMAMI_SCRIPT_URL ||
|
||||||
|
process.env.NEXT_PUBLIC_UMAMI_SCRIPT_URL ||
|
||||||
|
"https://analytics.infra.mintel.me";
|
||||||
|
const glitchtipUrl = process.env.SENTRY_DSN
|
||||||
|
? new URL(process.env.SENTRY_DSN).origin
|
||||||
|
: "https://errors.infra.mintel.me";
|
||||||
|
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
source: "/stats/:path*",
|
||||||
|
destination: `${umamiUrl}/:path*`,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
source: "/:locale(de)/stats/:path*",
|
||||||
|
destination: `${umamiUrl}/:path*`,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
source: "/errors/:path*",
|
||||||
|
destination: `${glitchtipUrl}/:path*`,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
source: "/:locale(de)/errors/:path*",
|
||||||
|
destination: `${glitchtipUrl}/:path*`,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
export default withPayload(withMintelConfig(nextConfig));
|
||||||
4574
package-lock.json
generated
4574
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
56
package.json
56
package.json
@@ -2,26 +2,60 @@
|
|||||||
"name": "mb-grid-solutions.com",
|
"name": "mb-grid-solutions.com",
|
||||||
"version": "1.0.0",
|
"version": "1.0.0",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
|
"packageManager": "pnpm@10.18.3",
|
||||||
"scripts": {
|
"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://mb-grid-solutions.localhost/admin\\n🚦 Traefik: http://localhost:8080\\n\\n(Press Ctrl+C to stop)\\n' && docker compose down --remove-orphans && docker compose up app mb-grid-db",
|
||||||
|
"dev:next": "next dev",
|
||||||
"build": "next build",
|
"build": "next build",
|
||||||
"start": "next start",
|
"start": "next start",
|
||||||
"lint": "next lint",
|
"lint": "eslint app components lib scripts",
|
||||||
"test": "vitest"
|
"test": "vitest",
|
||||||
|
"prepare": "husky",
|
||||||
|
"generate:types": "payload generate:types",
|
||||||
|
"generate:importmap": "payload generate:importmap",
|
||||||
|
"pagespeed:test": "mintel pagespeed test",
|
||||||
|
"check:http": "tsx ./scripts/check-http.ts",
|
||||||
|
"check:apis": "tsx ./scripts/check-apis.ts",
|
||||||
|
"check:locale": "tsx ./scripts/check-locale.ts"
|
||||||
},
|
},
|
||||||
"keywords": [],
|
"keywords": [],
|
||||||
"author": "",
|
"author": "",
|
||||||
"license": "ISC",
|
"license": "ISC",
|
||||||
"description": "",
|
"description": "",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
"@aws-sdk/client-s3": "^3.999.0",
|
||||||
|
"@mintel/mail": "^1.8.21",
|
||||||
|
"@mintel/next-config": "^1.8.20",
|
||||||
|
"@mintel/next-utils": "^1.8.20",
|
||||||
|
"@payloadcms/db-postgres": "^3.77.0",
|
||||||
|
"@payloadcms/email-nodemailer": "^3.77.0",
|
||||||
|
"@payloadcms/next": "^3.77.0",
|
||||||
|
"@payloadcms/richtext-lexical": "^3.77.0",
|
||||||
|
"@payloadcms/storage-s3": "^3.77.0",
|
||||||
|
"@payloadcms/ui": "^3.77.0",
|
||||||
|
"@react-email/components": "^1.0.8",
|
||||||
|
"@sentry/nextjs": "^10.38.0",
|
||||||
"framer-motion": "^12.29.2",
|
"framer-motion": "^12.29.2",
|
||||||
|
"graphql": "^16.13.0",
|
||||||
"lucide-react": "^0.562.0",
|
"lucide-react": "^0.562.0",
|
||||||
"next": "^16.1.6",
|
"next": "^16.1.6",
|
||||||
|
"next-intl": "^4.8.2",
|
||||||
"nodemailer": "^7.0.12",
|
"nodemailer": "^7.0.12",
|
||||||
|
"payload": "^3.77.0",
|
||||||
|
"pino": "^10.3.0",
|
||||||
"react": "^19.2.4",
|
"react": "^19.2.4",
|
||||||
"react-dom": "^19.2.4"
|
"react-dom": "^19.2.4",
|
||||||
|
"react-email": "^5.2.8",
|
||||||
|
"sharp": "^0.34.5",
|
||||||
|
"zod": "^3.24.1"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
|
"@commitlint/cli": "^20.4.0",
|
||||||
|
"@commitlint/config-conventional": "^20.4.0",
|
||||||
|
"@mintel/cli": "^1.8.20",
|
||||||
|
"@mintel/eslint-config": "^1.8.20",
|
||||||
|
"@mintel/husky-config": "^1.8.20",
|
||||||
|
"@mintel/tsconfig": "^1.8.20",
|
||||||
"@tailwindcss/postcss": "^4.1.18",
|
"@tailwindcss/postcss": "^4.1.18",
|
||||||
"@testing-library/jest-dom": "^6.9.1",
|
"@testing-library/jest-dom": "^6.9.1",
|
||||||
"@testing-library/react": "^16.3.2",
|
"@testing-library/react": "^16.3.2",
|
||||||
@@ -31,10 +65,24 @@
|
|||||||
"@types/react-dom": "^19.2.3",
|
"@types/react-dom": "^19.2.3",
|
||||||
"@vitejs/plugin-react": "^5.1.2",
|
"@vitejs/plugin-react": "^5.1.2",
|
||||||
"autoprefixer": "^10.4.23",
|
"autoprefixer": "^10.4.23",
|
||||||
|
"axios": "^1.13.5",
|
||||||
|
"cheerio": "^1.2.0",
|
||||||
|
"eslint": "^8.57.1",
|
||||||
|
"eslint-config-next": "15.1.6",
|
||||||
|
"happy-dom": "^20.6.1",
|
||||||
|
"husky": "^9.1.7",
|
||||||
"jsdom": "^27.4.0",
|
"jsdom": "^27.4.0",
|
||||||
|
"lint-staged": "^16.2.7",
|
||||||
|
"pino-pretty": "^13.1.3",
|
||||||
"postcss": "^8.5.6",
|
"postcss": "^8.5.6",
|
||||||
|
"prettier": "^3.5.0",
|
||||||
"tailwindcss": "^4.1.18",
|
"tailwindcss": "^4.1.18",
|
||||||
|
"tsx": "^4.21.0",
|
||||||
"typescript": "^5.9.3",
|
"typescript": "^5.9.3",
|
||||||
"vitest": "^4.0.18"
|
"vitest": "^4.0.18"
|
||||||
|
},
|
||||||
|
"repository": {
|
||||||
|
"type": "git",
|
||||||
|
"url": "ssh://git@git.infra.mintel.me:2222/mmintel/mb-grid-solutions.com.git"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
14581
pnpm-lock.yaml
generated
Normal file
14581
pnpm-lock.yaml
generated
Normal file
File diff suppressed because it is too large
Load Diff
7
pnpm-workspace.yaml
Normal file
7
pnpm-workspace.yaml
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
onlyBuiltDependencies:
|
||||||
|
- '@parcel/watcher'
|
||||||
|
- '@sentry/cli'
|
||||||
|
- '@swc/core'
|
||||||
|
- esbuild
|
||||||
|
- sharp
|
||||||
|
- unrs-resolver
|
||||||
Binary file not shown.
|
Before Width: | Height: | Size: 204 KiB After Width: | Height: | Size: 21 KiB |
Binary file not shown.
|
Before Width: | Height: | Size: 1.4 MiB After Width: | Height: | Size: 1.1 MiB |
Binary file not shown.
|
Before Width: | Height: | Size: 736 KiB After Width: | Height: | Size: 428 KiB |
136
scripts/check-apis.ts
Normal file
136
scripts/check-apis.ts
Normal file
@@ -0,0 +1,136 @@
|
|||||||
|
import axios from "axios";
|
||||||
|
import dns from "dns";
|
||||||
|
import { promisify } from "util";
|
||||||
|
import url from "url";
|
||||||
|
|
||||||
|
const resolve4 = promisify(dns.resolve4);
|
||||||
|
|
||||||
|
// This script verifies that external logging and analytics APIs are reachable
|
||||||
|
// from the deployment environment (which could be behind corporate firewalls or VPNs).
|
||||||
|
|
||||||
|
const umamiEndpoint =
|
||||||
|
process.env.UMAMI_API_ENDPOINT || "https://analytics.infra.mintel.me";
|
||||||
|
const sentryDsn = process.env.SENTRY_DSN || "";
|
||||||
|
|
||||||
|
async function checkUmami() {
|
||||||
|
console.log(`\n🔍 Checking Umami Analytics API Availability...`);
|
||||||
|
console.log(` Endpoint: ${umamiEndpoint}`);
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Umami usually exposes a /api/heartbeat or /api/health if we know the route.
|
||||||
|
// Trying root or /api/auth/verify (which will give 401 but proves routing works).
|
||||||
|
// A simple GET to the configured endpoint should return a 200 or 401, not a 5xx/timeout.
|
||||||
|
const response = await axios.get(
|
||||||
|
`${umamiEndpoint.replace(/\/$/, "")}/api/health`,
|
||||||
|
{
|
||||||
|
timeout: 5000,
|
||||||
|
validateStatus: () => true, // Accept any status, we just want to know it's reachable and not 5xx
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
// As long as it's not a 502/503/504 Bad Gateway/Timeout, the service is "up" from our perspective
|
||||||
|
if (response.status >= 500) {
|
||||||
|
throw new Error(
|
||||||
|
`Umami API responded with server error HTTP ${response.status}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log(` ✅ Umami Analytics is reachable (HTTP ${response.status})`);
|
||||||
|
return true;
|
||||||
|
} catch (error) {
|
||||||
|
const err = error as Error;
|
||||||
|
// If /api/health fails completely, maybe try a DNS check as a fallback
|
||||||
|
try {
|
||||||
|
console.warn(
|
||||||
|
` ⚠️ HTTP check failed, falling back to DNS resolution...`,
|
||||||
|
);
|
||||||
|
const umamiHost = new url.URL(umamiEndpoint).hostname;
|
||||||
|
await resolve4(umamiHost);
|
||||||
|
console.log(
|
||||||
|
` ✅ Umami Analytics DNS resolved successfully (${umamiHost})`,
|
||||||
|
);
|
||||||
|
return true;
|
||||||
|
} catch (error) {
|
||||||
|
const dnsErr = error as Error;
|
||||||
|
console.error(
|
||||||
|
` ❌ CRITICAL: Umami Analytics is completely unreachable! ${err.message} | DNS: ${dnsErr.message}`,
|
||||||
|
);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function checkSentry() {
|
||||||
|
console.log(`\n🔍 Checking Glitchtip/Sentry Error Tracking Availability...`);
|
||||||
|
|
||||||
|
if (!sentryDsn) {
|
||||||
|
console.log(` ℹ️ No SENTRY_DSN provided in environment. Skipping.`);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const parsedDsn = new url.URL(sentryDsn);
|
||||||
|
const host = parsedDsn.hostname;
|
||||||
|
console.log(` Host: ${host}`);
|
||||||
|
|
||||||
|
// We do a DNS lookup to ensure the runner can actually resolve the tracking server
|
||||||
|
const addresses = await resolve4(host);
|
||||||
|
|
||||||
|
if (addresses && addresses.length > 0) {
|
||||||
|
console.log(` ✅ Glitchtip/Sentry domain resolved: ${addresses[0]}`);
|
||||||
|
|
||||||
|
// Optional: Quick TCP/HTTP check to the host root (Glitchtip usually runs on 80/443 root)
|
||||||
|
try {
|
||||||
|
const proto = parsedDsn.protocol || "https:";
|
||||||
|
await axios.get(`${proto}//${host}/api/0/`, {
|
||||||
|
timeout: 5000,
|
||||||
|
validateStatus: () => true,
|
||||||
|
});
|
||||||
|
console.log(` ✅ Glitchtip/Sentry API root responds to HTTP.`);
|
||||||
|
} catch {
|
||||||
|
console.log(
|
||||||
|
` ⚠️ Glitchtip/Sentry HTTP ping failed or timed out, but DNS is valid. Proceeding.`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
throw new Error("No IP addresses found for DSN host");
|
||||||
|
} catch (error) {
|
||||||
|
const err = error as Error;
|
||||||
|
console.error(
|
||||||
|
` ❌ CRITICAL: Glitchtip/Sentry DSN is invalid or hostname is unresolvable! ${err.message}`,
|
||||||
|
);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function main() {
|
||||||
|
console.log("🚀 Starting External API Connectivity Smoke Test...");
|
||||||
|
|
||||||
|
let hasErrors = false;
|
||||||
|
|
||||||
|
const umamiOk = await checkUmami();
|
||||||
|
if (!umamiOk) hasErrors = true;
|
||||||
|
|
||||||
|
const sentryOk = await checkSentry();
|
||||||
|
if (!sentryOk) hasErrors = true;
|
||||||
|
|
||||||
|
if (hasErrors) {
|
||||||
|
console.error(
|
||||||
|
`\n🚨 POST-DEPLOY CHECK FAILED: One or more critical external APIs are unreachable.`,
|
||||||
|
);
|
||||||
|
console.error(
|
||||||
|
` This might mean the deployment environment lacks outbound internet access, `,
|
||||||
|
);
|
||||||
|
console.error(
|
||||||
|
` DNS is misconfigured, or the upstream services are down.`,
|
||||||
|
);
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log(`\n🎉 SUCCESS: All required external APIs are reachable!`);
|
||||||
|
process.exit(0);
|
||||||
|
}
|
||||||
|
|
||||||
|
main();
|
||||||
91
scripts/check-http.ts
Normal file
91
scripts/check-http.ts
Normal file
@@ -0,0 +1,91 @@
|
|||||||
|
import axios from "axios";
|
||||||
|
import * as cheerio from "cheerio";
|
||||||
|
|
||||||
|
const targetUrl =
|
||||||
|
process.argv[2] ||
|
||||||
|
process.env.NEXT_PUBLIC_BASE_URL ||
|
||||||
|
"http://localhost:3000";
|
||||||
|
const gatekeeperPassword = process.env.GATEKEEPER_PASSWORD || "lassmichrein";
|
||||||
|
|
||||||
|
async function main() {
|
||||||
|
console.log(`\n🚀 Starting HTTP Sitemap Validation for: ${targetUrl}\n`);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const sitemapUrl = `${targetUrl.replace(/\/$/, "")}/sitemap.xml`;
|
||||||
|
console.log(`📥 Fetching sitemap from ${sitemapUrl}...`);
|
||||||
|
|
||||||
|
const response = await axios.get(sitemapUrl, {
|
||||||
|
headers: { Cookie: `mintel_gatekeeper_session=${gatekeeperPassword}` },
|
||||||
|
validateStatus: (status) => status < 400,
|
||||||
|
});
|
||||||
|
|
||||||
|
const $ = cheerio.load(response.data, { xmlMode: true });
|
||||||
|
let urls = $("url loc")
|
||||||
|
.map((i, el) => $(el).text())
|
||||||
|
.get();
|
||||||
|
|
||||||
|
const urlPattern = /https?:\/\/[^\/]+/;
|
||||||
|
urls = [...new Set(urls)]
|
||||||
|
.filter((u) => u.startsWith("http"))
|
||||||
|
.map((u) => u.replace(urlPattern, targetUrl.replace(/\/$/, "")))
|
||||||
|
.sort();
|
||||||
|
|
||||||
|
console.log(`✅ Found ${urls.length} target URLs in sitemap.`);
|
||||||
|
|
||||||
|
if (urls.length === 0) {
|
||||||
|
console.error("❌ No URLs found in sitemap. Is the site up?");
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log(`\n🔍 Verifying HTTP Status Codes (Limit: None)...`);
|
||||||
|
let hasErrors = false;
|
||||||
|
|
||||||
|
// Run fetches sequentially to avoid overwhelming the server during CI
|
||||||
|
for (let i = 0; i < urls.length; i++) {
|
||||||
|
const u = urls[i];
|
||||||
|
try {
|
||||||
|
const res = await axios.get(u, {
|
||||||
|
headers: {
|
||||||
|
Cookie: `mintel_gatekeeper_session=${gatekeeperPassword}`,
|
||||||
|
},
|
||||||
|
validateStatus: null, // Don't throw on error status
|
||||||
|
});
|
||||||
|
|
||||||
|
if (res.status >= 400) {
|
||||||
|
console.error(`❌ ERROR ${res.status}: ${res.statusText} -> ${u}`);
|
||||||
|
hasErrors = true;
|
||||||
|
} else {
|
||||||
|
console.log(`✅ OK ${res.status} -> ${u}`);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
const err = error as Error;
|
||||||
|
console.error(`❌ NETWORK ERROR: ${err.message} -> ${u}`);
|
||||||
|
hasErrors = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (hasErrors) {
|
||||||
|
console.error(
|
||||||
|
`\n❌ HTTP Sitemap Validation Failed. One or more pages returned an error.`,
|
||||||
|
);
|
||||||
|
process.exit(1);
|
||||||
|
} else {
|
||||||
|
console.log(
|
||||||
|
`\n✨ Success: All ${urls.length} pages are healthy! (HTTP 200)`,
|
||||||
|
);
|
||||||
|
process.exit(0);
|
||||||
|
}
|
||||||
|
} catch (e: unknown) {
|
||||||
|
if (axios.isAxiosError(e) && e.response) {
|
||||||
|
console.error(
|
||||||
|
`\n❌ Critical Error during Sitemap Fetch: HTTP ${e.response.status} ${e.response.statusText}`,
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
const errorMsg = e instanceof Error ? e.message : String(e);
|
||||||
|
console.error(`\n❌ Critical Error during Sitemap Fetch: ${errorMsg}`);
|
||||||
|
}
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
main();
|
||||||
199
scripts/check-locale.ts
Normal file
199
scripts/check-locale.ts
Normal file
@@ -0,0 +1,199 @@
|
|||||||
|
import axios from "axios";
|
||||||
|
import * as cheerio from "cheerio";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Locale & Language Switcher Smoke Test
|
||||||
|
*
|
||||||
|
* For every URL in the sitemap:
|
||||||
|
* 1. Fetches the page HTML
|
||||||
|
* 2. Extracts <link rel="alternate" hreflang="..." href="..."> tags
|
||||||
|
* 3. Verifies each alternate URL uses correctly translated slugs
|
||||||
|
* 4. Verifies each alternate URL returns HTTP 200
|
||||||
|
*/
|
||||||
|
|
||||||
|
const targetUrl =
|
||||||
|
process.argv[2] ||
|
||||||
|
process.env.NEXT_PUBLIC_BASE_URL ||
|
||||||
|
"http://localhost:3000";
|
||||||
|
const gatekeeperPassword = process.env.GATEKEEPER_PASSWORD || "lassmichrein";
|
||||||
|
|
||||||
|
// Expected slug translations: German key → English value
|
||||||
|
const SLUG_MAP: Record<string, string> = {
|
||||||
|
// Add translations if mb-grid translates URLs: e.g. produkte: 'products'
|
||||||
|
};
|
||||||
|
|
||||||
|
// Reverse map: English → German
|
||||||
|
const REVERSE_SLUG_MAP: Record<string, string> = Object.fromEntries(
|
||||||
|
Object.entries(SLUG_MAP).map(([de, en]) => [en, de]),
|
||||||
|
);
|
||||||
|
|
||||||
|
const headers = { Cookie: `mintel_gatekeeper_session=${gatekeeperPassword}` };
|
||||||
|
|
||||||
|
function getExpectedTranslation(
|
||||||
|
sourcePath: string,
|
||||||
|
sourceLocale: string,
|
||||||
|
targetLocale: string,
|
||||||
|
): string {
|
||||||
|
const segments = sourcePath.split("/").filter(Boolean);
|
||||||
|
// First segment is locale
|
||||||
|
segments[0] = targetLocale;
|
||||||
|
|
||||||
|
const map = sourceLocale === "de" ? SLUG_MAP : REVERSE_SLUG_MAP;
|
||||||
|
|
||||||
|
return (
|
||||||
|
"/" +
|
||||||
|
segments
|
||||||
|
.map((seg, i) => {
|
||||||
|
if (i === 0) return seg; // locale
|
||||||
|
return map[seg] || seg; // translate or keep (product names like n2x2y stay the same)
|
||||||
|
})
|
||||||
|
.join("/")
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function main() {
|
||||||
|
console.log(`\n🌐 Starting Locale Smoke Test for: ${targetUrl}\n`);
|
||||||
|
|
||||||
|
// 1. Fetch sitemap
|
||||||
|
const sitemapUrl = `${targetUrl.replace(/\/$/, "")}/sitemap.xml`;
|
||||||
|
console.log(`📥 Fetching sitemap from ${sitemapUrl}...`);
|
||||||
|
const sitemapRes = await axios.get(sitemapUrl, {
|
||||||
|
headers,
|
||||||
|
validateStatus: (s) => s < 400,
|
||||||
|
});
|
||||||
|
const $sitemap = cheerio.load(sitemapRes.data, { xmlMode: true });
|
||||||
|
|
||||||
|
let urls = $sitemap("url loc")
|
||||||
|
.map((_i, el) => $sitemap(el).text())
|
||||||
|
.get();
|
||||||
|
|
||||||
|
const urlPattern = /https?:\/\/[^/]+/;
|
||||||
|
urls = [...new Set(urls)]
|
||||||
|
.filter((u) => u.startsWith("http"))
|
||||||
|
.map((u) => u.replace(urlPattern, targetUrl.replace(/\/$/, "")))
|
||||||
|
.sort();
|
||||||
|
|
||||||
|
console.log(`✅ Found ${urls.length} URLs in sitemap.\n`);
|
||||||
|
|
||||||
|
let totalChecked = 0;
|
||||||
|
let totalPassed = 0;
|
||||||
|
let totalFailed = 0;
|
||||||
|
const failures: string[] = [];
|
||||||
|
|
||||||
|
for (const url of urls) {
|
||||||
|
const path = new URL(url).pathname;
|
||||||
|
const locale = path.split("/")[1];
|
||||||
|
if (!locale || !["de", "en"].includes(locale)) continue;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const res = await axios.get(url, { headers, validateStatus: null });
|
||||||
|
if (res.status >= 400) continue; // Skip pages that are already broken (check-http catches those)
|
||||||
|
|
||||||
|
const $ = cheerio.load(res.data);
|
||||||
|
|
||||||
|
// Extract hreflang alternate links
|
||||||
|
const alternates: { hreflang: string; href: string }[] = [];
|
||||||
|
$('link[rel="alternate"][hreflang]').each((_i, el) => {
|
||||||
|
const hreflang = $(el).attr("hreflang") || "";
|
||||||
|
let href = $(el).attr("href") || "";
|
||||||
|
if (href && hreflang && hreflang !== "x-default") {
|
||||||
|
href = href.replace(urlPattern, targetUrl.replace(/\/$/, ""));
|
||||||
|
alternates.push({ hreflang, href });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
if (alternates.length === 0) {
|
||||||
|
// Some pages may not have alternates, that's OK
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
totalChecked++;
|
||||||
|
|
||||||
|
// Validate each alternate
|
||||||
|
let pageOk = true;
|
||||||
|
|
||||||
|
for (const alt of alternates) {
|
||||||
|
if (alt.hreflang === locale) continue; // Same locale, skip
|
||||||
|
|
||||||
|
// 1. Check slug translation is correct
|
||||||
|
const expectedPath = getExpectedTranslation(path, locale, alt.hreflang);
|
||||||
|
const actualPath = new URL(alt.href).pathname;
|
||||||
|
|
||||||
|
if (actualPath !== expectedPath) {
|
||||||
|
console.error(
|
||||||
|
`❌ SLUG MISMATCH: ${path} → hreflang="${alt.hreflang}" expected ${expectedPath} but got ${actualPath}`,
|
||||||
|
);
|
||||||
|
failures.push(
|
||||||
|
`Slug mismatch: ${path} → ${alt.hreflang}: expected ${expectedPath}, got ${actualPath}`,
|
||||||
|
);
|
||||||
|
pageOk = false;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. Check alternate URL returns 200
|
||||||
|
try {
|
||||||
|
const altRes = await axios.get(alt.href, {
|
||||||
|
headers,
|
||||||
|
validateStatus: null,
|
||||||
|
maxRedirects: 5,
|
||||||
|
});
|
||||||
|
if (altRes.status >= 400) {
|
||||||
|
console.error(
|
||||||
|
`❌ BROKEN ALTERNATE: ${path} → ${alt.href} returned ${altRes.status}`,
|
||||||
|
);
|
||||||
|
failures.push(
|
||||||
|
`Broken alternate: ${path} → ${alt.href} (${altRes.status})`,
|
||||||
|
);
|
||||||
|
pageOk = false;
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
const err = error as Error;
|
||||||
|
console.error(
|
||||||
|
`❌ NETWORK ERROR: ${path} → ${alt.href}: ${err.message}`,
|
||||||
|
);
|
||||||
|
failures.push(`Network error: ${path} → ${alt.href}: ${err.message}`);
|
||||||
|
pageOk = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (pageOk) {
|
||||||
|
console.log(
|
||||||
|
`✅ ${path} — alternates OK (${alternates
|
||||||
|
.map((a) => a.hreflang)
|
||||||
|
.filter((h) => h !== locale)
|
||||||
|
.join(", ")})`,
|
||||||
|
);
|
||||||
|
totalPassed++;
|
||||||
|
} else {
|
||||||
|
totalFailed++;
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
const err = error as Error;
|
||||||
|
console.error(`❌ NETWORK ERROR fetching ${url}: ${err.message}`);
|
||||||
|
totalFailed++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log(`\n${"─".repeat(60)}`);
|
||||||
|
console.log(`📊 Locale Smoke Test Results:`);
|
||||||
|
console.log(` Pages checked: ${totalChecked}`);
|
||||||
|
console.log(` Passed: ${totalPassed}`);
|
||||||
|
console.log(` Failed: ${totalFailed}`);
|
||||||
|
|
||||||
|
if (failures.length > 0) {
|
||||||
|
console.log(`\n❌ Failures:`);
|
||||||
|
failures.forEach((f) => console.log(` • ${f}`));
|
||||||
|
console.log(`\n❌ Locale Smoke Test FAILED.`);
|
||||||
|
process.exit(1);
|
||||||
|
} else {
|
||||||
|
console.log(
|
||||||
|
`\n✨ All locale alternates are correctly translated and reachable!`,
|
||||||
|
);
|
||||||
|
process.exit(0);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
main().catch((err) => {
|
||||||
|
console.error(`\n❌ Critical error:`, err.message);
|
||||||
|
process.exit(1);
|
||||||
|
});
|
||||||
86
scripts/upload-s3.ts
Normal file
86
scripts/upload-s3.ts
Normal file
@@ -0,0 +1,86 @@
|
|||||||
|
import { S3Client, PutObjectCommand } from "@aws-sdk/client-s3";
|
||||||
|
import * as fs from "fs";
|
||||||
|
import * as path from "path";
|
||||||
|
|
||||||
|
const S3_ENDPOINT = process.env.S3_ENDPOINT;
|
||||||
|
const S3_REGION = process.env.S3_REGION || "fsn1";
|
||||||
|
const S3_BUCKET = process.env.S3_BUCKET;
|
||||||
|
const S3_PREFIX = process.env.S3_PREFIX;
|
||||||
|
const S3_ACCESS_KEY = process.env.S3_ACCESS_KEY;
|
||||||
|
const S3_SECRET_KEY = process.env.S3_SECRET_KEY;
|
||||||
|
|
||||||
|
if (!S3_ENDPOINT || !S3_BUCKET || !S3_ACCESS_KEY || !S3_SECRET_KEY) {
|
||||||
|
console.error("Missing S3 credentials in environment");
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
const s3Client = new S3Client({
|
||||||
|
region: S3_REGION,
|
||||||
|
endpoint: S3_ENDPOINT,
|
||||||
|
credentials: {
|
||||||
|
accessKeyId: S3_ACCESS_KEY,
|
||||||
|
secretAccessKey: S3_SECRET_KEY,
|
||||||
|
},
|
||||||
|
forcePathStyle: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
async function uploadDirectory(dirPath: string, prefix: string) {
|
||||||
|
const files = fs.readdirSync(dirPath, { withFileTypes: true });
|
||||||
|
|
||||||
|
for (const file of files) {
|
||||||
|
if (file.name === ".DS_Store" || file.name === ".gitkeep") continue;
|
||||||
|
|
||||||
|
const fullPath = path.join(dirPath, file.name);
|
||||||
|
// Combine prefix with filename, ensuring no double slashes, e.g., mb-grid-solutions/media/filename.ext
|
||||||
|
const s3Key = `${prefix}/${file.name}`.replace(/\/+/g, "/");
|
||||||
|
|
||||||
|
if (file.isDirectory()) {
|
||||||
|
await uploadDirectory(fullPath, s3Key);
|
||||||
|
} else {
|
||||||
|
const fileContent = fs.readFileSync(fullPath);
|
||||||
|
let contentType = "application/octet-stream";
|
||||||
|
if (file.name.endsWith(".png")) contentType = "image/png";
|
||||||
|
else if (file.name.endsWith(".jpg") || file.name.endsWith(".jpeg"))
|
||||||
|
contentType = "image/jpeg";
|
||||||
|
else if (file.name.endsWith(".svg")) contentType = "image/svg+xml";
|
||||||
|
else if (file.name.endsWith(".webp")) contentType = "image/webp";
|
||||||
|
else if (file.name.endsWith(".pdf")) contentType = "application/pdf";
|
||||||
|
|
||||||
|
try {
|
||||||
|
await s3Client.send(
|
||||||
|
new PutObjectCommand({
|
||||||
|
Bucket: S3_BUCKET,
|
||||||
|
Key: s3Key,
|
||||||
|
Body: fileContent,
|
||||||
|
ContentType: contentType,
|
||||||
|
ACL: "public-read", // Hetzner requires public-read for public access usually
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
console.log(`✅ Uploaded ${file.name} to ${S3_BUCKET}/${s3Key}`);
|
||||||
|
} catch (err) {
|
||||||
|
console.error(`❌ Failed to upload ${file.name}:`, err);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function main() {
|
||||||
|
const mediaDir = path.resolve(process.cwd(), "public/media");
|
||||||
|
if (fs.existsSync(mediaDir)) {
|
||||||
|
console.log("Uploading public/media...");
|
||||||
|
// Media inside Payload CMS uses prefix/media usually, like mb-grid-solutions/media
|
||||||
|
await uploadDirectory(mediaDir, `${S3_PREFIX}/media`);
|
||||||
|
} else {
|
||||||
|
console.log("No public/media directory found.");
|
||||||
|
}
|
||||||
|
|
||||||
|
const assetsDir = path.resolve(process.cwd(), "public/assets");
|
||||||
|
if (fs.existsSync(assetsDir)) {
|
||||||
|
console.log("Uploading public/assets...");
|
||||||
|
await uploadDirectory(assetsDir, `${S3_PREFIX}/assets`);
|
||||||
|
} else {
|
||||||
|
console.log("No public/assets directory found.");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
main().catch(console.error);
|
||||||
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 {
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
13
sentry.client.config.ts
Normal file
13
sentry.client.config.ts
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
import * as Sentry from "@sentry/nextjs";
|
||||||
|
import { config } from "./lib/config";
|
||||||
|
|
||||||
|
if (config.errors.glitchtip.enabled) {
|
||||||
|
Sentry.init({
|
||||||
|
dsn: config.errors.glitchtip.dsn,
|
||||||
|
tracesSampleRate: 0.1,
|
||||||
|
debug: config.isDevelopment,
|
||||||
|
environment: config.target || "production",
|
||||||
|
// Use the proxy path defined in config
|
||||||
|
tunnel: config.errors.glitchtip.proxyPath,
|
||||||
|
});
|
||||||
|
}
|
||||||
16
sentry.edge.config.ts
Normal file
16
sentry.edge.config.ts
Normal file
@@ -0,0 +1,16 @@
|
|||||||
|
import * as Sentry from "@sentry/nextjs";
|
||||||
|
import { config } from "./lib/config";
|
||||||
|
|
||||||
|
if (config.errors.glitchtip.enabled) {
|
||||||
|
console.log("Initializing Sentry in Edge runtime...", {
|
||||||
|
environment: config.target || "production",
|
||||||
|
});
|
||||||
|
Sentry.init({
|
||||||
|
dsn: config.errors.glitchtip.dsn,
|
||||||
|
tracesSampleRate: 1.0,
|
||||||
|
debug: true, // Force debug for now to see why it's failing
|
||||||
|
environment: config.target || "production",
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
console.warn("Sentry is DISABLED in Edge runtime (missing DSN)");
|
||||||
|
}
|
||||||
11
sentry.server.config.ts
Normal file
11
sentry.server.config.ts
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
import * as Sentry from "@sentry/nextjs";
|
||||||
|
import { config } from "./lib/config";
|
||||||
|
|
||||||
|
if (config.errors.glitchtip.enabled) {
|
||||||
|
Sentry.init({
|
||||||
|
dsn: config.errors.glitchtip.dsn,
|
||||||
|
tracesSampleRate: 1.0,
|
||||||
|
debug: config.isDevelopment,
|
||||||
|
environment: config.target || "production",
|
||||||
|
});
|
||||||
|
}
|
||||||
1278
src/migrations/20260227_113637_v1_initial.json
Normal file
1278
src/migrations/20260227_113637_v1_initial.json
Normal file
File diff suppressed because it is too large
Load Diff
183
src/migrations/20260227_113637_v1_initial.ts
Normal file
183
src/migrations/20260227_113637_v1_initial.ts
Normal file
@@ -0,0 +1,183 @@
|
|||||||
|
import { MigrateUpArgs, MigrateDownArgs, sql } from "@payloadcms/db-postgres";
|
||||||
|
|
||||||
|
export async function up({ db, payload, req }: MigrateUpArgs): Promise<void> {
|
||||||
|
await db.execute(sql`
|
||||||
|
CREATE TABLE "users_sessions" (
|
||||||
|
"_order" integer NOT NULL,
|
||||||
|
"_parent_id" integer NOT NULL,
|
||||||
|
"id" varchar PRIMARY KEY NOT NULL,
|
||||||
|
"created_at" timestamp(3) with time zone,
|
||||||
|
"expires_at" timestamp(3) with time zone NOT NULL
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE "users" (
|
||||||
|
"id" serial PRIMARY KEY NOT NULL,
|
||||||
|
"updated_at" timestamp(3) with time zone DEFAULT now() NOT NULL,
|
||||||
|
"created_at" timestamp(3) with time zone DEFAULT now() NOT NULL,
|
||||||
|
"email" varchar NOT NULL,
|
||||||
|
"reset_password_token" varchar,
|
||||||
|
"reset_password_expiration" timestamp(3) with time zone,
|
||||||
|
"salt" varchar,
|
||||||
|
"hash" varchar,
|
||||||
|
"login_attempts" numeric DEFAULT 0,
|
||||||
|
"lock_until" timestamp(3) with time zone
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE "media" (
|
||||||
|
"id" serial PRIMARY KEY NOT NULL,
|
||||||
|
"alt" varchar NOT NULL,
|
||||||
|
"prefix" varchar DEFAULT 'mb-grid-solutions/media',
|
||||||
|
"updated_at" timestamp(3) with time zone DEFAULT now() NOT NULL,
|
||||||
|
"created_at" timestamp(3) with time zone DEFAULT now() NOT NULL,
|
||||||
|
"url" varchar,
|
||||||
|
"thumbnail_u_r_l" varchar,
|
||||||
|
"filename" varchar,
|
||||||
|
"mime_type" varchar,
|
||||||
|
"filesize" numeric,
|
||||||
|
"width" numeric,
|
||||||
|
"height" numeric,
|
||||||
|
"focal_x" numeric,
|
||||||
|
"focal_y" numeric,
|
||||||
|
"sizes_thumbnail_url" varchar,
|
||||||
|
"sizes_thumbnail_width" numeric,
|
||||||
|
"sizes_thumbnail_height" numeric,
|
||||||
|
"sizes_thumbnail_mime_type" varchar,
|
||||||
|
"sizes_thumbnail_filesize" numeric,
|
||||||
|
"sizes_thumbnail_filename" varchar,
|
||||||
|
"sizes_card_url" varchar,
|
||||||
|
"sizes_card_width" numeric,
|
||||||
|
"sizes_card_height" numeric,
|
||||||
|
"sizes_card_mime_type" varchar,
|
||||||
|
"sizes_card_filesize" numeric,
|
||||||
|
"sizes_card_filename" varchar
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE "form_submissions" (
|
||||||
|
"id" serial PRIMARY KEY NOT NULL,
|
||||||
|
"name" varchar NOT NULL,
|
||||||
|
"email" varchar NOT NULL,
|
||||||
|
"company" varchar,
|
||||||
|
"message" varchar NOT NULL,
|
||||||
|
"updated_at" timestamp(3) with time zone DEFAULT now() NOT NULL,
|
||||||
|
"created_at" timestamp(3) with time zone DEFAULT now() NOT NULL
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE "pages" (
|
||||||
|
"id" serial PRIMARY KEY NOT NULL,
|
||||||
|
"title" varchar NOT NULL,
|
||||||
|
"slug" varchar NOT NULL,
|
||||||
|
"content" jsonb NOT NULL,
|
||||||
|
"updated_at" timestamp(3) with time zone DEFAULT now() NOT NULL,
|
||||||
|
"created_at" timestamp(3) with time zone DEFAULT now() NOT NULL
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE "payload_kv" (
|
||||||
|
"id" serial PRIMARY KEY NOT NULL,
|
||||||
|
"key" varchar NOT NULL,
|
||||||
|
"data" jsonb NOT NULL
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE "payload_locked_documents" (
|
||||||
|
"id" serial PRIMARY KEY NOT NULL,
|
||||||
|
"global_slug" varchar,
|
||||||
|
"updated_at" timestamp(3) with time zone DEFAULT now() NOT NULL,
|
||||||
|
"created_at" timestamp(3) with time zone DEFAULT now() NOT NULL
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE "payload_locked_documents_rels" (
|
||||||
|
"id" serial PRIMARY KEY NOT NULL,
|
||||||
|
"order" integer,
|
||||||
|
"parent_id" integer NOT NULL,
|
||||||
|
"path" varchar NOT NULL,
|
||||||
|
"users_id" integer,
|
||||||
|
"media_id" integer,
|
||||||
|
"form_submissions_id" integer,
|
||||||
|
"pages_id" integer
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE "payload_preferences" (
|
||||||
|
"id" serial PRIMARY KEY NOT NULL,
|
||||||
|
"key" varchar,
|
||||||
|
"value" jsonb,
|
||||||
|
"updated_at" timestamp(3) with time zone DEFAULT now() NOT NULL,
|
||||||
|
"created_at" timestamp(3) with time zone DEFAULT now() NOT NULL
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE "payload_preferences_rels" (
|
||||||
|
"id" serial PRIMARY KEY NOT NULL,
|
||||||
|
"order" integer,
|
||||||
|
"parent_id" integer NOT NULL,
|
||||||
|
"path" varchar NOT NULL,
|
||||||
|
"users_id" integer
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE "payload_migrations" (
|
||||||
|
"id" serial PRIMARY KEY NOT NULL,
|
||||||
|
"name" varchar,
|
||||||
|
"batch" numeric,
|
||||||
|
"updated_at" timestamp(3) with time zone DEFAULT now() NOT NULL,
|
||||||
|
"created_at" timestamp(3) with time zone DEFAULT now() NOT NULL
|
||||||
|
);
|
||||||
|
|
||||||
|
ALTER TABLE "users_sessions" ADD CONSTRAINT "users_sessions_parent_id_fk" FOREIGN KEY ("_parent_id") REFERENCES "public"."users"("id") ON DELETE cascade ON UPDATE no action;
|
||||||
|
ALTER TABLE "payload_locked_documents_rels" ADD CONSTRAINT "payload_locked_documents_rels_parent_fk" FOREIGN KEY ("parent_id") REFERENCES "public"."payload_locked_documents"("id") ON DELETE cascade ON UPDATE no action;
|
||||||
|
ALTER TABLE "payload_locked_documents_rels" ADD CONSTRAINT "payload_locked_documents_rels_users_fk" FOREIGN KEY ("users_id") REFERENCES "public"."users"("id") ON DELETE cascade ON UPDATE no action;
|
||||||
|
ALTER TABLE "payload_locked_documents_rels" ADD CONSTRAINT "payload_locked_documents_rels_media_fk" FOREIGN KEY ("media_id") REFERENCES "public"."media"("id") ON DELETE cascade ON UPDATE no action;
|
||||||
|
ALTER TABLE "payload_locked_documents_rels" ADD CONSTRAINT "payload_locked_documents_rels_form_submissions_fk" FOREIGN KEY ("form_submissions_id") REFERENCES "public"."form_submissions"("id") ON DELETE cascade ON UPDATE no action;
|
||||||
|
ALTER TABLE "payload_locked_documents_rels" ADD CONSTRAINT "payload_locked_documents_rels_pages_fk" FOREIGN KEY ("pages_id") REFERENCES "public"."pages"("id") ON DELETE cascade ON UPDATE no action;
|
||||||
|
ALTER TABLE "payload_preferences_rels" ADD CONSTRAINT "payload_preferences_rels_parent_fk" FOREIGN KEY ("parent_id") REFERENCES "public"."payload_preferences"("id") ON DELETE cascade ON UPDATE no action;
|
||||||
|
ALTER TABLE "payload_preferences_rels" ADD CONSTRAINT "payload_preferences_rels_users_fk" FOREIGN KEY ("users_id") REFERENCES "public"."users"("id") ON DELETE cascade ON UPDATE no action;
|
||||||
|
CREATE INDEX "users_sessions_order_idx" ON "users_sessions" USING btree ("_order");
|
||||||
|
CREATE INDEX "users_sessions_parent_id_idx" ON "users_sessions" USING btree ("_parent_id");
|
||||||
|
CREATE INDEX "users_updated_at_idx" ON "users" USING btree ("updated_at");
|
||||||
|
CREATE INDEX "users_created_at_idx" ON "users" USING btree ("created_at");
|
||||||
|
CREATE UNIQUE INDEX "users_email_idx" ON "users" USING btree ("email");
|
||||||
|
CREATE INDEX "media_updated_at_idx" ON "media" USING btree ("updated_at");
|
||||||
|
CREATE INDEX "media_created_at_idx" ON "media" USING btree ("created_at");
|
||||||
|
CREATE UNIQUE INDEX "media_filename_idx" ON "media" USING btree ("filename");
|
||||||
|
CREATE INDEX "media_sizes_thumbnail_sizes_thumbnail_filename_idx" ON "media" USING btree ("sizes_thumbnail_filename");
|
||||||
|
CREATE INDEX "media_sizes_card_sizes_card_filename_idx" ON "media" USING btree ("sizes_card_filename");
|
||||||
|
CREATE INDEX "form_submissions_updated_at_idx" ON "form_submissions" USING btree ("updated_at");
|
||||||
|
CREATE INDEX "form_submissions_created_at_idx" ON "form_submissions" USING btree ("created_at");
|
||||||
|
CREATE INDEX "pages_updated_at_idx" ON "pages" USING btree ("updated_at");
|
||||||
|
CREATE INDEX "pages_created_at_idx" ON "pages" USING btree ("created_at");
|
||||||
|
CREATE UNIQUE INDEX "payload_kv_key_idx" ON "payload_kv" USING btree ("key");
|
||||||
|
CREATE INDEX "payload_locked_documents_global_slug_idx" ON "payload_locked_documents" USING btree ("global_slug");
|
||||||
|
CREATE INDEX "payload_locked_documents_updated_at_idx" ON "payload_locked_documents" USING btree ("updated_at");
|
||||||
|
CREATE INDEX "payload_locked_documents_created_at_idx" ON "payload_locked_documents" USING btree ("created_at");
|
||||||
|
CREATE INDEX "payload_locked_documents_rels_order_idx" ON "payload_locked_documents_rels" USING btree ("order");
|
||||||
|
CREATE INDEX "payload_locked_documents_rels_parent_idx" ON "payload_locked_documents_rels" USING btree ("parent_id");
|
||||||
|
CREATE INDEX "payload_locked_documents_rels_path_idx" ON "payload_locked_documents_rels" USING btree ("path");
|
||||||
|
CREATE INDEX "payload_locked_documents_rels_users_id_idx" ON "payload_locked_documents_rels" USING btree ("users_id");
|
||||||
|
CREATE INDEX "payload_locked_documents_rels_media_id_idx" ON "payload_locked_documents_rels" USING btree ("media_id");
|
||||||
|
CREATE INDEX "payload_locked_documents_rels_form_submissions_id_idx" ON "payload_locked_documents_rels" USING btree ("form_submissions_id");
|
||||||
|
CREATE INDEX "payload_locked_documents_rels_pages_id_idx" ON "payload_locked_documents_rels" USING btree ("pages_id");
|
||||||
|
CREATE INDEX "payload_preferences_key_idx" ON "payload_preferences" USING btree ("key");
|
||||||
|
CREATE INDEX "payload_preferences_updated_at_idx" ON "payload_preferences" USING btree ("updated_at");
|
||||||
|
CREATE INDEX "payload_preferences_created_at_idx" ON "payload_preferences" USING btree ("created_at");
|
||||||
|
CREATE INDEX "payload_preferences_rels_order_idx" ON "payload_preferences_rels" USING btree ("order");
|
||||||
|
CREATE INDEX "payload_preferences_rels_parent_idx" ON "payload_preferences_rels" USING btree ("parent_id");
|
||||||
|
CREATE INDEX "payload_preferences_rels_path_idx" ON "payload_preferences_rels" USING btree ("path");
|
||||||
|
CREATE INDEX "payload_preferences_rels_users_id_idx" ON "payload_preferences_rels" USING btree ("users_id");
|
||||||
|
CREATE INDEX "payload_migrations_updated_at_idx" ON "payload_migrations" USING btree ("updated_at");
|
||||||
|
CREATE INDEX "payload_migrations_created_at_idx" ON "payload_migrations" USING btree ("created_at");`);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function down({
|
||||||
|
db,
|
||||||
|
payload,
|
||||||
|
req,
|
||||||
|
}: MigrateDownArgs): Promise<void> {
|
||||||
|
await db.execute(sql`
|
||||||
|
DROP TABLE "users_sessions" CASCADE;
|
||||||
|
DROP TABLE "users" CASCADE;
|
||||||
|
DROP TABLE "media" CASCADE;
|
||||||
|
DROP TABLE "form_submissions" CASCADE;
|
||||||
|
DROP TABLE "pages" CASCADE;
|
||||||
|
DROP TABLE "payload_kv" CASCADE;
|
||||||
|
DROP TABLE "payload_locked_documents" CASCADE;
|
||||||
|
DROP TABLE "payload_locked_documents_rels" CASCADE;
|
||||||
|
DROP TABLE "payload_preferences" CASCADE;
|
||||||
|
DROP TABLE "payload_preferences_rels" CASCADE;
|
||||||
|
DROP TABLE "payload_migrations" CASCADE;`);
|
||||||
|
}
|
||||||
9
src/migrations/index.ts
Normal file
9
src/migrations/index.ts
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
import * as migration_20260227_113637_v1_initial from "./20260227_113637_v1_initial";
|
||||||
|
|
||||||
|
export const migrations = [
|
||||||
|
{
|
||||||
|
up: migration_20260227_113637_v1_initial.up,
|
||||||
|
down: migration_20260227_113637_v1_initial.down,
|
||||||
|
name: "20260227_113637_v1_initial",
|
||||||
|
},
|
||||||
|
];
|
||||||
4
src/payload/blocks/allBlocks.ts
Normal file
4
src/payload/blocks/allBlocks.ts
Normal file
@@ -0,0 +1,4 @@
|
|||||||
|
import { Block } from "payload";
|
||||||
|
|
||||||
|
// Define any custom blocks you want here. Leaving empty for now.
|
||||||
|
export const payloadBlocks: Block[] = [];
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user