Compare commits
48 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 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 |
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
|
||||||
@@ -7,138 +7,139 @@ on:
|
|||||||
tags:
|
tags:
|
||||||
- 'v*'
|
- 'v*'
|
||||||
workflow_dispatch:
|
workflow_dispatch:
|
||||||
|
inputs:
|
||||||
|
skip_checks:
|
||||||
|
description: 'Skip tests? (true/false)'
|
||||||
|
required: false
|
||||||
|
default: 'false'
|
||||||
|
|
||||||
concurrency:
|
concurrency:
|
||||||
group: ${{ github.workflow }}
|
group: ${{ github.workflow }}-${{ (github.ref_type == 'tag' && !contains(github.ref_name, '-')) && 'prod' || (github.ref_name == 'main' && 'testing' || github.ref_name) }}
|
||||||
cancel-in-progress: false
|
cancel-in-progress: true
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
|
# ──────────────────────────────────────────────────────────────────────────────
|
||||||
|
# JOB 1: Prepare Environment
|
||||||
|
# ──────────────────────────────────────────────────────────────────────────────
|
||||||
prepare:
|
prepare:
|
||||||
name: 🔍 Prepare Environment
|
name: 🔍 Prepare
|
||||||
runs-on: docker
|
runs-on: docker
|
||||||
container:
|
|
||||||
image: catthehacker/ubuntu:act-latest
|
|
||||||
outputs:
|
outputs:
|
||||||
target: ${{ steps.determine.outputs.target }}
|
target: ${{ steps.determine.outputs.target }}
|
||||||
image_tag: ${{ steps.determine.outputs.image_tag }}
|
image_tag: ${{ steps.determine.outputs.image_tag }}
|
||||||
env_file: ${{ steps.determine.outputs.env_file }}
|
env_file: ${{ steps.determine.outputs.env_file }}
|
||||||
traefik_host: ${{ steps.determine.outputs.traefik_host }}
|
traefik_host: ${{ steps.determine.outputs.traefik_host }}
|
||||||
next_public_base_url: ${{ steps.determine.outputs.next_public_base_url }}
|
|
||||||
directus_url: ${{ steps.determine.outputs.directus_url }}
|
|
||||||
directus_host: ${{ steps.determine.outputs.directus_host }}
|
|
||||||
gatekeeper_host: ${{ steps.determine.outputs.gatekeeper_host }}
|
|
||||||
traefik_rule: ${{ steps.determine.outputs.traefik_rule }}
|
traefik_rule: ${{ steps.determine.outputs.traefik_rule }}
|
||||||
gatekeeper_rule: ${{ steps.determine.outputs.gatekeeper_rule }}
|
next_public_url: ${{ steps.determine.outputs.next_public_url }}
|
||||||
traefik_middlewares: ${{ steps.determine.outputs.traefik_middlewares }}
|
directus_url: ${{ steps.determine.outputs.directus_url }}
|
||||||
project_name: ${{ steps.determine.outputs.project_name }}
|
project_name: ${{ steps.determine.outputs.project_name }}
|
||||||
|
short_sha: ${{ steps.determine.outputs.short_sha }}
|
||||||
|
container:
|
||||||
|
image: catthehacker/ubuntu:act-latest
|
||||||
steps:
|
steps:
|
||||||
- name: 🔍 Debug Info
|
- name: 🧹 Maintenance (High Density Cleanup)
|
||||||
shell: bash
|
shell: bash
|
||||||
run: |
|
run: |
|
||||||
echo "ref_name: ${{ github.ref_name }}"
|
echo "Purging old build layers and dangling images..."
|
||||||
echo "ref_type: ${{ github.ref_type }}"
|
docker image prune -f
|
||||||
echo "tag: ${{ github.ref_name }}"
|
docker builder prune -f --filter "until=6h"
|
||||||
|
|
||||||
- name: 🧹 Maintenance (Runner Cleanup)
|
|
||||||
continue-on-error: true
|
|
||||||
shell: bash
|
|
||||||
run: |
|
|
||||||
docker image prune -f || true
|
|
||||||
docker builder prune -f --filter "until=24h" || true
|
|
||||||
|
|
||||||
- name: Checkout repository
|
- name: Checkout repository
|
||||||
uses: actions/checkout@v4
|
uses: actions/checkout@v4
|
||||||
with:
|
with:
|
||||||
fetch-depth: 2
|
fetch-depth: 2
|
||||||
|
|
||||||
- name: 🔍 Determine Environment
|
- name: 🔍 Environment ermitteln
|
||||||
id: determine
|
id: determine
|
||||||
shell: bash
|
shell: bash
|
||||||
run: |
|
run: |
|
||||||
REF="${{ github.ref }}"
|
REF="${{ github.ref_name }}"
|
||||||
REF_NAME="${{ github.ref_name }}"
|
|
||||||
REF_TYPE="${{ github.ref_type }}"
|
|
||||||
SHORT_SHA=$(echo "${{ github.sha }}" | cut -c1-7)
|
SHORT_SHA=$(echo "${{ github.sha }}" | cut -c1-7)
|
||||||
DOMAIN_BASE="mb-grid-solutions.com"
|
DOMAIN="mb-grid-solutions.com"
|
||||||
PRJ_ID="mb-grid-solutions"
|
PRJ="mb-grid-solutions"
|
||||||
|
|
||||||
echo "Detecting environment for ref: $REF ($REF_NAME, type: $REF_TYPE)"
|
if [[ "${{ github.ref_type }}" == "branch" && "$REF" == "main" ]]; then
|
||||||
|
|
||||||
# Fallback for REF_TYPE if missing
|
|
||||||
if [[ -z "$REF_TYPE" ]]; then
|
|
||||||
if [[ "$REF" == refs/tags/* ]]; then
|
|
||||||
REF_TYPE="tag"
|
|
||||||
elif [[ "$REF" == refs/heads/* ]]; then
|
|
||||||
REF_TYPE="branch"
|
|
||||||
fi
|
|
||||||
fi
|
|
||||||
|
|
||||||
if [[ "$REF_TYPE" == "branch" && "$REF_NAME" == "main" ]]; then
|
|
||||||
TARGET="testing"
|
TARGET="testing"
|
||||||
IMAGE_TAG="testing-${SHORT_SHA}"
|
IMAGE_TAG="main-${SHORT_SHA}"
|
||||||
ENV_FILE=".env.testing"
|
ENV_FILE=".env.testing"
|
||||||
TRAEFIK_HOST="testing.${DOMAIN_BASE}"
|
TRAEFIK_HOST="testing.${DOMAIN}"
|
||||||
GATEKEEPER_HOST="gatekeeper.testing.${DOMAIN_BASE}"
|
elif [[ "${{ github.ref_type }}" == "tag" ]]; then
|
||||||
NEXT_PUBLIC_BASE_URL="https://testing.${DOMAIN_BASE}"
|
if [[ "$REF" =~ ^v[0-9]+\.[0-9]+(\.[0-9]+)?$ ]]; then
|
||||||
DIRECTUS_URL="https://cms.testing.${DOMAIN_BASE}"
|
|
||||||
DIRECTUS_HOST="cms.testing.${DOMAIN_BASE}"
|
|
||||||
elif [[ "$REF_TYPE" == "tag" ]]; then
|
|
||||||
if [[ "$REF_NAME" =~ ^v[0-9]+\.[0-9]+(\.[0-9]+)?$ ]]; then
|
|
||||||
TARGET="production"
|
TARGET="production"
|
||||||
IMAGE_TAG="$REF_NAME"
|
IMAGE_TAG="$REF"
|
||||||
ENV_FILE=".env.prod"
|
ENV_FILE=".env.prod"
|
||||||
TRAEFIK_HOST="${DOMAIN_BASE}" # Primary domain
|
TRAEFIK_HOST="${DOMAIN}, www.${DOMAIN}"
|
||||||
GATEKEEPER_HOST="gatekeeper.${DOMAIN_BASE}"
|
|
||||||
NEXT_PUBLIC_BASE_URL="https://${DOMAIN_BASE}"
|
|
||||||
DIRECTUS_URL="https://cms.${DOMAIN_BASE}"
|
|
||||||
DIRECTUS_HOST="cms.${DOMAIN_BASE}"
|
|
||||||
elif [[ "$REF_NAME" =~ -rc || "$REF_NAME" =~ -beta || "$REF_NAME" =~ -alpha ]]; then
|
|
||||||
TARGET="staging"
|
|
||||||
IMAGE_TAG="$REF_NAME"
|
|
||||||
ENV_FILE=".env.staging"
|
|
||||||
TRAEFIK_HOST="staging.${DOMAIN_BASE}"
|
|
||||||
GATEKEEPER_HOST="gatekeeper.staging.${DOMAIN_BASE}"
|
|
||||||
NEXT_PUBLIC_BASE_URL="https://staging.${DOMAIN_BASE}"
|
|
||||||
DIRECTUS_URL="https://cms.staging.${DOMAIN_BASE}"
|
|
||||||
DIRECTUS_HOST="cms.staging.${DOMAIN_BASE}"
|
|
||||||
else
|
else
|
||||||
TARGET="skip"
|
TARGET="staging"
|
||||||
echo "Tag $REF_NAME did not match any environment pattern."
|
IMAGE_TAG="$REF"
|
||||||
|
ENV_FILE=".env.staging"
|
||||||
|
TRAEFIK_HOST="staging.${DOMAIN}"
|
||||||
fi
|
fi
|
||||||
else
|
else
|
||||||
TARGET="skip"
|
TARGET="skip"
|
||||||
echo "Ref type $REF_TYPE is not handled for deployment."
|
|
||||||
fi
|
fi
|
||||||
|
|
||||||
# Determine Rules based on target (if not skipped)
|
|
||||||
if [[ "$TARGET" != "skip" ]]; then
|
if [[ "$TARGET" != "skip" ]]; then
|
||||||
if [[ "$TARGET" == "production" ]]; then
|
# Standardize Traefik Rule
|
||||||
TRAEFIK_RULE="Host(\`${DOMAIN_BASE}\`) || Host(\`www.${DOMAIN_BASE}\`)"
|
if [[ "$TRAEFIK_HOST" == *","* ]]; then
|
||||||
GATEKEEPER_RULE="(Host(\`${DOMAIN_BASE}\`) || Host(\`www.${DOMAIN_BASE}\`)) && PathPrefix(\`/gatekeeper\`) || Host(\`gatekeeper.${DOMAIN_BASE}\`)"
|
TRAEFIK_RULE=$(echo "$TRAEFIK_HOST" | sed 's/,/ /g' | awk '{for(i=1;i<=NF;i++) printf "Host(\`%s\`)%s", $i, (i==NF?"":" || ")}')
|
||||||
TRAEFIK_MIDDLEWARES="compress"
|
PRIMARY_HOST=$(echo "$TRAEFIK_HOST" | cut -d',' -f1 | sed 's/ //g')
|
||||||
else
|
else
|
||||||
TRAEFIK_RULE="Host(\`${TRAEFIK_HOST}\`)"
|
TRAEFIK_RULE="Host(\`$TRAEFIK_HOST\`)"
|
||||||
GATEKEEPER_RULE="(Host(\`${TRAEFIK_HOST}\`) && PathPrefix(\`/gatekeeper\`)) || Host(\`gatekeeper.${TRAEFIK_HOST}\`)"
|
PRIMARY_HOST="$TRAEFIK_HOST"
|
||||||
TRAEFIK_MIDDLEWARES="${PRJ_ID}-${TARGET}-auth"
|
|
||||||
fi
|
fi
|
||||||
|
|
||||||
|
{
|
||||||
|
echo "target=$TARGET"
|
||||||
|
echo "image_tag=$IMAGE_TAG"
|
||||||
|
echo "env_file=$ENV_FILE"
|
||||||
|
echo "traefik_host=$PRIMARY_HOST"
|
||||||
|
echo "traefik_rule=$TRAEFIK_RULE"
|
||||||
|
echo "next_public_url=https://$PRIMARY_HOST"
|
||||||
|
echo "directus_url=https://cms.$PRIMARY_HOST"
|
||||||
|
echo "project_name=$PRJ-$TARGET"
|
||||||
|
echo "short_sha=$SHORT_SHA"
|
||||||
|
} >> "$GITHUB_OUTPUT"
|
||||||
|
|
||||||
|
# ⏳ Wait for Upstream Packages/Images if Tagged
|
||||||
|
if [[ "${{ github.ref_type }}" == "tag" ]]; then
|
||||||
|
echo "🔎 Checking for @mintel dependencies in package.json..."
|
||||||
|
# Extract any @mintel/ version (they should be synced in monorepo)
|
||||||
|
UPSTREAM_VERSION=$(grep -o '"@mintel/.*": "[^"]*"' package.json | grep -v "next-utils" | cut -d'"' -f4 | sed 's/\^//; s/\~//' | sort -V | tail -1)
|
||||||
|
TAG_TO_WAIT="v$UPSTREAM_VERSION"
|
||||||
|
|
||||||
|
if [[ -n "$UPSTREAM_VERSION" && "$UPSTREAM_VERSION" != "workspace:"* ]]; then
|
||||||
|
# 1. Discovery (Works without token for public repositories)
|
||||||
|
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}')
|
||||||
|
|
||||||
|
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
|
||||||
|
echo "target=skip" >> "$GITHUB_OUTPUT"
|
||||||
fi
|
fi
|
||||||
|
|
||||||
echo "Target determined: $TARGET"
|
# ──────────────────────────────────────────────────────────────────────────────
|
||||||
echo "Image tag: $IMAGE_TAG"
|
# JOB 2: QA (Lint, Build Test)
|
||||||
|
# ──────────────────────────────────────────────────────────────────────────────
|
||||||
echo "target=$TARGET" >> "$GITHUB_OUTPUT"
|
|
||||||
echo "image_tag=$IMAGE_TAG" >> "$GITHUB_OUTPUT"
|
|
||||||
echo "env_file=$ENV_FILE" >> "$GITHUB_OUTPUT"
|
|
||||||
echo "traefik_host=$TRAEFIK_HOST" >> "$GITHUB_OUTPUT"
|
|
||||||
echo "traefik_rule=$TRAEFIK_RULE" >> "$GITHUB_OUTPUT"
|
|
||||||
echo "gatekeeper_rule=$GATEKEEPER_RULE" >> "$GITHUB_OUTPUT"
|
|
||||||
echo "traefik_middlewares=$TRAEFIK_MIDDLEWARES" >> "$GITHUB_OUTPUT"
|
|
||||||
echo "gatekeeper_host=$GATEKEEPER_HOST" >> "$GITHUB_OUTPUT"
|
|
||||||
echo "next_public_base_url=$NEXT_PUBLIC_BASE_URL" >> "$GITHUB_OUTPUT"
|
|
||||||
echo "directus_url=$DIRECTUS_URL" >> "$GITHUB_OUTPUT"
|
|
||||||
echo "directus_host=$DIRECTUS_HOST" >> "$GITHUB_OUTPUT"
|
|
||||||
echo "project_name=$PRJ_ID-$TARGET" >> "$GITHUB_OUTPUT"
|
|
||||||
|
|
||||||
qa:
|
qa:
|
||||||
name: 🧪 QA
|
name: 🧪 QA
|
||||||
needs: prepare
|
needs: prepare
|
||||||
@@ -153,25 +154,29 @@ jobs:
|
|||||||
uses: actions/setup-node@v4
|
uses: actions/setup-node@v4
|
||||||
with:
|
with:
|
||||||
node-version: 20
|
node-version: 20
|
||||||
- name: Install dependencies
|
- name: Setup pnpm
|
||||||
shell: bash
|
uses: pnpm/action-setup@v3
|
||||||
|
with:
|
||||||
|
version: 10
|
||||||
|
- name: 🔐 Registry Auth
|
||||||
run: |
|
run: |
|
||||||
corepack enable
|
echo "@mintel:registry=https://${{ vars.REGISTRY_HOST || 'npm.infra.mintel.me' }}" > .npmrc
|
||||||
pnpm install --frozen-lockfile
|
echo "//${{ vars.REGISTRY_HOST || 'npm.infra.mintel.me' }}/:_authToken=${{ secrets.REGISTRY_PASS }}" >> .npmrc
|
||||||
env:
|
- name: Install dependencies
|
||||||
NPM_TOKEN: ${{ secrets.NPM_TOKEN }}
|
run: pnpm install --frozen-lockfile
|
||||||
- name: 🧪 Lint
|
- name: 🧪 QA Checks
|
||||||
shell: bash
|
if: github.event.inputs.skip_checks != 'true'
|
||||||
run: pnpm lint
|
run: |
|
||||||
env:
|
pnpm lint
|
||||||
NPM_TOKEN: ${{ secrets.NPM_TOKEN }}
|
pnpm exec tsc --noEmit
|
||||||
|
pnpm test run
|
||||||
- name: 🏗️ Build Test
|
- name: 🏗️ Build Test
|
||||||
shell: bash
|
if: github.event.inputs.skip_checks != 'true'
|
||||||
run: pnpm build
|
run: pnpm build
|
||||||
env:
|
|
||||||
NPM_TOKEN: ${{ secrets.NPM_TOKEN }}
|
|
||||||
NEXT_PUBLIC_BASE_URL: https://dummy.test
|
|
||||||
|
|
||||||
|
# ──────────────────────────────────────────────────────────────────────────────
|
||||||
|
# JOB 3: Build & Push
|
||||||
|
# ──────────────────────────────────────────────────────────────────────────────
|
||||||
build:
|
build:
|
||||||
name: 🏗️ Build
|
name: 🏗️ Build
|
||||||
needs: prepare
|
needs: prepare
|
||||||
@@ -182,132 +187,226 @@ jobs:
|
|||||||
steps:
|
steps:
|
||||||
- name: Checkout repository
|
- name: Checkout repository
|
||||||
uses: actions/checkout@v4
|
uses: actions/checkout@v4
|
||||||
|
|
||||||
- name: 🐳 Set up Docker Buildx
|
- name: 🐳 Set up Docker Buildx
|
||||||
uses: docker/setup-buildx-action@v3
|
uses: docker/setup-buildx-action@v3
|
||||||
|
|
||||||
- name: 🔐 Registry Login
|
- name: 🔐 Registry Login
|
||||||
run: echo "${{ secrets.REGISTRY_PASS }}" | docker login registry.infra.mintel.me -u "${{ secrets.REGISTRY_USER }}" --password-stdin
|
run: echo "${{ secrets.REGISTRY_PASS }}" | docker login registry.infra.mintel.me -u "${{ secrets.REGISTRY_USER }}" --password-stdin
|
||||||
|
|
||||||
- name: 🏗️ Build and Push
|
- name: 🏗️ Build and Push
|
||||||
shell: bash
|
uses: docker/build-push-action@v5
|
||||||
run: |
|
with:
|
||||||
docker buildx build \
|
context: .
|
||||||
--pull \
|
push: true
|
||||||
--platform linux/arm64 \
|
platforms: linux/arm64
|
||||||
--build-arg NPM_TOKEN=${{ secrets.NPM_TOKEN }} \
|
build-args: |
|
||||||
--build-arg NEXT_PUBLIC_BASE_URL=${{ needs.prepare.outputs.next_public_base_url }} \
|
NEXT_PUBLIC_BASE_URL=${{ needs.prepare.outputs.next_public_url }}
|
||||||
--build-arg NEXT_PUBLIC_TARGET=${{ needs.prepare.outputs.target }} \
|
NEXT_PUBLIC_TARGET=${{ needs.prepare.outputs.target }}
|
||||||
--build-arg DIRECTUS_URL=${{ needs.prepare.outputs.directus_url }} \
|
DIRECTUS_URL=${{ needs.prepare.outputs.directus_url }}
|
||||||
--build-arg UMAMI_API_ENDPOINT=${{ secrets.UMAMI_API_ENDPOINT || secrets.NEXT_PUBLIC_UMAMI_SCRIPT_URL || vars.UMAMI_API_ENDPOINT || 'https://analytics.infra.mintel.me' }} \
|
NPM_TOKEN=${{ secrets.REGISTRY_PASS }}
|
||||||
-t registry.infra.mintel.me/mintel/mb-grid-solutions:${{ needs.prepare.outputs.image_tag }} \
|
tags: registry.infra.mintel.me/mintel/mb-grid-solutions:${{ needs.prepare.outputs.image_tag }}
|
||||||
--push .
|
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:
|
deploy:
|
||||||
name: 🚀 Deploy
|
name: 🚀 Deploy
|
||||||
needs: [prepare, build, qa]
|
needs: [prepare, build, qa]
|
||||||
if: needs.prepare.outputs.target != 'skip'
|
|
||||||
runs-on: docker
|
runs-on: docker
|
||||||
container:
|
container:
|
||||||
image: catthehacker/ubuntu:act-latest
|
image: catthehacker/ubuntu:act-latest
|
||||||
|
env:
|
||||||
|
TARGET: ${{ needs.prepare.outputs.target }}
|
||||||
|
IMAGE_TAG: ${{ needs.prepare.outputs.image_tag }}
|
||||||
|
PROJECT_NAME: ${{ needs.prepare.outputs.project_name }}
|
||||||
|
NEXT_PUBLIC_BASE_URL: ${{ needs.prepare.outputs.next_public_url }}
|
||||||
|
DIRECTUS_URL: ${{ needs.prepare.outputs.directus_url }}
|
||||||
|
DIRECTUS_HOST: cms.${{ needs.prepare.outputs.traefik_host }}
|
||||||
|
|
||||||
|
# Secrets mapping (Directus)
|
||||||
|
DIRECTUS_KEY: ${{ (needs.prepare.outputs.target == 'testing' && secrets.TESTING_DIRECTUS_KEY) || (needs.prepare.outputs.target == 'staging' && secrets.STAGING_DIRECTUS_KEY) || secrets.DIRECTUS_KEY || vars.DIRECTUS_KEY }}
|
||||||
|
DIRECTUS_SECRET: ${{ (needs.prepare.outputs.target == 'testing' && secrets.TESTING_DIRECTUS_SECRET) || (needs.prepare.outputs.target == 'staging' && secrets.STAGING_DIRECTUS_SECRET) || secrets.DIRECTUS_SECRET || vars.DIRECTUS_SECRET }}
|
||||||
|
DIRECTUS_ADMIN_EMAIL: ${{ (needs.prepare.outputs.target == 'testing' && secrets.TESTING_DIRECTUS_ADMIN_EMAIL) || (needs.prepare.outputs.target == 'staging' && secrets.STAGING_DIRECTUS_ADMIN_EMAIL) || secrets.DIRECTUS_ADMIN_EMAIL || vars.DIRECTUS_ADMIN_EMAIL || 'admin@mintel.me' }}
|
||||||
|
DIRECTUS_ADMIN_PASSWORD: ${{ (needs.prepare.outputs.target == 'testing' && secrets.TESTING_DIRECTUS_ADMIN_PASSWORD) || (needs.prepare.outputs.target == 'staging' && secrets.STAGING_DIRECTUS_ADMIN_PASSWORD) || secrets.DIRECTUS_ADMIN_PASSWORD || vars.DIRECTUS_ADMIN_PASSWORD }}
|
||||||
|
DIRECTUS_DB_NAME: ${{ secrets.DIRECTUS_DB_NAME || vars.DIRECTUS_DB_NAME || 'directus' }}
|
||||||
|
DIRECTUS_DB_USER: ${{ secrets.DIRECTUS_DB_USER || vars.DIRECTUS_DB_USER || 'directus' }}
|
||||||
|
DIRECTUS_DB_PASSWORD: ${{ (needs.prepare.outputs.target == 'testing' && secrets.TESTING_DIRECTUS_DB_PASSWORD) || (needs.prepare.outputs.target == 'staging' && secrets.STAGING_DIRECTUS_DB_PASSWORD) || secrets.DIRECTUS_DB_PASSWORD || vars.DIRECTUS_DB_PASSWORD || 'directus' }}
|
||||||
|
DIRECTUS_API_TOKEN: ${{ (needs.prepare.outputs.target == 'testing' && secrets.TESTING_DIRECTUS_API_TOKEN) || (needs.prepare.outputs.target == 'staging' && secrets.STAGING_DIRECTUS_API_TOKEN) || secrets.DIRECTUS_API_TOKEN || vars.DIRECTUS_API_TOKEN }}
|
||||||
|
|
||||||
|
# Secrets mapping (Mail)
|
||||||
|
MAIL_HOST: ${{ secrets.SMTP_HOST || vars.SMTP_HOST }}
|
||||||
|
MAIL_PORT: ${{ secrets.SMTP_PORT || vars.SMTP_PORT || '587' }}
|
||||||
|
MAIL_USERNAME: ${{ secrets.SMTP_USER || vars.SMTP_USER }}
|
||||||
|
MAIL_PASSWORD: ${{ secrets.SMTP_PASS || vars.SMTP_PASS }}
|
||||||
|
MAIL_FROM: ${{ secrets.SMTP_FROM || vars.SMTP_FROM }}
|
||||||
|
MAIL_RECIPIENTS: ${{ secrets.CONTACT_RECIPIENT || vars.CONTACT_RECIPIENT }}
|
||||||
|
|
||||||
|
# Authentication
|
||||||
|
GATEKEEPER_PASSWORD: ${{ secrets.GATEKEEPER_PASSWORD || vars.GATEKEEPER_PASSWORD }}
|
||||||
|
AUTH_COOKIE_NAME: ${{ secrets.AUTH_COOKIE_NAME || vars.AUTH_COOKIE_NAME || 'mintel_gatekeeper_session' }}
|
||||||
|
COOKIE_DOMAIN: ${{ secrets.COOKIE_DOMAIN || vars.COOKIE_DOMAIN || '.mb-grid-solutions.com' }}
|
||||||
|
|
||||||
|
# Monitoring & Services
|
||||||
|
SENTRY_DSN: ${{ secrets.SENTRY_DSN || vars.SENTRY_DSN }}
|
||||||
|
UMAMI_WEBSITE_ID: ${{ secrets.UMAMI_WEBSITE_ID || secrets.NEXT_PUBLIC_UMAMI_WEBSITE_ID || vars.UMAMI_WEBSITE_ID || vars.NEXT_PUBLIC_UMAMI_WEBSITE_ID }}
|
||||||
|
UMAMI_API_ENDPOINT: ${{ secrets.UMAMI_API_ENDPOINT || secrets.NEXT_PUBLIC_UMAMI_SCRIPT_URL || vars.UMAMI_API_ENDPOINT || 'https://analytics.infra.mintel.me' }}
|
||||||
|
PROJECT_COLOR: ${{ secrets.PROJECT_COLOR || vars.PROJECT_COLOR || '#82ed20' }}
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout repository
|
- name: Checkout repository
|
||||||
uses: actions/checkout@v4
|
uses: actions/checkout@v4
|
||||||
with:
|
- name: 📝 Generate Environment
|
||||||
fetch-depth: 1
|
|
||||||
|
|
||||||
- name: 🚀 Deploy via SSH
|
|
||||||
shell: bash
|
shell: bash
|
||||||
|
env:
|
||||||
|
TRAEFIK_RULE: ${{ needs.prepare.outputs.traefik_rule }}
|
||||||
|
TRAEFIK_HOST: ${{ needs.prepare.outputs.traefik_host }}
|
||||||
|
ENV_FILE: ${{ needs.prepare.outputs.env_file }}
|
||||||
run: |
|
run: |
|
||||||
echo "Deploying to alpha.mintel.me"
|
# Middleware & Auth Logic
|
||||||
|
LOG_LEVEL=$( [[ "$TARGET" == "testing" || "$TARGET" == "development" ]] && echo "debug" || echo "info" )
|
||||||
|
STD_MW="${PROJECT_NAME}-forward,compress"
|
||||||
|
|
||||||
|
if [[ "$TARGET" == "production" ]]; then
|
||||||
|
AUTH_MIDDLEWARE="$STD_MW"
|
||||||
|
COMPOSE_PROFILES=""
|
||||||
|
else
|
||||||
|
# Exclude Gatekeeper from the main app router to prevent redirect loops
|
||||||
|
TRAEFIK_RULE="Host(\`${TRAEFIK_HOST}\`) && !PathPrefix(\`/gatekeeper\`)"
|
||||||
|
# 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
|
||||||
|
|
||||||
|
# Directus
|
||||||
|
DIRECTUS_URL=$DIRECTUS_URL
|
||||||
|
DIRECTUS_HOST=$DIRECTUS_HOST
|
||||||
|
DIRECTUS_KEY=$DIRECTUS_KEY
|
||||||
|
DIRECTUS_SECRET=$DIRECTUS_SECRET
|
||||||
|
DIRECTUS_ADMIN_EMAIL=$DIRECTUS_ADMIN_EMAIL
|
||||||
|
DIRECTUS_ADMIN_PASSWORD=$DIRECTUS_ADMIN_PASSWORD
|
||||||
|
DIRECTUS_DB_NAME=$DIRECTUS_DB_NAME
|
||||||
|
DIRECTUS_DB_USER=$DIRECTUS_DB_USER
|
||||||
|
DIRECTUS_DB_PASSWORD=$DIRECTUS_DB_PASSWORD
|
||||||
|
DIRECTUS_API_TOKEN=$DIRECTUS_API_TOKEN
|
||||||
|
INTERNAL_DIRECTUS_URL=http://${PROJECT_NAME}-directus:8055
|
||||||
|
|
||||||
|
# Mail
|
||||||
|
MAIL_HOST=$MAIL_HOST
|
||||||
|
MAIL_PORT=$MAIL_PORT
|
||||||
|
MAIL_USERNAME=$MAIL_USERNAME
|
||||||
|
MAIL_PASSWORD=$MAIL_PASSWORD
|
||||||
|
MAIL_FROM=$MAIL_FROM
|
||||||
|
MAIL_RECIPIENTS=$MAIL_RECIPIENTS
|
||||||
|
|
||||||
|
# Authentication
|
||||||
|
GATEKEEPER_PASSWORD=$GATEKEEPER_PASSWORD
|
||||||
|
AUTH_COOKIE_NAME=$AUTH_COOKIE_NAME
|
||||||
|
COOKIE_DOMAIN=$COOKIE_DOMAIN
|
||||||
|
|
||||||
|
# Analytics
|
||||||
|
UMAMI_WEBSITE_ID=$UMAMI_WEBSITE_ID
|
||||||
|
NEXT_PUBLIC_UMAMI_WEBSITE_ID=$UMAMI_WEBSITE_ID
|
||||||
|
UMAMI_API_ENDPOINT=$UMAMI_API_ENDPOINT
|
||||||
|
|
||||||
|
TARGET=$TARGET
|
||||||
|
SENTRY_ENVIRONMENT=$TARGET
|
||||||
|
PROJECT_NAME=$PROJECT_NAME
|
||||||
|
ENV_FILE=$ENV_FILE
|
||||||
|
TRAEFIK_RULE="${TRAEFIK_RULE}"
|
||||||
|
TRAEFIK_HOST="${TRAEFIK_HOST}"
|
||||||
|
COMPOSE_PROFILES=$COMPOSE_PROFILES
|
||||||
|
TRAEFIK_MIDDLEWARES=$AUTH_MIDDLEWARE
|
||||||
|
EOF
|
||||||
|
|
||||||
|
- name: 🚀 SSH Deploy
|
||||||
|
shell: bash
|
||||||
|
env:
|
||||||
|
ENV_FILE: ${{ needs.prepare.outputs.env_file }}
|
||||||
|
run: |
|
||||||
mkdir -p ~/.ssh
|
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
|
||||||
ssh-keyscan -H alpha.mintel.me >> ~/.ssh/known_hosts 2>/dev/null
|
ssh-keyscan -H alpha.mintel.me >> ~/.ssh/known_hosts 2>/dev/null
|
||||||
|
|
||||||
# Generate Environment File
|
# Transfer and Restart
|
||||||
cat > .env.deploy << 'EOF'
|
SITE_DIR="/home/deploy/sites/mb-grid-solutions.com"
|
||||||
ENV_FILE=${{ needs.prepare.outputs.env_file }}
|
ssh root@alpha.mintel.me "mkdir -p $SITE_DIR/directus/schema $SITE_DIR/directus/uploads $SITE_DIR/directus/extensions"
|
||||||
IMAGE_TAG=${{ needs.prepare.outputs.image_tag }}
|
|
||||||
TRAEFIK_HOST=${{ needs.prepare.outputs.traefik_host }}
|
|
||||||
TRAEFIK_RULE=${{ needs.prepare.outputs.traefik_rule }}
|
|
||||||
TRAEFIK_MIDDLEWARES=${{ needs.prepare.outputs.traefik_middlewares }}
|
|
||||||
GATEKEEPER_RULE=${{ needs.prepare.outputs.gatekeeper_rule }}
|
|
||||||
GATEKEEPER_HOST=${{ needs.prepare.outputs.gatekeeper_host }}
|
|
||||||
PROJECT_NAME=${{ needs.prepare.outputs.project_name }}
|
|
||||||
NEXT_PUBLIC_BASE_URL=${{ needs.prepare.outputs.next_public_base_url }}
|
|
||||||
|
|
||||||
# Directus
|
scp .env.deploy root@alpha.mintel.me:$SITE_DIR/$ENV_FILE
|
||||||
DIRECTUS_URL=${{ needs.prepare.outputs.directus_url }}
|
scp docker-compose.yaml root@alpha.mintel.me:$SITE_DIR/docker-compose.yaml
|
||||||
DIRECTUS_HOST=${{ needs.prepare.outputs.directus_host }}
|
|
||||||
INTERNAL_DIRECTUS_URL=http://directus:8055
|
|
||||||
DIRECTUS_API_TOKEN=${{ secrets.DIRECTUS_API_TOKEN || vars.DIRECTUS_API_TOKEN }}
|
|
||||||
DIRECTUS_ADMIN_EMAIL=${{ secrets.DIRECTUS_ADMIN_EMAIL || vars.DIRECTUS_ADMIN_EMAIL || 'admin@mintel.me' }}
|
|
||||||
DIRECTUS_ADMIN_PASSWORD=${{ secrets.DIRECTUS_ADMIN_PASSWORD || vars.DIRECTUS_ADMIN_PASSWORD }}
|
|
||||||
DIRECTUS_DB_NAME=${{ secrets.DIRECTUS_DB_NAME || vars.DIRECTUS_DB_NAME || 'directus' }}
|
|
||||||
DIRECTUS_DB_USER=${{ secrets.DIRECTUS_DB_USER || vars.DIRECTUS_DB_USER || 'directus' }}
|
|
||||||
DIRECTUS_DB_PASSWORD=${{ secrets.DIRECTUS_DB_PASSWORD || vars.DIRECTUS_DB_PASSWORD }}
|
|
||||||
DIRECTUS_KEY=${{ secrets.DIRECTUS_KEY || vars.DIRECTUS_KEY }}
|
|
||||||
DIRECTUS_SECRET=${{ secrets.DIRECTUS_SECRET || vars.DIRECTUS_SECRET }}
|
|
||||||
|
|
||||||
# Mail
|
ssh root@alpha.mintel.me "cd $SITE_DIR && echo '${{ secrets.REGISTRY_PASS }}' | docker login registry.infra.mintel.me -u '${{ secrets.REGISTRY_USER }}' --password-stdin"
|
||||||
MAIL_HOST=${{ secrets.SMTP_HOST || vars.SMTP_HOST }}
|
ssh root@alpha.mintel.me "cd $SITE_DIR && docker compose -p '${{ needs.prepare.outputs.project_name }}' --env-file '$ENV_FILE' pull"
|
||||||
MAIL_PORT=${{ secrets.SMTP_PORT || vars.SMTP_PORT || '587' }}
|
ssh root@alpha.mintel.me "cd $SITE_DIR && docker compose -p '${{ needs.prepare.outputs.project_name }}' --env-file '$ENV_FILE' up -d --remove-orphans"
|
||||||
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
|
# Apply Directus Schema Snapshot if available
|
||||||
GATEKEEPER_PASSWORD=${{ secrets.GATEKEEPER_PASSWORD || vars.GATEKEEPER_PASSWORD }}
|
ssh root@alpha.mintel.me "cd $SITE_DIR && if docker compose -p '${{ needs.prepare.outputs.project_name }}' --env-file '$ENV_FILE' exec -T directus ls /directus/schema/snapshot.yaml >/dev/null 2>&1; then echo '→ Applying Directus Schema Snapshot...' && docker compose -p '${{ needs.prepare.outputs.project_name }}' --env-file '$ENV_FILE' exec -T directus npx directus schema apply /directus/schema/snapshot.yaml --yes; fi"
|
||||||
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' }}
|
|
||||||
|
|
||||||
# External Services
|
ssh root@alpha.mintel.me "docker system prune -f --filter 'until=24h'"
|
||||||
SENTRY_DSN=${{ secrets.SENTRY_DSN || vars.SENTRY_DSN }}
|
|
||||||
GOTIFY_URL=${{ secrets.GOTIFY_URL || vars.GOTIFY_URL }}
|
|
||||||
GOTIFY_TOKEN=${{ secrets.GOTIFY_TOKEN || vars.GOTIFY_TOKEN }}
|
|
||||||
UMAMI_WEBSITE_ID=${{ secrets.UMAMI_WEBSITE_ID || secrets.NEXT_PUBLIC_UMAMI_WEBSITE_ID || vars.UMAMI_WEBSITE_ID || vars.NEXT_PUBLIC_UMAMI_WEBSITE_ID }}
|
|
||||||
UMAMI_API_ENDPOINT=${{ secrets.UMAMI_API_ENDPOINT || secrets.NEXT_PUBLIC_UMAMI_SCRIPT_URL || vars.UMAMI_API_ENDPOINT || 'https://analytics.infra.mintel.me' }}
|
|
||||||
|
|
||||||
# Project
|
- name: 🧹 Post-Deploy Cleanup (Runner)
|
||||||
PROJECT_COLOR=${{ secrets.PROJECT_COLOR || vars.PROJECT_COLOR || '#82ed20' }}
|
if: always()
|
||||||
EOF
|
run: docker builder prune -f --filter "until=1h"
|
||||||
|
|
||||||
APP_DIR="/home/deploy/sites/mb-grid-solutions.com"
|
# ──────────────────────────────────────────────────────────────────────────────
|
||||||
ssh -o StrictHostKeyChecking=accept-new root@alpha.mintel.me "mkdir -p $APP_DIR"
|
# JOB 5: Health Check
|
||||||
|
# ──────────────────────────────────────────────────────────────────────────────
|
||||||
scp -o StrictHostKeyChecking=accept-new .env.deploy root@alpha.mintel.me:$APP_DIR/${{ needs.prepare.outputs.env_file }}
|
healthcheck:
|
||||||
scp -o StrictHostKeyChecking=accept-new docker-compose.yaml root@alpha.mintel.me:$APP_DIR/docker-compose.yaml
|
name: 🩺 Health Check
|
||||||
|
|
||||||
ssh -o StrictHostKeyChecking=accept-new root@alpha.mintel.me bash << 'EOF'
|
|
||||||
set -e
|
|
||||||
APP_DIR="/home/deploy/sites/mb-grid-solutions.com"
|
|
||||||
cd $APP_DIR
|
|
||||||
|
|
||||||
echo "${{ secrets.REGISTRY_PASS }}" | docker login registry.infra.mintel.me -u "${{ secrets.REGISTRY_USER }}" --password-stdin
|
|
||||||
docker compose -p "${{ needs.prepare.outputs.project_name }}" --env-file ${{ needs.prepare.outputs.env_file }} pull
|
|
||||||
docker compose -p "${{ needs.prepare.outputs.project_name }}" --env-file ${{ needs.prepare.outputs.env_file }} up -d --wait --remove-orphans
|
|
||||||
docker system prune -f --filter "until=24h"
|
|
||||||
EOF
|
|
||||||
|
|
||||||
notifications:
|
|
||||||
name: 🔔 Notifications
|
|
||||||
needs: [prepare, deploy]
|
needs: [prepare, deploy]
|
||||||
|
if: needs.deploy.result == 'success'
|
||||||
|
runs-on: docker
|
||||||
|
container:
|
||||||
|
image: catthehacker/ubuntu:act-latest
|
||||||
|
steps:
|
||||||
|
- name: 🔍 Smoke Test
|
||||||
|
run: |
|
||||||
|
URL="${{ needs.prepare.outputs.next_public_url }}"
|
||||||
|
echo "Checking health of $URL..."
|
||||||
|
for i in {1..12}; do
|
||||||
|
if curl -s -f -k -L "$URL" > /dev/null; then
|
||||||
|
echo "✅ Health check passed!"
|
||||||
|
exit 0
|
||||||
|
fi
|
||||||
|
echo "Waiting for service to be ready... ($i/12)"
|
||||||
|
sleep 10
|
||||||
|
done
|
||||||
|
echo "❌ Health check failed after 2 minutes."
|
||||||
|
exit 1
|
||||||
|
|
||||||
|
# ──────────────────────────────────────────────────────────────────────────────
|
||||||
|
# JOB 6: Notifications
|
||||||
|
# ──────────────────────────────────────────────────────────────────────────────
|
||||||
|
notifications:
|
||||||
|
name: 🔔 Notify
|
||||||
|
needs: [prepare, deploy, healthcheck]
|
||||||
if: always()
|
if: always()
|
||||||
runs-on: docker
|
runs-on: docker
|
||||||
container:
|
container:
|
||||||
image: catthehacker/ubuntu:act-latest
|
image: catthehacker/ubuntu:act-latest
|
||||||
steps:
|
steps:
|
||||||
- name: Notify Gotify
|
- name: 🔔 Gotify
|
||||||
shell: bash
|
|
||||||
run: |
|
run: |
|
||||||
STATUS="${{ needs.deploy.result }}"
|
STATUS="${{ needs.deploy.result }}"
|
||||||
COLOR="info"
|
TITLE="mb-grid-solutions.com: $STATUS"
|
||||||
[[ "$STATUS" == "success" ]] && PRIORITY=5 || PRIORITY=8
|
[[ "$STATUS" == "success" ]] && PRIORITY=5 || PRIORITY=8
|
||||||
|
|
||||||
curl -X POST "${{ secrets.GOTIFY_URL }}/message?token=${{ secrets.GOTIFY_TOKEN }}" \
|
curl -s -k -X POST "${{ secrets.GOTIFY_URL }}/message?token=${{ secrets.GOTIFY_TOKEN }}" \
|
||||||
-F "title=mb-grid-solutions Deployment" \
|
-F "title=$TITLE" \
|
||||||
-F "message=Status: $STATUS for ${{ needs.prepare.outputs.target }} (${{ needs.prepare.outputs.image_tag }})" \
|
-F "message=Deploy to ${{ needs.prepare.outputs.target }} finished with status $STATUS.\nVersion: ${{ needs.prepare.outputs.image_tag }}" \
|
||||||
-F "priority=$PRIORITY"
|
-F "priority=$PRIORITY" || true
|
||||||
|
|||||||
58
Dockerfile
58
Dockerfile
@@ -1,49 +1,67 @@
|
|||||||
# Start from the pre-built Nextjs Base image
|
# Stage 1: Builder
|
||||||
FROM registry.infra.mintel.me/mintel/nextjs:latest AS builder
|
FROM node:20-alpine AS builder
|
||||||
|
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
|
|
||||||
# Ensure we are in a clean, standalone environment
|
# Clean the workspace
|
||||||
RUN rm -rf packages apps pnpm-workspace.yaml 2>/dev/null || true
|
RUN rm -rf ./*
|
||||||
|
|
||||||
# Build-time environment variables for Next.js
|
# Arguments for build-time configuration
|
||||||
ARG NEXT_PUBLIC_BASE_URL
|
ARG NEXT_PUBLIC_BASE_URL
|
||||||
ARG UMAMI_API_ENDPOINT
|
|
||||||
ARG NEXT_PUBLIC_TARGET
|
ARG NEXT_PUBLIC_TARGET
|
||||||
ARG DIRECTUS_URL
|
ARG DIRECTUS_URL
|
||||||
|
ARG UMAMI_API_ENDPOINT
|
||||||
|
ARG NEXT_PUBLIC_UMAMI_WEBSITE_ID
|
||||||
ARG NPM_TOKEN
|
ARG NPM_TOKEN
|
||||||
|
|
||||||
|
# Environment variables for Next.js build
|
||||||
ENV NEXT_PUBLIC_BASE_URL=$NEXT_PUBLIC_BASE_URL
|
ENV NEXT_PUBLIC_BASE_URL=$NEXT_PUBLIC_BASE_URL
|
||||||
ENV UMAMI_API_ENDPOINT=$UMAMI_API_ENDPOINT
|
|
||||||
ENV NEXT_PUBLIC_TARGET=$NEXT_PUBLIC_TARGET
|
ENV NEXT_PUBLIC_TARGET=$NEXT_PUBLIC_TARGET
|
||||||
ENV DIRECTUS_URL=$DIRECTUS_URL
|
ENV DIRECTUS_URL=$DIRECTUS_URL
|
||||||
ENV NPM_TOKEN=$NPM_TOKEN
|
ENV UMAMI_API_ENDPOINT=$UMAMI_API_ENDPOINT
|
||||||
ENV SENTRY_SUPPRESS_TURBOPACK_WARNING=1
|
ENV NEXT_PUBLIC_UMAMI_WEBSITE_ID=$NEXT_PUBLIC_UMAMI_WEBSITE_ID
|
||||||
ENV SKIP_RUNTIME_ENV_VALIDATION=true
|
ENV SKIP_RUNTIME_ENV_VALIDATION=true
|
||||||
|
ENV CI=true
|
||||||
|
|
||||||
# Enable corepack
|
# Enable pnpm
|
||||||
RUN corepack enable
|
RUN corepack enable && corepack prepare pnpm@10.3.0 --activate
|
||||||
|
|
||||||
# Copy package files
|
# Copy lockfile and manifest for dependency installation caching
|
||||||
COPY package.json pnpm-lock.yaml* .npmrc ./
|
COPY pnpm-lock.yaml package.json .npmrc* ./
|
||||||
|
|
||||||
# Install dependencies
|
# Install dependencies with cache mount
|
||||||
RUN pnpm install --no-frozen-lockfile
|
RUN --mount=type=cache,id=pnpm,target=/pnpm/store \
|
||||||
|
--mount=type=secret,id=NPM_TOKEN \
|
||||||
|
export NPM_TOKEN=$(cat /run/secrets/NPM_TOKEN 2>/dev/null || echo $NPM_TOKEN) && \
|
||||||
|
pnpm install --frozen-lockfile
|
||||||
|
|
||||||
# Copy local files
|
# Copy source code
|
||||||
COPY . .
|
COPY . .
|
||||||
|
|
||||||
# Build the specific application
|
# Build application
|
||||||
RUN pnpm build
|
RUN pnpm build
|
||||||
|
|
||||||
# Production runner image
|
# Stage 2: Runner
|
||||||
FROM registry.infra.mintel.me/mintel/runtime:latest AS runner
|
FROM node:20-alpine AS runner
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
|
|
||||||
|
# Install curl for health checks
|
||||||
|
RUN apk add --no-cache curl
|
||||||
|
|
||||||
|
# Create nextjs user and group for security
|
||||||
|
RUN addgroup --system --gid 1001 nodejs && \
|
||||||
|
adduser --system --uid 1001 nextjs && \
|
||||||
|
chown -R nextjs:nodejs /app
|
||||||
|
|
||||||
|
ENV HOSTNAME="0.0.0.0"
|
||||||
|
ENV PORT=3000
|
||||||
|
ENV NODE_ENV=production
|
||||||
|
ENV NEXT_TELEMETRY_DISABLED=1
|
||||||
|
|
||||||
# Copy standalone output and static files
|
# Copy standalone output and static files
|
||||||
COPY --from=builder --chown=nextjs:nodejs /app/public ./public
|
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/standalone ./
|
||||||
COPY --from=builder --chown=nextjs:nodejs /app/.next/static ./.next/static
|
COPY --from=builder --chown=nextjs:nodejs /app/.next/static ./.next/static
|
||||||
|
COPY --from=builder --chown=nextjs:nodejs /app/.next/cache ./.next/cache
|
||||||
|
|
||||||
USER nextjs
|
USER nextjs
|
||||||
|
|
||||||
|
|||||||
@@ -6,11 +6,14 @@ import { NextIntlClientProvider } from "next-intl";
|
|||||||
import { getMessages } from "next-intl/server";
|
import { getMessages } from "next-intl/server";
|
||||||
import { notFound } from "next/navigation";
|
import { notFound } from "next/navigation";
|
||||||
import { LazyMotion, domAnimation } from "framer-motion";
|
import { LazyMotion, domAnimation } from "framer-motion";
|
||||||
|
import AnalyticsProvider from "@/components/analytics/AnalyticsProvider";
|
||||||
|
import { config } from "@/lib/config";
|
||||||
|
|
||||||
const inter = Inter({
|
const inter = Inter({
|
||||||
subsets: ["latin"],
|
subsets: ["latin"],
|
||||||
display: "swap",
|
display: "swap",
|
||||||
variable: "--font-inter",
|
variable: "--font-inter",
|
||||||
|
weight: ["400", "700", "800"], // Explicit weights to optimize download
|
||||||
});
|
});
|
||||||
|
|
||||||
export const metadata: Metadata = {
|
export const metadata: Metadata = {
|
||||||
@@ -108,10 +111,26 @@ export default async function RootLayout({
|
|||||||
|
|
||||||
// Track pageview on the server
|
// Track pageview on the server
|
||||||
// This is safe to call here because layout is a Server Component
|
// This is safe to call here because layout is a Server Component
|
||||||
const services = (
|
const serverServices = (
|
||||||
await import("@/lib/services/create-services.server")
|
await import("@/lib/services/create-services.server")
|
||||||
).getServerAppServices();
|
).getServerAppServices();
|
||||||
services.analytics.trackPageview();
|
|
||||||
|
// 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 (
|
return (
|
||||||
<html lang={locale} className={`${inter.variable}`}>
|
<html lang={locale} className={`${inter.variable}`}>
|
||||||
@@ -123,6 +142,7 @@ export default async function RootLayout({
|
|||||||
</head>
|
</head>
|
||||||
<body className="antialiased">
|
<body className="antialiased">
|
||||||
<NextIntlClientProvider messages={messages}>
|
<NextIntlClientProvider messages={messages}>
|
||||||
|
<AnalyticsProvider websiteId={config.analytics.umami.websiteId} />
|
||||||
<LazyMotion features={domAnimation}>
|
<LazyMotion features={domAnimation}>
|
||||||
<Layout>{children}</Layout>
|
<Layout>{children}</Layout>
|
||||||
</LazyMotion>
|
</LazyMotion>
|
||||||
|
|||||||
@@ -86,7 +86,7 @@ export default async function Image() {
|
|||||||
letterSpacing: "0.1em",
|
letterSpacing: "0.1em",
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
Engineering Excellence
|
Technische Beratung
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -118,6 +118,8 @@ export default async function Image() {
|
|||||||
{/* Title */}
|
{/* Title */}
|
||||||
<div
|
<div
|
||||||
style={{
|
style={{
|
||||||
|
display: "flex",
|
||||||
|
flexDirection: "row",
|
||||||
fontSize: "72px",
|
fontSize: "72px",
|
||||||
fontWeight: "900",
|
fontWeight: "900",
|
||||||
color: "#0f172a",
|
color: "#0f172a",
|
||||||
@@ -126,12 +128,19 @@ export default async function Image() {
|
|||||||
letterSpacing: "-0.02em",
|
letterSpacing: "-0.02em",
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
MB Grid <span style={{ color: "#10b981" }}>Solutions</span>
|
MB Grid{" "}
|
||||||
|
<span
|
||||||
|
style={{ color: "#10b981", display: "flex", marginLeft: "16px" }}
|
||||||
|
>
|
||||||
|
Solutions
|
||||||
|
</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Subtitle */}
|
{/* Subtitle */}
|
||||||
<div
|
<div
|
||||||
style={{
|
style={{
|
||||||
|
display: "flex",
|
||||||
|
flexDirection: "column",
|
||||||
fontSize: "32px",
|
fontSize: "32px",
|
||||||
fontWeight: "500",
|
fontWeight: "500",
|
||||||
color: "#64748b",
|
color: "#64748b",
|
||||||
@@ -140,9 +149,8 @@ export default async function Image() {
|
|||||||
lineHeight: 1.4,
|
lineHeight: 1.4,
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
Energiekabelprojekte & Technische Beratung
|
<span>Energiekabelprojekte & Technische Beratung</span>
|
||||||
<br />
|
<span>bis 110 kV</span>
|
||||||
bis 110 kV
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@@ -86,7 +86,7 @@ export default async function Image() {
|
|||||||
letterSpacing: "0.1em",
|
letterSpacing: "0.1em",
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
Engineering Excellence
|
Technische Beratung
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -118,6 +118,8 @@ export default async function Image() {
|
|||||||
{/* Title */}
|
{/* Title */}
|
||||||
<div
|
<div
|
||||||
style={{
|
style={{
|
||||||
|
display: "flex",
|
||||||
|
flexDirection: "row",
|
||||||
fontSize: "72px",
|
fontSize: "72px",
|
||||||
fontWeight: "900",
|
fontWeight: "900",
|
||||||
color: "#0f172a",
|
color: "#0f172a",
|
||||||
@@ -126,12 +128,19 @@ export default async function Image() {
|
|||||||
letterSpacing: "-0.02em",
|
letterSpacing: "-0.02em",
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
MB Grid <span style={{ color: "#10b981" }}>Solutions</span>
|
MB Grid{" "}
|
||||||
|
<span
|
||||||
|
style={{ color: "#10b981", display: "flex", marginLeft: "16px" }}
|
||||||
|
>
|
||||||
|
Solutions
|
||||||
|
</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Subtitle */}
|
{/* Subtitle */}
|
||||||
<div
|
<div
|
||||||
style={{
|
style={{
|
||||||
|
display: "flex",
|
||||||
|
flexDirection: "column",
|
||||||
fontSize: "32px",
|
fontSize: "32px",
|
||||||
fontWeight: "500",
|
fontWeight: "500",
|
||||||
color: "#64748b",
|
color: "#64748b",
|
||||||
@@ -140,9 +149,8 @@ export default async function Image() {
|
|||||||
lineHeight: 1.4,
|
lineHeight: 1.4,
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
Energiekabelprojekte & Technische Beratung
|
<span>Energiekabelprojekte & Technische Beratung</span>
|
||||||
<br />
|
<span>bis 110 kV</span>
|
||||||
bis 110 kV
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@@ -8,9 +8,23 @@ export async function POST(req: Request) {
|
|||||||
const services = getServerAppServices();
|
const services = getServerAppServices();
|
||||||
const logger = services.logger.child({ action: "contact_submission" });
|
const logger = services.logger.child({ action: "contact_submission" });
|
||||||
|
|
||||||
|
// Set analytics context from request headers for high-fidelity server-side tracking
|
||||||
|
// This fulfills the "server-side via nextjs proxy" requirement
|
||||||
|
if (services.analytics.setServerContext) {
|
||||||
|
services.analytics.setServerContext({
|
||||||
|
userAgent: req.headers.get("user-agent") || undefined,
|
||||||
|
language: req.headers.get("accept-language")?.split(",")[0] || undefined,
|
||||||
|
referrer: req.headers.get("referer") || undefined,
|
||||||
|
ip: req.headers.get("x-forwarded-for")?.split(",")[0] || undefined,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
try {
|
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) {
|
||||||
logger.info("Spam detected (honeypot)");
|
logger.info("Spam detected (honeypot)");
|
||||||
@@ -118,6 +132,11 @@ ${message}
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Track success
|
||||||
|
services.analytics.track("contact-form-success", {
|
||||||
|
has_company: Boolean(company),
|
||||||
|
});
|
||||||
|
|
||||||
return NextResponse.json({ message: "Ok" });
|
return NextResponse.json({ message: "Ok" });
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
logger.error("Global API Error", { error });
|
logger.error("Global API Error", { error });
|
||||||
|
|||||||
@@ -197,6 +197,7 @@ export default function Contact() {
|
|||||||
) : (
|
) : (
|
||||||
<form
|
<form
|
||||||
onSubmit={handleSubmit}
|
onSubmit={handleSubmit}
|
||||||
|
aria-label={t("form.submit")}
|
||||||
className="space-y-6 relative z-10"
|
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">
|
||||||
|
|||||||
@@ -1,21 +1,29 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { m } from "framer-motion";
|
|
||||||
import {
|
|
||||||
BarChart3,
|
|
||||||
CheckCircle2,
|
|
||||||
ChevronRight,
|
|
||||||
Shield,
|
|
||||||
Zap,
|
|
||||||
} from "lucide-react";
|
|
||||||
import Image from "next/image";
|
import Image from "next/image";
|
||||||
import Link from "next/link";
|
|
||||||
import { Button } from "./Button";
|
import { Button } from "./Button";
|
||||||
import { Counter } from "./Counter";
|
import { Counter } from "./Counter";
|
||||||
import { Reveal } from "./Reveal";
|
import { Reveal } from "./Reveal";
|
||||||
import { TechBackground } from "./TechBackground";
|
import { TechBackground } from "./TechBackground";
|
||||||
import { useTranslations } from "next-intl";
|
import { useTranslations } from "next-intl";
|
||||||
|
|
||||||
|
import dynamic from "next/dynamic";
|
||||||
|
|
||||||
|
const PortfolioSection = dynamic(() =>
|
||||||
|
import("./sections/PortfolioSection").then((mod) => mod.PortfolioSection),
|
||||||
|
);
|
||||||
|
const ExpertiseSection = dynamic(() =>
|
||||||
|
import("./sections/ExpertiseSection").then((mod) => mod.ExpertiseSection),
|
||||||
|
);
|
||||||
|
const TechnicalSpecsSection = dynamic(() =>
|
||||||
|
import("./sections/TechnicalSpecsSection").then(
|
||||||
|
(mod) => mod.TechnicalSpecsSection,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
const CTASection = dynamic(() =>
|
||||||
|
import("./sections/CTASection").then((mod) => mod.CTASection),
|
||||||
|
);
|
||||||
|
|
||||||
export default function Home() {
|
export default function Home() {
|
||||||
const t = useTranslations("Index");
|
const t = useTranslations("Index");
|
||||||
|
|
||||||
@@ -74,7 +82,7 @@ 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 />
|
||||||
@@ -127,272 +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">
|
|
||||||
{t("portfolio.tag")}
|
|
||||||
</span>
|
|
||||||
<h2 className="text-3xl md:text-5xl font-bold text-white mb-6">
|
|
||||||
{t("portfolio.title")}
|
|
||||||
</h2>
|
|
||||||
<p className="text-slate-400 text-base md:text-xl">
|
|
||||||
{t("portfolio.description")}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
<Link
|
|
||||||
href="/ueber-uns"
|
|
||||||
className="text-accent font-bold flex items-center gap-2 hover:text-white transition-colors group"
|
|
||||||
>
|
|
||||||
{t("portfolio.link")}{" "}
|
|
||||||
<ChevronRight
|
|
||||||
className="transition-transform group-hover:translate-x-1"
|
|
||||||
size={20}
|
|
||||||
/>
|
|
||||||
</Link>
|
|
||||||
</Reveal>
|
|
||||||
|
|
||||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-8">
|
|
||||||
{[
|
|
||||||
{
|
|
||||||
icon: <Zap size={32} />,
|
|
||||||
title: t("portfolio.items.beratung.title"),
|
|
||||||
desc: t("portfolio.items.beratung.desc"),
|
|
||||||
},
|
|
||||||
{
|
|
||||||
icon: <Shield size={32} />,
|
|
||||||
title: t("portfolio.items.begleitung.title"),
|
|
||||||
desc: t("portfolio.items.begleitung.desc"),
|
|
||||||
},
|
|
||||||
{
|
|
||||||
icon: <BarChart3 size={32} />,
|
|
||||||
title: t("portfolio.items.beschaffung.title"),
|
|
||||||
desc: t("portfolio.items.beschaffung.desc"),
|
|
||||||
},
|
|
||||||
].map((item, i) => (
|
|
||||||
<Reveal key={i} delay={i * 0.1}>
|
|
||||||
<div className="bg-white/5 p-8 rounded-2xl border border-white/10 group hover:-translate-y-2 transition-[box-shadow,transform] duration-300 h-full relative overflow-hidden">
|
|
||||||
<div className="absolute top-0 right-0 w-16 h-16 bg-accent/5 -mr-8 -mt-8 rounded-full group-hover:bg-accent/10 transition-colors" />
|
|
||||||
<div className="w-16 h-16 rounded-2xl bg-accent/10 text-accent flex items-center justify-center mb-8 group-hover:bg-accent group-hover:text-white transition-colors relative z-10">
|
|
||||||
{item.icon}
|
|
||||||
</div>
|
|
||||||
<h3 className="text-2xl font-bold text-white mb-4 relative z-10">
|
|
||||||
{item.title}
|
|
||||||
</h3>
|
|
||||||
<p className="text-slate-400 leading-relaxed relative z-10">
|
|
||||||
{item.desc}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</Reveal>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
|
|
||||||
{/* Expertise Section */}
|
|
||||||
<section className="bg-white relative overflow-hidden">
|
|
||||||
<TechBackground />
|
|
||||||
<div className="container-custom relative z-10">
|
|
||||||
<Counter value={3} className="section-number" />
|
|
||||||
<div className="grid grid-cols-1 lg:grid-cols-2 items-center gap-16 md:gap-24">
|
|
||||||
<Reveal direction="right">
|
|
||||||
<div className="relative overflow-hidden rounded-2xl shadow-lg group">
|
|
||||||
<div className="absolute inset-0 bg-accent/10 opacity-0 group-hover:opacity-100 transition-opacity duration-500 z-10 pointer-events-none" />
|
|
||||||
<Image
|
|
||||||
src="/media/cables/hs-kabel.png"
|
|
||||||
alt="Technical Engineering"
|
|
||||||
width={800}
|
|
||||||
height={600}
|
|
||||||
className="w-full h-[400px] md:h-[500px] object-cover hover:scale-105 transition-transform duration-700"
|
|
||||||
/>
|
|
||||||
<div className="tech-corner top-4 left-4 border-t-2 border-l-2 z-20" />
|
|
||||||
<div className="tech-corner bottom-4 right-4 border-b-2 border-r-2 z-20" />
|
|
||||||
</div>
|
|
||||||
</Reveal>
|
|
||||||
<div>
|
|
||||||
<Reveal>
|
|
||||||
<span className="text-accent font-bold uppercase tracking-widest text-sm mb-4 block">
|
|
||||||
{t("expertise.tag")}
|
|
||||||
</span>
|
|
||||||
<h2 className="text-3xl md:text-5xl font-bold text-primary mb-6 md:mb-8">
|
|
||||||
{t("expertise.title")}
|
|
||||||
</h2>
|
|
||||||
<p className="text-slate-600 text-base md:text-xl mb-8 md:mb-12">
|
|
||||||
{t("expertise.description")}
|
|
||||||
</p>
|
|
||||||
</Reveal>
|
|
||||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
|
|
||||||
{t.raw("expertise.groups").map((item: string, i: number) => (
|
|
||||||
<Reveal key={i} delay={i * 0.05}>
|
|
||||||
<div className="flex items-center gap-4 p-4 bg-slate-50 rounded-xl border border-slate-100 hover:border-accent/30 transition-colors group relative overflow-hidden">
|
|
||||||
<div className="absolute top-0 left-0 w-1 h-full bg-accent/0 group-hover:bg-accent/100 transition-all duration-300" />
|
|
||||||
<div className="w-8 h-8 rounded-full bg-white flex items-center justify-center shadow-sm group-hover:bg-accent group-hover:text-white transition-colors">
|
|
||||||
<CheckCircle2 size={16} />
|
|
||||||
</div>
|
|
||||||
<span className="text-primary font-semibold">{item}</span>
|
|
||||||
</div>
|
|
||||||
</Reveal>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
|
|
||||||
{/* Technical Specs Section */}
|
|
||||||
<section className="relative py-24 md:py-32 text-white overflow-hidden bg-slate-900">
|
|
||||||
<div className="absolute inset-0 opacity-20">
|
|
||||||
<Image
|
|
||||||
src="/media/drums/about-hero.jpg"
|
|
||||||
alt="Background"
|
|
||||||
fill
|
|
||||||
className="object-cover"
|
|
||||||
/>
|
|
||||||
<div className="absolute inset-0 bg-gradient-to-b from-slate-900 via-slate-900/80 to-slate-900" />
|
|
||||||
</div>
|
|
||||||
<TechBackground />
|
|
||||||
|
|
||||||
<div className="container-custom relative z-10">
|
|
||||||
<Counter value={4} className="section-number !text-white/5" />
|
|
||||||
{/* Data Stream Effect */}
|
|
||||||
<div className="absolute -top-10 right-0 w-px h-64 bg-gradient-to-b from-transparent via-accent/50 to-transparent animate-pulse" />
|
|
||||||
<div className="absolute -bottom-10 left-10 w-px h-64 bg-gradient-to-b from-transparent via-accent/30 to-transparent animate-pulse delay-700" />
|
|
||||||
|
|
||||||
<Reveal className="mb-20">
|
|
||||||
<span className="text-accent font-bold uppercase tracking-widest text-sm mb-4 block">
|
|
||||||
{t("specs.tag")}
|
|
||||||
</span>
|
|
||||||
<h2 className="text-3xl md:text-5xl font-bold text-white mb-6">
|
|
||||||
{t("specs.title")}
|
|
||||||
</h2>
|
|
||||||
</Reveal>
|
|
||||||
|
|
||||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-6 md:gap-8">
|
|
||||||
{[
|
|
||||||
{
|
|
||||||
label: t("specs.items.kabel.label"),
|
|
||||||
value: t("specs.items.kabel.value"),
|
|
||||||
desc: t("specs.items.kabel.desc"),
|
|
||||||
},
|
|
||||||
{
|
|
||||||
label: t("specs.items.spannung.label"),
|
|
||||||
value: t("specs.items.spannung.value"),
|
|
||||||
desc: t("specs.items.spannung.desc"),
|
|
||||||
},
|
|
||||||
{
|
|
||||||
label: t("specs.items.technologie.label"),
|
|
||||||
value: t("specs.items.technologie.value"),
|
|
||||||
desc: t("specs.items.technologie.desc"),
|
|
||||||
},
|
|
||||||
].map((item, i) => (
|
|
||||||
<Reveal key={i} delay={i * 0.1}>
|
|
||||||
<div className="p-10 rounded-3xl bg-white/5 border border-white/10 backdrop-blur-sm hover:bg-white/10 transition-colors h-full relative group overflow-hidden">
|
|
||||||
<div className="absolute top-0 left-0 w-full h-1 bg-accent/0 group-hover:bg-accent/50 transition-all duration-500" />
|
|
||||||
<h4 className="text-accent font-bold text-xs uppercase tracking-widest mb-6">
|
|
||||||
{item.label}
|
|
||||||
</h4>
|
|
||||||
<p className="text-2xl font-bold text-white mb-4 leading-tight">
|
|
||||||
{item.value}
|
|
||||||
</p>
|
|
||||||
<p className="text-slate-400 leading-relaxed">{item.desc}</p>
|
|
||||||
</div>
|
|
||||||
</Reveal>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
|
|
||||||
{/* CTA Section */}
|
|
||||||
<section className="bg-white relative overflow-hidden">
|
|
||||||
<TechBackground />
|
|
||||||
{/* Decorative Background Elements */}
|
|
||||||
<div className="absolute top-0 left-0 w-64 h-64 bg-accent/5 rounded-full blur-3xl -translate-x-1/2 -translate-y-1/2" />
|
|
||||||
<div className="absolute bottom-0 right-0 w-96 h-96 bg-primary/5 rounded-full blur-3xl translate-x-1/2 translate-y-1/2" />
|
|
||||||
|
|
||||||
<div className="container-custom relative z-10">
|
|
||||||
<Counter value={5} className="section-number" />
|
|
||||||
<Reveal>
|
|
||||||
<div className="relative rounded-3xl md:rounded-[2.5rem] bg-primary p-8 md:p-24 overflow-hidden group">
|
|
||||||
{/* Corner Accents */}
|
|
||||||
<div className="tech-corner top-8 left-8 border-t-2 border-l-2" />
|
|
||||||
<div className="tech-corner top-8 right-8 border-t-2 border-r-2" />
|
|
||||||
<div className="tech-corner bottom-8 left-8 border-b-2 border-l-2" />
|
|
||||||
<div className="tech-corner bottom-8 right-8 border-b-2 border-r-2" />
|
|
||||||
|
|
||||||
<div className="absolute top-0 right-0 w-1/2 h-full opacity-10 pointer-events-none">
|
|
||||||
<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>
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -275,7 +275,7 @@ const Layout = ({ children }: { children: React.ReactNode }) => {
|
|||||||
<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{" "}
|
Website entwickelt von{" "}
|
||||||
<a
|
<a
|
||||||
href="https://mintel.me"
|
href="https://mintel.me"
|
||||||
target="_blank"
|
target="_blank"
|
||||||
|
|||||||
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>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -12,32 +12,39 @@ services:
|
|||||||
- "traefik.http.routers.${PROJECT_NAME}.entrypoints=websecure"
|
- "traefik.http.routers.${PROJECT_NAME}.entrypoints=websecure"
|
||||||
- "traefik.http.routers.${PROJECT_NAME}.tls.certresolver=le"
|
- "traefik.http.routers.${PROJECT_NAME}.tls.certresolver=le"
|
||||||
- "traefik.http.routers.${PROJECT_NAME}.tls=true"
|
- "traefik.http.routers.${PROJECT_NAME}.tls=true"
|
||||||
|
- "traefik.http.routers.${PROJECT_NAME}.priority=1000"
|
||||||
- "traefik.http.services.${PROJECT_NAME}.loadbalancer.server.port=3000"
|
- "traefik.http.services.${PROJECT_NAME}.loadbalancer.server.port=3000"
|
||||||
- "traefik.http.routers.${PROJECT_NAME}.middlewares=${TRAEFIK_MIDDLEWARES:-${PROJECT_NAME}-auth}"
|
- "traefik.http.routers.${PROJECT_NAME}.middlewares=${TRAEFIK_MIDDLEWARES:-${PROJECT_NAME}-auth,${PROJECT_NAME}-forward,compress}"
|
||||||
- "traefik.docker.network=infra"
|
- "traefik.docker.network=infra"
|
||||||
|
|
||||||
# Gatekeeper Router (Shared Host + dedicated Subdomain)
|
# Forwarded Headers (Protocol Normalization)
|
||||||
- "traefik.http.routers.${PROJECT_NAME}-gatekeeper.rule=${GATEKEEPER_RULE:-(Host(`${TRAEFIK_HOST:-mb-grid-solutions.localhost}`) && PathPrefix(`/gatekeeper`)) || Host(`gatekeeper.${TRAEFIK_HOST:-mb-grid-solutions.localhost}`)}"
|
- "traefik.http.middlewares.${PROJECT_NAME}-forward.headers.customrequestheaders.X-Forwarded-Proto=https"
|
||||||
|
- "traefik.http.middlewares.${PROJECT_NAME}-forward.headers.customrequestheaders.X-Forwarded-Ssl=on"
|
||||||
|
|
||||||
|
# Gatekeeper Router (Path-based)
|
||||||
|
- "traefik.http.routers.${PROJECT_NAME}-gatekeeper.rule=(Host(`${TRAEFIK_HOST}`) && PathPrefix(`/gatekeeper`))"
|
||||||
- "traefik.http.routers.${PROJECT_NAME}-gatekeeper.entrypoints=websecure"
|
- "traefik.http.routers.${PROJECT_NAME}-gatekeeper.entrypoints=websecure"
|
||||||
- "traefik.http.routers.${PROJECT_NAME}-gatekeeper.tls.certresolver=le"
|
- "traefik.http.routers.${PROJECT_NAME}-gatekeeper.tls.certresolver=le"
|
||||||
- "traefik.http.routers.${PROJECT_NAME}-gatekeeper.tls=true"
|
- "traefik.http.routers.${PROJECT_NAME}-gatekeeper.tls=true"
|
||||||
|
- "traefik.http.routers.${PROJECT_NAME}-gatekeeper.priority=2000"
|
||||||
- "traefik.http.routers.${PROJECT_NAME}-gatekeeper.service=${PROJECT_NAME}-gatekeeper"
|
- "traefik.http.routers.${PROJECT_NAME}-gatekeeper.service=${PROJECT_NAME}-gatekeeper"
|
||||||
|
|
||||||
- "traefik.http.middlewares.${PROJECT_NAME}-auth.forwardauth.address=http://${PROJECT_NAME}-gatekeeper:3000/api/verify"
|
- "traefik.http.middlewares.${PROJECT_NAME}-auth.forwardauth.address=http://${PROJECT_NAME}-gatekeeper:3000/gatekeeper/api/verify"
|
||||||
- "traefik.http.middlewares.${PROJECT_NAME}-auth.forwardauth.trustForwardHeader=true"
|
- "traefik.http.middlewares.${PROJECT_NAME}-auth.forwardauth.trustForwardHeader=true"
|
||||||
|
- "traefik.http.middlewares.${PROJECT_NAME}-auth.forwardauth.authRequestHeaders=X-Forwarded-Host,X-Forwarded-Proto,X-Forwarded-For,Cookie"
|
||||||
- "traefik.http.middlewares.${PROJECT_NAME}-auth.forwardauth.authResponseHeaders=X-Auth-User"
|
- "traefik.http.middlewares.${PROJECT_NAME}-auth.forwardauth.authResponseHeaders=X-Auth-User"
|
||||||
- "traefik.docker.network=infra"
|
|
||||||
healthcheck:
|
healthcheck:
|
||||||
test: [ "CMD", "wget", "--no-verbose", "--tries=1", "--spider", "http://localhost:3000/api/health" ]
|
test: [ "CMD", "node", "-e", "fetch('http://127.0.0.1:3000/api/health').then(r => r.ok ? process.exit(0) : process.exit(1)).catch(() => process.exit(1))" ]
|
||||||
interval: 10s
|
interval: 10s
|
||||||
timeout: 5s
|
timeout: 5s
|
||||||
retries: 5
|
retries: 5
|
||||||
start_period: 30s
|
start_period: 30s
|
||||||
|
|
||||||
gatekeeper:
|
gatekeeper:
|
||||||
image: registry.infra.mintel.me/mintel/gatekeeper:latest
|
image: registry.infra.mintel.me/mintel/gatekeeper:v1.7.12
|
||||||
container_name: ${PROJECT_NAME:-mb-grid-solutions}-gatekeeper
|
container_name: ${PROJECT_NAME:-mb-grid-solutions}-gatekeeper
|
||||||
restart: always
|
profiles: [ "gatekeeper" ]
|
||||||
|
restart: unless-stopped
|
||||||
networks:
|
networks:
|
||||||
infra:
|
infra:
|
||||||
aliases:
|
aliases:
|
||||||
@@ -48,11 +55,15 @@ services:
|
|||||||
PORT: ${PORT:-3000}
|
PORT: ${PORT:-3000}
|
||||||
PROJECT_NAME: ${PROJECT_NAME:-MB Grid Solutions}
|
PROJECT_NAME: ${PROJECT_NAME:-MB Grid Solutions}
|
||||||
PROJECT_COLOR: ${PROJECT_COLOR:-#82ed20}
|
PROJECT_COLOR: ${PROJECT_COLOR:-#82ed20}
|
||||||
COOKIE_DOMAIN: ${COOKIE_DOMAIN:-.mb-grid-solutions.com}
|
COOKIE_DOMAIN: ${COOKIE_DOMAIN}
|
||||||
AUTH_COOKIE_NAME: ${AUTH_COOKIE_NAME:-mintel_gatekeeper_session}
|
AUTH_COOKIE_NAME: ${AUTH_COOKIE_NAME}
|
||||||
GATEKEEPER_PASSWORD: ${GATEKEEPER_PASSWORD:-mintel}
|
GATEKEEPER_PASSWORD: ${GATEKEEPER_PASSWORD}
|
||||||
# Dedicated Base URL for Gatekeeper subdomain to prevent redirect loops
|
NEXT_PUBLIC_BASE_URL: ${GATEKEEPER_ORIGIN}
|
||||||
NEXT_PUBLIC_BASE_URL: https://${GATEKEEPER_HOST:-gatekeeper.mb-grid-solutions.localhost}
|
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:
|
labels:
|
||||||
- "traefik.enable=true"
|
- "traefik.enable=true"
|
||||||
- "traefik.http.services.${PROJECT_NAME}-gatekeeper.loadbalancer.server.port=3000"
|
- "traefik.http.services.${PROJECT_NAME}-gatekeeper.loadbalancer.server.port=3000"
|
||||||
@@ -62,8 +73,10 @@ services:
|
|||||||
image: directus/directus:11
|
image: directus/directus:11
|
||||||
restart: always
|
restart: always
|
||||||
networks:
|
networks:
|
||||||
- infra
|
infra:
|
||||||
- backend
|
aliases:
|
||||||
|
- ${PROJECT_NAME:-mb-grid-solutions}-directus
|
||||||
|
testing-backend:
|
||||||
env_file:
|
env_file:
|
||||||
- ${ENV_FILE:-.env}
|
- ${ENV_FILE:-.env}
|
||||||
environment:
|
environment:
|
||||||
@@ -72,8 +85,8 @@ services:
|
|||||||
DB_PORT: '5432'
|
DB_PORT: '5432'
|
||||||
WEBSOCKETS_ENABLED: 'true'
|
WEBSOCKETS_ENABLED: 'true'
|
||||||
PUBLIC_URL: ${DIRECTUS_URL}
|
PUBLIC_URL: ${DIRECTUS_URL}
|
||||||
KEY: ${DIRECTUS_KEY}
|
KEY: ${DIRECTUS_KEY:-01234567-89ab-cdef-0123-456789abcdef}
|
||||||
SECRET: ${DIRECTUS_SECRET}
|
SECRET: ${DIRECTUS_SECRET:-long-secret-for-signing-tokens-must-be-32-chars}
|
||||||
ADMIN_EMAIL: ${DIRECTUS_ADMIN_EMAIL}
|
ADMIN_EMAIL: ${DIRECTUS_ADMIN_EMAIL}
|
||||||
ADMIN_PASSWORD: ${DIRECTUS_ADMIN_PASSWORD}
|
ADMIN_PASSWORD: ${DIRECTUS_ADMIN_PASSWORD}
|
||||||
DB_DATABASE: ${DIRECTUS_DB_NAME:-directus}
|
DB_DATABASE: ${DIRECTUS_DB_NAME:-directus}
|
||||||
@@ -92,16 +105,22 @@ services:
|
|||||||
- "traefik.http.routers.${PROJECT_NAME}-directus.entrypoints=websecure"
|
- "traefik.http.routers.${PROJECT_NAME}-directus.entrypoints=websecure"
|
||||||
- "traefik.http.routers.${PROJECT_NAME}-directus.tls.certresolver=le"
|
- "traefik.http.routers.${PROJECT_NAME}-directus.tls.certresolver=le"
|
||||||
- "traefik.http.routers.${PROJECT_NAME}-directus.tls=true"
|
- "traefik.http.routers.${PROJECT_NAME}-directus.tls=true"
|
||||||
|
- "traefik.http.routers.${PROJECT_NAME}-directus.priority=1000"
|
||||||
- "traefik.http.routers.${PROJECT_NAME}-directus.middlewares=${PROJECT_NAME}-forward,compress"
|
- "traefik.http.routers.${PROJECT_NAME}-directus.middlewares=${PROJECT_NAME}-forward,compress"
|
||||||
- "traefik.http.services.${PROJECT_NAME}-directus.loadbalancer.server.port=8055"
|
- "traefik.http.services.${PROJECT_NAME}-directus.loadbalancer.server.port=8055"
|
||||||
- "traefik.http.middlewares.${PROJECT_NAME}-forward.headers.customrequestheaders.X-Forwarded-Proto=https"
|
|
||||||
- "traefik.docker.network=infra"
|
- "traefik.docker.network=infra"
|
||||||
|
healthcheck:
|
||||||
|
test: [ "CMD", "node", "-e", "fetch('http://localhost:8055/admin').then(r => r.ok ? process.exit(0) : process.exit(1)).catch(() => process.exit(1))" ]
|
||||||
|
interval: 30s
|
||||||
|
timeout: 10s
|
||||||
|
retries: 3
|
||||||
|
start_period: 30s
|
||||||
|
|
||||||
directus-db:
|
directus-db:
|
||||||
image: postgres:15-alpine
|
image: postgres:15-alpine
|
||||||
restart: always
|
restart: always
|
||||||
networks:
|
networks:
|
||||||
- backend
|
- testing-backend
|
||||||
env_file:
|
env_file:
|
||||||
- ${ENV_FILE:-.env}
|
- ${ENV_FILE:-.env}
|
||||||
environment:
|
environment:
|
||||||
@@ -114,7 +133,7 @@ services:
|
|||||||
networks:
|
networks:
|
||||||
infra:
|
infra:
|
||||||
external: true
|
external: true
|
||||||
backend:
|
testing-backend:
|
||||||
internal: true
|
internal: true
|
||||||
|
|
||||||
volumes:
|
volumes:
|
||||||
|
|||||||
@@ -27,9 +27,11 @@ function createConfig() {
|
|||||||
|
|
||||||
analytics: {
|
analytics: {
|
||||||
umami: {
|
umami: {
|
||||||
websiteId: env.UMAMI_WEBSITE_ID,
|
websiteId: env.NEXT_PUBLIC_UMAMI_WEBSITE_ID || env.UMAMI_WEBSITE_ID,
|
||||||
apiEndpoint: env.UMAMI_API_ENDPOINT,
|
apiEndpoint: env.UMAMI_API_ENDPOINT,
|
||||||
enabled: Boolean(env.UMAMI_WEBSITE_ID),
|
enabled: Boolean(
|
||||||
|
env.NEXT_PUBLIC_UMAMI_WEBSITE_ID || env.UMAMI_WEBSITE_ID,
|
||||||
|
),
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
|
||||||
|
|||||||
@@ -1,43 +1,28 @@
|
|||||||
import { createDirectus, rest, authentication } from "@directus/sdk";
|
import {
|
||||||
import { config } from "./config";
|
createMintelDirectusClient,
|
||||||
|
ensureDirectusAuthenticated,
|
||||||
|
} from "@mintel/next-utils";
|
||||||
import { getServerAppServices } from "./services/create-services.server";
|
import { getServerAppServices } from "./services/create-services.server";
|
||||||
|
|
||||||
const { url, adminEmail, password, token, internalUrl } = config.directus;
|
// Initialize client using Mintel standards (environment-aware)
|
||||||
|
const client = createMintelDirectusClient();
|
||||||
// Use internal URL if on server to bypass Gatekeeper/Auth/Proxy issues
|
|
||||||
const effectiveUrl =
|
|
||||||
typeof window === "undefined" && internalUrl ? internalUrl : url;
|
|
||||||
|
|
||||||
const client = createDirectus(effectiveUrl).with(rest()).with(authentication());
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Ensures the client is authenticated.
|
* Ensures the client is authenticated.
|
||||||
* Falls back to login with admin credentials if no static token is provided.
|
* Standardized using @mintel/next-utils ensureDirectusAuthenticated.
|
||||||
*/
|
*/
|
||||||
export async function ensureAuthenticated() {
|
export async function ensureAuthenticated() {
|
||||||
if (token) {
|
try {
|
||||||
client.setToken(token);
|
await ensureDirectusAuthenticated(client);
|
||||||
return;
|
} catch (e) {
|
||||||
}
|
if (typeof window === "undefined") {
|
||||||
|
getServerAppServices().errors.captureException(e, {
|
||||||
if (adminEmail && password) {
|
phase: "directus_auth_standardized",
|
||||||
try {
|
});
|
||||||
await client.login({ email: adminEmail, password: password });
|
|
||||||
return;
|
|
||||||
} catch (e) {
|
|
||||||
if (typeof window === "undefined") {
|
|
||||||
getServerAppServices().errors.captureException(e, {
|
|
||||||
phase: "directus_auth_fallback",
|
|
||||||
});
|
|
||||||
}
|
|
||||||
console.error("Failed to authenticate with Directus login fallback:", e);
|
|
||||||
throw e;
|
|
||||||
}
|
}
|
||||||
|
console.error("Failed to authenticate with Directus:", e);
|
||||||
|
throw e;
|
||||||
}
|
}
|
||||||
|
|
||||||
throw new Error(
|
|
||||||
"Missing Directus authentication credentials (token or admin email/password)",
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export default client;
|
export default client;
|
||||||
|
|||||||
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;
|
||||||
|
});
|
||||||
|
});
|
||||||
159
lib/env.ts
159
lib/env.ts
@@ -1,139 +1,42 @@
|
|||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
|
import {
|
||||||
/**
|
validateMintelEnv,
|
||||||
* Helper to treat empty strings as undefined.
|
mintelEnvSchema,
|
||||||
*/
|
withMintelRefinements,
|
||||||
const preprocessEmptyString = (val: unknown) => (val === "" ? undefined : val);
|
} from "@mintel/next-utils";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Environment variable schema.
|
* Environment variable schema.
|
||||||
|
* Extends the default Mintel environment schema which already includes:
|
||||||
|
* - Directus (URL, TOKEN, INTERNAL_URL, etc.)
|
||||||
|
* - Mail (HOST, PORT, etc.)
|
||||||
|
* - Gotify
|
||||||
|
* - Logging
|
||||||
|
* - Analytics
|
||||||
*/
|
*/
|
||||||
export const envSchema = z
|
const envExtension = {
|
||||||
.object({
|
// Project specific overrides or additions
|
||||||
NODE_ENV: z
|
AUTH_COOKIE_NAME: z.string().default("mb_gatekeeper_session"),
|
||||||
.enum(["development", "production", "test"])
|
|
||||||
.default("development"),
|
|
||||||
NEXT_PUBLIC_BASE_URL: z.preprocess(
|
|
||||||
preprocessEmptyString,
|
|
||||||
z.string().url().optional(),
|
|
||||||
),
|
|
||||||
NEXT_PUBLIC_TARGET: z
|
|
||||||
.enum(["development", "testing", "staging", "production"])
|
|
||||||
.optional(),
|
|
||||||
|
|
||||||
// Analytics
|
INFRA_DIRECTUS_URL: z.string().url().optional(),
|
||||||
UMAMI_WEBSITE_ID: z.preprocess(
|
INFRA_DIRECTUS_TOKEN: z.string().optional(),
|
||||||
preprocessEmptyString,
|
};
|
||||||
z.string().optional(),
|
|
||||||
),
|
|
||||||
UMAMI_API_ENDPOINT: z.preprocess(
|
|
||||||
preprocessEmptyString,
|
|
||||||
z.string().url().default("https://analytics.infra.mintel.me"),
|
|
||||||
),
|
|
||||||
|
|
||||||
// Error Tracking
|
|
||||||
SENTRY_DSN: z.preprocess(preprocessEmptyString, z.string().optional()),
|
|
||||||
|
|
||||||
// Logging
|
|
||||||
LOG_LEVEL: z.enum(["debug", "info", "warn", "error"]).default("info"),
|
|
||||||
|
|
||||||
// Mail
|
|
||||||
MAIL_HOST: z.preprocess(preprocessEmptyString, z.string().optional()),
|
|
||||||
MAIL_PORT: z.preprocess(
|
|
||||||
preprocessEmptyString,
|
|
||||||
z.coerce.number().default(587),
|
|
||||||
),
|
|
||||||
MAIL_USERNAME: z.preprocess(preprocessEmptyString, z.string().optional()),
|
|
||||||
MAIL_PASSWORD: z.preprocess(preprocessEmptyString, z.string().optional()),
|
|
||||||
MAIL_FROM: z.preprocess(preprocessEmptyString, z.string().optional()),
|
|
||||||
MAIL_RECIPIENTS: z.preprocess(
|
|
||||||
(val) => (typeof val === "string" ? val.split(",").filter(Boolean) : val),
|
|
||||||
z.array(z.string()).default([]),
|
|
||||||
),
|
|
||||||
|
|
||||||
// Directus
|
|
||||||
DIRECTUS_URL: z.preprocess(
|
|
||||||
preprocessEmptyString,
|
|
||||||
z.string().url().default("http://localhost:8055"),
|
|
||||||
),
|
|
||||||
DIRECTUS_ADMIN_EMAIL: z.preprocess(
|
|
||||||
preprocessEmptyString,
|
|
||||||
z.string().optional(),
|
|
||||||
),
|
|
||||||
DIRECTUS_ADMIN_PASSWORD: z.preprocess(
|
|
||||||
preprocessEmptyString,
|
|
||||||
z.string().optional(),
|
|
||||||
),
|
|
||||||
DIRECTUS_API_TOKEN: z.preprocess(
|
|
||||||
preprocessEmptyString,
|
|
||||||
z.string().optional(),
|
|
||||||
),
|
|
||||||
INTERNAL_DIRECTUS_URL: z.preprocess(
|
|
||||||
preprocessEmptyString,
|
|
||||||
z.string().url().optional(),
|
|
||||||
),
|
|
||||||
|
|
||||||
// Deploy Target
|
|
||||||
TARGET: z
|
|
||||||
.enum(["development", "testing", "staging", "production"])
|
|
||||||
.optional(),
|
|
||||||
// Gotify
|
|
||||||
GOTIFY_URL: z.preprocess(
|
|
||||||
preprocessEmptyString,
|
|
||||||
z.string().url().optional(),
|
|
||||||
),
|
|
||||||
GOTIFY_TOKEN: z.preprocess(preprocessEmptyString, z.string().optional()),
|
|
||||||
})
|
|
||||||
.superRefine((data, ctx) => {
|
|
||||||
const target = data.NEXT_PUBLIC_TARGET || data.TARGET;
|
|
||||||
const isDev = target === "development" || !target;
|
|
||||||
const isBuildTimeValidation =
|
|
||||||
process.env.SKIP_RUNTIME_ENV_VALIDATION === "true";
|
|
||||||
const isServer = typeof window === "undefined";
|
|
||||||
|
|
||||||
// Only enforce server-only variables when running on the server.
|
|
||||||
// In the browser, non-NEXT_PUBLIC_ variables are undefined and should not trigger validation errors.
|
|
||||||
if (isServer && !isDev && !isBuildTimeValidation && !data.MAIL_HOST) {
|
|
||||||
ctx.addIssue({
|
|
||||||
code: z.ZodIssueCode.custom,
|
|
||||||
message: "MAIL_HOST is required in non-development environments",
|
|
||||||
path: ["MAIL_HOST"],
|
|
||||||
});
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
export type Env = z.infer<typeof envSchema>;
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Collects all environment variables from the process.
|
* Full schema including Mintel base and refinements
|
||||||
* Explicitly references NEXT_PUBLIC_ variables for Next.js inlining.
|
*/
|
||||||
|
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() {
|
export function getRawEnv() {
|
||||||
return {
|
return env;
|
||||||
NODE_ENV: process.env.NODE_ENV,
|
|
||||||
NEXT_PUBLIC_BASE_URL: process.env.NEXT_PUBLIC_BASE_URL,
|
|
||||||
NEXT_PUBLIC_TARGET: process.env.NEXT_PUBLIC_TARGET,
|
|
||||||
UMAMI_WEBSITE_ID:
|
|
||||||
process.env.UMAMI_WEBSITE_ID || process.env.NEXT_PUBLIC_UMAMI_WEBSITE_ID,
|
|
||||||
UMAMI_API_ENDPOINT:
|
|
||||||
process.env.UMAMI_API_ENDPOINT ||
|
|
||||||
process.env.UMAMI_SCRIPT_URL ||
|
|
||||||
process.env.NEXT_PUBLIC_UMAMI_SCRIPT_URL,
|
|
||||||
SENTRY_DSN: process.env.SENTRY_DSN,
|
|
||||||
LOG_LEVEL: process.env.LOG_LEVEL,
|
|
||||||
MAIL_HOST: process.env.MAIL_HOST,
|
|
||||||
MAIL_PORT: process.env.MAIL_PORT,
|
|
||||||
MAIL_USERNAME: process.env.MAIL_USERNAME,
|
|
||||||
MAIL_PASSWORD: process.env.MAIL_PASSWORD,
|
|
||||||
MAIL_FROM: process.env.MAIL_FROM,
|
|
||||||
MAIL_RECIPIENTS: process.env.MAIL_RECIPIENTS,
|
|
||||||
DIRECTUS_URL: process.env.DIRECTUS_URL,
|
|
||||||
DIRECTUS_ADMIN_EMAIL: process.env.DIRECTUS_ADMIN_EMAIL,
|
|
||||||
DIRECTUS_ADMIN_PASSWORD: process.env.DIRECTUS_ADMIN_PASSWORD,
|
|
||||||
DIRECTUS_API_TOKEN: process.env.DIRECTUS_API_TOKEN,
|
|
||||||
INTERNAL_DIRECTUS_URL: process.env.INTERNAL_DIRECTUS_URL,
|
|
||||||
TARGET: process.env.TARGET,
|
|
||||||
GOTIFY_URL: process.env.GOTIFY_URL,
|
|
||||||
GOTIFY_TOKEN: process.env.GOTIFY_TOKEN,
|
|
||||||
};
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -73,4 +73,15 @@ export interface AnalyticsService {
|
|||||||
* ```
|
* ```
|
||||||
*/
|
*/
|
||||||
trackPageview(url?: string): void;
|
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;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -68,4 +68,16 @@ export class NoopAnalyticsService implements AnalyticsService {
|
|||||||
trackPageview(_url?: string) {
|
trackPageview(_url?: string) {
|
||||||
// intentionally noop - analytics are disabled
|
// 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
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -25,6 +25,12 @@ export type UmamiAnalyticsServiceOptions = {
|
|||||||
export class UmamiAnalyticsService implements AnalyticsService {
|
export class UmamiAnalyticsService implements AnalyticsService {
|
||||||
private websiteId?: string;
|
private websiteId?: string;
|
||||||
private endpoint: string;
|
private endpoint: string;
|
||||||
|
private serverContext?: {
|
||||||
|
userAgent?: string;
|
||||||
|
language?: string;
|
||||||
|
referrer?: string;
|
||||||
|
ip?: string;
|
||||||
|
};
|
||||||
|
|
||||||
constructor(private readonly options: UmamiAnalyticsServiceOptions) {
|
constructor(private readonly options: UmamiAnalyticsServiceOptions) {
|
||||||
this.websiteId = config.analytics.umami.websiteId;
|
this.websiteId = config.analytics.umami.websiteId;
|
||||||
@@ -36,6 +42,19 @@ export class UmamiAnalyticsService implements AnalyticsService {
|
|||||||
: "/stats";
|
: "/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.
|
* Internal method to send the payload to Umami API.
|
||||||
*/
|
*/
|
||||||
@@ -47,24 +66,47 @@ export class UmamiAnalyticsService implements AnalyticsService {
|
|||||||
const payload = {
|
const payload = {
|
||||||
website: this.websiteId,
|
website: this.websiteId,
|
||||||
hostname:
|
hostname:
|
||||||
typeof window !== "undefined" ? window.location.hostname : "server",
|
typeof window !== "undefined"
|
||||||
|
? window.location.hostname
|
||||||
|
: this.serverContext?.referrer
|
||||||
|
? new URL(this.serverContext.referrer).hostname
|
||||||
|
: "server",
|
||||||
screen:
|
screen:
|
||||||
typeof window !== "undefined"
|
typeof window !== "undefined"
|
||||||
? `${window.screen.width}x${window.screen.height}`
|
? `${window.screen.width}x${window.screen.height}`
|
||||||
: undefined,
|
: undefined,
|
||||||
language:
|
language:
|
||||||
typeof window !== "undefined" ? navigator.language : undefined,
|
typeof window !== "undefined"
|
||||||
referrer: typeof window !== "undefined" ? document.referrer : undefined,
|
? navigator.language
|
||||||
|
: this.serverContext?.language,
|
||||||
|
referrer:
|
||||||
|
typeof window !== "undefined"
|
||||||
|
? document.referrer
|
||||||
|
: this.serverContext?.referrer,
|
||||||
...data,
|
...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`, {
|
const response = await fetch(`${this.endpoint}/api/send`, {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
headers: {
|
headers,
|
||||||
"Content-Type": "application/json",
|
|
||||||
"User-Agent":
|
|
||||||
typeof window === "undefined" ? "KLZ-Server" : navigator.userAgent,
|
|
||||||
},
|
|
||||||
body: JSON.stringify({ type, payload }),
|
body: JSON.stringify({ type, payload }),
|
||||||
keepalive: true,
|
keepalive: true,
|
||||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
@@ -93,7 +135,9 @@ export class UmamiAnalyticsService implements AnalyticsService {
|
|||||||
url:
|
url:
|
||||||
typeof window !== "undefined"
|
typeof window !== "undefined"
|
||||||
? window.location.pathname + window.location.search
|
? window.location.pathname + window.location.search
|
||||||
: undefined,
|
: this.serverContext?.referrer
|
||||||
|
? new URL(this.serverContext.referrer).pathname
|
||||||
|
: "/",
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -106,7 +150,9 @@ export class UmamiAnalyticsService implements AnalyticsService {
|
|||||||
url ||
|
url ||
|
||||||
(typeof window !== "undefined"
|
(typeof window !== "undefined"
|
||||||
? window.location.pathname + window.location.search
|
? window.location.pathname + window.location.search
|
||||||
: undefined),
|
: this.serverContext?.referrer
|
||||||
|
? new URL(this.serverContext.referrer).pathname
|
||||||
|
: "/"),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -31,33 +31,63 @@ export class PinoLoggerService implements LoggerService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
trace(msg: string, ...args: unknown[]) {
|
trace(msg: string, ...args: unknown[]) {
|
||||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
if (args.length > 0 && typeof args[0] === "object" && args[0] !== null) {
|
||||||
this.logger.trace(msg, ...(args as any));
|
// 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[]) {
|
debug(msg: string, ...args: unknown[]) {
|
||||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
if (args.length > 0 && typeof args[0] === "object" && args[0] !== null) {
|
||||||
this.logger.debug(msg, ...(args as any));
|
// 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[]) {
|
info(msg: string, ...args: unknown[]) {
|
||||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
if (args.length > 0 && typeof args[0] === "object" && args[0] !== null) {
|
||||||
this.logger.info(msg, ...(args as any));
|
// 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[]) {
|
warn(msg: string, ...args: unknown[]) {
|
||||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
if (args.length > 0 && typeof args[0] === "object" && args[0] !== null) {
|
||||||
this.logger.warn(msg, ...(args as any));
|
// 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[]) {
|
error(msg: string, ...args: unknown[]) {
|
||||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
if (args.length > 0 && typeof args[0] === "object" && args[0] !== null) {
|
||||||
this.logger.error(msg, ...(args as any));
|
// 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[]) {
|
fatal(msg: string, ...args: unknown[]) {
|
||||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
if (args.length > 0 && typeof args[0] === "object" && args[0] !== null) {
|
||||||
this.logger.fatal(msg, ...(args as any));
|
// 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 {
|
child(bindings: Record<string, unknown>): LoggerService {
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
{
|
{
|
||||||
"Index": {
|
"Index": {
|
||||||
"hero": {
|
"hero": {
|
||||||
"tag": "Engineering Excellence",
|
"tag": "Technische Beratung",
|
||||||
"title": "Spezialisierter Partner für Energiekabelprojekte",
|
"title": "Spezialisierter Partner für Energiekabelprojekte",
|
||||||
"titleHighlight": "Energiekabelprojekte",
|
"titleHighlight": "Energiekabelprojekte",
|
||||||
"subtitle": "Herstellerneutrale technische Beratung für Ihre Projekte in Mittel- und Hochspannungsnetzen bis zu 110 kV.",
|
"subtitle": "Herstellerneutrale technische Beratung für Ihre Projekte in Mittel- und Hochspannungsnetzen bis zu 110 kV.",
|
||||||
@@ -31,7 +31,7 @@
|
|||||||
"expertise": {
|
"expertise": {
|
||||||
"tag": "Expertise",
|
"tag": "Expertise",
|
||||||
"title": "Anwendungen & Zielgruppen",
|
"title": "Anwendungen & Zielgruppen",
|
||||||
"description": "Wir unterstützen Akteure der Energiewende bei der Realisierung komplexer Kabelprojekte mit höchster Präzision.",
|
"description": "Wir unterstützen Sie bei der Realisierung Ihrer Kabelprojekte.",
|
||||||
"groups": [
|
"groups": [
|
||||||
"Energieversorger",
|
"Energieversorger",
|
||||||
"Ingenieurbüros",
|
"Ingenieurbüros",
|
||||||
@@ -83,16 +83,16 @@
|
|||||||
"datenschutz": "Datenschutz",
|
"datenschutz": "Datenschutz",
|
||||||
"agb": "AGB",
|
"agb": "AGB",
|
||||||
"rights": "Alle Rechte vorbehalten.",
|
"rights": "Alle Rechte vorbehalten.",
|
||||||
"madeWith": "Made with",
|
"madeWith": "Entwickelt mit",
|
||||||
"precision": "precision",
|
"precision": "Präzision",
|
||||||
"inGermany": "in Germany"
|
"inGermany": "in Deutschland"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"About": {
|
"About": {
|
||||||
"hero": {
|
"hero": {
|
||||||
"tagline": "Über uns",
|
"tagline": "Über uns",
|
||||||
"title": "Wir gestalten die Infrastructure der Zukunft",
|
"title": "Zuverlässige Begleitung für Ihre Netzinfrastruktur",
|
||||||
"subtitle": "MB Grid Solution steht für technische Exzellenz in der Energiekabeltechnologie. Wir verstehen uns als Ihr technischer Lotse."
|
"subtitle": "Herstellerneutrale Beratung in der Energiekabeltechnologie. Wir verstehen uns als Ihr technischer Lotse."
|
||||||
},
|
},
|
||||||
"intro": {
|
"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.",
|
"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.",
|
||||||
|
|||||||
@@ -14,5 +14,9 @@ export default createMiddleware({
|
|||||||
export const config = {
|
export const config = {
|
||||||
// Matcher for all pages and internationalized pathnames
|
// Matcher for all pages and internationalized pathnames
|
||||||
// excluding api, _next, static files, etc.
|
// excluding api, _next, static files, etc.
|
||||||
matcher: ["/((?!api|_next|_vercel|.*\\..*).*)", "/", "/(de)/:path*"],
|
matcher: [
|
||||||
|
"/((?!api|stats|errors|_next|_vercel|.*\\..*).*)",
|
||||||
|
"/",
|
||||||
|
"/(de)/:path*",
|
||||||
|
],
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -17,10 +17,18 @@ const nextConfig = {
|
|||||||
source: "/stats/:path*",
|
source: "/stats/:path*",
|
||||||
destination: `${umamiUrl}/:path*`,
|
destination: `${umamiUrl}/:path*`,
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
source: "/:locale(de)/stats/:path*",
|
||||||
|
destination: `${umamiUrl}/:path*`,
|
||||||
|
},
|
||||||
{
|
{
|
||||||
source: "/errors/:path*",
|
source: "/errors/:path*",
|
||||||
destination: `${glitchtipUrl}/:path*`,
|
destination: `${glitchtipUrl}/:path*`,
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
source: "/:locale(de)/errors/:path*",
|
||||||
|
destination: `${glitchtipUrl}/:path*`,
|
||||||
|
},
|
||||||
];
|
];
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|||||||
15
package.json
15
package.json
@@ -25,8 +25,8 @@
|
|||||||
"license": "ISC",
|
"license": "ISC",
|
||||||
"description": "",
|
"description": "",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@mintel/next-config": "^1.1.13",
|
"@mintel/next-config": "^1.8.21",
|
||||||
"@mintel/next-utils": "^1.1.13",
|
"@mintel/next-utils": "^1.8.21",
|
||||||
"@sentry/nextjs": "^10.38.0",
|
"@sentry/nextjs": "^10.38.0",
|
||||||
"framer-motion": "^12.29.2",
|
"framer-motion": "^12.29.2",
|
||||||
"lucide-react": "^0.562.0",
|
"lucide-react": "^0.562.0",
|
||||||
@@ -36,16 +36,16 @@
|
|||||||
"pino": "^10.3.0",
|
"pino": "^10.3.0",
|
||||||
"react": "^19.2.4",
|
"react": "^19.2.4",
|
||||||
"react-dom": "^19.2.4",
|
"react-dom": "^19.2.4",
|
||||||
"zod": "^4.3.6"
|
"zod": "^3.24.1"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@commitlint/cli": "^20.4.0",
|
"@commitlint/cli": "^20.4.0",
|
||||||
"@commitlint/config-conventional": "^20.4.0",
|
"@commitlint/config-conventional": "^20.4.0",
|
||||||
"@directus/sdk": "^21.0.0",
|
"@directus/sdk": "^21.0.0",
|
||||||
"@mintel/cli": "^1.1.13",
|
"@mintel/cli": "^1.8.21",
|
||||||
"@mintel/eslint-config": "^1.1.13",
|
"@mintel/eslint-config": "^1.8.21",
|
||||||
"@mintel/husky-config": "^1.1.13",
|
"@mintel/husky-config": "^1.8.21",
|
||||||
"@mintel/tsconfig": "^1.1.13",
|
"@mintel/tsconfig": "^1.8.21",
|
||||||
"@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",
|
||||||
@@ -57,6 +57,7 @@
|
|||||||
"autoprefixer": "^10.4.23",
|
"autoprefixer": "^10.4.23",
|
||||||
"eslint": "^8.57.1",
|
"eslint": "^8.57.1",
|
||||||
"eslint-config-next": "15.1.6",
|
"eslint-config-next": "15.1.6",
|
||||||
|
"happy-dom": "^20.6.1",
|
||||||
"husky": "^9.1.7",
|
"husky": "^9.1.7",
|
||||||
"jsdom": "^27.4.0",
|
"jsdom": "^27.4.0",
|
||||||
"lint-staged": "^16.2.7",
|
"lint-staged": "^16.2.7",
|
||||||
|
|||||||
1527
pnpm-lock.yaml
generated
1527
pnpm-lock.yaml
generated
File diff suppressed because it is too large
Load Diff
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 |
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",
|
||||||
|
});
|
||||||
|
}
|
||||||
129
tests/contact.test.tsx
Normal file
129
tests/contact.test.tsx
Normal file
@@ -0,0 +1,129 @@
|
|||||||
|
import { render, screen, fireEvent, waitFor } from "@testing-library/react";
|
||||||
|
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||||
|
import { NextIntlClientProvider } from "next-intl";
|
||||||
|
import messages from "../messages/de.json";
|
||||||
|
|
||||||
|
// Mocks MUST be defined before component import to ensure they are picked up
|
||||||
|
vi.mock("../components/Reveal", () => ({
|
||||||
|
Reveal: ({ children }: any) => <>{children}</>,
|
||||||
|
Stagger: ({ children }: any) => <>{children}</>,
|
||||||
|
}));
|
||||||
|
|
||||||
|
// Better FormData mock for happy-dom
|
||||||
|
global.FormData = class MockFormData {
|
||||||
|
private data = new Map();
|
||||||
|
constructor(form?: HTMLFormElement) {
|
||||||
|
if (form) {
|
||||||
|
const elements = form.elements as any;
|
||||||
|
for (let i = 0; i < elements.length; i++) {
|
||||||
|
const item = elements.item(i);
|
||||||
|
if (item.name && item.value) {
|
||||||
|
this.data.set(item.name, item.value);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
append(key: string, value: any) {
|
||||||
|
this.data.set(key, value);
|
||||||
|
}
|
||||||
|
get(key: string) {
|
||||||
|
return this.data.get(key);
|
||||||
|
}
|
||||||
|
entries() {
|
||||||
|
return Array.from(this.data.entries())[Symbol.iterator]();
|
||||||
|
}
|
||||||
|
} as any;
|
||||||
|
|
||||||
|
// Mock alert
|
||||||
|
const alertMock = vi.fn();
|
||||||
|
global.alert = alertMock;
|
||||||
|
|
||||||
|
// Import component AFTER mocks
|
||||||
|
import Contact from "../components/ContactContent";
|
||||||
|
|
||||||
|
// Mock fetch
|
||||||
|
const fetchMock = vi.fn();
|
||||||
|
global.fetch = fetchMock;
|
||||||
|
|
||||||
|
const renderContact = () => {
|
||||||
|
return render(
|
||||||
|
<NextIntlClientProvider locale="de" messages={messages}>
|
||||||
|
<Contact />
|
||||||
|
</NextIntlClientProvider>,
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
describe("Contact Page", () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.clearAllMocks();
|
||||||
|
fetchMock.mockReset();
|
||||||
|
fetchMock.mockResolvedValue({
|
||||||
|
ok: true,
|
||||||
|
json: async () => ({ success: true }),
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("renders the contact form correctly", () => {
|
||||||
|
renderContact();
|
||||||
|
|
||||||
|
expect(screen.getByLabelText(/Name \*/i)).toBeInTheDocument();
|
||||||
|
expect(screen.getByLabelText(/Firma/i)).toBeInTheDocument();
|
||||||
|
expect(screen.getByLabelText(/E-Mail \*/i)).toBeInTheDocument();
|
||||||
|
expect(screen.getByLabelText(/Nachricht \*/i)).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("submits the form successfully", async () => {
|
||||||
|
renderContact();
|
||||||
|
|
||||||
|
fireEvent.change(screen.getByLabelText(/Name \*/i), {
|
||||||
|
target: { value: "John Doe" },
|
||||||
|
});
|
||||||
|
fireEvent.change(screen.getByLabelText(/E-Mail \*/i), {
|
||||||
|
target: { value: "john@example.com" },
|
||||||
|
});
|
||||||
|
fireEvent.change(screen.getByLabelText(/Nachricht \*/i), {
|
||||||
|
target: { value: "This is a test message that is long enough." },
|
||||||
|
});
|
||||||
|
|
||||||
|
const form = screen.getByRole("form");
|
||||||
|
fireEvent.submit(form);
|
||||||
|
|
||||||
|
await waitFor(
|
||||||
|
() => {
|
||||||
|
expect(fetchMock).toHaveBeenCalled();
|
||||||
|
},
|
||||||
|
{ timeout: 2000 },
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(
|
||||||
|
(await screen.findAllByText(/Anfrage erfolgreich übermittelt/i)).length,
|
||||||
|
).toBeGreaterThanOrEqual(1);
|
||||||
|
expect(
|
||||||
|
(await screen.findAllByText(/Ihr Anliegen wurde erfasst/i)).length,
|
||||||
|
).toBeGreaterThanOrEqual(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("handles submission errors", async () => {
|
||||||
|
fetchMock.mockResolvedValueOnce({
|
||||||
|
ok: false,
|
||||||
|
json: async () => ({ error: "Server error" }),
|
||||||
|
});
|
||||||
|
|
||||||
|
renderContact();
|
||||||
|
|
||||||
|
fireEvent.change(screen.getByLabelText(/Name \*/i), {
|
||||||
|
target: { value: "John Doe" },
|
||||||
|
});
|
||||||
|
fireEvent.change(screen.getByLabelText(/E-Mail \*/i), {
|
||||||
|
target: { value: "john@example.com" },
|
||||||
|
});
|
||||||
|
fireEvent.change(screen.getByLabelText(/Nachricht \*/i), {
|
||||||
|
target: { value: "This is a test message." },
|
||||||
|
});
|
||||||
|
|
||||||
|
const form = screen.getByRole("form");
|
||||||
|
fireEvent.submit(form);
|
||||||
|
|
||||||
|
expect(await screen.findByText(/Server error/i)).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
});
|
||||||
37
tests/home.test.tsx
Normal file
37
tests/home.test.tsx
Normal file
@@ -0,0 +1,37 @@
|
|||||||
|
import { render, screen } from "@testing-library/react";
|
||||||
|
import { describe, it, expect } from "vitest";
|
||||||
|
import Home from "../components/HomeContent";
|
||||||
|
import { NextIntlClientProvider } from "next-intl";
|
||||||
|
import messages from "../messages/de.json";
|
||||||
|
|
||||||
|
const renderHome = () => {
|
||||||
|
return render(
|
||||||
|
<NextIntlClientProvider locale="de" messages={messages}>
|
||||||
|
<Home />
|
||||||
|
</NextIntlClientProvider>,
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
describe("Home Page", () => {
|
||||||
|
it("renders the hero section with correct title", () => {
|
||||||
|
renderHome();
|
||||||
|
expect(
|
||||||
|
screen.getByRole("heading", { name: /Spezialisierter Partner/i }),
|
||||||
|
).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("contains the CTA button", () => {
|
||||||
|
renderHome();
|
||||||
|
const ctaButton = screen.getByRole("link", { name: /Projekt anfragen/i });
|
||||||
|
expect(ctaButton).toBeInTheDocument();
|
||||||
|
expect(ctaButton).toHaveAttribute("href", "/kontakt");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("renders the portfolio section", async () => {
|
||||||
|
renderHome();
|
||||||
|
expect(await screen.findByText(/Unsere Leistungen/i)).toBeInTheDocument();
|
||||||
|
// Use getAllByText because it appears in both hero description and card title
|
||||||
|
const elements = await screen.findAllByText(/Technische Beratung/i);
|
||||||
|
expect(elements.length).toBeGreaterThan(0);
|
||||||
|
});
|
||||||
|
});
|
||||||
56
tests/setup.tsx
Normal file
56
tests/setup.tsx
Normal file
@@ -0,0 +1,56 @@
|
|||||||
|
import "@testing-library/jest-dom/vitest";
|
||||||
|
import React from "react";
|
||||||
|
import { vi } from "vitest";
|
||||||
|
|
||||||
|
// Mock next/navigation
|
||||||
|
vi.mock("next/navigation", () => ({
|
||||||
|
usePathname: () => "/",
|
||||||
|
useRouter: () => ({
|
||||||
|
push: vi.fn(),
|
||||||
|
replace: vi.fn(),
|
||||||
|
prefetch: vi.fn(),
|
||||||
|
back: vi.fn(),
|
||||||
|
}),
|
||||||
|
useSearchParams: () => new URLSearchParams(),
|
||||||
|
}));
|
||||||
|
|
||||||
|
// Mock next-intl to avoid transitive next/server issues
|
||||||
|
vi.mock("next-intl/middleware", () => ({
|
||||||
|
default: vi.fn(() => (req: any) => req),
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock("next-intl/server", () => ({
|
||||||
|
getRequestConfig: vi.fn(),
|
||||||
|
}));
|
||||||
|
|
||||||
|
// Mock next/server
|
||||||
|
vi.mock("next/server", () => ({
|
||||||
|
NextResponse: {
|
||||||
|
json: vi.fn(),
|
||||||
|
next: vi.fn(),
|
||||||
|
redirect: vi.fn(),
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
|
||||||
|
// Mock next/dynamic to be synchronous in tests
|
||||||
|
vi.mock("next/dynamic", () => ({
|
||||||
|
default: vi.fn((loader) => {
|
||||||
|
return (props: any) => {
|
||||||
|
const [Component, setComponent] = React.useState<any>(null);
|
||||||
|
React.useEffect(() => {
|
||||||
|
loader().then((mod: any) => {
|
||||||
|
setComponent(
|
||||||
|
() =>
|
||||||
|
mod.default ||
|
||||||
|
mod.PortfolioSection ||
|
||||||
|
mod.ExpertiseSection ||
|
||||||
|
mod.TechnicalSpecsSection ||
|
||||||
|
mod.CTASection ||
|
||||||
|
mod,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
}, []);
|
||||||
|
return Component ? <Component {...props} /> : null;
|
||||||
|
};
|
||||||
|
}),
|
||||||
|
}));
|
||||||
@@ -1,126 +0,0 @@
|
|||||||
import { render, screen, fireEvent, waitFor } from "@testing-library/react";
|
|
||||||
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
|
|
||||||
import Contact from "../app/kontakt/page";
|
|
||||||
|
|
||||||
// Mock fetch
|
|
||||||
const fetchMock = vi.fn();
|
|
||||||
global.fetch = fetchMock;
|
|
||||||
|
|
||||||
// Mock alert
|
|
||||||
const alertMock = vi.fn();
|
|
||||||
global.alert = alertMock;
|
|
||||||
|
|
||||||
describe("Contact Page", () => {
|
|
||||||
beforeEach(() => {
|
|
||||||
fetchMock.mockClear();
|
|
||||||
alertMock.mockClear();
|
|
||||||
});
|
|
||||||
|
|
||||||
it("renders the contact form correctly", () => {
|
|
||||||
render(<Contact />);
|
|
||||||
|
|
||||||
expect(screen.getByLabelText(/Name \*/i)).toBeInTheDocument();
|
|
||||||
expect(screen.getByLabelText(/Firma/i)).toBeInTheDocument();
|
|
||||||
expect(screen.getByLabelText(/E-Mail \*/i)).toBeInTheDocument();
|
|
||||||
expect(screen.getByLabelText(/Nachricht \*/i)).toBeInTheDocument();
|
|
||||||
expect(
|
|
||||||
screen.getByRole("button", { name: /Nachricht senden/i }),
|
|
||||||
).toBeInTheDocument();
|
|
||||||
});
|
|
||||||
|
|
||||||
it("submits the form successfully", async () => {
|
|
||||||
fetchMock.mockResolvedValueOnce({
|
|
||||||
ok: true,
|
|
||||||
json: async () => ({ success: true }),
|
|
||||||
});
|
|
||||||
|
|
||||||
render(<Contact />);
|
|
||||||
|
|
||||||
fireEvent.change(screen.getByLabelText(/Name \*/i), {
|
|
||||||
target: { value: "John Doe" },
|
|
||||||
});
|
|
||||||
fireEvent.change(screen.getByLabelText(/Firma/i), {
|
|
||||||
target: { value: "Acme Corp" },
|
|
||||||
});
|
|
||||||
fireEvent.change(screen.getByLabelText(/E-Mail \*/i), {
|
|
||||||
target: { value: "john@example.com" },
|
|
||||||
});
|
|
||||||
fireEvent.change(screen.getByLabelText(/Nachricht \*/i), {
|
|
||||||
target: { value: "This is a test message that is long enough." },
|
|
||||||
});
|
|
||||||
|
|
||||||
fireEvent.click(screen.getByRole("button", { name: /Nachricht senden/i }));
|
|
||||||
|
|
||||||
await waitFor(() => {
|
|
||||||
expect(fetchMock).toHaveBeenCalledTimes(1);
|
|
||||||
expect(fetchMock).toHaveBeenCalledWith(
|
|
||||||
"/api/contact",
|
|
||||||
expect.objectContaining({
|
|
||||||
method: "POST",
|
|
||||||
headers: { "Content-Type": "application/json" },
|
|
||||||
body: JSON.stringify({
|
|
||||||
name: "John Doe",
|
|
||||||
company: "Acme Corp",
|
|
||||||
email: "john@example.com",
|
|
||||||
message: "This is a test message that is long enough.",
|
|
||||||
website: "",
|
|
||||||
}),
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
expect(screen.getByText(/Nachricht gesendet/i)).toBeInTheDocument();
|
|
||||||
expect(
|
|
||||||
screen.getByText(/Vielen Dank für Ihre Anfrage/i),
|
|
||||||
).toBeInTheDocument();
|
|
||||||
});
|
|
||||||
|
|
||||||
it("handles submission errors", async () => {
|
|
||||||
fetchMock.mockResolvedValueOnce({
|
|
||||||
ok: false,
|
|
||||||
json: async () => ({ error: "Server error" }),
|
|
||||||
});
|
|
||||||
|
|
||||||
render(<Contact />);
|
|
||||||
|
|
||||||
fireEvent.change(screen.getByLabelText(/Name \*/i), {
|
|
||||||
target: { value: "John Doe" },
|
|
||||||
});
|
|
||||||
fireEvent.change(screen.getByLabelText(/E-Mail \*/i), {
|
|
||||||
target: { value: "john@example.com" },
|
|
||||||
});
|
|
||||||
fireEvent.change(screen.getByLabelText(/Nachricht \*/i), {
|
|
||||||
target: { value: "This is a test message that is long enough." },
|
|
||||||
});
|
|
||||||
|
|
||||||
fireEvent.click(screen.getByRole("button", { name: /Nachricht senden/i }));
|
|
||||||
|
|
||||||
await waitFor(() => {
|
|
||||||
expect(alertMock).toHaveBeenCalledWith("Fehler: Server error");
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
it("handles network errors", async () => {
|
|
||||||
fetchMock.mockRejectedValueOnce(new Error("Network error"));
|
|
||||||
|
|
||||||
render(<Contact />);
|
|
||||||
|
|
||||||
fireEvent.change(screen.getByLabelText(/Name \*/i), {
|
|
||||||
target: { value: "John Doe" },
|
|
||||||
});
|
|
||||||
fireEvent.change(screen.getByLabelText(/E-Mail \*/i), {
|
|
||||||
target: { value: "john@example.com" },
|
|
||||||
});
|
|
||||||
fireEvent.change(screen.getByLabelText(/Nachricht \*/i), {
|
|
||||||
target: { value: "This is a test message that is long enough." },
|
|
||||||
});
|
|
||||||
|
|
||||||
fireEvent.click(screen.getByRole("button", { name: /Nachricht senden/i }));
|
|
||||||
|
|
||||||
await waitFor(() => {
|
|
||||||
expect(alertMock).toHaveBeenCalledWith(
|
|
||||||
"Es gab einen Fehler beim Senden Ihrer Nachricht.",
|
|
||||||
);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -1,27 +0,0 @@
|
|||||||
import { render, screen } from "@testing-library/react";
|
|
||||||
import { describe, it, expect } from "vitest";
|
|
||||||
import Home from "../app/page";
|
|
||||||
|
|
||||||
describe("Home Page", () => {
|
|
||||||
it("renders the hero section with correct title", () => {
|
|
||||||
render(<Home />);
|
|
||||||
expect(
|
|
||||||
screen.getByText(/Spezialisierter Partner für Energiekabelprojekte/i),
|
|
||||||
).toBeInTheDocument();
|
|
||||||
});
|
|
||||||
|
|
||||||
it("contains the CTA button", () => {
|
|
||||||
render(<Home />);
|
|
||||||
const ctaButton = screen.getByRole("link", { name: /Projekt anfragen/i });
|
|
||||||
expect(ctaButton).toBeInTheDocument();
|
|
||||||
expect(ctaButton).toHaveAttribute("href", "/kontakt");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("renders the portfolio section", () => {
|
|
||||||
render(<Home />);
|
|
||||||
expect(screen.getByText(/Unsere Leistungen/i)).toBeInTheDocument();
|
|
||||||
// Use getAllByText because it appears in both hero description and card title
|
|
||||||
const elements = screen.getAllByText(/Technische Beratung/i);
|
|
||||||
expect(elements.length).toBeGreaterThan(0);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -1,12 +0,0 @@
|
|||||||
import "@testing-library/jest-dom";
|
|
||||||
import { vi } from "vitest";
|
|
||||||
|
|
||||||
// Mock next/navigation
|
|
||||||
vi.mock("next/navigation", () => ({
|
|
||||||
usePathname: () => "/",
|
|
||||||
useRouter: () => ({
|
|
||||||
push: vi.fn(),
|
|
||||||
replace: vi.fn(),
|
|
||||||
prefetch: vi.fn(),
|
|
||||||
}),
|
|
||||||
}));
|
|
||||||
20
vitest.config.mts
Normal file
20
vitest.config.mts
Normal file
@@ -0,0 +1,20 @@
|
|||||||
|
import { defineConfig } from 'vitest/config';
|
||||||
|
import react from '@vitejs/plugin-react';
|
||||||
|
|
||||||
|
export default defineConfig({
|
||||||
|
plugins: [react()],
|
||||||
|
test: {
|
||||||
|
environment: 'happy-dom',
|
||||||
|
globals: true,
|
||||||
|
setupFiles: ['./tests/setup.tsx'],
|
||||||
|
alias: {
|
||||||
|
'next/server': 'next/server.js',
|
||||||
|
},
|
||||||
|
exclude: ['**/node_modules/**', '**/.next/**'],
|
||||||
|
server: {
|
||||||
|
deps: {
|
||||||
|
inline: ['next-intl', '@mintel/next-utils'],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
@@ -1,17 +0,0 @@
|
|||||||
import { defineConfig } from 'vitest/config'
|
|
||||||
import react from '@vitejs/plugin-react'
|
|
||||||
import path from 'path'
|
|
||||||
|
|
||||||
export default defineConfig({
|
|
||||||
plugins: [react()],
|
|
||||||
test: {
|
|
||||||
environment: 'jsdom',
|
|
||||||
globals: true,
|
|
||||||
setupFiles: './tests/setup.ts',
|
|
||||||
},
|
|
||||||
resolve: {
|
|
||||||
alias: {
|
|
||||||
'@': path.resolve(__dirname, './'),
|
|
||||||
},
|
|
||||||
},
|
|
||||||
})
|
|
||||||
Reference in New Issue
Block a user