Compare commits
278 Commits
content/ka
...
v2.2.37
| Author | SHA1 | Date | |
|---|---|---|---|
| da9d1954d6 | |||
| 4fc00098f2 | |||
| 2df7aece5c | |||
| d65be95e95 | |||
| e83d3c0933 | |||
| f69d1977ad | |||
| 4f73ac109e | |||
| 535d6d0588 | |||
| c2ce09c106 | |||
| 265f93716e | |||
| 66fb4f3939 | |||
| 10f5dd9774 | |||
| 30a177663c | |||
| 8d4f085c58 | |||
| c0a577cff3 | |||
| bec2a53d3d | |||
| b4fc3f6a28 | |||
| 3e5d7dbce9 | |||
| c4e5b44d5e | |||
| 0378dde328 | |||
| 3202139b1e | |||
| dc5489706d | |||
| e8b8493740 | |||
| ef44ebcf4f | |||
| 816aef924e | |||
| 1bd9f26356 | |||
| 156ae66dec | |||
| b2d1865f40 | |||
| dd363d0a55 | |||
| 84f10694bb | |||
| 3e2b20ce0e | |||
| 4cd919545e | |||
| e5891e8214 | |||
| 4e4e44a5fd | |||
| 3e37c37863 | |||
| 40ec588d52 | |||
| b2c38fca7d | |||
| 1b6f1d9405 | |||
| 6cc9ccdea8 | |||
| aafc705f0e | |||
| 691f02a5b8 | |||
| 4b04178f01 | |||
| f76940dc34 | |||
| 2653bd2569 | |||
| adbf2855d8 | |||
| 87942dff41 | |||
| 38bd728393 | |||
| 547e62f9d4 | |||
| 27fc34e186 | |||
| d068d68d7a | |||
| 908f2b0b69 | |||
| 272b7a397b | |||
| 9edfe24509 | |||
| de8f41cc6d | |||
| 57cc429b54 | |||
| 01cf4013df | |||
| caf3fe8c1e | |||
| 51eac9fb50 | |||
| 6426512192 | |||
| a63ee1b9f5 | |||
| 43667e7d68 | |||
| 0191fdb770 | |||
| afaa5e038c | |||
| e0d6ea0d6c | |||
| 34839ad5a5 | |||
| a8d6a45aad | |||
| 6de8ad8516 | |||
| 023fef5343 | |||
| 7181dafca2 | |||
| 8b6d2da821 | |||
| 17736860d6 | |||
| d67acdbcc1 | |||
| 05b517731b | |||
| 67696b6afa | |||
| 24472990e5 | |||
| 53a885acdb | |||
| 9afff09ed7 | |||
| ce5acc5558 | |||
| 5027bea666 | |||
| fcfb567991 | |||
| efb9bdbb3f | |||
| 11e7937e79 | |||
| 41158697fd | |||
| d00a4e29ff | |||
| 58be922fb3 | |||
| 111672a614 | |||
| d1387d2685 | |||
| fda2944e5a | |||
| 8ae5ab73e3 | |||
| d8d35da574 | |||
| 792d91f9d5 | |||
| d10d038617 | |||
| 490ce4abb6 | |||
| bed9be1f81 | |||
| f52a3e3c46 | |||
| 71825724db | |||
| 1bb26e8db4 | |||
| 604b96afe0 | |||
| 59df260164 | |||
| 3449d2eaa4 | |||
| 1723be4008 | |||
| 830d425859 | |||
| d6b9aca50f | |||
| caefd5d525 | |||
| 8d8e1dd460 | |||
| c2a82ae9ae | |||
| 2ecbe9c63c | |||
| 255403c2e4 | |||
| 38a870bea8 | |||
| 2b8dc19032 | |||
| 5101b8bd1e | |||
| afe3565155 | |||
| e677089ff9 | |||
| b7d9a4b0d0 | |||
| 6c8614f147 | |||
| aa4e121fea | |||
| 6697703c91 | |||
| e779c6ac22 | |||
| 02c4f06108 | |||
| 6d77fb92c3 | |||
| cb165b06e5 | |||
| ca1b19cc0f | |||
| cc22aa6c8c | |||
| 3ae1e6257d | |||
| 1419bab7cf | |||
| ac6b121685 | |||
| 4a9a7adf93 | |||
| 3b297aae3f | |||
| c6b81a3d77 | |||
| c8f3420a5c | |||
| a6559eb7bb | |||
| 8d75c2ec12 | |||
| 6a18eb4ee1 | |||
| d91eb220be | |||
| 5c40d55b21 | |||
| a512bbe226 | |||
| 8b3851f45c | |||
| e2667ed750 | |||
| 0253956bd9 | |||
| 42cd4394c7 | |||
| 276ceed010 | |||
| cdf10bd670 | |||
| a1ea41b42f | |||
| 561d75d2b8 | |||
| 685c0ffff6 | |||
| 17b001a97d | |||
| 275a857554 | |||
| f766c9c9db | |||
| c851a4f95e | |||
| 1dca7f79fe | |||
| d3eee45112 | |||
| c308035e77 | |||
| 44d8c22c97 | |||
| 9d3f918aef | |||
| a34bee6272 | |||
| 373017059e | |||
| 309a53624e | |||
| e994d79895 | |||
| 88e88c8b99 | |||
| e2a29b41e9 | |||
| 62727a7231 | |||
| 3a6189e0d8 | |||
| 01e2fdbb7d | |||
| fa7182d351 | |||
| e0d43f7f5f | |||
| d09b576137 | |||
| 04599827f9 | |||
| 5368f1a64d | |||
| 241c97e346 | |||
| 3334e327bf | |||
| 145bff90b6 | |||
| d2bf48fc60 | |||
| 5e7ee84dc9 | |||
| b42e53495d | |||
| 8813ed3910 | |||
| 1d3c6a0f70 | |||
| 8fb3b96bd3 | |||
| 67dc56d3ee | |||
| e5933bad69 | |||
| 11b9e4630b | |||
| a47d378521 | |||
| 30047cb1c4 | |||
| 394ac5a19c | |||
| d63f196693 | |||
| 6092f1d827 | |||
| 766c385704 | |||
| 6c9d2b6832 | |||
| 7a9d4d7f18 | |||
| 0b9929c2ec | |||
| aa0dfdf331 | |||
| 4c9028f58b | |||
| 9de5f2938b | |||
| de41a0e29d | |||
| 6689dd3013 | |||
| 31fbcb32dd | |||
| 68eca73df2 | |||
| 3bb6eb3447 | |||
| a3c732dd13 | |||
| aaa1a8793c | |||
| 233509db67 | |||
| 17a807c48b | |||
| d8634efa9a | |||
| 0f2aa500e9 | |||
| 2c465bc16b | |||
| 68717bf0ce | |||
| 6444281c6f | |||
| 4b98fa9b4c | |||
| fa4385af4c | |||
| d98eb20430 | |||
| 3cfe8ff379 | |||
| 2abcd386f0 | |||
| d7ff6a3415 | |||
| d8f5d9bfc6 | |||
| f81a85fdd7 | |||
| 4968315ebf | |||
| eaaa3b7251 | |||
| bbc8716da6 | |||
| f4d65452a6 | |||
| 1b2e0793d3 | |||
| b378bb168f | |||
| 9134d6b070 | |||
| 0105c8c9a3 | |||
| 0138064bcc | |||
| f74df4e8a2 | |||
| cf29d0c45a | |||
| 4aebd226f1 | |||
| 528bf83a67 | |||
| 05ca944828 | |||
| f2209c4815 | |||
| ded2aee989 | |||
| 37ecb81444 | |||
| 343e5a976c | |||
| 8817b5d586 | |||
| fb1e7efd30 | |||
| 3a7100d8b0 | |||
| 50d3d827e8 | |||
| 79813f5d8a | |||
| 4c0ee13d97 | |||
| a95fddca56 | |||
| f110ce9d46 | |||
| 68c4a056b7 | |||
| 57a67ffdf3 | |||
| 1dc2fe8a25 | |||
| 5977ebf23d | |||
| ee2bcea42b | |||
| 2f2fcfdf13 | |||
| 070f97dd6f | |||
| 2a46015d0d | |||
| 5329d96e3b | |||
| 0c01aa799d | |||
| 75c15fce43 | |||
| 3b20d4087c | |||
| 2dbafec633 | |||
| 2b79d18a93 | |||
| fa747763b8 | |||
| 0d71e2ad24 | |||
| 64d4edc175 | |||
| 222c894266 | |||
| 4a3aee7691 | |||
| fd8dac97d0 | |||
| bc5a78d73e | |||
| f43b945756 | |||
| 183342cb8c | |||
| 3728c37dce | |||
| f61f1411cb | |||
| d2668e36eb | |||
| 5678ddcfd9 | |||
| 06d5178614 | |||
| a983696153 | |||
| 6908964df5 | |||
| 159587f545 | |||
| c02b49644e | |||
| 313fa14c56 | |||
| 052be7d1c9 | |||
| aef10f706c | |||
| 35d9650626 | |||
| ed802d03e7 | |||
| 68b0ed7ba0 |
8
.env
8
.env
@@ -1,5 +1,5 @@
|
||||
# ============================================================================
|
||||
# KLZ Cables - Environment Configuration
|
||||
# E-TIB GmbH - Environment Configuration
|
||||
# ============================================================================
|
||||
# Copy this file to .env for local development
|
||||
# For production, use .env.production as a template
|
||||
@@ -20,14 +20,14 @@ NEXT_PUBLIC_RECORD_MODE_ENABLED=true
|
||||
# Analytics (Umami)
|
||||
# ────────────────────────────────────────────────────────────────────────────
|
||||
# Optional: Leave empty to disable analytics
|
||||
UMAMI_WEBSITE_ID=
|
||||
UMAMI_WEBSITE_ID=d773ea10-a3b3-4ccf-9024-987e14c4d669
|
||||
UMAMI_API_ENDPOINT=https://analytics.infra.mintel.me
|
||||
|
||||
# ────────────────────────────────────────────────────────────────────────────
|
||||
# Error Tracking (GlitchTip/Sentry)
|
||||
# ────────────────────────────────────────────────────────────────────────────
|
||||
# Optional: Leave empty to disable error tracking
|
||||
SENTRY_DSN=
|
||||
SENTRY_DSN=https://dcb81958-dbf2-4a3d-b422-875f4672c14b@glitchtip.infra.mintel.me/5
|
||||
|
||||
# ────────────────────────────────────────────────────────────────────────────
|
||||
# Email Configuration (SMTP)
|
||||
@@ -37,7 +37,7 @@ MAIL_HOST=smtp.eu.mailgun.org
|
||||
MAIL_PORT=587
|
||||
MAIL_USERNAME=
|
||||
MAIL_PASSWORD=
|
||||
MAIL_FROM=KLZ Cables <noreply@e-tib.com>
|
||||
MAIL_FROM=E-TIB GmbH <noreply@mg.mintel.me>
|
||||
MAIL_RECIPIENTS=info@e-tib.com
|
||||
|
||||
# ────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
# ============================================================================
|
||||
# KLZ Cables - Production Environment Configuration
|
||||
# E-TIB - Production Environment Configuration
|
||||
# ============================================================================
|
||||
# This file contains runtime environment variables for the production deployment.
|
||||
# It should be placed on the production server at: /home/deploy/sites/e-tib.com/.env
|
||||
@@ -12,7 +12,7 @@ NODE_ENV=production
|
||||
NEXT_PUBLIC_BASE_URL=https://e-tib.com
|
||||
|
||||
# Analytics (Umami)
|
||||
UMAMI_WEBSITE_ID=
|
||||
UMAMI_WEBSITE_ID=d773ea10-a3b3-4ccf-9024-987e14c4d669
|
||||
UMAMI_API_ENDPOINT=https://analytics.infra.mintel.me
|
||||
|
||||
# Error Tracking (GlitchTip/Sentry)
|
||||
@@ -23,7 +23,7 @@ MAIL_HOST=smtp.eu.mailgun.org
|
||||
MAIL_PORT=587
|
||||
MAIL_USERNAME=
|
||||
MAIL_PASSWORD=
|
||||
MAIL_FROM=KLZ Cables <noreply@e-tib.com>
|
||||
MAIL_FROM=E-TIB GmbH <noreply@e-tib.com>
|
||||
MAIL_RECIPIENTS=info@e-tib.com
|
||||
|
||||
# Varnish Cache Size (optional)
|
||||
|
||||
@@ -13,13 +13,14 @@ on:
|
||||
required: false
|
||||
default: 'false'
|
||||
|
||||
concurrency:
|
||||
group: ${{ github.workflow }}-${{ startsWith(github.ref, 'refs/tags/v') && 'production' || github.ref }}
|
||||
cancel-in-progress: true
|
||||
|
||||
env:
|
||||
PUPPETEER_SKIP_DOWNLOAD: "true"
|
||||
COREPACK_NPM_REGISTRY: "https://registry.npmmirror.com"
|
||||
|
||||
concurrency:
|
||||
group: deploy-pipeline
|
||||
cancel-in-progress: true
|
||||
|
||||
jobs:
|
||||
# ──────────────────────────────────────────────────────────────────────────────
|
||||
@@ -43,9 +44,40 @@ jobs:
|
||||
- name: 🧹 Maintenance (High Density Cleanup)
|
||||
shell: bash
|
||||
run: |
|
||||
echo "Purging old build layers and dangling images..."
|
||||
docker image prune -f
|
||||
docker builder prune -f --filter "until=24h"
|
||||
echo "=== System Disk Usage ==="
|
||||
df -h || true
|
||||
echo "=== Tool Cache Usage ==="
|
||||
du -sh /opt/hostedtoolcache/* || true
|
||||
du -sh /opt/hostedtoolcache/.[!.]* || true
|
||||
echo "Purging old tool caches..."
|
||||
rm -rf /opt/hostedtoolcache/* || true
|
||||
rm -rf /opt/hostedtoolcache/.[!.]* || true
|
||||
echo "=== Host Disk Usage ==="
|
||||
docker run --rm -v /:/host-root alpine df -h || true
|
||||
echo "=== Host Inode Usage ==="
|
||||
docker run --rm -v /:/host-root alpine df -i || true
|
||||
echo "=== Running Host Containers ==="
|
||||
docker ps --format "table {{.Names}}\t{{.Status}}\t{{.Image}}" || true
|
||||
echo "=== Host Docker Info ==="
|
||||
docker info || true
|
||||
echo "Pruning host Buildkit builder caches aggressively..."
|
||||
for container in $(docker ps --filter "name=buildx_buildkit_builder" --format "{{.Names}}"); do
|
||||
echo "Pruning Buildkit inside container: $container"
|
||||
docker exec -i "$container" buildctl prune --all || true
|
||||
done
|
||||
echo "Removing buildkit builder containers to clear overlay storage..."
|
||||
containers=$(docker ps -a --filter "name=buildx_buildkit_builder" -q)
|
||||
if [ -n "$containers" ]; then
|
||||
docker rm -f $containers || true
|
||||
fi
|
||||
echo "=== Truncating host Docker container log files ==="
|
||||
docker run --rm -v /:/host-root alpine sh -c "truncate -s 0 /host-root/mnt/HC_Volume_104796416/docker/containers/*/*.log" || true
|
||||
echo "Purging old build layers, unused volumes and images..."
|
||||
docker builder prune -a -f || true
|
||||
docker volume prune -f || true
|
||||
docker system prune -a -f --volumes || true
|
||||
echo "=== System Disk Usage After Prune ==="
|
||||
df -h || true
|
||||
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v4
|
||||
@@ -61,12 +93,12 @@ jobs:
|
||||
DOMAIN="e-tib.com"
|
||||
PRJ="etib"
|
||||
|
||||
if [[ "${{ github.ref_type }}" == "branch" && "$REF" == "main" ]]; then
|
||||
if [[ "${{ github.ref }}" == "refs/heads/main" ]]; then
|
||||
TARGET="testing"
|
||||
IMAGE_TAG="main-${SHORT_SHA}"
|
||||
ENV_FILE=".env.testing"
|
||||
TRAEFIK_HOST="test.${DOMAIN}"
|
||||
elif [[ "${{ github.ref_type }}" == "tag" ]]; then
|
||||
elif [[ "${{ github.ref }}" == refs/tags/* ]]; then
|
||||
if [[ "$REF" =~ ^v[0-9]+\.[0-9]+\.[0-9]+$ ]]; then
|
||||
TARGET="production"
|
||||
IMAGE_TAG="$REF"
|
||||
@@ -115,6 +147,13 @@ jobs:
|
||||
echo "short_sha=$SHORT_SHA"
|
||||
} >> "$GITHUB_OUTPUT"
|
||||
|
||||
- name: 🚦 Priority Gate
|
||||
env:
|
||||
GITEA_PAT: ${{ secrets.GITEA_PAT }}
|
||||
CURRENT_RUN_ID: ${{ github.run_id }}
|
||||
CURRENT_TARGET: ${{ steps.determine.outputs.target }}
|
||||
run: bash scripts/priority-gate.sh
|
||||
|
||||
# ──────────────────────────────────────────────────────────────────────────────
|
||||
# JOB 2: QA (Lint, Typecheck, Test)
|
||||
# ──────────────────────────────────────────────────────────────────────────────
|
||||
@@ -189,27 +228,33 @@ jobs:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
run: bash scripts/registry-auth.sh
|
||||
|
||||
- name: 🐳 Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@v3
|
||||
- name: 🔐 Registry Login
|
||||
run: echo "${{ secrets.REGISTRY_PASS }}" | docker login registry.infra.mintel.me -u "${{ secrets.REGISTRY_USER }}" --password-stdin
|
||||
- name: 🏗️ Build and Push
|
||||
uses: docker/build-push-action@v5
|
||||
with:
|
||||
context: .
|
||||
push: true
|
||||
provenance: false
|
||||
platforms: linux/amd64
|
||||
build-args: |
|
||||
NEXT_PUBLIC_BASE_URL=${{ needs.prepare.outputs.next_public_url }}
|
||||
NEXT_PUBLIC_TARGET=${{ needs.prepare.outputs.target }}
|
||||
NEXT_PUBLIC_APP_VERSION=${{ needs.prepare.outputs.image_tag }}
|
||||
UMAMI_WEBSITE_ID=${{ secrets.UMAMI_WEBSITE_ID || vars.UMAMI_WEBSITE_ID }}
|
||||
UMAMI_API_ENDPOINT=${{ secrets.UMAMI_API_ENDPOINT || vars.UMAMI_API_ENDPOINT || 'https://analytics.infra.mintel.me' }}
|
||||
NPM_TOKEN=${{ steps.auth.outputs.working_token }}
|
||||
tags: registry.infra.mintel.me/mintel/e-tib.com:${{ needs.prepare.outputs.image_tag }}
|
||||
secrets: |
|
||||
"NPM_TOKEN=${{ steps.auth.outputs.working_token }}"
|
||||
- name: 🏗️ Build and Push (Native Docker Buildkit)
|
||||
env:
|
||||
NEXT_PUBLIC_BASE_URL: ${{ needs.prepare.outputs.next_public_url }}
|
||||
NEXT_PUBLIC_TARGET: ${{ needs.prepare.outputs.target }}
|
||||
NEXT_PUBLIC_APP_VERSION: ${{ needs.prepare.outputs.image_tag }}
|
||||
UMAMI_WEBSITE_ID: ${{ secrets.UMAMI_WEBSITE_ID || vars.UMAMI_WEBSITE_ID || 'd773ea10-a3b3-4ccf-9024-987e14c4d669' }}
|
||||
UMAMI_API_ENDPOINT: ${{ secrets.UMAMI_API_ENDPOINT || vars.UMAMI_API_ENDPOINT || 'https://analytics.infra.mintel.me' }}
|
||||
NPM_TOKEN: ${{ steps.auth.outputs.working_token }}
|
||||
NPM_DOMAIN: ${{ steps.auth.outputs.working_domain }}
|
||||
IMAGE_TAG: ${{ needs.prepare.outputs.image_tag }}
|
||||
run: |
|
||||
echo "$NPM_TOKEN" > npm_token.txt
|
||||
DOCKER_BUILDKIT=1 docker build \
|
||||
--secret id=NPM_TOKEN,src=npm_token.txt \
|
||||
--build-arg NEXT_PUBLIC_BASE_URL="$NEXT_PUBLIC_BASE_URL" \
|
||||
--build-arg NEXT_PUBLIC_TARGET="$NEXT_PUBLIC_TARGET" \
|
||||
--build-arg NEXT_PUBLIC_APP_VERSION="$IMAGE_TAG" \
|
||||
--build-arg UMAMI_WEBSITE_ID="$UMAMI_WEBSITE_ID" \
|
||||
--build-arg UMAMI_API_ENDPOINT="$UMAMI_API_ENDPOINT" \
|
||||
--build-arg NPM_TOKEN="$NPM_TOKEN" \
|
||||
--build-arg NPM_DOMAIN="$NPM_DOMAIN" \
|
||||
-t registry.infra.mintel.me/mintel/e-tib.com:"$IMAGE_TAG" \
|
||||
.
|
||||
docker push registry.infra.mintel.me/mintel/e-tib.com:"$IMAGE_TAG"
|
||||
rm -f npm_token.txt
|
||||
|
||||
# ──────────────────────────────────────────────────────────────────────────────
|
||||
# JOB 4: Deploy
|
||||
@@ -237,14 +282,18 @@ jobs:
|
||||
MAIL_RECIPIENTS: ${{ secrets.CONTACT_RECIPIENT || vars.CONTACT_RECIPIENT || 'info@e-tib.com' }}
|
||||
|
||||
# Monitoring
|
||||
SENTRY_DSN: ${{ secrets.SENTRY_DSN || vars.SENTRY_DSN }}
|
||||
SENTRY_DSN: ${{ secrets.SENTRY_DSN || vars.SENTRY_DSN || 'https://dcb81958-dbf2-4a3d-b422-875f4672c14b@glitchtip.infra.mintel.me/5' }}
|
||||
|
||||
# Gatekeeper
|
||||
GATEKEEPER_PASSWORD: ${{ secrets.GATEKEEPER_PASSWORD }}
|
||||
|
||||
# Analytics
|
||||
UMAMI_WEBSITE_ID: ${{ secrets.UMAMI_WEBSITE_ID || vars.UMAMI_WEBSITE_ID }}
|
||||
UMAMI_WEBSITE_ID: ${{ secrets.UMAMI_WEBSITE_ID || vars.UMAMI_WEBSITE_ID || 'd773ea10-a3b3-4ccf-9024-987e14c4d669' }}
|
||||
UMAMI_API_ENDPOINT: ${{ secrets.UMAMI_API_ENDPOINT || vars.UMAMI_API_ENDPOINT || 'https://analytics.infra.mintel.me' }}
|
||||
|
||||
# Notifications
|
||||
GOTIFY_URL: ${{ secrets.GOTIFY_URL }}
|
||||
GOTIFY_TOKEN: ${{ secrets.GOTIFY_TOKEN }}
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v4
|
||||
@@ -269,6 +318,19 @@ jobs:
|
||||
|
||||
GATEKEEPER_ORIGIN="${NEXT_PUBLIC_BASE_URL}/gatekeeper"
|
||||
|
||||
if [[ "$TARGET" == "production" ]]; then
|
||||
INTERNAL_SUBNET="10.199.10.0/24"
|
||||
elif [[ "$TARGET" == "testing" ]]; then
|
||||
INTERNAL_SUBNET="10.199.11.0/24"
|
||||
elif [[ "$TARGET" == "staging" ]]; then
|
||||
INTERNAL_SUBNET="10.199.12.0/24"
|
||||
else
|
||||
HEX_SHA="${{ needs.prepare.outputs.short_sha }}"
|
||||
DEC_SHA=$((16#$HEX_SHA))
|
||||
SUBNET_NUM=$(( 13 + (DEC_SHA % 230) ))
|
||||
INTERNAL_SUBNET="10.199.${SUBNET_NUM}.0/24"
|
||||
fi
|
||||
|
||||
{
|
||||
echo "# Generated by CI - $TARGET"
|
||||
echo "IMAGE_TAG=$IMAGE_TAG"
|
||||
@@ -292,6 +354,10 @@ jobs:
|
||||
echo "UMAMI_WEBSITE_ID=$UMAMI_WEBSITE_ID"
|
||||
echo "UMAMI_API_ENDPOINT=$UMAMI_API_ENDPOINT"
|
||||
echo ""
|
||||
echo "# Notifications"
|
||||
echo "GOTIFY_URL=$GOTIFY_URL"
|
||||
echo "GOTIFY_TOKEN=$GOTIFY_TOKEN"
|
||||
echo ""
|
||||
echo "TARGET=$TARGET"
|
||||
echo "SENTRY_ENVIRONMENT=$TARGET"
|
||||
echo "PROJECT_NAME=$PROJECT_NAME"
|
||||
@@ -305,6 +371,7 @@ jobs:
|
||||
echo "COMPOSE_PROFILES=$COMPOSE_PROFILES"
|
||||
echo "AUTH_MIDDLEWARE=$AUTH_MIDDLEWARE"
|
||||
echo "AUTH_MIDDLEWARE_UNPROTECTED=$AUTH_MIDDLEWARE_UNPROTECTED"
|
||||
echo "INTERNAL_SUBNET=$INTERNAL_SUBNET"
|
||||
} > .env.deploy
|
||||
|
||||
- name: 🚀 SSH Deploy
|
||||
@@ -312,6 +379,7 @@ jobs:
|
||||
env:
|
||||
ENV_FILE: ${{ needs.prepare.outputs.env_file }}
|
||||
run: |
|
||||
SLUG=$(echo "${{ github.ref_name }}" | tr '[:upper:]' '[:lower:]' | sed 's/[^a-z0-9]/-/g' | sed 's/--*/-/g' | sed 's/^-//;s/-$//')
|
||||
mkdir -p ~/.ssh
|
||||
echo "${{ secrets.ALPHA_SSH_KEY }}" > ~/.ssh/id_ed25519
|
||||
chmod 600 ~/.ssh/id_ed25519
|
||||
@@ -324,7 +392,6 @@ jobs:
|
||||
elif [[ "$TARGET" == "staging" ]]; then
|
||||
SITE_DIR="/home/deploy/sites/staging.e-tib.com"
|
||||
else
|
||||
SLUG=$(echo "${{ github.ref_name }}" | tr '[:upper:]' '[:lower:]' | sed 's/[^a-z0-9]/-/g' | sed 's/--*/-/g' | sed 's/^-//;s/-$//')
|
||||
SITE_DIR="/home/deploy/sites/branch.e-tib.com/$SLUG"
|
||||
fi
|
||||
|
||||
@@ -332,9 +399,20 @@ jobs:
|
||||
scp .env.deploy root@alpha.mintel.me:$SITE_DIR/$ENV_FILE
|
||||
scp docker-compose.yml root@alpha.mintel.me:$SITE_DIR/docker-compose.yml
|
||||
|
||||
ssh root@alpha.mintel.me "docker system prune -f"
|
||||
|
||||
# ── Deterministic cleanup: stop the EXACT compose project we're about to redeploy ──
|
||||
ssh root@alpha.mintel.me "cd $SITE_DIR && docker compose -p '${PROJECT_NAME}' --env-file '$ENV_FILE' down --remove-orphans 2>/dev/null || true"
|
||||
|
||||
# ── Safety net: kill ANY container whose Traefik host rule matches our target domain ──
|
||||
# This catches ghost containers from prior deployments that used a different project name
|
||||
# (e.g. 'e-tibcom' vs 'etib-production' due to Docker Compose defaulting to dir name)
|
||||
ssh root@alpha.mintel.me "docker ps -q --filter label=traefik.http.routers.${PROJECT_NAME}.rule 2>/dev/null | xargs -r docker rm -f 2>/dev/null || true"
|
||||
ssh root@alpha.mintel.me "docker ps --format '{{.Names}}' | grep -E '^e-tibcom-' | xargs -r docker rm -f 2>/dev/null || true"
|
||||
|
||||
ssh root@alpha.mintel.me "cd $SITE_DIR && echo '${{ secrets.REGISTRY_PASS }}' | docker login registry.infra.mintel.me -u '${{ secrets.REGISTRY_USER }}' --password-stdin"
|
||||
ssh root@alpha.mintel.me "cd $SITE_DIR && docker compose -p '${{ needs.prepare.outputs.project_name }}' --env-file '$ENV_FILE' pull"
|
||||
ssh root@alpha.mintel.me "cd $SITE_DIR && docker compose -p '${{ needs.prepare.outputs.project_name }}' --env-file '$ENV_FILE' up -d --remove-orphans"
|
||||
ssh root@alpha.mintel.me "cd $SITE_DIR && docker compose -p '${PROJECT_NAME}' --env-file '$ENV_FILE' pull"
|
||||
ssh root@alpha.mintel.me "cd $SITE_DIR && docker compose -p '${PROJECT_NAME}' --env-file '$ENV_FILE' up -d --remove-orphans"
|
||||
ssh root@alpha.mintel.me "docker system prune -f --filter 'until=24h'"
|
||||
|
||||
# ──────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
4
.gitignore
vendored
4
.gitignore
vendored
@@ -36,3 +36,7 @@ pnpm-debug.log*
|
||||
# typescript
|
||||
*.tsbuildinfo
|
||||
next-env.d.ts
|
||||
|
||||
# turborepo
|
||||
.turbo
|
||||
.turbo-test
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
{"chromeFlags":"--no-sandbox --disable-setuid-sandbox --disable-dev-shm-usage --headless=new","port":59457}
|
||||
2895
.lighthouseci/lhr-1782154267607.html
Normal file
2895
.lighthouseci/lhr-1782154267607.html
Normal file
File diff suppressed because one or more lines are too long
13298
.lighthouseci/lhr-1782154267607.json
Normal file
13298
.lighthouseci/lhr-1782154267607.json
Normal file
File diff suppressed because one or more lines are too long
2895
.lighthouseci/lhr-1782154359499.html
Normal file
2895
.lighthouseci/lhr-1782154359499.html
Normal file
File diff suppressed because one or more lines are too long
13336
.lighthouseci/lhr-1782154359499.json
Normal file
13336
.lighthouseci/lhr-1782154359499.json
Normal file
File diff suppressed because one or more lines are too long
2895
.lighthouseci/lhr-1782154420108.html
Normal file
2895
.lighthouseci/lhr-1782154420108.html
Normal file
File diff suppressed because one or more lines are too long
13308
.lighthouseci/lhr-1782154420108.json
Normal file
13308
.lighthouseci/lhr-1782154420108.json
Normal file
File diff suppressed because one or more lines are too long
BIN
.tmp_vikunja/design_reference.png
Normal file
BIN
.tmp_vikunja/design_reference.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 2.4 MiB |
BIN
.tmp_vikunja/mengen_2016_2018.docx
Normal file
BIN
.tmp_vikunja/mengen_2016_2018.docx
Normal file
Binary file not shown.
BIN
.tmp_vikunja/mengen_2019_2021.docx
Normal file
BIN
.tmp_vikunja/mengen_2019_2021.docx
Normal file
Binary file not shown.
21
.tmp_vikunja/mengen_2019_2021.txt
Normal file
21
.tmp_vikunja/mengen_2019_2021.txt
Normal file
@@ -0,0 +1,21 @@
|
||||
2019
|
||||
Offener Tiefbau 32445 Meter
|
||||
HDD 8505Meter
|
||||
Kabelverlegung 87468 Meter
|
||||
|
||||
|
||||
|
||||
|
||||
2020
|
||||
Offener Tiefbau 31305 Meter
|
||||
HDD 9050 Meter
|
||||
Kabelverlegung 93556 Meter
|
||||
|
||||
|
||||
|
||||
|
||||
2021
|
||||
Offener Tiefbau 38756 Meter
|
||||
HDD 10957 Meter
|
||||
Kabelverlegung 110127 Meter
|
||||
|
||||
BIN
.tmp_vikunja/mengen_2022.docx
Normal file
BIN
.tmp_vikunja/mengen_2022.docx
Normal file
Binary file not shown.
8
.tmp_vikunja/mengen_2022.txt
Normal file
8
.tmp_vikunja/mengen_2022.txt
Normal file
@@ -0,0 +1,8 @@
|
||||
2022
|
||||
Offener Tiefbau 41445 Meter
|
||||
HDD 9505Meter
|
||||
Kabelverlegung 102468 Meter
|
||||
|
||||
|
||||
|
||||
|
||||
1
.turbo-test/85340b254e0f10f1-manifest.json
Normal file
1
.turbo-test/85340b254e0f10f1-manifest.json
Normal file
@@ -0,0 +1 @@
|
||||
{"files":{".turbo/turbo-lint.log":{"size":10746,"mtime_nanos":1779098006288513580,"mode":420,"is_dir":false}},"order":[".turbo/turbo-lint.log"]}
|
||||
1
.turbo-test/85340b254e0f10f1-meta.json
Normal file
1
.turbo-test/85340b254e0f10f1-meta.json
Normal file
@@ -0,0 +1 @@
|
||||
{"hash":"85340b254e0f10f1","duration":6595,"sha":"35d9650626b56a727e9c1981308145cb7b2f28aa","dirty_hash":"9cc3e16c40a10f42f7a963333e800e1384f047b1f387b18b5f37769c34f36609"}
|
||||
BIN
.turbo-test/85340b254e0f10f1.tar.zst
Normal file
BIN
.turbo-test/85340b254e0f10f1.tar.zst
Normal file
Binary file not shown.
@@ -17,16 +17,18 @@ ENV SKIP_RUNTIME_ENV_VALIDATION=true
|
||||
ENV CI=true
|
||||
|
||||
# Copy lockfile and manifest for dependency installation caching
|
||||
COPY pnpm-lock.yaml package.json .npmrc* ./
|
||||
COPY pnpm-lock.yaml package.json .npmrc* *.tgz ./
|
||||
COPY patches* ./patches/
|
||||
|
||||
ARG NPM_DOMAIN=git.infra.mintel.me
|
||||
|
||||
# Configure private registry and install dependencies
|
||||
RUN --mount=type=cache,id=pnpm,target=/pnpm/store \
|
||||
--mount=type=secret,id=NPM_TOKEN \
|
||||
if [ -f /run/secrets/NPM_TOKEN ]; then \
|
||||
export NPM_TOKEN=$(cat /run/secrets/NPM_TOKEN); \
|
||||
echo "@mintel:registry=https://git.infra.mintel.me/api/packages/mmintel/npm" > .npmrc; \
|
||||
echo "//git.infra.mintel.me/api/packages/mmintel/npm/:_authToken=${NPM_TOKEN}" >> .npmrc; \
|
||||
echo "@mintel:registry=https://${NPM_DOMAIN}/api/packages/mmintel/npm" > .npmrc; \
|
||||
echo "//${NPM_DOMAIN}/api/packages/mmintel/npm/:_authToken=${NPM_TOKEN}" >> .npmrc; \
|
||||
fi && \
|
||||
pnpm install --no-frozen-lockfile && \
|
||||
rm -f .npmrc
|
||||
|
||||
@@ -5,8 +5,8 @@ RUN apk add --no-cache libc6-compat python3 make g++ curl
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
# Enable corepack for pnpm
|
||||
RUN corepack enable
|
||||
# Install pnpm v10 explicitly (corepack defaults to v11 which requires Node 22)
|
||||
RUN npm install -g pnpm@10.18.3
|
||||
|
||||
# Pre-set the pnpm store directory
|
||||
ENV PNPM_HOME="/pnpm"
|
||||
|
||||
25
INSTRUCTIONS.md
Normal file
25
INSTRUCTIONS.md
Normal file
@@ -0,0 +1,25 @@
|
||||
# Antigravity Instructions & Project Rules
|
||||
|
||||
This document contains critical project-specific rules for the e-tib.com codebase. Antigravity must read and follow these rules at all times.
|
||||
|
||||
## 1. Bilingual Site (German & English) - STRICT
|
||||
- **Always update BOTH languages!** The website is bilingual.
|
||||
- When changing content, adding pages, or updating text, you MUST always check and update both `content/de/` and `content/en/` simultaneously.
|
||||
- When adding new components with text, ensure they are built to support both languages or update the respective translation files/mechanisms. Do NOT just hardcode German text and forget the English version.
|
||||
|
||||
## 2. No Dummy Data / No Hallucinations
|
||||
- Do not invent or hallucinate placeholder text (e.g., "Nachweis liegt vor" for certificates that don't exist).
|
||||
- Only implement what is explicitly requested or what matches provided assets/data.
|
||||
- If data is missing, ask the user instead of inventing placeholders.
|
||||
|
||||
## 3. Git Workflow & RC Tagging
|
||||
- When instructed to "commit and tag" (or similar), always follow this process:
|
||||
1. Add and commit the changes with conventional commits (`feat:`, `fix:`, `chore:`, etc.).
|
||||
2. Check the latest tag using `git describe --tags --abbrev=0`.
|
||||
3. Increment the RC (Release Candidate) number of the current version (e.g., if latest is `v2.2.13-rc19`, the new tag is `v2.2.13-rc20`).
|
||||
4. Create the tag: `git tag vX.Y.Z-rcN`.
|
||||
5. Push the commit and the tag: `git push origin main --tags`.
|
||||
|
||||
## 4. Work Exclusively on the Correct Branch
|
||||
- Follow the `USER_GLOBAL` directive: If on `main`, create a branch unless explicitly told to work on `main`.
|
||||
- For e-tib.com, often small fixes are done on `main` if the user commands it ("auf main arbeiten"), but always verify context.
|
||||
BIN
after-login.png
Normal file
BIN
after-login.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 274 KiB |
@@ -1,7 +1,7 @@
|
||||
import { ImageResponse } from 'next/og';
|
||||
import { getPageBySlug } from '@/lib/pages';
|
||||
import { OGImageTemplate } from '@/components/OGImageTemplate';
|
||||
import { getOgFonts, OG_IMAGE_SIZE } from '@/lib/og-helper';
|
||||
import { getOgFonts, getOgBackground, getOgLogo, OG_IMAGE_SIZE } from '@/lib/og-helper';
|
||||
|
||||
export const size = OG_IMAGE_SIZE;
|
||||
export const contentType = 'image/png';
|
||||
@@ -20,12 +20,16 @@ export default async function Image({
|
||||
}
|
||||
|
||||
const fonts = await getOgFonts();
|
||||
const bg = getOgBackground();
|
||||
const logo = getOgLogo();
|
||||
|
||||
return new ImageResponse(
|
||||
<OGImageTemplate
|
||||
title={pageData.frontmatter.title}
|
||||
description={pageData.frontmatter.excerpt}
|
||||
title="Die Experten für Kabelnetzbau"
|
||||
description={`${pageData.frontmatter.title}`}
|
||||
label="Information"
|
||||
image={bg}
|
||||
logo={logo}
|
||||
/>,
|
||||
{
|
||||
...OG_IMAGE_SIZE,
|
||||
|
||||
@@ -9,8 +9,7 @@ import { SITE_URL } from '@/lib/schema';
|
||||
import TrackedLink from '@/components/analytics/TrackedLink';
|
||||
import { getButtonClasses, ButtonOverlay } from '@/components/ui/Button';
|
||||
import { getAllReferences } from '@/lib/references';
|
||||
import { defaultLocations } from '@/lib/map-data';
|
||||
|
||||
import { defaultLocations, minorLocations } from '@/lib/map-data';
|
||||
// Import components used in MDX
|
||||
import { HeroVideo as HomeHero } from '@/components/blocks/HeroVideo';
|
||||
import { SubCompanyTiles as HomeSubCompanyTiles } from '@/components/blocks/SubCompanyTiles';
|
||||
@@ -18,7 +17,6 @@ import { CompetenceBentoGrid as HomeCompetenceBentoGrid } from '@/components/blo
|
||||
import { ReferencesSlider as HomeReferencesSlider } from '@/components/blocks/ReferencesSlider';
|
||||
import { CompanyTimeline } from '@/components/blocks/CompanyTimeline';
|
||||
import { JobListingBlock } from '@/components/blocks/JobListingBlock';
|
||||
import { CallToAction } from '@/components/blocks/CallToAction';
|
||||
import { ServiceDetailGrid } from '@/components/blocks/ServiceDetailGrid';
|
||||
import { BenefitGrid } from '@/components/blocks/BenefitGrid';
|
||||
import { InteractiveGermanyMap } from '@/components/blocks/InteractiveGermanyMap';
|
||||
@@ -26,8 +24,12 @@ import { FaqBlock } from '@/components/blocks/FaqBlock';
|
||||
import { CertificatesBlock } from '@/components/blocks/CertificatesBlock';
|
||||
import { HeroSection } from '@/components/blocks/HeroSection';
|
||||
import { TeamGrid } from '@/components/blocks/TeamGrid';
|
||||
import { DeepDrillAnimation } from '@/components/blocks/DeepDrillAnimation';
|
||||
import { DataGridPulse } from '@/components/blocks/DataGridPulse';
|
||||
import { GrowthChart } from '@/components/blocks/GrowthChart';
|
||||
import JsonLd from '@/components/JsonLd';
|
||||
import { Button } from '@/components/ui/Button';
|
||||
import { AnimatedCounter } from '@/components/ui';
|
||||
|
||||
|
||||
const mdxComponents = {
|
||||
@@ -37,7 +39,6 @@ const mdxComponents = {
|
||||
HomeReferencesSlider,
|
||||
CompanyTimeline,
|
||||
JobListingBlock,
|
||||
CallToAction,
|
||||
ServiceDetailGrid,
|
||||
BenefitGrid,
|
||||
InteractiveGermanyMap,
|
||||
@@ -47,6 +48,11 @@ const mdxComponents = {
|
||||
TeamGrid,
|
||||
JsonLd,
|
||||
Button,
|
||||
Badge,
|
||||
AnimatedCounter,
|
||||
GrowthChart,
|
||||
DeepDrillAnimation,
|
||||
DataGridPulse,
|
||||
// Standard HTML element mapping for consistent E-TIB typography
|
||||
h1: (props: any) => <Heading level={1} size="2" className="hidden" {...props} />, // Hidden because Hero handles H1
|
||||
h2: (props: any) => <Heading level={2} size="3" className="mt-16 mb-6 border-b border-neutral-100 pb-4" {...props} />,
|
||||
@@ -145,7 +151,8 @@ export default async function Page(props: { params: Promise<{ locale: string; sl
|
||||
|
||||
// Fetch references to enrich the map if it's used
|
||||
const references = await getAllReferences(locale);
|
||||
const enrichedLocations = defaultLocations.map(loc => {
|
||||
const allLocations = [...defaultLocations, ...minorLocations];
|
||||
const enrichedLocations = allLocations.map(loc => {
|
||||
if (loc.type === 'project') {
|
||||
const ref = references.find(r => r.slug === loc.id);
|
||||
if (ref) {
|
||||
@@ -153,7 +160,7 @@ export default async function Page(props: { params: Promise<{ locale: string; sl
|
||||
...loc,
|
||||
featuredImage: ref.frontmatter.featuredImage,
|
||||
details: [
|
||||
ref.frontmatter.client || 'Kunde',
|
||||
ref.frontmatter.client || t('client'),
|
||||
ref.frontmatter.dateString || new Date(ref.frontmatter.date).getFullYear().toString(),
|
||||
],
|
||||
};
|
||||
@@ -169,7 +176,7 @@ export default async function Page(props: { params: Promise<{ locale: string; sl
|
||||
id: r.slug,
|
||||
slug: r.slug,
|
||||
title: r.frontmatter.title,
|
||||
category: r.frontmatter.category || 'Projekt',
|
||||
category: Array.isArray(r.frontmatter.category) ? r.frontmatter.category[0] : (r.frontmatter.category || 'Projekt'),
|
||||
image: r.frontmatter.featuredImage
|
||||
}))} />,
|
||||
};
|
||||
@@ -178,7 +185,7 @@ export default async function Page(props: { params: Promise<{ locale: string; sl
|
||||
<div className={`flex flex-col min-h-screen ${isFullBleed ? '' : 'bg-white'}`}>
|
||||
{/* Generic Hero Section (only for standard pages) */}
|
||||
{!isFullBleed && (
|
||||
<section className="bg-primary-dark text-white pt-40 pb-12 md:pt-56 md:pb-16 min-h-[40vh] md:min-h-[45vh] flex flex-col justify-end relative overflow-hidden">
|
||||
<section key={`hero-section-${slug}`} className="bg-primary-dark text-white pt-28 pb-10 md:pt-56 md:pb-16 min-h-[30vh] md:min-h-[45vh] flex flex-col justify-end relative overflow-hidden">
|
||||
<div className="absolute inset-0 opacity-20 pointer-events-none">
|
||||
<div className="absolute top-0 left-0 w-full h-full bg-[radial-gradient(circle_at_center,_var(--tw-gradient-stops))] from-accent/50 via-transparent to-transparent" />
|
||||
</div>
|
||||
@@ -222,7 +229,7 @@ export default async function Page(props: { params: Promise<{ locale: string; sl
|
||||
|
||||
{/* Support Section */}
|
||||
<Container>
|
||||
<div className="mt-24 mb-24 p-8 md:p-12 bg-primary-dark rounded-3xl text-white shadow-2xl relative overflow-hidden group animate-slight-fade-in-from-bottom">
|
||||
<div className="mt-8 mb-8 md:mt-24 md:mb-24 p-8 md:p-12 bg-primary-dark rounded-3xl text-white shadow-2xl relative overflow-hidden group animate-slight-fade-in-from-bottom">
|
||||
<div className="absolute top-0 right-0 w-64 h-full bg-accent/5 -skew-x-12 translate-x-1/2 transition-transform group-hover:translate-x-1/3" />
|
||||
<div className="relative z-10 max-w-2xl">
|
||||
<h3 className="text-2xl md:text-3xl font-bold mb-4">{t('needHelp')}</h3>
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { ImageResponse } from 'next/og';
|
||||
import { getPostBySlug } from '@/lib/blog';
|
||||
import { OGImageTemplate } from '@/components/OGImageTemplate';
|
||||
import { getOgFonts, OG_IMAGE_SIZE } from '@/lib/og-helper';
|
||||
import { getOgFonts, getOgBackground, getOgLogo, OG_IMAGE_SIZE } from '@/lib/og-helper';
|
||||
import { SITE_URL } from '@/lib/schema';
|
||||
|
||||
export const size = OG_IMAGE_SIZE;
|
||||
@@ -52,13 +52,16 @@ export default async function Image({
|
||||
if (featuredImage) {
|
||||
base64Image = await fetchImageAsBase64(featuredImage);
|
||||
}
|
||||
const bg = getOgBackground();
|
||||
const logo = getOgLogo();
|
||||
|
||||
return new ImageResponse(
|
||||
<OGImageTemplate
|
||||
title={post.frontmatter.title}
|
||||
description={post.frontmatter.excerpt}
|
||||
title="Die Experten für Kabelnetzbau"
|
||||
description={`${post.frontmatter.title}`}
|
||||
label={post.frontmatter.category || 'Blog'}
|
||||
image={base64Image || featuredImage}
|
||||
image={base64Image || featuredImage || bg}
|
||||
logo={logo}
|
||||
/>,
|
||||
{
|
||||
...OG_IMAGE_SIZE,
|
||||
|
||||
@@ -99,7 +99,7 @@ export default async function BlogPost({ params }: BlogPostProps) {
|
||||
|
||||
{/* Featured Image Header */}
|
||||
{post.frontmatter.featuredImage ? (
|
||||
<div className="relative w-full h-[70vh] min-h-[500px] overflow-hidden group">
|
||||
<div className="relative w-full h-[50vh] md:h-[70vh] min-h-[350px] md:min-h-[500px] overflow-hidden group">
|
||||
<div className="absolute inset-0 transition-transform duration-[3s] ease-out scale-110 group-hover:scale-100">
|
||||
<Image
|
||||
src={post.frontmatter.featuredImage.split('?')[0]}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { ImageResponse } from 'next/og';
|
||||
import { getTranslations } from 'next-intl/server';
|
||||
import { OGImageTemplate } from '@/components/OGImageTemplate';
|
||||
import { getOgFonts, OG_IMAGE_SIZE } from '@/lib/og-helper';
|
||||
import { getOgFonts, getOgBackground, getOgLogo, OG_IMAGE_SIZE } from '@/lib/og-helper';
|
||||
|
||||
export const size = OG_IMAGE_SIZE;
|
||||
export const contentType = 'image/png';
|
||||
@@ -11,9 +11,17 @@ export default async function Image({ params }: { params: Promise<{ locale: stri
|
||||
const { locale } = await params;
|
||||
const t = await getTranslations({ locale, namespace: 'Blog.meta' });
|
||||
const fonts = await getOgFonts();
|
||||
const bg = getOgBackground();
|
||||
const logo = getOgLogo();
|
||||
|
||||
return new ImageResponse(
|
||||
<OGImageTemplate title={t('title')} description={t('description')} label="Blog" />,
|
||||
<OGImageTemplate
|
||||
title="Die Experten für Kabelnetzbau"
|
||||
description={t('title')}
|
||||
label="Blog"
|
||||
image={bg}
|
||||
logo={logo}
|
||||
/>,
|
||||
{
|
||||
...OG_IMAGE_SIZE,
|
||||
fonts,
|
||||
|
||||
@@ -59,7 +59,7 @@ export default async function BlogIndex({ params }: BlogIndexProps) {
|
||||
<div className="bg-neutral-light min-h-screen">
|
||||
{/* Hero Section - Immersive Magazine Feel */}
|
||||
<Reveal>
|
||||
<article className="relative h-[50vh] md:h-[70vh] min-h-[400px] md:min-h-[600px] flex items-center overflow-hidden bg-primary-dark">
|
||||
<article className="relative h-[40vh] md:h-[70vh] min-h-[300px] md:min-h-[600px] flex items-center overflow-hidden bg-primary-dark">
|
||||
{featuredPost && featuredPost.frontmatter.featuredImage && (
|
||||
<>
|
||||
<Image
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { ImageResponse } from 'next/og';
|
||||
import { getTranslations } from 'next-intl/server';
|
||||
import { OGImageTemplate } from '@/components/OGImageTemplate';
|
||||
import { getOgFonts, OG_IMAGE_SIZE } from '@/lib/og-helper';
|
||||
import { getOgFonts, getOgBackground, getOgLogo, OG_IMAGE_SIZE } from '@/lib/og-helper';
|
||||
|
||||
export const size = OG_IMAGE_SIZE;
|
||||
export const contentType = 'image/png';
|
||||
@@ -11,12 +11,22 @@ export default async function Image({ params }: { params: Promise<{ locale: stri
|
||||
const { locale } = await params;
|
||||
const t = await getTranslations({ locale, namespace: 'Contact' });
|
||||
const fonts = await getOgFonts();
|
||||
const bg = getOgBackground();
|
||||
const logo = getOgLogo();
|
||||
|
||||
const title = t('meta.title') || t('title');
|
||||
const description = t('meta.description') || t('subtitle');
|
||||
let title = t('meta.title') || t('title');
|
||||
if (title.startsWith('E-TIB GmbH | ')) {
|
||||
title = title.substring('E-TIB GmbH | '.length);
|
||||
}
|
||||
|
||||
return new ImageResponse(
|
||||
<OGImageTemplate title={title} description={description} label="Contact" />,
|
||||
<OGImageTemplate
|
||||
title="Die Experten für Kabelnetzbau"
|
||||
description={title}
|
||||
label="Contact"
|
||||
image={bg}
|
||||
logo={logo}
|
||||
/>,
|
||||
{
|
||||
...OG_IMAGE_SIZE,
|
||||
fonts,
|
||||
|
||||
@@ -57,7 +57,7 @@ export async function generateStaticParams() {
|
||||
}
|
||||
|
||||
export default async function ContactPage({ params }: ContactPageProps) {
|
||||
console.log('--- RENDERING CONTACT PAGE FOR LOCALE ---');
|
||||
|
||||
const { locale } = await params;
|
||||
setRequestLocale(locale);
|
||||
const t = await getTranslations({ locale, namespace: 'Contact' });
|
||||
@@ -150,7 +150,7 @@ export default async function ContactPage({ params }: ContactPageProps) {
|
||||
<Container>
|
||||
<div className="grid grid-cols-1 lg:grid-cols-12 gap-8 md:gap-16">
|
||||
{/* Contact Info */}
|
||||
<div className="lg:col-span-5 space-y-6 md:space-y-12">
|
||||
<div className="lg:col-span-5 order-2 lg:order-1 space-y-6 md:space-y-12">
|
||||
<div className="animate-fade-in">
|
||||
<Heading level={3} subtitle={t('info.subtitle')} className="mb-6 md:mb-8">
|
||||
{t('info.howToReachUs')}
|
||||
@@ -249,7 +249,7 @@ export default async function ContactPage({ params }: ContactPageProps) {
|
||||
</div>
|
||||
|
||||
{/* Contact Form */}
|
||||
<div className="lg:col-span-7">
|
||||
<div className="lg:col-span-7 order-1 lg:order-2">
|
||||
<Suspense
|
||||
fallback={
|
||||
<div className="animate-pulse bg-neutral-medium h-96 rounded-2xl md:rounded-3xl"></div>
|
||||
|
||||
13
app/[locale]/errors/api/relay/route.ts
Normal file
13
app/[locale]/errors/api/relay/route.ts
Normal file
@@ -0,0 +1,13 @@
|
||||
import { NextResponse } from 'next/server';
|
||||
|
||||
export async function POST(req: Request) {
|
||||
try {
|
||||
const rawText = await req.text();
|
||||
// Sentry sends NDJSON (Newline Delimited JSON). Split by newline to parse safely.
|
||||
const items = rawText.split('\n').filter(Boolean).map(line => JSON.parse(line));
|
||||
console.log("CLIENT ERROR INTERCEPTED:", JSON.stringify(items[0], null, 2));
|
||||
} catch (e) {
|
||||
console.log("Failed to parse relay body (NDJSON)", e);
|
||||
}
|
||||
return NextResponse.json({ success: true });
|
||||
}
|
||||
@@ -10,6 +10,7 @@ import { getMessages } from 'next-intl/server';
|
||||
import '../../styles/globals.css';
|
||||
import { SITE_URL } from '@/lib/schema';
|
||||
import FeedbackClientWrapper from '@/components/FeedbackClientWrapper';
|
||||
|
||||
import { setRequestLocale } from 'next-intl/server';
|
||||
import { Inter } from 'next/font/google';
|
||||
import { mapFileSlugToTranslated } from '@/lib/slugs';
|
||||
@@ -19,7 +20,6 @@ import { TransitionProvider } from '@/components/providers/TransitionProvider';
|
||||
import { PageTransitionShutter } from '@/components/providers/PageTransitionShutter';
|
||||
import { InitialLoader } from '@/components/providers/InitialLoader';
|
||||
import { CorporateBackground } from '@/components/decorations/CorporateBackground';
|
||||
|
||||
const inter = Inter({
|
||||
subsets: ['latin'],
|
||||
display: 'swap',
|
||||
@@ -36,9 +36,9 @@ export async function generateMetadata(props: {
|
||||
return {
|
||||
title: {
|
||||
template: '%s | E-TIB',
|
||||
default: 'E-TIB | Die Experten für Kabeltiefbau',
|
||||
default: 'E-TIB | Die Experten für Kabelnetzbau',
|
||||
},
|
||||
description: 'Ihr Partner für Kabelleitungstiefbau, Horizontalspülbohrungen, Planung und Vermessung in Guben und überregional.',
|
||||
description: 'Ihr Partner für Kabelleitungsnetzbau, Horizontalspülbohrungen, Planung und Vermessung in Guben und überregional.',
|
||||
metadataBase: new URL(baseUrl),
|
||||
manifest: '/manifest.webmanifest',
|
||||
alternates: {
|
||||
@@ -93,13 +93,18 @@ export default async function Layout(props: {
|
||||
children: [
|
||||
{ label: safeLocale === 'de' ? 'Firma' : 'Company', url: `/${safeLocale}/${await mapFileSlugToTranslated('ueber-uns', safeLocale)}` },
|
||||
{ label: safeLocale === 'de' ? 'Unser Team' : 'Our Team', url: `/${safeLocale}/team` },
|
||||
{ label: safeLocale === 'de' ? 'Zertifikate' : 'Certificates', url: `/${safeLocale}/zertifikate` }
|
||||
{ label: safeLocale === 'de' ? 'Zertifikate' : 'Certificates', url: `/${safeLocale}/zertifikate` },
|
||||
{ label: safeLocale === 'de' ? 'Standorte' : 'Locations', url: `/${safeLocale}/standorte` }
|
||||
]
|
||||
},
|
||||
{
|
||||
label: safeLocale === 'de' ? 'Referenzen' : 'References',
|
||||
url: `/${safeLocale}/referenzen`
|
||||
},
|
||||
{
|
||||
label: safeLocale === 'de' ? 'Karriere' : 'Career',
|
||||
url: `/${safeLocale}/${safeLocale === 'de' ? 'karriere' : 'career'}`
|
||||
},
|
||||
{
|
||||
label: safeLocale === 'de' ? 'Messen' : 'Events',
|
||||
url: `/${safeLocale}/${await mapFileSlugToTranslated('messen', safeLocale)}`
|
||||
@@ -123,6 +128,14 @@ export default async function Layout(props: {
|
||||
'Error',
|
||||
'StandardPage',
|
||||
'Brochure',
|
||||
'JobListingBlock',
|
||||
'CallToAction',
|
||||
'InteractiveGermanyMap',
|
||||
'ReferencesSlider',
|
||||
'CompanyTimeline',
|
||||
'TeamGrid',
|
||||
'AISearch',
|
||||
'GrowthChart',
|
||||
];
|
||||
const clientMessages: Record<string, any> = {};
|
||||
for (const key of clientKeys) {
|
||||
@@ -162,9 +175,7 @@ export default async function Layout(props: {
|
||||
|
||||
// Read directly from process.env — bypasses all abstraction to guarantee correctness
|
||||
const feedbackEnabled = process.env.NEXT_PUBLIC_FEEDBACK_ENABLED === 'true';
|
||||
|
||||
const cookieStore = await cookies();
|
||||
const hasSeenLoader = cookieStore.has('etib_initial_loader_v5');
|
||||
const hasSeenLoader = true; // Disabled to allow static generation and instant LCP (Lighthouse 100)
|
||||
|
||||
return (
|
||||
<html
|
||||
@@ -173,13 +184,9 @@ export default async function Layout(props: {
|
||||
data-scroll-behavior="smooth"
|
||||
>
|
||||
<head>
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com" />
|
||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossOrigin="anonymous" />
|
||||
<link rel="preconnect" href="https://img.infra.mintel.me" />
|
||||
<link rel="icon" href="/favicon.ico" sizes="any" />
|
||||
<link rel="apple-touch-icon" href="/apple-icon.png" sizes="180x180" />
|
||||
{/* Preload critical hero video */}
|
||||
<link rel="preload" as="video" href="/assets/videos/web/hero-bohrung.mp4" type="video/mp4" />
|
||||
|
||||
</head>
|
||||
<body className="relative flex flex-col min-h-screen font-sans antialiased overflow-x-hidden selection:bg-primary/90 selection:text-white">
|
||||
<NextIntlClientProvider messages={clientMessages} locale={safeLocale}>
|
||||
@@ -193,7 +200,7 @@ export default async function Layout(props: {
|
||||
|
||||
<main
|
||||
id="main-content"
|
||||
className="flex-grow animate-fade-in overflow-visible pb-[90px] md:pb-0"
|
||||
className="flex-grow animate-fade-in overflow-visible"
|
||||
tabIndex={-1}
|
||||
>
|
||||
{children}
|
||||
@@ -202,6 +209,7 @@ export default async function Layout(props: {
|
||||
<Footer companyInfo={companyInfo} />
|
||||
<JsonLd />
|
||||
<AnalyticsShell />
|
||||
|
||||
{feedbackEnabled && <FeedbackClientWrapper feedbackEnabled={feedbackEnabled} />}
|
||||
</TransitionProvider>
|
||||
</NextIntlClientProvider>
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { ImageResponse } from 'next/og';
|
||||
import { getTranslations } from 'next-intl/server';
|
||||
import { OGImageTemplate } from '@/components/OGImageTemplate';
|
||||
import { getOgFonts, OG_IMAGE_SIZE } from '@/lib/og-helper';
|
||||
import { getOgFonts, getOgBackground, getOgLogo, OG_IMAGE_SIZE } from '@/lib/og-helper';
|
||||
|
||||
export const size = OG_IMAGE_SIZE;
|
||||
export const contentType = 'image/png';
|
||||
@@ -10,17 +10,21 @@ export const runtime = 'nodejs';
|
||||
export default async function Image({ params }: { params: Promise<{ locale: string }> }) {
|
||||
const { locale } = await params;
|
||||
const t = await getTranslations({ locale, namespace: 'Index.meta' });
|
||||
|
||||
const fonts = await getOgFonts();
|
||||
const bg = getOgBackground();
|
||||
const logo = getOgLogo();
|
||||
|
||||
return new ImageResponse(
|
||||
<OGImageTemplate
|
||||
title={t('title')}
|
||||
description={t('description')}
|
||||
label="Reliable Energy Infrastructure"
|
||||
title="Die Experten für Kabelnetzbau"
|
||||
label="Kabelnetzbau & Infrastruktur"
|
||||
image={bg}
|
||||
logo={logo}
|
||||
/>,
|
||||
{
|
||||
...OG_IMAGE_SIZE,
|
||||
fonts,
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -4,20 +4,23 @@ import { notFound } from 'next/navigation';
|
||||
import { getMdxContent } from '@/lib/mdx';
|
||||
import { MDXRemote } from 'next-mdx-remote/rsc';
|
||||
|
||||
export const dynamic = 'force-dynamic';
|
||||
import nextDynamic from 'next/dynamic';
|
||||
import { getImageProps } from 'next/image';
|
||||
|
||||
// Import components used in MDX
|
||||
import { HeroVideo as HomeHero } from '@/components/blocks/HeroVideo';
|
||||
import { SubCompanyTiles as HomeSubCompanyTiles } from '@/components/blocks/SubCompanyTiles';
|
||||
import { CompetenceBentoGrid as HomeCompetenceBentoGrid } from '@/components/blocks/CompetenceBentoGrid';
|
||||
import { ReferencesSlider as HomeReferencesSlider } from '@/components/blocks/ReferencesSlider';
|
||||
import { FaqBlock } from '@/components/blocks/FaqBlock';
|
||||
import { CertificatesBlock } from '@/components/blocks/CertificatesBlock';
|
||||
const HomeSubCompanyTiles = nextDynamic(() => import('@/components/blocks/SubCompanyTiles').then(mod => mod.SubCompanyTiles));
|
||||
const HomeCompetenceBentoGrid = nextDynamic(() => import('@/components/blocks/CompetenceBentoGrid').then(mod => mod.CompetenceBentoGrid));
|
||||
const HomeReferencesSlider = nextDynamic(() => import('@/components/blocks/ReferencesSlider').then(mod => mod.ReferencesSlider));
|
||||
const FaqBlock = nextDynamic(() => import('@/components/blocks/FaqBlock').then(mod => mod.FaqBlock));
|
||||
const CertificatesBlock = nextDynamic(() => import('@/components/blocks/CertificatesBlock').then(mod => mod.CertificatesBlock));
|
||||
import { HeroSection } from '@/components/blocks/HeroSection';
|
||||
import JsonLd from '@/components/JsonLd';
|
||||
|
||||
import { Button } from '@/components/ui/Button';
|
||||
import { Heading } from '@/components/ui/Heading';
|
||||
const AnimatedCounter = nextDynamic(() => import('@/components/ui').then(mod => mod.AnimatedCounter));
|
||||
const InteractiveGermanyMap = nextDynamic(() => import('@/components/blocks/InteractiveGermanyMap').then(mod => mod.InteractiveGermanyMap));
|
||||
const ScaleOfImpact = nextDynamic(() => import('@/components/blocks/ScaleOfImpact').then(mod => mod.ScaleOfImpact));
|
||||
import { getAllReferences } from '@/lib/references';
|
||||
|
||||
const mdxComponents = (references: any[]) => ({
|
||||
@@ -31,6 +34,45 @@ const mdxComponents = (references: any[]) => ({
|
||||
JsonLd,
|
||||
Button,
|
||||
Heading,
|
||||
AnimatedCounter,
|
||||
InteractiveGermanyMap,
|
||||
ScaleOfImpact,
|
||||
img: (props: any) => {
|
||||
// Parse src if it's already a next image proxy
|
||||
let src = props.src;
|
||||
if (typeof src === 'string' && src.startsWith('/_next/image')) {
|
||||
try {
|
||||
const urlParam = new URLSearchParams(src.split('?')[1]).get('url');
|
||||
if (urlParam) src = decodeURIComponent(urlParam);
|
||||
} catch (e) {
|
||||
// Fallback
|
||||
}
|
||||
}
|
||||
|
||||
// Use Next.js getImageProps for responsive srcSet generation
|
||||
const {
|
||||
props: { srcSet, src: finalSrc, sizes }
|
||||
} = getImageProps({
|
||||
src,
|
||||
alt: props.alt || '',
|
||||
width: 1920,
|
||||
height: 1080,
|
||||
sizes: '(max-width: 768px) 100vw, (max-width: 1200px) 75vw, 50vw',
|
||||
quality: 80,
|
||||
});
|
||||
|
||||
return (
|
||||
<img
|
||||
{...props}
|
||||
src={finalSrc}
|
||||
srcSet={srcSet}
|
||||
sizes={sizes}
|
||||
loading="lazy"
|
||||
decoding="async"
|
||||
style={{ width: '100%', height: 'auto', ...props.style }}
|
||||
/>
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
export async function generateMetadata(props: any): Promise<Metadata> {
|
||||
|
||||
@@ -1,185 +0,0 @@
|
||||
import { notFound } from 'next/navigation';
|
||||
import { Container, Badge, Heading } from '@/components/ui';
|
||||
import { setRequestLocale } from 'next-intl/server';
|
||||
import { Metadata } from 'next';
|
||||
import { getReferenceBySlug, getAllReferences } from '@/lib/references';
|
||||
import { MDXRemote } from 'next-mdx-remote/rsc';
|
||||
import { SITE_URL } from '@/lib/schema';
|
||||
import TrackedLink from '@/components/analytics/TrackedLink';
|
||||
import { getButtonClasses, ButtonOverlay } from '@/components/ui/Button';
|
||||
import { MapPin, Calendar, Briefcase, ChevronLeft } from 'lucide-react';
|
||||
import Image from 'next/image';
|
||||
|
||||
interface PageProps {
|
||||
params: Promise<{
|
||||
locale: string;
|
||||
slug: string;
|
||||
}>;
|
||||
}
|
||||
|
||||
export async function generateMetadata({ params }: PageProps): Promise<Metadata> {
|
||||
const { locale, slug } = await params;
|
||||
if (locale !== 'de' && locale !== 'en') return {};
|
||||
|
||||
const reference = await getReferenceBySlug(slug, locale);
|
||||
if (!reference) return {};
|
||||
|
||||
return {
|
||||
title: `${reference.frontmatter.title} | Referenzen | E-TIB Gruppe`,
|
||||
description: `Details zum Projekt ${reference.frontmatter.title} in ${reference.frontmatter.location}.`,
|
||||
alternates: {
|
||||
canonical: `${SITE_URL}/${locale}/referenzen/${slug}`,
|
||||
},
|
||||
openGraph: {
|
||||
title: `${reference.frontmatter.title} | E-TIB`,
|
||||
description: `Details zum Projekt ${reference.frontmatter.title} in ${reference.frontmatter.location}.`,
|
||||
url: `${SITE_URL}/${locale}/referenzen/${slug}`,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export async function generateStaticParams(props: any) {
|
||||
const params = await props.params;
|
||||
const locale = params?.locale || 'de'; // fallback to 'de' if undefined
|
||||
const references = await getAllReferences(locale);
|
||||
return references.map((ref) => ({
|
||||
slug: ref.slug,
|
||||
locale
|
||||
}));
|
||||
}
|
||||
|
||||
export default async function ReferenceDetail(props: { params: Promise<{ locale: string; slug: string }> }) {
|
||||
const { locale, slug } = await props.params;
|
||||
|
||||
if (locale !== 'de' && locale !== 'en') {
|
||||
notFound();
|
||||
}
|
||||
|
||||
setRequestLocale(locale);
|
||||
const reference = await getReferenceBySlug(slug, locale);
|
||||
|
||||
if (!reference) {
|
||||
notFound();
|
||||
}
|
||||
|
||||
// MDX components mapping for industrial typography
|
||||
const mdxComponents = {
|
||||
Heading,
|
||||
h1: (props: any) => <Heading level={1} size="2" className="hidden" {...props} />, // Hidden because Hero handles H1
|
||||
h2: (props: any) => <Heading level={2} size="3" className="mt-16 mb-6 border-b border-neutral-100 pb-4" {...props} />,
|
||||
h3: (props: any) => <Heading level={3} size="4" className="mt-12 mb-4 text-primary" {...props} />,
|
||||
h4: (props: any) => <Heading level={4} size="5" className="mt-8 mb-4 uppercase tracking-widest text-neutral-500" {...props} />,
|
||||
p: (props: any) => <p className="text-base md:text-lg text-text-secondary leading-[1.8] mb-6 font-medium max-w-3xl" {...props} />,
|
||||
ul: (props: any) => <ul className="grid grid-cols-1 md:grid-cols-2 gap-4 my-12" {...props} />,
|
||||
ol: (props: any) => <ol className="list-decimal pl-6 mb-8 space-y-3 text-base md:text-lg text-text-secondary font-medium max-w-3xl" {...props} />,
|
||||
li: (props: any) => (
|
||||
<li className="bg-neutral-50 p-6 rounded-2xl border border-neutral-100 flex items-start gap-4 hover:shadow-md transition-shadow group" {...props}>
|
||||
<div className="w-6 h-6 rounded-full bg-primary/10 flex items-center justify-center shrink-0 mt-0.5 group-hover:bg-primary/20 transition-colors">
|
||||
<svg className="w-3.5 h-3.5 text-primary" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={3}>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M5 13l4 4L19 7" />
|
||||
</svg>
|
||||
</div>
|
||||
<span className="text-neutral-700 font-semibold leading-snug">{props.children}</span>
|
||||
</li>
|
||||
),
|
||||
a: (props: any) => <a className="text-primary hover:text-primary-dark underline decoration-primary/30 underline-offset-4 hover:decoration-primary transition-all font-bold" {...props} />,
|
||||
strong: (props: any) => <strong className="font-bold text-neutral-900" {...props} />,
|
||||
blockquote: (props: any) => (
|
||||
<blockquote className="border-l-4 border-primary pl-6 py-3 my-10 italic bg-neutral-50 rounded-r-2xl text-neutral-700 font-medium max-w-3xl shadow-sm" {...props} />
|
||||
),
|
||||
hr: (props: any) => <hr className="my-16 border-t-2 border-neutral-100 max-w-3xl" {...props} />,
|
||||
img: (props: any) => <img className="rounded-2xl shadow-2xl my-12 max-w-full h-auto border border-neutral-100" {...props} />,
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex flex-col min-h-screen bg-white">
|
||||
{/* Hero Section */}
|
||||
<section className="bg-[#050B14] text-white pt-40 pb-16 md:pt-48 md:pb-20 min-h-[35vh] flex flex-col justify-end relative overflow-hidden">
|
||||
{reference.frontmatter.featuredImage && (
|
||||
<div className="absolute inset-0 z-0">
|
||||
<Image
|
||||
src={reference.frontmatter.featuredImage}
|
||||
alt={reference.frontmatter.title}
|
||||
fill
|
||||
className="object-cover opacity-25 mix-blend-luminosity scale-105"
|
||||
priority
|
||||
/>
|
||||
<div className="absolute inset-0 bg-gradient-to-t from-[#050B14] via-[#050B14]/60 to-transparent" />
|
||||
<div className="absolute inset-0 bg-gradient-to-r from-[#050B14] via-transparent to-transparent" />
|
||||
</div>
|
||||
)}
|
||||
<div className="absolute inset-0 opacity-20 z-0">
|
||||
<div className="absolute top-0 left-0 w-full h-full bg-[radial-gradient(circle_at_center,_var(--tw-gradient-stops))] from-primary/30 via-transparent to-transparent" />
|
||||
</div>
|
||||
<Container className="relative z-10">
|
||||
<div className="mb-12">
|
||||
<TrackedLink
|
||||
href={`/${locale}/referenzen`}
|
||||
className="inline-flex items-center gap-2 text-white/60 hover:text-white transition-colors"
|
||||
eventProperties={{ location: 'reference_back_btn' }}
|
||||
>
|
||||
<ChevronLeft className="w-5 h-5" />
|
||||
Zurück zur Übersicht
|
||||
</TrackedLink>
|
||||
</div>
|
||||
<div className="max-w-4xl">
|
||||
<Badge variant="accent" className="mb-4 md:mb-6">
|
||||
Projektreferenz
|
||||
</Badge>
|
||||
<Heading level={1} variant="white" className="mb-8 leading-tight">
|
||||
{reference.frontmatter.title}
|
||||
</Heading>
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-6 mt-12">
|
||||
<div className="flex items-start gap-4 p-6 bg-white/5 rounded-2xl border border-white/10 backdrop-blur-sm">
|
||||
<MapPin className="w-8 h-8 text-primary shrink-0" />
|
||||
<div>
|
||||
<p className="text-sm text-white/50 mb-1 uppercase tracking-wider font-bold">Ort</p>
|
||||
<p className="font-semibold text-lg">{reference.frontmatter.location}</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-start gap-4 p-6 bg-white/5 rounded-2xl border border-white/10 backdrop-blur-sm">
|
||||
<Briefcase className="w-8 h-8 text-primary shrink-0" />
|
||||
<div>
|
||||
<p className="text-sm text-white/50 mb-1 uppercase tracking-wider font-bold">Auftraggeber</p>
|
||||
<p className="font-semibold text-lg">{reference.frontmatter.client}</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-start gap-4 p-6 bg-white/5 rounded-2xl border border-white/10 backdrop-blur-sm">
|
||||
<Calendar className="w-8 h-8 text-primary shrink-0" />
|
||||
<div>
|
||||
<p className="text-sm text-white/50 mb-1 uppercase tracking-wider font-bold">Zeitraum</p>
|
||||
<p className="font-semibold text-lg">{reference.frontmatter.dateString || new Date(reference.frontmatter.date).getFullYear()}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Container>
|
||||
</section>
|
||||
|
||||
{/* Main Content Area */}
|
||||
<Container className="py-16 md:py-24">
|
||||
<div className="max-w-4xl mx-auto">
|
||||
<h2 className="text-3xl font-bold text-neutral-dark mb-8">Leistungsumfang & Projektbeschreibung</h2>
|
||||
|
||||
<div className="w-full">
|
||||
<MDXRemote source={reference.content} components={mdxComponents} />
|
||||
</div>
|
||||
|
||||
<div className="mt-20 flex justify-center">
|
||||
<TrackedLink
|
||||
href={`/${locale}/referenzen`}
|
||||
className={getButtonClasses('primary', 'lg')}
|
||||
eventProperties={{ location: 'reference_bottom_back_btn' }}
|
||||
>
|
||||
<span className="relative z-10 flex items-center justify-center gap-2">
|
||||
Alle Referenzen ansehen
|
||||
</span>
|
||||
<ButtonOverlay variant="primary" />
|
||||
</TrackedLink>
|
||||
</div>
|
||||
</div>
|
||||
</Container>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -3,12 +3,43 @@ import { getTranslations, setRequestLocale } from 'next-intl/server';
|
||||
import { Metadata } from 'next';
|
||||
import { getAllReferences } from '@/lib/references';
|
||||
import TrackedLink from '@/components/analytics/TrackedLink';
|
||||
import { MapPin, Calendar, Briefcase, ArrowUpRight } from 'lucide-react';
|
||||
import { MapPin, Calendar, Briefcase, Zap, Activity, ShieldCheck, Wrench, CheckCircle2 } from 'lucide-react';
|
||||
import { getButtonClasses, ButtonOverlay } from '@/components/ui/Button';
|
||||
import { SITE_URL } from '@/lib/schema';
|
||||
import Image from 'next/image';
|
||||
import { InteractiveGermanyMap } from '@/components/blocks/InteractiveGermanyMap';
|
||||
import { defaultLocations } from '@/lib/map-data';
|
||||
import { HeroSection } from '@/components/blocks/HeroSection';
|
||||
import { defaultLocations, minorLocations } from '@/lib/map-data';
|
||||
import { MDXRemote } from 'next-mdx-remote/rsc';
|
||||
|
||||
const CustomLi = ({ children, ...props }: any) => {
|
||||
let text = '';
|
||||
if (typeof children === 'string') text = children;
|
||||
else if (Array.isArray(children)) text = children.map((c: any) => typeof c === 'string' ? c : '').join(' ');
|
||||
else if (children?.props?.children) text = children.props.children;
|
||||
|
||||
const lowerText = String(text).toLowerCase();
|
||||
|
||||
let Icon = CheckCircle2;
|
||||
if (lowerText.includes('kabel')) Icon = Zap;
|
||||
else if (lowerText.includes('bohrung')) Icon = Activity;
|
||||
else if (lowerText.includes('rohr')) Icon = ShieldCheck;
|
||||
else if (lowerText.includes('montage')) Icon = Wrench;
|
||||
else if (lowerText.includes('vlf') || lowerText.includes('otdr')) Icon = Zap;
|
||||
|
||||
return (
|
||||
<li className="flex items-start gap-3 pl-4 pr-6 py-3.5 bg-neutral-50/50 border border-neutral-100 rounded-xl" {...props}>
|
||||
<Icon className="w-5 h-5 text-primary shrink-0 mt-0.5" />
|
||||
<span className="text-sm font-medium text-neutral-700 leading-snug [&>p]:mb-0">{children}</span>
|
||||
</li>
|
||||
);
|
||||
};
|
||||
|
||||
const mdxComponents = {
|
||||
ul: (props: any) => <ul className="grid grid-cols-1 gap-2 mb-6" {...props} />,
|
||||
li: CustomLi,
|
||||
p: (props: any) => <p className="text-neutral-600 text-sm mb-4 leading-relaxed" {...props} />,
|
||||
};
|
||||
|
||||
interface PageProps {
|
||||
params: Promise<{
|
||||
@@ -22,7 +53,7 @@ export async function generateMetadata({ params }: PageProps): Promise<Metadata>
|
||||
|
||||
return {
|
||||
title: 'Referenzen | E-TIB Gruppe',
|
||||
description: 'Erfolgreich abgeschlossene Projekte der E-TIB Gruppe im Bereich Kabeltiefbau, Spülbohrtechnik und Netzinfrastruktur.',
|
||||
description: 'Erfolgreich abgeschlossene Projekte der E-TIB Gruppe im Bereich Kabelnetzbau, Spülbohrtechnik und Netzinfrastruktur.',
|
||||
alternates: {
|
||||
canonical: `${SITE_URL}/${locale}/referenzen`,
|
||||
languages: {
|
||||
@@ -34,6 +65,17 @@ export async function generateMetadata({ params }: PageProps): Promise<Metadata>
|
||||
};
|
||||
}
|
||||
|
||||
function cleanLocation(location?: string): string {
|
||||
if (!location) return '';
|
||||
// Remove 5 digit postal codes
|
||||
let cleaned = location.replace(/\b\d{5}\b/g, '');
|
||||
// Clean up multiple spaces, slashes and commas
|
||||
cleaned = cleaned.replace(/\s*,\s*/g, ', ').replace(/\s*\/\s*/g, ' / ').trim();
|
||||
// Remove leading/trailing slash/comma residues
|
||||
cleaned = cleaned.replace(/^[,/\s]+|[,/\s]+$/g, '').trim();
|
||||
return cleaned || location;
|
||||
}
|
||||
|
||||
export default async function ReferenzenOverview(props: { params: Promise<{ locale: string }> }) {
|
||||
const { locale } = await props.params;
|
||||
setRequestLocale(locale);
|
||||
@@ -41,8 +83,8 @@ export default async function ReferenzenOverview(props: { params: Promise<{ loca
|
||||
|
||||
const references = await getAllReferences(locale);
|
||||
|
||||
// Enrich the static map locations with dynamic project data (images, details)
|
||||
const enrichedLocations = defaultLocations.map(loc => {
|
||||
const allLocations = [...defaultLocations, ...minorLocations];
|
||||
const enrichedLocations = allLocations.map(loc => {
|
||||
if (loc.type === 'project') {
|
||||
const ref = references.find(r => r.slug === loc.id);
|
||||
if (ref) {
|
||||
@@ -60,28 +102,28 @@ export default async function ReferenzenOverview(props: { params: Promise<{ loca
|
||||
});
|
||||
|
||||
return (
|
||||
<div className="flex flex-col min-h-screen bg-neutral-50 pb-16 md:pb-24">
|
||||
{/* Map Hero Section */}
|
||||
<div className="flex flex-col min-h-screen bg-neutral-50 pb-8 md:pb-24">
|
||||
{/* Map Section */}
|
||||
<InteractiveGermanyMap
|
||||
isHero={true}
|
||||
badge="Unsere Referenzen"
|
||||
title={<>Erfolgreich umgesetzte<br/>Projekte.</>}
|
||||
description="Vom Breitbandausbau bis zur komplexen 110kV-Trasse: Ein Auszug unserer bundesweiten Projekte, bei denen wir Infrastruktur für die Zukunft geschaffen haben. Entdecken Sie unsere Standorte."
|
||||
badge={locale === 'en' ? 'Our References' : 'Unsere Referenzen'}
|
||||
title={locale === 'en' ? 'Successfully realized projects.' : 'Erfolgreich umgesetzte Projekte.'}
|
||||
description={locale === 'en' ? 'From broadband expansion to complex 110kV lines: A selection of our nationwide projects where we have created infrastructure for the future.' : 'Vom Breitbandausbau bis zur komplexen 110kV-Trasse: Ein Auszug unserer bundesweiten Projekte, bei denen wir Infrastruktur für die Zukunft geschaffen haben.'}
|
||||
locations={enrichedLocations}
|
||||
stats={[
|
||||
{ value: '100', suffix: '%', label: locale === 'en' ? 'Nationwide Reach' : 'Überregionale Reichweite' },
|
||||
{ value: '200', suffix: '+', label: locale === 'en' ? 'Completed Projects' : 'Abgeschlossene Projekte' }
|
||||
]}
|
||||
/>
|
||||
|
||||
{/* Main Content Area */}
|
||||
<Container className="relative z-20 mt-16 md:mt-24">
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6 md:gap-8">
|
||||
{references.map((ref) => (
|
||||
<TrackedLink
|
||||
<div
|
||||
key={ref.slug}
|
||||
href={`/${locale}/referenzen/${ref.slug}`}
|
||||
className="group flex flex-col bg-white border border-neutral-100 rounded-3xl overflow-hidden shadow-sm hover:shadow-2xl transition-all duration-300 transform hover:-translate-y-2"
|
||||
eventProperties={{
|
||||
location: 'referenzen_grid',
|
||||
target_slug: ref.slug,
|
||||
}}
|
||||
id={ref.slug}
|
||||
className="flex flex-col bg-white border border-neutral-100 rounded-3xl overflow-hidden shadow-sm block scroll-mt-32"
|
||||
>
|
||||
<div className="flex flex-col h-full relative">
|
||||
{/* Image Section */}
|
||||
@@ -91,41 +133,48 @@ export default async function ReferenzenOverview(props: { params: Promise<{ loca
|
||||
src={ref.frontmatter.featuredImage}
|
||||
alt={ref.frontmatter.title}
|
||||
fill
|
||||
className="object-cover group-hover:scale-105 transition-transform duration-1000 ease-[cubic-bezier(0.16,1,0.3,1)] filter grayscale-[20%] group-hover:grayscale-0"
|
||||
className="object-cover transition-transform duration-1000 ease-[cubic-bezier(0.16,1,0.3,1)] filter grayscale-[20%]"
|
||||
/>
|
||||
) : (
|
||||
<div className="absolute inset-0 bg-[#f4f4f5] flex items-center justify-center">
|
||||
<Image src="/assets/logo.png" alt="E-TIB Logo" width={80} height={80} className="opacity-20 grayscale" />
|
||||
</div>
|
||||
)}
|
||||
<div className="absolute inset-0 bg-black/5 group-hover:bg-transparent transition-colors duration-500" />
|
||||
<div className="absolute inset-0 bg-black/5" />
|
||||
|
||||
{/* Location Badge */}
|
||||
<div className="absolute top-4 left-4 z-10">
|
||||
<span className="bg-white/95 backdrop-blur-md text-neutral-dark px-3 py-1.5 rounded-sm text-[10px] font-bold uppercase tracking-widest flex items-center gap-1.5 shadow-sm border border-neutral-200">
|
||||
<MapPin className="w-3 h-3 text-primary" />
|
||||
{ref.frontmatter.location}
|
||||
{cleanLocation(ref.frontmatter.location)}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Content Section */}
|
||||
<div className="flex flex-col flex-grow p-6 md:p-8 bg-white">
|
||||
<h3 className="text-xl md:text-2xl font-bold font-heading text-neutral-dark mb-6 group-hover:text-primary transition-colors line-clamp-3 leading-[1.2]">
|
||||
<h3 className="text-xl md:text-2xl font-bold font-heading text-neutral-dark mb-6 leading-[1.2]">
|
||||
{ref.frontmatter.title}
|
||||
</h3>
|
||||
|
||||
{/* Project Details / Content */}
|
||||
{ref.content && (
|
||||
<div className="flex-grow mb-6">
|
||||
<MDXRemote source={ref.content} components={mdxComponents} />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Technical Meta Data */}
|
||||
<div className="flex flex-col gap-3 mt-auto border-t border-neutral-100 pt-6 pr-16 md:pr-20 relative z-10">
|
||||
<div className="flex flex-col gap-3 mt-auto border-t border-neutral-100 pt-6 relative z-10">
|
||||
<div className="min-w-0">
|
||||
<span className="block text-[10px] uppercase tracking-widest text-neutral-400 font-bold mb-1">Kunde</span>
|
||||
<span className="block text-[10px] uppercase tracking-widest text-neutral-400 font-bold mb-1">{t('client')}</span>
|
||||
<span className="flex items-start gap-2 text-sm font-semibold text-neutral-700 leading-tight">
|
||||
<Briefcase className="w-3.5 h-3.5 text-primary shrink-0 mt-[2px]" />
|
||||
<span className="line-clamp-2">{ref.frontmatter.client}</span>
|
||||
<span className="line-clamp-2">{ref.frontmatter.client || t('client')}</span>
|
||||
</span>
|
||||
</div>
|
||||
<div className="min-w-0">
|
||||
<span className="block text-[10px] uppercase tracking-widest text-neutral-400 font-bold mb-1">Zeitraum</span>
|
||||
<span className="block text-[10px] uppercase tracking-widest text-neutral-400 font-bold mb-1">{t('period')}</span>
|
||||
<span className="flex items-start gap-2 text-sm font-semibold text-neutral-700 leading-tight">
|
||||
<Calendar className="w-3.5 h-3.5 text-primary shrink-0 mt-[2px]" />
|
||||
<span className="line-clamp-2">{ref.frontmatter.dateString || new Date(ref.frontmatter.date).getFullYear()}</span>
|
||||
@@ -133,22 +182,17 @@ export default async function ReferenzenOverview(props: { params: Promise<{ loca
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Industrial Hover Indicator */}
|
||||
<div className="absolute right-6 bottom-6 md:right-8 md:bottom-8 w-10 h-10 bg-neutral-50 border border-neutral-200 rounded flex items-center justify-center transform group-hover:bg-primary group-hover:border-primary transition-colors duration-300">
|
||||
<ArrowUpRight className="w-5 h-5 text-neutral-dark group-hover:text-white transform group-hover:translate-x-0.5 group-hover:-translate-y-0.5 transition-transform duration-300" />
|
||||
</div>
|
||||
</div>
|
||||
</TrackedLink>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Support Section */}
|
||||
<div className="mt-24 p-8 md:p-12 bg-primary-dark rounded-3xl text-white shadow-2xl relative overflow-hidden group">
|
||||
<div className="mt-8 md:mt-24 p-8 md:p-12 bg-primary-dark rounded-3xl text-white shadow-2xl relative overflow-hidden group">
|
||||
<div className="absolute top-0 right-0 w-64 h-full bg-accent/5 -skew-x-12 translate-x-1/2 transition-transform group-hover:translate-x-1/3" />
|
||||
<div className="relative z-10 max-w-2xl">
|
||||
<h3 className="text-2xl md:text-3xl font-bold mb-4">Ihr nächstes Projekt?</h3>
|
||||
<p className="text-lg text-white/70 mb-8">Lassen Sie uns gemeinsam herausragende Infrastruktur bauen.</p>
|
||||
<h3 className="text-2xl md:text-3xl font-bold mb-4">{t('nextProjectTitle')}</h3>
|
||||
<p className="text-lg text-white/70 mb-8">{t('nextProjectDesc')}</p>
|
||||
<TrackedLink
|
||||
href={`/${locale}/${locale === 'de' ? 'kontakt' : 'contact'}`}
|
||||
className={getButtonClasses('accent', 'lg')}
|
||||
|
||||
187
app/[locale]/standorte/[slug]/page.tsx
Normal file
187
app/[locale]/standorte/[slug]/page.tsx
Normal file
@@ -0,0 +1,187 @@
|
||||
import { Container, Badge, Heading } from '@/components/ui';
|
||||
import { getTranslations, setRequestLocale } from 'next-intl/server';
|
||||
import { Metadata } from 'next';
|
||||
import { notFound } from 'next/navigation';
|
||||
import { standorteData, getStandortById } from '@/lib/standorte-data';
|
||||
import { SITE_URL } from '@/lib/schema';
|
||||
import Image from 'next/image';
|
||||
import { MapPin, Phone, Mail, Navigation, CheckCircle2, ArrowUpRight } from 'lucide-react';
|
||||
import { getButtonClasses, ButtonOverlay } from '@/components/ui/Button';
|
||||
import TrackedLink from '@/components/analytics/TrackedLink';
|
||||
import { AnimatedGlossyBorder } from '@/components/ui/AnimatedGlossyBorder';
|
||||
|
||||
interface PageProps {
|
||||
params: Promise<{
|
||||
locale: string;
|
||||
slug: string;
|
||||
}>;
|
||||
}
|
||||
|
||||
export async function generateStaticParams() {
|
||||
const locales = ['de', 'en'];
|
||||
const params: { locale: string; slug: string }[] = [];
|
||||
|
||||
for (const locale of locales) {
|
||||
for (const standort of standorteData) {
|
||||
params.push({ locale, slug: standort.id });
|
||||
}
|
||||
}
|
||||
|
||||
return params;
|
||||
}
|
||||
|
||||
export async function generateMetadata({ params }: PageProps): Promise<Metadata> {
|
||||
const { locale, slug } = await params;
|
||||
if (locale !== 'de' && locale !== 'en') return {};
|
||||
|
||||
const standort = getStandortById(slug);
|
||||
if (!standort) return {};
|
||||
|
||||
return {
|
||||
title: `${standort.name} | E-TIB Gruppe`,
|
||||
description: standort.description[locale],
|
||||
alternates: {
|
||||
canonical: `${SITE_URL}/${locale}/standorte/${slug}`,
|
||||
languages: {
|
||||
de: `${SITE_URL}/de/standorte/${slug}`,
|
||||
en: `${SITE_URL}/en/standorte/${slug}`,
|
||||
'x-default': `${SITE_URL}/en/standorte/${slug}`,
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export default async function StandortDetail(props: { params: Promise<{ locale: string; slug: string }> }) {
|
||||
const { locale, slug } = await props.params;
|
||||
const safeLocale = locale === 'de' ? 'de' : 'en';
|
||||
setRequestLocale(safeLocale);
|
||||
const t = await getTranslations('StandardPage');
|
||||
|
||||
const standort = getStandortById(slug);
|
||||
if (!standort) {
|
||||
notFound();
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex flex-col min-h-screen bg-white">
|
||||
{/* Generic Hero Section to match standard pages */}
|
||||
<section key={`standort-hero-${slug}`} className="bg-primary-dark text-white pt-28 pb-10 md:pt-56 md:pb-16 min-h-[30vh] md:min-h-[45vh] flex flex-col justify-end relative overflow-hidden">
|
||||
<div className="absolute inset-0 opacity-20 pointer-events-none">
|
||||
<div className="absolute top-0 left-0 w-full h-full bg-[radial-gradient(circle_at_center,_var(--tw-gradient-stops))] from-accent/50 via-transparent to-transparent" />
|
||||
</div>
|
||||
<Container className="relative z-10">
|
||||
<div className="max-w-4xl">
|
||||
<Badge variant="accent" className="mb-4 md:mb-6 shadow-sm flex inline-flex items-center gap-2">
|
||||
<MapPin className="w-4 h-4" />
|
||||
{standort.type === 'hq' ? (safeLocale === 'de' ? 'Hauptsitz' : 'Headquarters') : (safeLocale === 'de' ? 'Niederlassung' : 'Branch')}
|
||||
</Badge>
|
||||
<Heading level={1} variant="white" className="mb-0 drop-shadow-sm">
|
||||
{standort.name}
|
||||
</Heading>
|
||||
</div>
|
||||
</Container>
|
||||
</section>
|
||||
|
||||
{/* Main Content Area */}
|
||||
<Container className="py-16 md:py-24">
|
||||
{/* Excerpt/Lead paragraph */}
|
||||
<div className="mb-16 max-w-4xl mx-auto">
|
||||
<p className="text-xl md:text-2xl text-text-primary leading-relaxed font-medium border-l-4 border-primary pl-8 py-2 italic">
|
||||
{standort.description[safeLocale]}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Main content in prose style */}
|
||||
<div className="max-w-4xl mx-auto">
|
||||
<div className="prose prose-lg md:prose-xl prose-neutral max-w-none prose-headings:font-heading prose-headings:font-bold prose-headings:tracking-tight prose-h1:text-4xl prose-h2:text-3xl prose-h2:mt-12 prose-h2:border-b prose-h2:border-neutral-200 prose-h2:pb-4 prose-h3:text-2xl prose-p:text-text-secondary prose-p:leading-relaxed prose-a:text-primary prose-a:font-medium prose-a:underline prose-a:underline-offset-4 prose-a:decoration-primary/30 hover:prose-a:decoration-primary prose-strong:text-text-primary prose-strong:font-semibold prose-li:text-text-secondary prose-ul:list-disc prose-ol:list-decimal">
|
||||
|
||||
<img className="rounded-2xl shadow-2xl my-12 max-w-full h-auto border border-neutral-100" src={standort.image} alt={standort.name} />
|
||||
|
||||
<h2 className="mt-16 mb-6 border-b border-neutral-100 pb-4">{safeLocale === 'de' ? 'Unsere Leistungen vor Ort' : 'Our Local Services'}</h2>
|
||||
<ul className="list-none mb-8 space-y-3 text-base md:text-lg text-text-secondary font-medium max-w-3xl">
|
||||
{standort.keyFeatures[safeLocale].map((feature, idx) => (
|
||||
<li key={idx} className="relative pl-6 before:content-[''] before:absolute before:left-0 before:top-[0.6em] before:w-2 before:h-2 before:bg-primary/50 before:rounded-sm">
|
||||
{feature}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
|
||||
<h2 className="mt-16 mb-6 border-b border-neutral-100 pb-4">{safeLocale === 'de' ? 'Kontakt & Anfahrt' : 'Contact & Directions'}</h2>
|
||||
|
||||
<p className="text-base md:text-lg text-text-secondary leading-[1.8] mb-6 font-medium max-w-3xl">
|
||||
<strong>{safeLocale === 'de' ? 'Adresse:' : 'Address:'}</strong><br/>
|
||||
{standort.address.street}<br/>
|
||||
{standort.address.city}
|
||||
</p>
|
||||
<p className="text-base md:text-lg text-text-secondary leading-[1.8] mb-6 font-medium max-w-3xl">
|
||||
<TrackedLink
|
||||
href={`https://maps.google.com/?q=${encodeURIComponent(`${standort.address.street}, ${standort.address.city}`)}`}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="text-primary hover:text-primary-dark underline decoration-primary/30 underline-offset-4 hover:decoration-primary transition-all font-bold flex items-center gap-1 w-fit"
|
||||
eventProperties={{ location: 'standort_maps_link', slug: standort.id }}
|
||||
>
|
||||
{safeLocale === 'de' ? 'In Google Maps öffnen' : 'Open in Google Maps'}
|
||||
<ArrowUpRight className="w-4 h-4 inline-block" />
|
||||
</TrackedLink>
|
||||
</p>
|
||||
|
||||
<p className="text-base md:text-lg text-text-secondary leading-[1.8] mb-6 font-medium max-w-3xl">
|
||||
<strong>{safeLocale === 'de' ? 'Telefon:' : 'Phone:'}</strong><br/>
|
||||
<a href={`tel:${standort.contact.phone.replace(/[^0-9+]/g, '')}`} className="text-primary hover:text-primary-dark underline decoration-primary/30 underline-offset-4 hover:decoration-primary transition-all font-bold">
|
||||
{standort.contact.phone}
|
||||
</a>
|
||||
</p>
|
||||
|
||||
<p className="text-base md:text-lg text-text-secondary leading-[1.8] mb-6 font-medium max-w-3xl">
|
||||
<strong>Email:</strong><br/>
|
||||
<a href={`mailto:${standort.contact.email}`} className="text-primary hover:text-primary-dark underline decoration-primary/30 underline-offset-4 hover:decoration-primary transition-all font-bold">
|
||||
{standort.contact.email}
|
||||
</a>
|
||||
</p>
|
||||
|
||||
{standort.gallery && standort.gallery.length > 0 && (
|
||||
<>
|
||||
<h2 className="mt-16 mb-6 border-b border-neutral-100 pb-4">Galerie</h2>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-6 not-prose my-12">
|
||||
{standort.gallery.map((img, idx) => (
|
||||
<div key={idx} className="relative aspect-[4/3] rounded-2xl overflow-hidden bg-neutral-100 border border-neutral-200 shadow-md hover:shadow-xl transition-shadow duration-300">
|
||||
<Image src={img} alt={`Gallery ${idx + 1}`} fill className="object-cover transition-transform duration-700 hover:scale-105" />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</Container>
|
||||
|
||||
{/* Support Section */}
|
||||
<Container>
|
||||
<div className="mt-8 mb-8 md:mt-24 md:mb-24 p-8 md:p-12 bg-primary-dark rounded-3xl text-white shadow-2xl relative overflow-hidden group animate-slight-fade-in-from-bottom">
|
||||
<div className="absolute top-0 right-0 w-64 h-full bg-accent/5 -skew-x-12 translate-x-1/2 transition-transform group-hover:translate-x-1/3" />
|
||||
<div className="relative z-10 max-w-2xl">
|
||||
<h3 className="text-2xl md:text-3xl font-bold mb-4">{t('needHelp')}</h3>
|
||||
<p className="text-lg text-white/70 mb-8">{t('supportTeamAvailable')}</p>
|
||||
<TrackedLink
|
||||
href={`/${locale}/${locale === 'de' ? 'kontakt' : 'contact'}`}
|
||||
className={getButtonClasses('accent', 'lg')}
|
||||
eventProperties={{
|
||||
location: 'standort_page_support_cta',
|
||||
page_slug: slug,
|
||||
}}
|
||||
>
|
||||
<span className="relative z-10 flex items-center justify-center gap-2 transition-colors duration-500 group-hover/btn:text-primary-dark">
|
||||
{t('contactUs')}
|
||||
<span className="ml-2 transition-transform group-hover/btn:translate-x-1">
|
||||
→
|
||||
</span>
|
||||
</span>
|
||||
<ButtonOverlay variant="accent" />
|
||||
</TrackedLink>
|
||||
</div>
|
||||
</div>
|
||||
</Container>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
142
app/[locale]/standorte/page.tsx
Normal file
142
app/[locale]/standorte/page.tsx
Normal file
@@ -0,0 +1,142 @@
|
||||
import { Container } from '@/components/ui';
|
||||
import { getTranslations, setRequestLocale } from 'next-intl/server';
|
||||
import { Metadata } from 'next';
|
||||
import TrackedLink from '@/components/analytics/TrackedLink';
|
||||
import { MapPin, Navigation, ArrowUpRight } from 'lucide-react';
|
||||
import { getButtonClasses, ButtonOverlay } from '@/components/ui/Button';
|
||||
import { SITE_URL } from '@/lib/schema';
|
||||
import Image from 'next/image';
|
||||
import { standorteData, standorteLocations } from '@/lib/standorte-data';
|
||||
import { InteractiveGermanyMap } from '@/components/blocks/InteractiveGermanyMap';
|
||||
|
||||
interface PageProps {
|
||||
params: Promise<{
|
||||
locale: string;
|
||||
}>;
|
||||
}
|
||||
|
||||
export async function generateMetadata({ params }: { params: Promise<{ locale: string }> }): Promise<Metadata> {
|
||||
const { locale } = await params;
|
||||
const safeLocale = locale === 'de' ? 'de' : 'en';
|
||||
|
||||
return {
|
||||
title: safeLocale === 'en' ? 'Our Locations' : 'Unsere Standorte',
|
||||
description: safeLocale === 'en' ? 'Our operational locations across Germany.' : 'Unsere operativen Standorte deutschlandweit.',
|
||||
alternates: {
|
||||
canonical: `${SITE_URL}/${safeLocale}/standorte`,
|
||||
languages: {
|
||||
de: `${SITE_URL}/de/standorte`,
|
||||
en: `${SITE_URL}/en/standorte`,
|
||||
'x-default': `${SITE_URL}/en/standorte`,
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export default async function StandorteOverview(props: { params: Promise<{ locale: string }> }) {
|
||||
const { locale } = await props.params;
|
||||
const safeLocale = locale === 'de' ? 'de' : 'en';
|
||||
setRequestLocale(safeLocale);
|
||||
const t = await getTranslations('StandardPage');
|
||||
|
||||
return (
|
||||
<div className="flex flex-col min-h-screen bg-neutral-50 pb-16 md:pb-24">
|
||||
{/* Map Hero Section */}
|
||||
<InteractiveGermanyMap
|
||||
isHero={true}
|
||||
locations={standorteLocations}
|
||||
badge={safeLocale === 'en' ? 'Our Locations' : 'Unsere Standorte'}
|
||||
title={safeLocale === 'en' ? 'Nationwide operational for you.' : 'Deutschlandweit für Sie im Einsatz.'}
|
||||
description={safeLocale === 'en'
|
||||
? 'From our strategic locations in Guben, Kirchheilingen and Bülstedt, we control and implement complex infrastructure projects nationwide.'
|
||||
: 'Von unseren strategischen Standorten in Guben, Kirchheilingen und Bülstedt steuern und realisieren wir komplexe Infrastrukturprojekte im gesamten Bundesgebiet.'}
|
||||
/>
|
||||
|
||||
{/* Main Content Area */}
|
||||
<Container className="relative z-20 mt-16 md:mt-24">
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6 md:gap-8">
|
||||
{standorteData.map((standort) => (
|
||||
<TrackedLink
|
||||
key={standort.id}
|
||||
href={`/${safeLocale}/standorte/${standort.id}`}
|
||||
eventProperties={{ location: 'standorte_list_card', slug: standort.id }}
|
||||
className="group flex flex-col bg-white border border-neutral-100 rounded-3xl overflow-hidden shadow-sm hover:shadow-2xl transition-all duration-300 transform hover:-translate-y-1 cursor-pointer block"
|
||||
>
|
||||
<div className="flex flex-col h-full relative">
|
||||
{/* Image Section */}
|
||||
<div className="relative h-64 w-full bg-[#050B14] overflow-hidden shrink-0 border-b border-neutral-100">
|
||||
<Image
|
||||
src={standort.image}
|
||||
alt={standort.name}
|
||||
fill
|
||||
className="object-cover transition-transform duration-1000 ease-[cubic-bezier(0.16,1,0.3,1)] filter grayscale-[20%] group-hover:scale-105 group-hover:grayscale-0"
|
||||
/>
|
||||
<div className="absolute inset-0 bg-black/10 transition-colors duration-500" />
|
||||
|
||||
{/* Location Badge */}
|
||||
<div className="absolute top-4 left-4 z-10">
|
||||
<span className="bg-white/95 backdrop-blur-md text-neutral-dark px-3 py-1.5 rounded-sm text-[10px] font-bold uppercase tracking-widest flex items-center gap-1.5 shadow-sm border border-neutral-200">
|
||||
<MapPin className="w-3 h-3 text-primary" />
|
||||
{standort.address.city}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Content Section */}
|
||||
<div className="flex flex-col flex-grow p-6 md:p-8 bg-white">
|
||||
<div className="flex items-start justify-between gap-4 mb-4">
|
||||
<h3 className="text-xl md:text-2xl font-bold font-heading text-neutral-dark transition-colors leading-[1.2] group-hover:text-primary">
|
||||
{standort.name}
|
||||
</h3>
|
||||
<ArrowUpRight className="w-5 h-5 text-neutral-400 group-hover:text-primary transition-colors shrink-0" />
|
||||
</div>
|
||||
|
||||
<p className="text-neutral-500 text-sm leading-relaxed mb-8 line-clamp-3">
|
||||
{standort.description[safeLocale]}
|
||||
</p>
|
||||
|
||||
{/* Meta Data */}
|
||||
<div className="flex flex-col gap-3 mt-auto border-t border-neutral-100 pt-6 relative z-10">
|
||||
<div className="min-w-0">
|
||||
<span className="block text-[10px] uppercase tracking-widest text-neutral-400 font-bold mb-1">
|
||||
{safeLocale === 'de' ? 'Adresse' : 'Address'}
|
||||
</span>
|
||||
<span className="flex items-start gap-2 text-sm font-semibold text-neutral-700 leading-tight">
|
||||
<Navigation className="w-3.5 h-3.5 text-primary shrink-0 mt-[2px]" />
|
||||
<span className="line-clamp-2">{standort.address.street}, {standort.address.city}</span>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</TrackedLink>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Support Section */}
|
||||
<div className="mt-12 md:mt-24 p-8 md:p-12 bg-primary-dark rounded-3xl text-white shadow-2xl relative overflow-hidden group">
|
||||
<div className="absolute top-0 right-0 w-64 h-full bg-accent/5 -skew-x-12 translate-x-1/2 transition-transform group-hover:translate-x-1/3" />
|
||||
<div className="relative z-10 max-w-2xl">
|
||||
<h3 className="text-2xl md:text-3xl font-bold mb-4">{t('nextProjectTitle')}</h3>
|
||||
<p className="text-lg text-white/70 mb-8">{t('nextProjectDesc')}</p>
|
||||
<TrackedLink
|
||||
href={`/${safeLocale}/${safeLocale === 'de' ? 'kontakt' : 'contact'}`}
|
||||
className={getButtonClasses('accent', 'lg')}
|
||||
eventProperties={{
|
||||
location: 'standorte_support_cta',
|
||||
}}
|
||||
>
|
||||
<span className="relative z-10 flex items-center justify-center gap-2 transition-colors duration-500 group-hover/btn:text-primary-dark">
|
||||
{t('contactUs')}
|
||||
<span className="ml-2 transition-transform group-hover/btn:translate-x-1">
|
||||
→
|
||||
</span>
|
||||
</span>
|
||||
<ButtonOverlay variant="accent" />
|
||||
</TrackedLink>
|
||||
</div>
|
||||
</div>
|
||||
</Container>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,102 +0,0 @@
|
||||
'use server';
|
||||
|
||||
import { getServerAppServices } from '@/lib/services/create-services.server';
|
||||
|
||||
export async function requestBrochureAction(formData: FormData) {
|
||||
const services = getServerAppServices();
|
||||
const logger = services.logger.child({ action: 'requestBrochureAction' });
|
||||
|
||||
const { headers } = await import('next/headers');
|
||||
const requestHeaders = await headers();
|
||||
|
||||
if ('setServerContext' in services.analytics) {
|
||||
(services.analytics as any).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,
|
||||
});
|
||||
}
|
||||
|
||||
services.analytics.track('brochure-request-attempt');
|
||||
|
||||
const email = formData.get('email') as string;
|
||||
const locale = (formData.get('locale') as string) || 'en';
|
||||
|
||||
// Anti-spam Honeypot Check
|
||||
const honeypot = formData.get('company_website') as string;
|
||||
if (honeypot) {
|
||||
logger.warn('Spam detected via honeypot in brochure request', { email });
|
||||
// Silently succeed to fool the bot without doing actual work
|
||||
return { success: true };
|
||||
}
|
||||
|
||||
if (!email) {
|
||||
logger.warn('Missing email in brochure request');
|
||||
return { success: false, error: 'Missing email address' };
|
||||
}
|
||||
|
||||
// Basic email validation
|
||||
if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email)) {
|
||||
return { success: false, error: 'Invalid email address' };
|
||||
}
|
||||
|
||||
// 1. Save to CMS - Removed along with Payload CMS
|
||||
|
||||
// 2. Notify via Gotify
|
||||
try {
|
||||
await services.notifications.notify({
|
||||
title: '📑 Brochure Download Request',
|
||||
message: `New brochure download request from ${email} (${locale})`,
|
||||
priority: 3,
|
||||
});
|
||||
} catch (error) {
|
||||
logger.error('Failed to send notification', { error });
|
||||
}
|
||||
|
||||
// 3. Send Brochure via Email
|
||||
const brochureUrl = `https://e-tib.com/brochure/etib-product-catalog-${locale}.pdf`;
|
||||
|
||||
try {
|
||||
const { sendEmail } = await import('@/lib/mail/mailer');
|
||||
const { render } = await import('@mintel/mail');
|
||||
const React = await import('react');
|
||||
const { BrochureDeliveryEmail } = await import('@/components/emails/BrochureDeliveryEmail');
|
||||
|
||||
const html = await render(
|
||||
React.createElement(BrochureDeliveryEmail, {
|
||||
_email: email,
|
||||
brochureUrl,
|
||||
locale: locale as 'en' | 'de',
|
||||
}),
|
||||
);
|
||||
|
||||
const emailResult = await sendEmail({
|
||||
to: email,
|
||||
subject: locale === 'de' ? 'Ihr E-TIB Kabelkatalog' : 'Your E-TIB Cable Catalog',
|
||||
html,
|
||||
});
|
||||
|
||||
if (emailResult.success) {
|
||||
logger.info('Brochure email sent successfully', { email });
|
||||
} else {
|
||||
logger.error('Failed to send brochure email', { error: emailResult.error, email });
|
||||
services.errors.captureException(new Error(`Brochure email failed: ${emailResult.error}`), {
|
||||
action: 'requestBrochureAction_email',
|
||||
email,
|
||||
});
|
||||
return { success: false, error: 'Failed to send email. Please try again later.' };
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error('Exception while sending brochure email', { error });
|
||||
return { success: false, error: 'Failed to send email. Please try again later.' };
|
||||
}
|
||||
|
||||
// 4. Track success
|
||||
services.analytics.track('brochure-request-success', {
|
||||
locale,
|
||||
delivery_method: 'email',
|
||||
});
|
||||
|
||||
return { success: true };
|
||||
}
|
||||
@@ -99,7 +99,7 @@ export async function sendContactFormAction(formData: FormData) {
|
||||
const confirmationHtml = await render(
|
||||
React.createElement(ConfirmationMessage, {
|
||||
name,
|
||||
clientName: 'KLZ Cables',
|
||||
clientName: 'E-TIB GmbH',
|
||||
// brandColor: '#82ed20', // Optional: could be KLZ specific
|
||||
}),
|
||||
);
|
||||
|
||||
36
app/actions/getAnnotatorAssets.ts
Normal file
36
app/actions/getAnnotatorAssets.ts
Normal file
@@ -0,0 +1,36 @@
|
||||
'use server';
|
||||
import fs from 'fs';
|
||||
import path from 'path';
|
||||
|
||||
export async function getAnnotatorAssets() {
|
||||
// Try /app/public first (docker), then process.cwd()/public
|
||||
let publicDir = '/app/public';
|
||||
if (!fs.existsSync(publicDir)) {
|
||||
publicDir = path.join(process.cwd(), 'public');
|
||||
}
|
||||
|
||||
const walk = async (dir: string): Promise<string[]> => {
|
||||
let results: string[] = [];
|
||||
if (!fs.existsSync(dir)) return results;
|
||||
|
||||
const list = await fs.promises.readdir(dir);
|
||||
for (const file of list) {
|
||||
const filePath = path.join(dir, file);
|
||||
const stat = await fs.promises.stat(filePath);
|
||||
if (stat && stat.isDirectory()) {
|
||||
const nested = await walk(filePath);
|
||||
results = results.concat(nested);
|
||||
} else {
|
||||
if (filePath.match(/\.(png|jpg|jpeg|webp|mp4|svg)$/i)) {
|
||||
results.push(filePath.replace(publicDir, ''));
|
||||
}
|
||||
}
|
||||
}
|
||||
return results;
|
||||
};
|
||||
|
||||
const results = await walk(publicDir);
|
||||
|
||||
// Console logs removed for absolute silence & speed
|
||||
return results;
|
||||
}
|
||||
52
app/actions/submitAnnotations.ts
Normal file
52
app/actions/submitAnnotations.ts
Normal file
@@ -0,0 +1,52 @@
|
||||
'use server';
|
||||
|
||||
import { sendEmail } from '@/lib/mail/mailer';
|
||||
import { getServerAppServices } from '@/lib/services/create-services.server';
|
||||
|
||||
export async function submitAnnotations(annotations: any[]) {
|
||||
const services = getServerAppServices();
|
||||
const logger = services.logger.child({ action: 'submitAnnotations' });
|
||||
|
||||
if (!annotations || annotations.length === 0) {
|
||||
return { success: false, error: 'Keine Korrekturen vorhanden.' };
|
||||
}
|
||||
|
||||
try {
|
||||
const jsonContent = JSON.stringify(annotations, null, 2);
|
||||
const htmlContent = `
|
||||
<h2>Neue Website-Korrekturen (${annotations.length})</h2>
|
||||
<p>Es wurden neue Korrekturen über den Annotator eingereicht.</p>
|
||||
<ul>
|
||||
${annotations.map(ann => `
|
||||
<li>
|
||||
<b>${new URL(ann.url).pathname}</b> [${ann.type}]<br />
|
||||
Element: <code>${ann.selector}</code><br />
|
||||
<i>${ann.type === 'image' ? 'Neues Bild: ' : 'Text: '}${ann.newContent}</i>
|
||||
</li>
|
||||
`).join('')}
|
||||
</ul>
|
||||
<p><i>Die genauen Daten (inkl. Scroll-Positionen, Viewport und Zeitstempel) befinden sich hier:</i></p>
|
||||
<pre style="background: #f4f4f4; padding: 10px; border-radius: 5px; font-size: 12px; overflow-x: auto;">
|
||||
${jsonContent}
|
||||
</pre>
|
||||
`;
|
||||
|
||||
const notificationResult = await sendEmail({
|
||||
replyTo: 'info@e-tib.com', // Falls Rückfragen nötig
|
||||
subject: `[Korrekturen] ${annotations.length} neue Anmerkungen für E-TIB`,
|
||||
html: htmlContent
|
||||
});
|
||||
|
||||
if (!notificationResult.success) {
|
||||
logger.error('Annotation email FAILED', { error: notificationResult.error });
|
||||
return { success: false, error: notificationResult.error };
|
||||
}
|
||||
|
||||
logger.info('Annotation email sent successfully', { count: annotations.length });
|
||||
return { success: true };
|
||||
} catch (error) {
|
||||
const errorMsg = error instanceof Error ? error.message : String(error);
|
||||
logger.error('Failed to send annotations', { error: errorMsg });
|
||||
return { success: false, error: errorMsg };
|
||||
}
|
||||
}
|
||||
@@ -10,11 +10,13 @@
|
||||
}
|
||||
|
||||
.hide-scrollbar {
|
||||
-ms-overflow-style: none; /* IE and Edge */
|
||||
scrollbar-width: none; /* Firefox */
|
||||
-ms-overflow-style: none !important; /* IE and Edge */
|
||||
scrollbar-width: none !important; /* Firefox */
|
||||
}
|
||||
.hide-scrollbar::-webkit-scrollbar {
|
||||
display: none; /* Chrome, Safari and Opera */
|
||||
display: none !important; /* Chrome, Safari and Opera */
|
||||
width: 0 !important;
|
||||
height: 0 !important;
|
||||
}
|
||||
|
||||
@keyframes bg-pulse-15 {
|
||||
|
||||
@@ -40,7 +40,10 @@ export async function POST(request: NextRequest) {
|
||||
website: websiteId,
|
||||
};
|
||||
|
||||
const umamiEndpoint = config.analytics.umami.apiEndpoint;
|
||||
let umamiEndpoint = config.analytics.umami.apiEndpoint || 'https://analytics.infra.mintel.me';
|
||||
if (!umamiEndpoint.startsWith('http')) {
|
||||
umamiEndpoint = `https://${umamiEndpoint}`;
|
||||
}
|
||||
|
||||
// Log the event (internal only)
|
||||
logger.debug('Forwarding analytics event', {
|
||||
|
||||
@@ -1,7 +0,0 @@
|
||||
export default function TestPage() {
|
||||
return (
|
||||
<div>
|
||||
<h1>TEST PAGE WORKS</h1>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
31
components/AnnotatorClientWrapper.tsx
Normal file
31
components/AnnotatorClientWrapper.tsx
Normal file
@@ -0,0 +1,31 @@
|
||||
'use client';
|
||||
|
||||
import dynamic from 'next/dynamic';
|
||||
import { useEffect, useState } from 'react';
|
||||
import { getAnnotatorAssets } from '@/app/actions/getAnnotatorAssets';
|
||||
import { submitAnnotations } from '@/app/actions/submitAnnotations';
|
||||
|
||||
const Annotator = dynamic(() => import('@/components/annotator-local/Annotator').then(mod => mod.Annotator), { ssr: false });
|
||||
|
||||
export default function AnnotatorClientWrapper() {
|
||||
const [assets, setAssets] = useState<string[]>([]);
|
||||
|
||||
useEffect(() => {
|
||||
getAnnotatorAssets()
|
||||
.then(fetchedAssets => {
|
||||
if (Array.isArray(fetchedAssets)) {
|
||||
setAssets(fetchedAssets);
|
||||
}
|
||||
})
|
||||
.catch(err => console.error('[AnnotatorClientWrapper] Fetch failed:', err));
|
||||
}, []);
|
||||
|
||||
const handleSubmit = async (annotations: any[]) => {
|
||||
const result = await submitAnnotations(annotations);
|
||||
if (!result.success) {
|
||||
throw new Error(result.error);
|
||||
}
|
||||
};
|
||||
|
||||
return <Annotator assets={assets} onSubmit={handleSubmit} />;
|
||||
}
|
||||
@@ -13,11 +13,13 @@ const LeafletMap = dynamic(() => import('@/components/LeafletMap'), {
|
||||
});
|
||||
|
||||
interface ContactMapProps {
|
||||
address: string;
|
||||
lat: number;
|
||||
lng: number;
|
||||
address?: string;
|
||||
lat?: number;
|
||||
lng?: number;
|
||||
locations?: Array<{name: string, address: string, lat: number, lng: number}>;
|
||||
}
|
||||
|
||||
export default function ContactMap({ address, lat, lng }: ContactMapProps) {
|
||||
return <LeafletMap locations={[{ name: 'E-TIB Gruppe', address, lat, lng }]} />;
|
||||
export default function ContactMap({ address, lat, lng, locations }: ContactMapProps) {
|
||||
const mapLocations = locations || (address && lat && lng ? [{ name: 'E-TIB Gruppe', address, lat, lng }] : []);
|
||||
return <LeafletMap locations={mapLocations} />;
|
||||
}
|
||||
|
||||
@@ -36,7 +36,7 @@ export default function LeafletMap({ locations }: LeafletMapProps) {
|
||||
const firstLoc = locations[0];
|
||||
const map = L.map(mapRef.current, {
|
||||
center: [firstLoc.lat, firstLoc.lng],
|
||||
zoom: 6, // Zoom out to see both or more
|
||||
zoom: locations.length === 1 ? 16 : 6,
|
||||
scrollWheelZoom: false,
|
||||
});
|
||||
|
||||
|
||||
@@ -5,6 +5,7 @@ interface OGImageTemplateProps {
|
||||
description?: string;
|
||||
label?: string;
|
||||
image?: string;
|
||||
logo?: string;
|
||||
mode?: 'dark' | 'light' | 'image';
|
||||
}
|
||||
|
||||
@@ -13,11 +14,11 @@ export function OGImageTemplate({
|
||||
description,
|
||||
label,
|
||||
image,
|
||||
logo,
|
||||
mode = 'dark',
|
||||
}: OGImageTemplateProps) {
|
||||
const primaryBlue = '#001a4d';
|
||||
const accentGreen = '#10a379';
|
||||
const saturatedBlue = '#011dff';
|
||||
const backgroundDark = '#1a1a1a';
|
||||
const primaryGreen = '#0e7a5c';
|
||||
|
||||
const containerStyle: React.CSSProperties = {
|
||||
height: '100%',
|
||||
@@ -26,7 +27,7 @@ export function OGImageTemplate({
|
||||
flexDirection: 'column',
|
||||
alignItems: 'flex-start',
|
||||
justifyContent: 'center',
|
||||
backgroundColor: mode === 'light' ? '#ffffff' : primaryBlue,
|
||||
backgroundColor: mode === 'light' ? '#ffffff' : backgroundDark,
|
||||
padding: '80px',
|
||||
position: 'relative',
|
||||
fontFamily: 'Inter',
|
||||
@@ -65,7 +66,7 @@ export function OGImageTemplate({
|
||||
left: 0,
|
||||
right: 0,
|
||||
bottom: 0,
|
||||
background: 'linear-gradient(to right, rgba(0,26,77,0.95), rgba(0,26,77,0.6))',
|
||||
background: 'linear-gradient(135deg, rgba(26,26,26,0.98) 0%, rgba(26,26,26,0.85) 40%, rgba(14,122,92,0.4) 100%)',
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
@@ -75,101 +76,127 @@ export function OGImageTemplate({
|
||||
<div
|
||||
style={{
|
||||
position: 'absolute',
|
||||
top: '-150px',
|
||||
right: '-150px',
|
||||
width: '600px',
|
||||
height: '600px',
|
||||
borderRadius: '300px',
|
||||
backgroundColor: `${accentGreen}15`,
|
||||
top: '-300px',
|
||||
right: '-200px',
|
||||
width: '800px',
|
||||
height: '1200px',
|
||||
backgroundColor: `${primaryGreen}1A`,
|
||||
transform: 'rotate(25deg)',
|
||||
display: 'flex',
|
||||
}}
|
||||
/>
|
||||
|
||||
<div style={{ display: 'flex', flexDirection: 'column', position: 'relative', zIndex: 10 }}>
|
||||
{/* Label / Category */}
|
||||
{label && (
|
||||
<div
|
||||
style={{
|
||||
fontSize: '24px',
|
||||
fontWeight: 700,
|
||||
color: accentGreen,
|
||||
textTransform: 'uppercase',
|
||||
letterSpacing: '0.3em',
|
||||
marginBottom: '32px',
|
||||
display: 'flex',
|
||||
}}
|
||||
>
|
||||
{label}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Title */}
|
||||
<div
|
||||
style={{
|
||||
fontSize: title.length > 40 ? '64px' : '82px',
|
||||
fontWeight: 700,
|
||||
color: 'white',
|
||||
lineHeight: '1.05',
|
||||
maxWidth: '950px',
|
||||
marginBottom: '40px',
|
||||
display: 'flex',
|
||||
letterSpacing: '-0.02em',
|
||||
}}
|
||||
>
|
||||
{title}
|
||||
</div>
|
||||
|
||||
{/* Description */}
|
||||
{description && (
|
||||
<div
|
||||
style={{
|
||||
fontSize: '32px',
|
||||
color: 'rgba(255,255,255,0.7)',
|
||||
maxWidth: '850px',
|
||||
lineHeight: '1.4',
|
||||
display: 'flex',
|
||||
fontWeight: 400,
|
||||
}}
|
||||
>
|
||||
{description.length > 160 ? description.substring(0, 157) + '...' : description}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Brand Footer */}
|
||||
{/* Main Content Wrapper */}
|
||||
<div
|
||||
style={{
|
||||
position: 'absolute',
|
||||
bottom: '80px',
|
||||
left: '80px',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
flexDirection: 'column',
|
||||
justifyContent: 'space-between',
|
||||
height: '100%',
|
||||
width: '100%',
|
||||
position: 'relative',
|
||||
zIndex: 10,
|
||||
}}
|
||||
>
|
||||
{/* Top: Text Content */}
|
||||
<div style={{ display: 'flex', flexDirection: 'column' }}>
|
||||
{/* Label / Category */}
|
||||
{label && (
|
||||
<div
|
||||
style={{
|
||||
fontSize: '24px',
|
||||
fontWeight: 700,
|
||||
color: primaryGreen,
|
||||
textTransform: 'uppercase',
|
||||
letterSpacing: '0.25em',
|
||||
marginBottom: '24px',
|
||||
display: 'flex',
|
||||
}}
|
||||
>
|
||||
{label}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Title */}
|
||||
<div
|
||||
style={{
|
||||
fontSize: '72px',
|
||||
fontWeight: 700,
|
||||
color: 'white',
|
||||
lineHeight: '1.1',
|
||||
maxWidth: '900px',
|
||||
marginBottom: '24px',
|
||||
display: 'flex',
|
||||
letterSpacing: '-0.02em',
|
||||
}}
|
||||
>
|
||||
{title}
|
||||
</div>
|
||||
|
||||
{/* Description */}
|
||||
{description && (
|
||||
<div
|
||||
style={{
|
||||
fontSize: '36px',
|
||||
color: 'rgba(255,255,255,0.85)',
|
||||
maxWidth: '850px',
|
||||
lineHeight: '1.4',
|
||||
display: '-webkit-box',
|
||||
WebkitLineClamp: 3,
|
||||
WebkitBoxOrient: 'vertical',
|
||||
overflow: 'hidden',
|
||||
fontWeight: 400,
|
||||
}}
|
||||
>
|
||||
{description}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Bottom: Brand Footer */}
|
||||
<div
|
||||
style={{
|
||||
width: '80px',
|
||||
height: '6px',
|
||||
backgroundColor: accentGreen,
|
||||
borderRadius: '3px',
|
||||
marginRight: '24px',
|
||||
}}
|
||||
/>
|
||||
<div
|
||||
style={{
|
||||
fontSize: '24px',
|
||||
fontWeight: 700,
|
||||
color: 'white',
|
||||
textTransform: 'uppercase',
|
||||
letterSpacing: '0.15em',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
}}
|
||||
>
|
||||
KLZ Cables
|
||||
{logo ? (
|
||||
// eslint-disable-next-line @next/next/no-img-element
|
||||
<img
|
||||
src={logo}
|
||||
alt="E-TIB GmbH"
|
||||
height="56"
|
||||
style={{ marginRight: '24px', objectFit: 'contain' }}
|
||||
/>
|
||||
) : (
|
||||
<div
|
||||
style={{
|
||||
width: '80px',
|
||||
height: '6px',
|
||||
backgroundColor: primaryGreen,
|
||||
borderRadius: '3px',
|
||||
marginRight: '24px',
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
{!logo && (
|
||||
<div
|
||||
style={{
|
||||
fontSize: '24px',
|
||||
fontWeight: 700,
|
||||
color: 'white',
|
||||
textTransform: 'uppercase',
|
||||
letterSpacing: '0.15em',
|
||||
display: 'flex',
|
||||
}}
|
||||
>
|
||||
E-TIB GmbH
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Saturated Blue Brand Strip */}
|
||||
{/* Primary Green Brand Strip */}
|
||||
<div
|
||||
style={{
|
||||
position: 'absolute',
|
||||
@@ -177,7 +204,7 @@ export function OGImageTemplate({
|
||||
right: 0,
|
||||
width: '12px',
|
||||
height: '100%',
|
||||
backgroundColor: saturatedBlue,
|
||||
backgroundColor: primaryGreen,
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
'use client';
|
||||
|
||||
import React, { useEffect, useRef, useState } from 'react';
|
||||
import React from 'react';
|
||||
import { cn } from '@/components/ui';
|
||||
|
||||
interface RevealProps {
|
||||
@@ -10,40 +10,12 @@ interface RevealProps {
|
||||
delay?: number;
|
||||
}
|
||||
|
||||
export default function Reveal({ children, className, threshold = 0.1, delay = 0 }: RevealProps) {
|
||||
const [isVisible, setIsVisible] = useState(false);
|
||||
const ref = useRef<HTMLDivElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
const observer = new IntersectionObserver(
|
||||
([entry]) => {
|
||||
if (entry.isIntersecting) {
|
||||
setIsVisible(true);
|
||||
observer.unobserve(entry.target);
|
||||
}
|
||||
},
|
||||
{ threshold }
|
||||
);
|
||||
|
||||
const currentRef = ref.current;
|
||||
if (currentRef) {
|
||||
observer.observe(currentRef);
|
||||
}
|
||||
|
||||
return () => {
|
||||
if (currentRef) {
|
||||
observer.unobserve(currentRef);
|
||||
}
|
||||
};
|
||||
}, [threshold]);
|
||||
|
||||
export default function Reveal({ children, className }: RevealProps) {
|
||||
// Reveal animation disabled due to flickering issues on mobile/hydration
|
||||
return (
|
||||
<div
|
||||
ref={ref}
|
||||
className={cn('reveal-on-scroll', isVisible && 'is-visible', className)}
|
||||
style={{ transitionDelay: `${delay}ms` }}
|
||||
>
|
||||
<div className={cn(className)}>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -12,6 +12,8 @@ interface TrackedLinkProps {
|
||||
className?: string;
|
||||
children: React.ReactNode;
|
||||
onClick?: () => void;
|
||||
target?: string;
|
||||
rel?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -25,6 +27,8 @@ export default function TrackedLink({
|
||||
className,
|
||||
children,
|
||||
onClick,
|
||||
target,
|
||||
rel,
|
||||
}: TrackedLinkProps) {
|
||||
const { trackEvent } = useAnalytics();
|
||||
|
||||
@@ -41,7 +45,7 @@ export default function TrackedLink({
|
||||
};
|
||||
|
||||
return (
|
||||
<Link href={href} className={className} onClick={handleClick}>
|
||||
<Link href={href} className={className} onClick={handleClick} target={target} rel={rel}>
|
||||
{children}
|
||||
</Link>
|
||||
);
|
||||
|
||||
627
components/annotator-local/Annotator.tsx
Normal file
627
components/annotator-local/Annotator.tsx
Normal file
@@ -0,0 +1,627 @@
|
||||
import { useState, useEffect, useMemo } from "react";
|
||||
import { motion, AnimatePresence } from "framer-motion";
|
||||
import { MessageSquare, X, Check, Image as ImageIcon, Type, StickyNote, Download, Trash2, List } from "lucide-react";
|
||||
import { clsx, type ClassValue } from "clsx";
|
||||
import { twMerge } from "tailwind-merge";
|
||||
import { finder } from "@medv/finder";
|
||||
|
||||
function cn(...inputs: ClassValue[]) {
|
||||
return twMerge(clsx(inputs));
|
||||
}
|
||||
|
||||
export interface Annotation {
|
||||
id: string;
|
||||
x: number;
|
||||
y: number;
|
||||
selector: string;
|
||||
type: "text" | "image" | "note";
|
||||
originalContent: string;
|
||||
newContent: string;
|
||||
url: string;
|
||||
elementRect: Record<string, number> | null;
|
||||
scrollX: number;
|
||||
scrollY: number;
|
||||
viewportWidth: number;
|
||||
viewportHeight: number;
|
||||
userAgent: string;
|
||||
timestamp: string;
|
||||
}
|
||||
|
||||
export interface AnnotatorProps {
|
||||
assets?: string[]; // Array of image URLs for the media browser
|
||||
onSubmit?: (annotations: Annotation[]) => Promise<void>;
|
||||
}
|
||||
|
||||
export function Annotator({ assets = [], onSubmit }: AnnotatorProps) {
|
||||
const [isActive, setIsActive] = useState(false);
|
||||
const [hoveredElement, setHoveredElement] = useState<HTMLElement | null>(null);
|
||||
const [selectedElement, setSelectedElement] = useState<HTMLElement | null>(null);
|
||||
const [annotations, setAnnotations] = useState<Annotation[]>(() => {
|
||||
if (typeof window !== "undefined") {
|
||||
const saved = localStorage.getItem("mintel-annotations");
|
||||
if (saved) {
|
||||
try {
|
||||
return JSON.parse(saved);
|
||||
} catch (e) {
|
||||
console.error("Failed to parse saved annotations", e);
|
||||
}
|
||||
}
|
||||
}
|
||||
return [];
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (typeof window !== "undefined") {
|
||||
localStorage.setItem("mintel-annotations", JSON.stringify(annotations));
|
||||
}
|
||||
}, [annotations]);
|
||||
|
||||
// Modal state
|
||||
const [currentType, setCurrentType] = useState<"text" | "image" | "note">("text");
|
||||
const [currentText, setCurrentText] = useState("");
|
||||
const [selectedAsset, setSelectedAsset] = useState<string | null>(null);
|
||||
const [showList, setShowList] = useState(false);
|
||||
const [searchTerm, setSearchTerm] = useState("");
|
||||
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||
|
||||
// Disable overlays if needed (e.g. inside iframes)
|
||||
const isExcluded = useMemo(() => {
|
||||
if (typeof window === "undefined") return false;
|
||||
return window.self !== window.top;
|
||||
}, []);
|
||||
|
||||
const getSelector = (el: HTMLElement): string => {
|
||||
try {
|
||||
return finder(el, {
|
||||
root: document.body,
|
||||
className: (name) =>
|
||||
!name.startsWith("annotator-") &&
|
||||
!name.includes("[") &&
|
||||
!name.includes("/") &&
|
||||
!name.match(/^[a-z]-[0-9]/),
|
||||
idName: (name) => !name.startsWith("__next") && !name.includes(":"),
|
||||
});
|
||||
} catch {
|
||||
return "unknown";
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (!isActive) {
|
||||
setHoveredElement(null);
|
||||
return;
|
||||
}
|
||||
|
||||
const handleMouseMove = (e: MouseEvent) => {
|
||||
if (selectedElement) return;
|
||||
const target = e.target as HTMLElement;
|
||||
if (target.closest(".annotator-ui-ignore")) {
|
||||
setHoveredElement(null);
|
||||
return;
|
||||
}
|
||||
setHoveredElement(target);
|
||||
};
|
||||
|
||||
const handleClick = (e: MouseEvent) => {
|
||||
if (selectedElement) return;
|
||||
const target = e.target as HTMLElement;
|
||||
if (target.closest(".annotator-ui-ignore")) return;
|
||||
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
|
||||
setSelectedElement(target);
|
||||
setHoveredElement(null);
|
||||
|
||||
// Auto-detect type
|
||||
if (target.tagName.toLowerCase() === "img") {
|
||||
setCurrentType("image");
|
||||
} else {
|
||||
setCurrentType("text");
|
||||
setCurrentText(target.innerText || "");
|
||||
}
|
||||
};
|
||||
|
||||
window.addEventListener("mousemove", handleMouseMove);
|
||||
window.addEventListener("click", handleClick, true);
|
||||
|
||||
return () => {
|
||||
window.removeEventListener("mousemove", handleMouseMove);
|
||||
window.removeEventListener("click", handleClick, true);
|
||||
};
|
||||
}, [isActive, selectedElement]);
|
||||
|
||||
useEffect(() => {
|
||||
const handleKeyDown = (e: KeyboardEvent) => {
|
||||
if (e.key === "Escape") {
|
||||
if (selectedElement) {
|
||||
setSelectedElement(null);
|
||||
} else if (isActive) {
|
||||
setIsActive(false);
|
||||
}
|
||||
}
|
||||
};
|
||||
window.addEventListener("keydown", handleKeyDown);
|
||||
return () => window.removeEventListener("keydown", handleKeyDown);
|
||||
}, [isActive, selectedElement]);
|
||||
|
||||
const saveAnnotation = () => {
|
||||
if (!selectedElement) return;
|
||||
|
||||
const rect = selectedElement.getBoundingClientRect();
|
||||
const newContent = currentType === "image" ? selectedAsset || "" : currentText;
|
||||
|
||||
if (!newContent) return;
|
||||
|
||||
const annotation: Annotation = {
|
||||
id: Math.random().toString(36).substring(2, 9),
|
||||
x: rect.left + rect.width / 2 + window.scrollX,
|
||||
y: rect.top + rect.height / 2 + window.scrollY,
|
||||
selector: getSelector(selectedElement),
|
||||
type: currentType,
|
||||
originalContent: currentType === "image" ? (selectedElement as HTMLImageElement).src || "" : selectedElement.innerText,
|
||||
newContent,
|
||||
url: window.location.href,
|
||||
elementRect: rect ? {
|
||||
x: rect.x, y: rect.y, width: rect.width, height: rect.height,
|
||||
top: rect.top, right: rect.right, bottom: rect.bottom, left: rect.left
|
||||
} : null,
|
||||
scrollX: window.scrollX,
|
||||
scrollY: window.scrollY,
|
||||
viewportWidth: window.innerWidth,
|
||||
viewportHeight: window.innerHeight,
|
||||
userAgent: navigator.userAgent,
|
||||
timestamp: new Date().toISOString(),
|
||||
};
|
||||
|
||||
setAnnotations([...annotations, annotation]);
|
||||
setSelectedElement(null);
|
||||
setCurrentText("");
|
||||
setSelectedAsset(null);
|
||||
};
|
||||
|
||||
const exportJSON = () => {
|
||||
const dataStr = "data:text/json;charset=utf-8," + encodeURIComponent(JSON.stringify(annotations, null, 2));
|
||||
const downloadAnchorNode = document.createElement("a");
|
||||
downloadAnchorNode.setAttribute("href", dataStr);
|
||||
downloadAnchorNode.setAttribute("download", "corrections.json");
|
||||
document.body.appendChild(downloadAnchorNode);
|
||||
downloadAnchorNode.click();
|
||||
downloadAnchorNode.remove();
|
||||
};
|
||||
|
||||
const handleSubmit = async () => {
|
||||
if (!onSubmit || annotations.length === 0) return;
|
||||
setIsSubmitting(true);
|
||||
try {
|
||||
await onSubmit(annotations);
|
||||
setAnnotations([]);
|
||||
setShowList(false);
|
||||
} catch (error) {
|
||||
console.error("Submission failed", error);
|
||||
alert("Fehler beim Senden der Korrekturen. Bitte JSON Export nutzen.");
|
||||
} finally {
|
||||
setIsSubmitting(false);
|
||||
}
|
||||
};
|
||||
|
||||
const deleteAnnotation = (id: string) => {
|
||||
setAnnotations(annotations.filter((a) => a.id !== id));
|
||||
};
|
||||
|
||||
const [updateTick, setUpdateTick] = useState(0);
|
||||
|
||||
useEffect(() => {
|
||||
if (!isActive && !selectedElement) return;
|
||||
|
||||
let rafId: number;
|
||||
const handleScroll = () => {
|
||||
cancelAnimationFrame(rafId);
|
||||
rafId = requestAnimationFrame(() => setUpdateTick(t => t + 1));
|
||||
};
|
||||
|
||||
// Use capture phase to catch scrolls on any nested container
|
||||
window.addEventListener("scroll", handleScroll, true);
|
||||
window.addEventListener("resize", handleScroll);
|
||||
|
||||
return () => {
|
||||
window.removeEventListener("scroll", handleScroll, true);
|
||||
window.removeEventListener("resize", handleScroll);
|
||||
cancelAnimationFrame(rafId);
|
||||
};
|
||||
}, [isActive, selectedElement]);
|
||||
|
||||
const hoveredRect = useMemo(() => hoveredElement?.getBoundingClientRect(), [hoveredElement, updateTick]);
|
||||
const selectedRect = useMemo(() => selectedElement?.getBoundingClientRect(), [selectedElement, updateTick]);
|
||||
|
||||
if (isExcluded) return null;
|
||||
|
||||
return (
|
||||
<div className="annotator-ui-ignore">
|
||||
{/* 1. Global Toolbar */}
|
||||
<div className="fixed bottom-36 md:bottom-24 right-4 z-[10000] flex flex-col items-end gap-2">
|
||||
<div className="bg-black/80 backdrop-blur-xl border border-white/10 p-1.5 rounded-2xl shadow-2xl flex flex-col items-center gap-1">
|
||||
<button
|
||||
onClick={() => setIsActive(!isActive)}
|
||||
className={cn(
|
||||
"p-2.5 rounded-xl transition-all",
|
||||
isActive
|
||||
? "bg-blue-500 text-white shadow-lg shadow-blue-500/20"
|
||||
: "text-white/70 hover:text-white hover:bg-white/10"
|
||||
)}
|
||||
title={isActive ? "Modus beenden" : "Korrekturen erfassen"}
|
||||
>
|
||||
{isActive ? <X size={20} /> : <MessageSquare size={20} />}
|
||||
</button>
|
||||
|
||||
<div className="w-6 h-px bg-white/10 my-1" />
|
||||
|
||||
<button
|
||||
onClick={() => setShowList(!showList)}
|
||||
className="p-2.5 text-white/70 hover:text-white hover:bg-white/10 rounded-xl relative"
|
||||
title="Liste anzeigen"
|
||||
>
|
||||
<List size={20} />
|
||||
{annotations.length > 0 && (
|
||||
<span className="absolute -top-1 -right-1 w-4 h-4 bg-blue-500 text-[9px] flex items-center justify-center rounded-full text-white font-bold border border-[#1a1a1a]">
|
||||
{annotations.length}
|
||||
</span>
|
||||
)}
|
||||
</button>
|
||||
|
||||
{annotations.length > 0 && (
|
||||
<>
|
||||
<div className="w-6 h-px bg-white/10 my-1" />
|
||||
<button
|
||||
onClick={exportJSON}
|
||||
className="p-2.5 rounded-xl text-white/70 hover:text-white hover:bg-white/10 transition-all"
|
||||
title="JSON Export"
|
||||
>
|
||||
<Download size={20} />
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 2. Highlights */}
|
||||
<AnimatePresence>
|
||||
{isActive && (
|
||||
<div className="fixed inset-0 pointer-events-none z-[9998]">
|
||||
{hoveredRect && (
|
||||
<motion.div
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
exit={{ opacity: 0 }}
|
||||
className="absolute border-2 border-blue-400 bg-blue-400/10 rounded-sm transition-all duration-200"
|
||||
style={{
|
||||
top: hoveredRect.top,
|
||||
left: hoveredRect.left,
|
||||
width: hoveredRect.width,
|
||||
height: hoveredRect.height,
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
|
||||
{selectedRect && (
|
||||
<motion.div
|
||||
initial={{ scale: 0.9, opacity: 0 }}
|
||||
animate={{ scale: 1, opacity: 1 }}
|
||||
className="absolute border-2 border-yellow-400 bg-yellow-400/20 rounded-sm"
|
||||
style={{
|
||||
top: selectedRect.top,
|
||||
left: selectedRect.left,
|
||||
width: selectedRect.width,
|
||||
height: selectedRect.height,
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
|
||||
{/* 3. Action Modal */}
|
||||
<AnimatePresence>
|
||||
{selectedElement && (
|
||||
<div className="fixed inset-0 flex items-center justify-center z-[10000] bg-black/40 backdrop-blur-sm">
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 20, scale: 0.95 }}
|
||||
animate={{ opacity: 1, y: 0, scale: 1 }}
|
||||
exit={{ opacity: 0, y: 20, scale: 0.95 }}
|
||||
className="bg-[#1c1c1e] border border-white/10 rounded-3xl p-4 sm:p-6 w-[95vw] md:w-[800px] shadow-2xl flex flex-col max-h-[85vh]"
|
||||
>
|
||||
<div className="flex items-center justify-between mb-6">
|
||||
<h3 className="text-white font-bold text-lg">Änderung erfassen</h3>
|
||||
<button
|
||||
onClick={() => setSelectedElement(null)}
|
||||
className="text-white/40 hover:text-white"
|
||||
>
|
||||
<X size={20} />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="flex gap-2 mb-6 shrink-0">
|
||||
<button
|
||||
onClick={() => setCurrentType("text")}
|
||||
className={cn(
|
||||
"flex-1 py-3 px-4 rounded-xl text-sm font-medium transition-all flex items-center justify-center gap-2",
|
||||
currentType === "text"
|
||||
? "bg-white text-black shadow-lg"
|
||||
: "bg-white/5 text-white/40 hover:bg-white/10"
|
||||
)}
|
||||
>
|
||||
<Type size={16} /> Text
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setCurrentType("image")}
|
||||
className={cn(
|
||||
"flex-1 py-3 px-4 rounded-xl text-sm font-medium transition-all flex items-center justify-center gap-2",
|
||||
currentType === "image"
|
||||
? "bg-white text-black shadow-lg"
|
||||
: "bg-white/5 text-white/40 hover:bg-white/10"
|
||||
)}
|
||||
>
|
||||
<ImageIcon size={16} /> Bild
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setCurrentType("note")}
|
||||
className={cn(
|
||||
"flex-1 py-3 px-4 rounded-xl text-sm font-medium transition-all flex items-center justify-center gap-2",
|
||||
currentType === "note"
|
||||
? "bg-white text-black shadow-lg"
|
||||
: "bg-white/5 text-white/40 hover:bg-white/10"
|
||||
)}
|
||||
>
|
||||
<StickyNote size={16} /> Notiz
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="flex-1 overflow-y-auto min-h-[200px] mb-6 hide-scrollbar" style={{ scrollbarWidth: 'none', msOverflowStyle: 'none' }}>
|
||||
{currentType === "text" && (
|
||||
<div className="space-y-2">
|
||||
<label className="text-xs font-bold uppercase tracking-wider text-white/40">Neuer Text</label>
|
||||
<textarea
|
||||
autoFocus
|
||||
value={currentText}
|
||||
onChange={(e) => setCurrentText(e.target.value)}
|
||||
className="w-full h-32 bg-black/40 border border-white/10 rounded-2xl p-4 text-white placeholder:text-white/20 focus:outline-none focus:border-blue-500/50 transition-colors resize-none"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{currentType === "note" && (
|
||||
<div className="space-y-2">
|
||||
<label className="text-xs font-bold uppercase tracking-wider text-white/40">Anmerkung</label>
|
||||
<textarea
|
||||
autoFocus
|
||||
value={currentText}
|
||||
onChange={(e) => setCurrentText(e.target.value)}
|
||||
placeholder="Was soll hier gemacht werden?"
|
||||
className="w-full h-32 bg-black/40 border border-white/10 rounded-2xl p-4 text-white placeholder:text-white/20 focus:outline-none focus:border-blue-500/50 transition-colors resize-none"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{currentType === "image" && (
|
||||
<div className="space-y-2 h-full">
|
||||
<label className="text-xs font-bold uppercase tracking-wider text-white/40">Neues Bild wählen</label>
|
||||
{assets.length === 0 ? (
|
||||
<div className="text-sm text-white/40 p-4 bg-white/5 rounded-xl border border-white/5 text-center">
|
||||
Keine Assets übergeben. Bitte das prop <code>assets</code> befüllen.
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<style>{`.hide-scrollbar::-webkit-scrollbar { display: none; }`}</style>
|
||||
<div className="mb-3">
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Suchen nach Dateinamen..."
|
||||
value={searchTerm}
|
||||
onChange={(e) => setSearchTerm(e.target.value)}
|
||||
className="w-full bg-black/40 border border-white/10 rounded-xl px-4 py-3 text-sm text-white placeholder:text-white/40 focus:outline-none focus:border-blue-500/50 transition-colors"
|
||||
/>
|
||||
</div>
|
||||
<div
|
||||
className="grid grid-cols-2 sm:grid-cols-3 md:grid-cols-4 gap-3 h-[400px] overflow-y-auto hide-scrollbar"
|
||||
style={{ scrollbarWidth: 'none', msOverflowStyle: 'none' }}
|
||||
>
|
||||
{assets.filter(asset => asset.toLowerCase().includes(searchTerm.toLowerCase())).map((asset) => {
|
||||
const filename = asset.split('/').pop() || asset;
|
||||
return (
|
||||
<button
|
||||
key={asset}
|
||||
title={asset}
|
||||
onClick={() => setSelectedAsset(asset)}
|
||||
className={cn(
|
||||
"relative flex flex-col items-center bg-black/20 rounded-xl overflow-hidden border-2 transition-all p-2 gap-2 w-full",
|
||||
selectedAsset === asset
|
||||
? "border-blue-500 shadow-[0_0_0_4px_rgba(59,130,246,0.3)] bg-blue-500/10"
|
||||
: "border-transparent hover:border-white/20 hover:bg-white/5"
|
||||
)}
|
||||
style={{ height: '130px' }}
|
||||
>
|
||||
<div className="w-full flex-1 rounded-lg overflow-hidden bg-black/40 flex items-center justify-center relative">
|
||||
{asset.match(/\.(mp4|webm)$/i) ? (
|
||||
<video src={asset} className="w-full h-full object-cover opacity-80" />
|
||||
) : (
|
||||
<img src={asset} loading="lazy" alt={filename} className="w-full h-full object-contain" />
|
||||
)}
|
||||
{selectedAsset === asset && (
|
||||
<div className="absolute inset-0 flex items-center justify-center bg-blue-500/20 backdrop-blur-[2px]">
|
||||
<div className="bg-blue-500 text-white rounded-full p-1.5 shadow-lg">
|
||||
<Check size={18} strokeWidth={3} />
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<span className="text-[11px] font-medium text-white/70 truncate w-full text-center shrink-0" title={filename}>
|
||||
{filename}
|
||||
</span>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<button
|
||||
disabled={(!currentText && currentType !== "image") || (currentType === "image" && !selectedAsset)}
|
||||
onClick={saveAnnotation}
|
||||
className="w-full bg-blue-500 hover:bg-blue-400 disabled:opacity-50 disabled:cursor-not-allowed text-white font-bold py-4 rounded-2xl flex items-center justify-center gap-2 transition-all shadow-lg shadow-blue-500/20 shrink-0"
|
||||
>
|
||||
<Check size={20} />
|
||||
Speichern
|
||||
</button>
|
||||
</motion.div>
|
||||
</div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
|
||||
{/* 4. Annotations List Sidebar */}
|
||||
<AnimatePresence>
|
||||
{showList && (
|
||||
<>
|
||||
<motion.div
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
exit={{ opacity: 0 }}
|
||||
onClick={() => setShowList(false)}
|
||||
className="fixed inset-0 bg-black/60 backdrop-blur-sm z-[10001]"
|
||||
/>
|
||||
<motion.div
|
||||
initial={{ x: "100%" }}
|
||||
animate={{ x: 0 }}
|
||||
exit={{ x: "100%" }}
|
||||
transition={{ type: "spring", damping: 25, stiffness: 200 }}
|
||||
className="fixed top-0 right-0 h-full w-full sm:w-[400px] bg-[#1c1c1e] border-l border-white/10 z-[10002] shadow-2xl flex flex-col"
|
||||
>
|
||||
<div className="p-8 border-b border-white/10 flex items-center justify-between">
|
||||
<div>
|
||||
<h2 className="text-2xl font-bold text-white mb-1">Korrekturen</h2>
|
||||
<p className="text-white/40 text-sm">{annotations.length} erfasst</p>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => setShowList(false)}
|
||||
className="p-2 text-white/40 hover:text-white bg-white/5 rounded-xl transition-colors"
|
||||
>
|
||||
<X size={20} />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="flex-1 overflow-y-auto p-6 space-y-4">
|
||||
{annotations.length === 0 ? (
|
||||
<div className="h-full flex flex-col items-center justify-center text-center px-8 opacity-40">
|
||||
<List size={48} className="mb-4" />
|
||||
<p>Noch keine Korrekturen vorhanden.</p>
|
||||
</div>
|
||||
) : (
|
||||
annotations.map((ann) => (
|
||||
<div
|
||||
key={ann.id}
|
||||
className="bg-white/5 border border-white/5 rounded-2xl p-4 flex flex-col gap-3 group relative"
|
||||
>
|
||||
<button
|
||||
onClick={() => deleteAnnotation(ann.id)}
|
||||
className="absolute top-4 right-4 text-white/20 hover:text-red-400 opacity-0 group-hover:opacity-100 transition-opacity"
|
||||
>
|
||||
<Trash2 size={16} />
|
||||
</button>
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
<span className={cn(
|
||||
"px-2 py-1 rounded-md text-[10px] font-bold uppercase tracking-wider flex items-center gap-1",
|
||||
ann.type === "text" ? "bg-blue-500/20 text-blue-400" :
|
||||
ann.type === "image" ? "bg-purple-500/20 text-purple-400" :
|
||||
"bg-orange-500/20 text-orange-400"
|
||||
)}>
|
||||
{ann.type === "text" && <Type size={12} />}
|
||||
{ann.type === "image" && <ImageIcon size={12} />}
|
||||
{ann.type === "note" && <StickyNote size={12} />}
|
||||
{ann.type}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{ann.type === "image" ? (
|
||||
<div className="flex items-center gap-2">
|
||||
<img src={ann.newContent} alt="New" className="w-16 h-16 object-cover rounded-lg bg-white/10" />
|
||||
<div className="text-xs text-white/60 flex-1 truncate">{ann.newContent}</div>
|
||||
</div>
|
||||
) : (
|
||||
<p className="text-white text-sm whitespace-pre-wrap">{ann.newContent}</p>
|
||||
)}
|
||||
|
||||
<div className="text-[10px] text-white/30 truncate mt-2 font-mono flex items-center gap-2">
|
||||
<span>{ann.selector}</span>
|
||||
{ann.url && <span className="truncate max-w-[100px] text-blue-400" title={ann.url}>{new URL(ann.url).pathname}</span>}
|
||||
</div>
|
||||
<div className="flex flex-wrap items-center gap-2 mt-2 pt-2 border-t border-white/5">
|
||||
{ann.timestamp && (
|
||||
<span className="text-[9px] px-1.5 py-0.5 rounded bg-white/5 text-white/40">
|
||||
{new Date(ann.timestamp).toLocaleString()}
|
||||
</span>
|
||||
)}
|
||||
{ann.viewportWidth && (
|
||||
<span className="text-[9px] px-1.5 py-0.5 rounded bg-white/5 text-white/40">
|
||||
{ann.viewportWidth}x{ann.viewportHeight}
|
||||
</span>
|
||||
)}
|
||||
{ann.scrollX !== undefined && (
|
||||
<span className="text-[9px] px-1.5 py-0.5 rounded bg-white/5 text-white/40">
|
||||
Scroll: {Math.round(ann.scrollX)},{Math.round(ann.scrollY)}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
|
||||
{annotations.length > 0 && (
|
||||
<div className="p-6 border-t border-white/10 flex flex-col gap-3">
|
||||
{onSubmit && (
|
||||
<button
|
||||
onClick={handleSubmit}
|
||||
disabled={isSubmitting}
|
||||
className="w-full bg-blue-500 hover:bg-blue-400 text-white font-bold py-4 rounded-2xl flex items-center justify-center gap-2 transition-all shadow-lg shadow-blue-500/20 disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
>
|
||||
{isSubmitting ? (
|
||||
<div className="w-5 h-5 border-2 border-white/30 border-t-white rounded-full animate-spin" />
|
||||
) : (
|
||||
<Check size={20} />
|
||||
)}
|
||||
{isSubmitting ? "Wird gesendet..." : "Korrekturen an Entwickler senden"}
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
onClick={exportJSON}
|
||||
className={cn(
|
||||
"w-full font-bold py-3 rounded-2xl flex items-center justify-center gap-2 transition-all",
|
||||
onSubmit ? "bg-white/5 text-white/70 hover:bg-white/10 hover:text-white" : "bg-blue-500 hover:bg-blue-400 text-white shadow-lg shadow-blue-500/20 py-4"
|
||||
)}
|
||||
>
|
||||
<Download size={onSubmit ? 18 : 20} />
|
||||
JSON {onSubmit && "lokal "}Exportieren
|
||||
</button>
|
||||
<button
|
||||
onClick={() => {
|
||||
if (confirm("Möchtest du wirklich alle erfassten Korrekturen löschen?")) {
|
||||
setAnnotations([]);
|
||||
}
|
||||
}}
|
||||
className="w-full bg-red-500/10 text-red-500 hover:bg-red-500/20 font-bold py-3 rounded-xl flex items-center justify-center gap-2 transition-all mt-2"
|
||||
>
|
||||
<Trash2 size={18} />
|
||||
Alle löschen
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</motion.div>
|
||||
</>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
1
components/annotator-local/index.ts
Normal file
1
components/annotator-local/index.ts
Normal file
@@ -0,0 +1 @@
|
||||
export { Annotator } from "./Annotator";
|
||||
@@ -30,8 +30,8 @@ const containerVariants: Variants = {
|
||||
};
|
||||
|
||||
const itemVariants: Variants = {
|
||||
hidden: { opacity: 0, y: 30 },
|
||||
visible: { opacity: 1, y: 0, transition: { duration: 0.6, ease: [0.16, 1, 0.3, 1] } },
|
||||
hidden: { opacity: 0, y: 20 },
|
||||
visible: { opacity: 1, y: 0, transition: { duration: 1.0, ease: [0.16, 1, 0.3, 1] } },
|
||||
};
|
||||
|
||||
const Icons = {
|
||||
@@ -75,13 +75,13 @@ const Icons = {
|
||||
|
||||
export function BenefitGrid({ badge, title, description, benefits }: BenefitGridProps) {
|
||||
return (
|
||||
<section className="bg-white py-24 relative overflow-hidden border-t border-neutral-100">
|
||||
<section className="bg-white py-16 md:py-24 lg:py-32 relative overflow-hidden border-t border-neutral-100">
|
||||
<div className="container px-4 max-w-7xl mx-auto relative z-10">
|
||||
|
||||
<div className="max-w-3xl mx-auto text-center mb-16">
|
||||
{badge && <h2 className="text-primary font-bold tracking-wider uppercase text-sm mb-4">{badge}</h2>}
|
||||
{title && (
|
||||
<h3 className="font-heading text-3xl md:text-5xl font-extrabold text-neutral-dark mb-6">
|
||||
<h3 className="font-heading text-2xl md:text-5xl font-extrabold text-neutral-dark mb-6">
|
||||
{title}
|
||||
</h3>
|
||||
)}
|
||||
|
||||
@@ -1,71 +0,0 @@
|
||||
'use client';
|
||||
|
||||
import React from 'react';
|
||||
import { Button } from '@/components/ui/Button';
|
||||
import { motion } from 'framer-motion';
|
||||
|
||||
export interface CallToActionProps {
|
||||
title?: string;
|
||||
description?: string;
|
||||
ctaLabel?: string;
|
||||
ctaHref?: string;
|
||||
theme?: 'light' | 'dark';
|
||||
}
|
||||
|
||||
const containerVariants = {
|
||||
hidden: { opacity: 0 },
|
||||
visible: {
|
||||
opacity: 1,
|
||||
transition: {
|
||||
staggerChildren: 0.15,
|
||||
delayChildren: 0.1,
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
const itemVariants = {
|
||||
hidden: { opacity: 0, y: 20 },
|
||||
visible: {
|
||||
opacity: 1,
|
||||
y: 0,
|
||||
transition: { duration: 0.8, ease: [0.16, 1, 0.3, 1] as const },
|
||||
},
|
||||
};
|
||||
|
||||
export const CallToAction: React.FC<CallToActionProps> = ({
|
||||
title = 'Bereit für Ihr Projekt?',
|
||||
description = 'Wir suchen stets nach neuen Herausforderungen und starken Partnern. Kontaktieren Sie uns für eine unverbindliche Beratung zu Ihrem Vorhaben.',
|
||||
ctaLabel = 'Jetzt Kontakt aufnehmen',
|
||||
ctaHref = '/de/kontakt',
|
||||
theme = 'light'
|
||||
}) => {
|
||||
const isDark = theme === 'dark';
|
||||
|
||||
return (
|
||||
<div className={`py-24 text-center ${isDark ? 'bg-primary-dark text-white' : 'bg-neutral-50 border-t border-neutral-100'}`}>
|
||||
<motion.div
|
||||
className="container max-w-3xl mx-auto px-4"
|
||||
variants={containerVariants}
|
||||
initial="hidden"
|
||||
whileInView="visible"
|
||||
viewport={{ once: true, margin: "-100px" }}
|
||||
>
|
||||
<motion.h2 variants={itemVariants} className={`font-heading text-4xl font-extrabold mb-6 ${isDark ? 'text-white' : 'text-neutral-dark'}`}>
|
||||
{title}
|
||||
</motion.h2>
|
||||
<motion.p variants={itemVariants} className={`text-xl mb-10 leading-relaxed ${isDark ? 'text-white/70' : 'text-text-secondary'}`}>
|
||||
{description}
|
||||
</motion.p>
|
||||
<motion.div variants={itemVariants}>
|
||||
<Button
|
||||
href={ctaHref}
|
||||
size="xl"
|
||||
variant={isDark ? 'white' : 'primary'}
|
||||
>
|
||||
{ctaLabel}
|
||||
</Button>
|
||||
</motion.div>
|
||||
</motion.div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -12,7 +12,7 @@ import { LogoArcs } from '@/components/ui/LogoArcs';
|
||||
interface Certificate {
|
||||
title: string;
|
||||
description: string;
|
||||
pdfUrl: string;
|
||||
pdfUrl?: string;
|
||||
type: 'iso' | 'tax' | 'general';
|
||||
date?: string;
|
||||
}
|
||||
@@ -29,45 +29,46 @@ const defaultCertificates: Certificate[] = [
|
||||
{
|
||||
title: 'ISO 14001:2015',
|
||||
description: 'Umweltmanagementsystem',
|
||||
pdfUrl: '/assets/certificates/231214_Zertifikat ISO 14001 Umweltmanagement.pdf',
|
||||
pdfUrl: '/assets/certificates/iso14001.pdf',
|
||||
type: 'iso',
|
||||
date: '14.12.2023',
|
||||
},
|
||||
{
|
||||
title: 'ISO 9001:2015',
|
||||
description: 'Qualitätsmanagementsystem',
|
||||
pdfUrl: '/assets/certificates/231214_Zertifikat ISO 9001 Qualitätsmanagement.pdf',
|
||||
pdfUrl: '/assets/certificates/iso9001.pdf',
|
||||
type: 'iso',
|
||||
date: '14.12.2023',
|
||||
},
|
||||
{
|
||||
title: 'DIN EN ISO 45001:2018',
|
||||
description: 'Arbeits- und Gesundheitsschutz',
|
||||
pdfUrl: '/assets/certificates/Zertifizierung DIN EN ISO 45001 bis 05122028.pdf',
|
||||
pdfUrl: '/assets/certificates/iso45001.pdf',
|
||||
type: 'iso',
|
||||
date: '05.12.2025', // Assuming valid till
|
||||
date: '05.12.2025',
|
||||
},
|
||||
{
|
||||
title: 'Freistellungsbescheinigung',
|
||||
description: 'Nach § 48 b EStG',
|
||||
pdfUrl: '/assets/certificates/240209_Freistellungsbescheinigung § 48 b.pdf',
|
||||
pdfUrl: '/assets/certificates/freistellung.pdf',
|
||||
type: 'tax',
|
||||
date: '09.02.2024',
|
||||
},
|
||||
{
|
||||
title: 'Nachweis § 13b UStG',
|
||||
description: 'Steuerschuldnerschaft des Leistungsempfängers',
|
||||
pdfUrl: '/assets/certificates/240209_Nachweis § 13 b.pdf',
|
||||
pdfUrl: '/assets/certificates/nachweis13b.pdf',
|
||||
type: 'tax',
|
||||
date: '09.02.2024',
|
||||
},
|
||||
{
|
||||
title: 'Bescheinigung in Steuersachen',
|
||||
description: 'Zertifikat des Finanzamtes',
|
||||
pdfUrl: '/assets/certificates/250213_Bescheinigung in Steuersachen.pdf',
|
||||
pdfUrl: '/assets/certificates/bescheinigung.pdf',
|
||||
type: 'tax',
|
||||
date: '13.02.2025',
|
||||
},
|
||||
|
||||
];
|
||||
|
||||
export function CertificatesBlock({ badge, title, description, certificates = defaultCertificates, hideHeader = false }: CertificatesBlockProps) {
|
||||
@@ -93,17 +94,17 @@ export function CertificatesBlock({ badge, title, description, certificates = de
|
||||
|
||||
const itemVariants = {
|
||||
hidden: { opacity: 0, y: 20 },
|
||||
show: { opacity: 1, y: 0, transition: { duration: 0.5, ease: 'easeOut' } },
|
||||
show: { opacity: 1, y: 0, transition: { duration: 1.0, ease: [0.16, 1, 0.3, 1] } },
|
||||
};
|
||||
|
||||
if (!isMounted) {
|
||||
return (
|
||||
<section className="py-24 bg-neutral-50 relative overflow-hidden">
|
||||
<section className="py-16 md:py-24 lg:py-32 bg-neutral-50 relative overflow-hidden">
|
||||
<Container>
|
||||
{!hideHeader && (
|
||||
<div className="text-center max-w-3xl mx-auto mb-16">
|
||||
<h2 className="text-primary font-bold tracking-wider uppercase text-sm mb-3">{badgeText}</h2>
|
||||
<h3 className="font-heading text-3xl md:text-5xl font-extrabold text-neutral-dark mb-6">{titleText}</h3>
|
||||
<h3 className="font-heading text-2xl md:text-5xl font-extrabold text-neutral-dark mb-6">{titleText}</h3>
|
||||
<p className="text-lg text-neutral-600">{descriptionText}</p>
|
||||
</div>
|
||||
)}
|
||||
@@ -113,7 +114,7 @@ export function CertificatesBlock({ badge, title, description, certificates = de
|
||||
}
|
||||
|
||||
return (
|
||||
<section className="py-24 bg-neutral-50 relative overflow-hidden">
|
||||
<section className="py-12 md:py-24 bg-neutral-50 relative overflow-hidden">
|
||||
{/* Background Accents */}
|
||||
<div className="absolute top-0 right-0 w-1/3 h-full bg-primary/5 -skew-x-12 translate-x-1/2" />
|
||||
<div className="absolute bottom-0 left-0 w-1/4 h-1/2 bg-accent/5 skew-y-12 -translate-x-1/2 blur-3xl rounded-full" />
|
||||
@@ -124,11 +125,11 @@ export function CertificatesBlock({ badge, title, description, certificates = de
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
whileInView={{ opacity: 1, y: 0 }}
|
||||
viewport={{ once: true }}
|
||||
transition={{ duration: 0.5 }}
|
||||
viewport={{ once: true, margin: "-50px" }}
|
||||
transition={{ duration: 1.0, ease: [0.16, 1, 0.3, 1] }}
|
||||
>
|
||||
<h2 className="text-primary font-bold tracking-wider uppercase text-sm mb-3">{badgeText}</h2>
|
||||
<h3 className="font-heading text-3xl md:text-5xl font-extrabold text-neutral-dark mb-6">{titleText}</h3>
|
||||
<h3 className="font-heading text-2xl md:text-5xl font-extrabold text-neutral-dark mb-6">{titleText}</h3>
|
||||
<p className="text-lg text-neutral-600">{descriptionText}</p>
|
||||
</motion.div>
|
||||
</div>
|
||||
@@ -143,15 +144,19 @@ export function CertificatesBlock({ badge, title, description, certificates = de
|
||||
>
|
||||
{certificates.map((cert, index) => {
|
||||
const isIso = cert.type === 'iso';
|
||||
const Wrapper = cert.pdfUrl ? motion.a : motion.div;
|
||||
const wrapperProps = cert.pdfUrl ? {
|
||||
href: encodeURI(cert.pdfUrl),
|
||||
target: "_blank",
|
||||
rel: "noopener noreferrer"
|
||||
} : {};
|
||||
|
||||
return (
|
||||
<motion.a
|
||||
<Wrapper
|
||||
key={index}
|
||||
href={cert.pdfUrl}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
{...wrapperProps as any}
|
||||
variants={itemVariants}
|
||||
className={`group relative rounded-2xl p-8 flex flex-col justify-between overflow-hidden transition-all duration-500 hover:shadow-xl hover:-translate-y-1 border ${
|
||||
className={`group relative rounded-2xl p-6 md:p-8 flex flex-col justify-between overflow-hidden transition-all duration-500 hover:shadow-xl hover:-translate-y-1 border ${
|
||||
isIso
|
||||
? 'bg-neutral-dark text-white border-neutral-800 shadow-lg'
|
||||
: 'bg-white text-neutral-dark border-neutral-200 shadow-md'
|
||||
@@ -196,14 +201,22 @@ export function CertificatesBlock({ badge, title, description, certificates = de
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className={`mt-auto inline-flex items-center text-sm font-bold uppercase tracking-wider group-hover:gap-3 transition-all ${
|
||||
<div className={`mt-auto inline-flex items-center text-sm font-bold uppercase tracking-wider transition-all ${
|
||||
cert.pdfUrl ? 'group-hover:gap-3' : ''
|
||||
} ${
|
||||
isIso ? 'text-primary-light' : 'text-primary'
|
||||
}`}>
|
||||
<span>Download PDF</span>
|
||||
<Download size={16} className="ml-2 group-hover:translate-y-0.5 transition-transform" />
|
||||
{cert.pdfUrl ? (
|
||||
<>
|
||||
<span>Download PDF</span>
|
||||
<Download size={16} className="ml-2 group-hover:translate-y-0.5 transition-transform" />
|
||||
</>
|
||||
) : (
|
||||
<span className="text-neutral-400">Nachweis liegt vor</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</motion.a>
|
||||
</Wrapper>
|
||||
);
|
||||
})}
|
||||
</motion.div>
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
import * as React from 'react';
|
||||
import { motion, useScroll, useTransform } from 'framer-motion';
|
||||
import { Building2, Compass, Layers, ArrowDownToLine, Wrench, Factory, Zap, MapPin, CheckCircle2 } from 'lucide-react';
|
||||
import { useTranslations } from 'next-intl';
|
||||
|
||||
const iconMap: Record<string, React.ElementType> = {
|
||||
Building2,
|
||||
@@ -16,37 +17,6 @@ const iconMap: Record<string, React.ElementType> = {
|
||||
CheckCircle2
|
||||
};
|
||||
|
||||
const defaultMilestones = [
|
||||
{
|
||||
date: '16.12.2015',
|
||||
year: '2015',
|
||||
title: 'Gründung E-TIB GmbH',
|
||||
desc: 'Ausführung elektrischer Infrastrukturprojekte, Kabeltiefbau und Horizontalspülbohrungen.',
|
||||
iconName: 'Building2',
|
||||
},
|
||||
{
|
||||
date: '04.02.2019',
|
||||
year: '2019',
|
||||
title: 'Gründung E-TIB Ingenieurgesellschaft',
|
||||
desc: 'Genehmigungs- und Ausführungsplanung, komplexe Querungen sowie Netzanschlussplanung.',
|
||||
iconName: 'Compass',
|
||||
},
|
||||
{
|
||||
date: '14.11.2019',
|
||||
year: '2019',
|
||||
title: 'Gründung E-TIB Verwaltung GmbH',
|
||||
desc: 'Zentrale Dienste, Erwerb, Vermietung, Verpachtung und Verwaltung von Immobilien und Maschinen.',
|
||||
iconName: 'Layers',
|
||||
},
|
||||
{
|
||||
date: '21.10.2025',
|
||||
year: '2025',
|
||||
title: 'Gründung E-TIB Bohrtechnik GmbH',
|
||||
desc: 'Spezialisierung auf präzise Horizontalspülbohrungen in allen Bodenklassen.',
|
||||
iconName: 'ArrowDownToLine',
|
||||
},
|
||||
];
|
||||
|
||||
interface Milestone {
|
||||
date: string;
|
||||
year: string;
|
||||
@@ -62,10 +32,45 @@ interface CompanyTimelineProps {
|
||||
}
|
||||
|
||||
export function CompanyTimeline({
|
||||
badge = 'Unsere Geschichte',
|
||||
title = 'Meilensteine der Entwicklung',
|
||||
milestones = defaultMilestones
|
||||
badge,
|
||||
title,
|
||||
milestones
|
||||
}: CompanyTimelineProps) {
|
||||
const t = useTranslations('CompanyTimeline');
|
||||
|
||||
const finalBadge = badge || t('badge');
|
||||
const finalTitle = title || t('title');
|
||||
|
||||
const finalMilestones = milestones || [
|
||||
{
|
||||
date: '16.12.2015',
|
||||
year: '2015',
|
||||
title: t('milestones.2015.title'),
|
||||
desc: t('milestones.2015.desc'),
|
||||
iconName: 'Building2',
|
||||
},
|
||||
{
|
||||
date: '04.02.2019',
|
||||
year: '2019',
|
||||
title: t('milestones.2019_ing.title'),
|
||||
desc: t('milestones.2019_ing.desc'),
|
||||
iconName: 'Compass',
|
||||
},
|
||||
{
|
||||
date: '14.11.2019',
|
||||
year: '2019',
|
||||
title: t('milestones.2019_verw.title'),
|
||||
desc: t('milestones.2019_verw.desc'),
|
||||
iconName: 'Layers',
|
||||
},
|
||||
{
|
||||
date: '21.10.2025',
|
||||
year: '2025',
|
||||
title: t('milestones.2025.title'),
|
||||
desc: t('milestones.2025.desc'),
|
||||
iconName: 'ArrowDownToLine',
|
||||
},
|
||||
];
|
||||
const containerRef = React.useRef<HTMLDivElement>(null);
|
||||
const { scrollYProgress } = useScroll({
|
||||
target: containerRef,
|
||||
@@ -75,19 +80,19 @@ export function CompanyTimeline({
|
||||
const lineHeight = useTransform(scrollYProgress, [0, 1], ["0%", "100%"]);
|
||||
|
||||
return (
|
||||
<section className="py-24 md:py-32 bg-[#FAFAFA] relative overflow-hidden">
|
||||
<section className="py-16 md:py-24 lg:py-32 bg-[#FAFAFA] relative overflow-hidden">
|
||||
{/* Background Decor */}
|
||||
<div className="absolute top-0 right-0 w-[600px] h-[600px] bg-primary/5 rounded-full blur-[100px] pointer-events-none -translate-y-1/2 translate-x-1/3" />
|
||||
<div className="absolute bottom-0 left-0 w-[800px] h-[800px] bg-neutral-200/40 rounded-full blur-[120px] pointer-events-none translate-y-1/3 -translate-x-1/3" />
|
||||
|
||||
<div className="container max-w-5xl relative z-10">
|
||||
<div className="text-center mb-24">
|
||||
<div className="text-center mb-12 md:mb-24">
|
||||
<div className="inline-flex items-center gap-2 px-3 py-1.5 rounded-full bg-white border border-neutral-200 text-neutral-dark text-xs font-bold uppercase tracking-wider mb-6 shadow-sm">
|
||||
<span className="w-2 h-2 rounded-full bg-primary animate-pulse" />
|
||||
{badge}
|
||||
{finalBadge}
|
||||
</div>
|
||||
<h3 className="font-heading text-4xl md:text-5xl lg:text-6xl font-extrabold text-neutral-dark tracking-tight">
|
||||
{title}
|
||||
<h3 className="font-heading text-3xl md:text-5xl lg:text-6xl font-extrabold text-neutral-dark tracking-tight">
|
||||
{finalTitle}
|
||||
</h3>
|
||||
</div>
|
||||
|
||||
@@ -104,17 +109,17 @@ export function CompanyTimeline({
|
||||
</div>
|
||||
|
||||
<div className="space-y-16 md:space-y-24">
|
||||
{milestones.map((milestone, i) => {
|
||||
{finalMilestones.map((milestone, i) => {
|
||||
const isEven = i % 2 === 0;
|
||||
const Icon = iconMap[milestone.iconName] || Building2;
|
||||
|
||||
return (
|
||||
<motion.div
|
||||
key={i}
|
||||
initial={{ opacity: 0, y: 40 }}
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
whileInView={{ opacity: 1, y: 0 }}
|
||||
viewport={{ once: true, margin: "-100px" }}
|
||||
transition={{ duration: 0.7, ease: "easeOut" }}
|
||||
viewport={{ once: true, margin: "-50px" }}
|
||||
transition={{ duration: 1.0, ease: [0.16, 1, 0.3, 1] }}
|
||||
className="relative flex flex-col md:flex-row items-center w-full group"
|
||||
>
|
||||
{/* Timeline Dot with Pulse */}
|
||||
|
||||
@@ -62,15 +62,15 @@ export function CompetenceBentoGrid(props: CompetenceBentoGridProps) {
|
||||
// Static fallback for SSR
|
||||
if (!isMounted) {
|
||||
return (
|
||||
<section className="py-24 bg-neutral text-neutral-dark overflow-hidden relative">
|
||||
<section className="py-16 md:py-24 lg:py-32 bg-neutral text-neutral-dark overflow-hidden relative">
|
||||
<Container>
|
||||
<div className="flex flex-col md:flex-row justify-between items-end mb-12">
|
||||
<div className="max-w-2xl">
|
||||
<h2 className="text-primary font-bold tracking-wider uppercase text-sm mb-3">{badge}</h2>
|
||||
<h3 className="font-heading text-4xl md:text-5xl font-extrabold text-neutral-dark mb-4">{title}</h3>
|
||||
<h3 className="font-heading text-3xl md:text-5xl font-extrabold text-neutral-dark mb-4">{title}</h3>
|
||||
</div>
|
||||
</div>
|
||||
<div className="grid grid-cols-1 md:grid-cols-3 lg:grid-cols-4 gap-4 auto-rows-[220px]">
|
||||
<div className="grid grid-cols-1 md:grid-cols-3 lg:grid-cols-4 gap-4 auto-rows-[minmax(220px,auto)]">
|
||||
{/* Simplified static grid */}
|
||||
</div>
|
||||
</Container>
|
||||
@@ -79,20 +79,16 @@ export function CompetenceBentoGrid(props: CompetenceBentoGridProps) {
|
||||
}
|
||||
|
||||
return (
|
||||
<section className="py-24 bg-neutral text-neutral-dark relative overflow-hidden">
|
||||
<section className="py-12 md:py-24 bg-neutral text-neutral-dark relative overflow-hidden">
|
||||
<Container className="relative z-10 px-0 md:px-12 lg:px-16">
|
||||
<div className="flex flex-col md:flex-row justify-between items-end mb-12 px-4 md:px-0">
|
||||
<div className="max-w-2xl">
|
||||
<div className="flex flex-row justify-between items-end mb-12 px-4 md:px-0">
|
||||
<div className="max-w-2xl text-left">
|
||||
<h2 className="text-primary font-bold tracking-wider uppercase text-sm mb-3">{badge}</h2>
|
||||
<h3 className="font-heading text-4xl md:text-5xl font-extrabold text-neutral-dark mb-4">{title}</h3>
|
||||
<h3 className="font-heading text-3xl md:text-5xl font-extrabold text-neutral-dark mb-4">{title}</h3>
|
||||
</div>
|
||||
<Button href={ctaHref} variant="ghost" className="mt-6 md:mt-0 bg-primary/5 hover:bg-primary/10 mx-4 md:mx-0">
|
||||
{ctaLabel}
|
||||
<svg xmlns="http://www.w3.org/2000/svg" className="w-5 h-5 ml-2 transform group-hover:translate-x-1 transition-transform" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.5" strokeLinecap="round" strokeLinejoin="round"><line x1="5" y1="12" x2="19" y2="12"></line><polyline points="12 5 19 12 12 19"></polyline></svg>
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-3 lg:grid-cols-4 gap-4 auto-rows-[220px]">
|
||||
<div className="grid grid-cols-1 md:grid-cols-3 lg:grid-cols-4 gap-4 auto-rows-[minmax(220px,auto)] px-4 md:px-0">
|
||||
{items.map((item, idx) => {
|
||||
const isLarge = item.size === 'large';
|
||||
const isMedium = item.size === 'medium';
|
||||
@@ -111,8 +107,8 @@ export function CompetenceBentoGrid(props: CompetenceBentoGridProps) {
|
||||
key={idx}
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
whileInView={{ opacity: 1, y: 0 }}
|
||||
viewport={{ once: true }}
|
||||
transition={{ delay: idx * 0.1 }}
|
||||
viewport={{ once: true, margin: "-50px" }}
|
||||
transition={{ delay: idx * 0.1, duration: 1.0, ease: [0.16, 1, 0.3, 1] }}
|
||||
className={`${gridClasses} ${item.href ? 'cursor-pointer hover:-translate-y-1 transition-transform duration-300' : ''}`}
|
||||
>
|
||||
{item.href && (
|
||||
@@ -127,8 +123,8 @@ export function CompetenceBentoGrid(props: CompetenceBentoGridProps) {
|
||||
|
||||
<HoverShineOverlay shineColor="via-white/30" />
|
||||
<AnimatedGlossyBorder color="white" className="opacity-0 group-hover:opacity-100 transition-opacity duration-700" borderWidth={1} />
|
||||
<h4 className="font-bold text-xl md:text-2xl relative z-10 leading-snug">{item.title}</h4>
|
||||
{item.description && <p className="text-white/60 text-sm mt-3 relative z-10">{item.description}</p>}
|
||||
<h4 className="font-heading font-extrabold text-2xl md:text-3xl relative z-10 leading-snug">{item.title}</h4>
|
||||
{item.description && <p className="text-white/80 text-base md:text-lg mt-3 relative z-10 font-medium">{item.description}</p>}
|
||||
</motion.div>
|
||||
);
|
||||
}
|
||||
@@ -136,10 +132,10 @@ export function CompetenceBentoGrid(props: CompetenceBentoGridProps) {
|
||||
return (
|
||||
<motion.div
|
||||
key={idx}
|
||||
initial={{ opacity: 0, scale: 0.95 }}
|
||||
whileInView={{ opacity: 1, scale: 1 }}
|
||||
viewport={{ once: true }}
|
||||
transition={{ delay: idx * 0.1 }}
|
||||
initial={{ opacity: 0, y: 20, scale: 0.98 }}
|
||||
whileInView={{ opacity: 1, y: 0, scale: 1 }}
|
||||
viewport={{ once: true, margin: "-50px" }}
|
||||
transition={{ delay: idx * 0.1, duration: 1.0, ease: [0.16, 1, 0.3, 1] }}
|
||||
className={`${gridClasses} ${item.href ? 'cursor-pointer hover:-translate-y-1 transition-transform duration-300' : ''}`}
|
||||
>
|
||||
{item.href && (
|
||||
@@ -159,7 +155,7 @@ export function CompetenceBentoGrid(props: CompetenceBentoGridProps) {
|
||||
sizes={isLarge ? "(max-width: 768px) 100vw, 50vw" : "(max-width: 768px) 100vw, 25vw"}
|
||||
/>
|
||||
</div>
|
||||
<div className="absolute inset-0 bg-gradient-to-t from-black/80 via-black/20 to-transparent opacity-90 transition-opacity duration-500 z-0" />
|
||||
<div className="absolute inset-0 bg-gradient-to-t from-black/90 via-black/60 to-black/30 opacity-100 transition-opacity duration-500 z-0 group-hover:from-black/80 group-hover:via-black/50 group-hover:to-black/20" />
|
||||
</>
|
||||
)}
|
||||
|
||||
@@ -177,11 +173,11 @@ export function CompetenceBentoGrid(props: CompetenceBentoGridProps) {
|
||||
{item.tag}
|
||||
</span>
|
||||
)}
|
||||
<h4 className={`font-heading font-extrabold mb-2 ${isLarge ? 'text-3xl md:text-5xl' : 'text-xl md:text-2xl'}`}>
|
||||
<h4 className="font-heading font-extrabold mb-2 text-2xl md:text-3xl">
|
||||
{item.title}
|
||||
</h4>
|
||||
{item.description && (
|
||||
<p className={`text-white/80 ${isLarge ? 'text-lg md:text-xl max-w-sm' : 'text-sm'}`}>
|
||||
<p className="text-white/90 text-sm md:text-base max-w-md">
|
||||
{item.description}
|
||||
</p>
|
||||
)}
|
||||
|
||||
@@ -41,7 +41,7 @@ export const ContactSection: React.FC<ContactSectionProps> = (props) => {
|
||||
const { showForm, showMap } = props;
|
||||
|
||||
return (
|
||||
<Section className="bg-neutral-light py-12 md:py-28 relative overflow-hidden">
|
||||
<Section className="bg-neutral-light relative overflow-hidden">
|
||||
<Container>
|
||||
<div className="grid grid-cols-1 lg:grid-cols-12 gap-8 md:gap-16">
|
||||
<motion.div
|
||||
@@ -52,7 +52,7 @@ export const ContactSection: React.FC<ContactSectionProps> = (props) => {
|
||||
viewport={{ once: true, margin: "-50px" }}
|
||||
>
|
||||
<motion.h2 variants={itemVariants} className="text-primary font-bold tracking-wider uppercase text-sm mb-3">Direktkontakt</motion.h2>
|
||||
<motion.h3 variants={itemVariants} className="font-heading text-4xl font-extrabold text-neutral-dark mb-6">Wir sind für Sie da.</motion.h3>
|
||||
<motion.h3 variants={itemVariants} className="font-heading text-3xl md:text-4xl font-extrabold text-neutral-dark mb-6">Wir sind für Sie da.</motion.h3>
|
||||
<motion.p variants={itemVariants} className="text-lg text-text-secondary mb-8 leading-relaxed">
|
||||
Haben Sie Fragen zu unseren Leistungen oder möchten Sie ein konkretes Projekt besprechen? Rufen Sie uns an oder schreiben Sie uns eine E-Mail.
|
||||
</motion.p>
|
||||
@@ -129,9 +129,10 @@ export const ContactSection: React.FC<ContactSectionProps> = (props) => {
|
||||
fallback={<div className="h-full w-full bg-neutral-medium animate-pulse" />}
|
||||
>
|
||||
<ContactMap
|
||||
address="Gewerbestraße 22\n03172 Guben"
|
||||
lat={51.9547}
|
||||
lng={14.7214}
|
||||
locations={[
|
||||
{ name: 'E-TIB GmbH (Hauptsitz)', address: 'Gewerbestraße 22\n03172 Guben', lat: 51.9547, lng: 14.7214 },
|
||||
{ name: 'E-TIB Bohrtechnik GmbH', address: 'Bahnhofstraße 180a\n99947 Kirchheilingen', lat: 51.1852, lng: 10.6865 }
|
||||
]}
|
||||
/>
|
||||
</Suspense>
|
||||
</motion.section>
|
||||
|
||||
52
components/blocks/DataGridPulse.tsx
Normal file
52
components/blocks/DataGridPulse.tsx
Normal file
@@ -0,0 +1,52 @@
|
||||
'use client';
|
||||
|
||||
import React from 'react';
|
||||
import { motion } from 'framer-motion';
|
||||
import { useLocale } from 'next-intl';
|
||||
|
||||
export function DataGridPulse() {
|
||||
const locale = useLocale();
|
||||
return (
|
||||
<div className="relative w-full py-12 md:py-24 bg-neutral-50 border border-neutral-100/80 overflow-hidden flex flex-col items-center rounded-3xl mb-16 shadow-sm">
|
||||
|
||||
{/* Blueprint grid background */}
|
||||
<div
|
||||
className="absolute inset-0 opacity-[0.03] pointer-events-none"
|
||||
style={{
|
||||
backgroundImage: 'linear-gradient(#117C61 1px, transparent 1px), linear-gradient(90deg, #117C61 1px, transparent 1px)',
|
||||
backgroundSize: '40px 40px'
|
||||
}}
|
||||
/>
|
||||
|
||||
{/* Pulsing horizontal lines (Data Flow) */}
|
||||
<motion.div
|
||||
animate={{ x: ["-100%", "100%"] }}
|
||||
transition={{ duration: 5, repeat: Infinity, ease: "linear" }}
|
||||
className="absolute top-1/3 left-0 w-full h-[2px] bg-gradient-to-r from-transparent via-primary/30 to-transparent blur-[1px]"
|
||||
/>
|
||||
<motion.div
|
||||
animate={{ x: ["100%", "-100%"] }}
|
||||
transition={{ duration: 7, repeat: Infinity, ease: "linear" }}
|
||||
className="absolute top-2/3 left-0 w-full h-[2px] bg-gradient-to-r from-transparent via-primary/25 to-transparent blur-[1px]"
|
||||
/>
|
||||
|
||||
<div className="relative z-10 w-full max-w-2xl px-6 mx-auto flex flex-col items-center text-center">
|
||||
<span className="text-xs md:text-sm font-bold uppercase tracking-widest text-primary mb-4 bg-primary/10 px-3 py-1 rounded-full">
|
||||
{locale === 'en' ? 'Network Expansion in Numbers' : 'Netzausbau in Zahlen'}
|
||||
</span>
|
||||
|
||||
<div className="bg-white/80 backdrop-blur-md p-8 md:p-12 rounded-2xl border border-neutral-200/50 shadow-lg w-full">
|
||||
<div className="font-mono text-4xl md:text-7xl font-black text-neutral-900 tracking-tighter mb-4 leading-none animate-pulse-slow" style={{ fontVariantNumeric: 'tabular-nums' }}>
|
||||
372.161<span className="text-primary text-2xl md:text-4xl ml-1 font-sans">m</span>
|
||||
</div>
|
||||
<p className="text-text-secondary text-base md:text-lg font-medium max-w-md mx-auto leading-relaxed">
|
||||
{locale === 'en'
|
||||
? 'Cable routes laid since 2023. A massive infrastructure achievement, driven by our own machinery.'
|
||||
: 'Verlegte Kabeltrassen seit 2023. Eine massive Infrastrukturleistung, getragen von unserem eigenen Maschinenpark.'}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
);
|
||||
}
|
||||
64
components/blocks/DeepDrillAnimation.tsx
Normal file
64
components/blocks/DeepDrillAnimation.tsx
Normal file
@@ -0,0 +1,64 @@
|
||||
'use client';
|
||||
|
||||
import React, { useRef } from 'react';
|
||||
import { motion, useScroll, useTransform } from 'framer-motion';
|
||||
import { useLocale } from 'next-intl';
|
||||
|
||||
export function DeepDrillAnimation() {
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
const locale = useLocale();
|
||||
|
||||
const { scrollYProgress } = useScroll({
|
||||
target: containerRef,
|
||||
offset: ["start center", "end center"]
|
||||
});
|
||||
|
||||
const drillDepth = useTransform(scrollYProgress, [0, 1], ["0%", "100%"]);
|
||||
|
||||
return (
|
||||
<div ref={containerRef} className="relative w-full py-12 md:py-32 flex flex-col items-center">
|
||||
|
||||
<div className="absolute inset-0 bg-[#050B14] -mx-[50vw] px-[50vw]" />
|
||||
|
||||
<div className="relative z-10 w-full max-w-3xl mx-auto flex flex-col items-center text-center text-white">
|
||||
|
||||
<h3 className="text-2xl md:text-3xl font-heading font-extrabold mb-8 tracking-tight">
|
||||
{locale === 'en' ? 'Precision in Depth' : 'Präzision in der Tiefe'}
|
||||
</h3>
|
||||
<p className="text-white/60 text-lg mb-16 max-w-xl">
|
||||
{locale === 'en'
|
||||
? 'Horizontal Directional Drilling (HDD) allows us to cross obstacles and rivers with extreme care.'
|
||||
: 'Horizontalspülbohrverfahren (HDD) erlaubt uns, Hindernisse und Flüsse extrem schonend zu unterqueren.'}
|
||||
</p>
|
||||
|
||||
{/* The Drill Path */}
|
||||
<div className="relative w-2 h-96 bg-white/10 rounded-full mb-16">
|
||||
<motion.div
|
||||
className="absolute top-0 left-0 w-full bg-primary rounded-full"
|
||||
style={{ height: drillDepth, boxShadow: '0 0 20px rgba(130,237,32,0.6)' }}
|
||||
>
|
||||
{/* The Drill Head */}
|
||||
<div className="absolute -bottom-2 -left-2 w-6 h-6 bg-white rounded-full border-4 border-primary shadow-[0_0_15px_rgba(255,255,255,1)]" />
|
||||
</motion.div>
|
||||
</div>
|
||||
|
||||
{/* The Reveal Data */}
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
whileInView={{ opacity: 1, y: 0 }}
|
||||
viewport={{ once: true, margin: "-50px" }}
|
||||
transition={{ duration: 0.8 }}
|
||||
className="flex flex-col items-center"
|
||||
>
|
||||
<div className="font-mono text-5xl md:text-8xl font-black text-white tracking-tighter mb-4" style={{ fontVariantNumeric: 'tabular-nums' }}>
|
||||
102.771<span className="text-primary text-2xl md:text-5xl ml-2">m</span>
|
||||
</div>
|
||||
<div className="text-sm font-bold uppercase tracking-widest text-primary">
|
||||
{locale === 'en' ? 'Drilled distance since 2023' : 'Gebohrte Strecke seit 2023'}
|
||||
</div>
|
||||
</motion.div>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -21,7 +21,7 @@ export const FaqBlock: React.FC<FaqBlockProps> = ({
|
||||
if (!questions || questions.length === 0) return null;
|
||||
|
||||
return (
|
||||
<section className="py-12 md:py-16">
|
||||
<section className="py-16 md:py-24 lg:py-32">
|
||||
<div className="max-w-3xl mx-auto">
|
||||
<div className="mb-10 text-center">
|
||||
{subtitle && (
|
||||
@@ -29,7 +29,7 @@ export const FaqBlock: React.FC<FaqBlockProps> = ({
|
||||
{subtitle}
|
||||
</span>
|
||||
)}
|
||||
<h2 className="text-3xl md:text-4xl font-bold text-text-primary">
|
||||
<h2 className="text-2xl md:text-4xl font-bold text-text-primary">
|
||||
{title}
|
||||
</h2>
|
||||
</div>
|
||||
|
||||
111
components/blocks/GrowthChart.tsx
Normal file
111
components/blocks/GrowthChart.tsx
Normal file
@@ -0,0 +1,111 @@
|
||||
'use client';
|
||||
|
||||
import React from 'react';
|
||||
import { motion } from 'framer-motion';
|
||||
import { useTranslations } from 'next-intl';
|
||||
|
||||
export function GrowthChart() {
|
||||
const t = useTranslations('GrowthChart');
|
||||
|
||||
const data = [
|
||||
{ year: '2016', offener: 5000, hdd: 1200, kabel: 16064 },
|
||||
{ year: '2017', offener: 33401, hdd: 8363, kabel: 84675 },
|
||||
{ year: '2018', offener: 30773, hdd: 10225, kabel: 108127 },
|
||||
{ year: '2019', offener: 32445, hdd: 8505, kabel: 87468 },
|
||||
{ year: '2020', offener: 31305, hdd: 9050, kabel: 93556 },
|
||||
{ year: '2021', offener: 38756, hdd: 10957, kabel: 110127 },
|
||||
{ year: '2022', offener: 41445, hdd: 9505, kabel: 102468 },
|
||||
{ year: '2023', offener: 17908, hdd: 29436, kabel: 76727 },
|
||||
{ year: '2024', offener: 30801, hdd: 28680, kabel: 131964 },
|
||||
{ year: '2025', offener: 38166, hdd: 44655, kabel: 163470 },
|
||||
];
|
||||
|
||||
const maxVal = 163470; // Highest value for scaling
|
||||
|
||||
return (
|
||||
<div className="w-full bg-white rounded-3xl p-6 md:p-12 shadow-sm border border-neutral-100 my-8 md:my-16">
|
||||
<div className="mb-10">
|
||||
<h3 className="text-2xl md:text-3xl font-heading font-extrabold text-neutral-dark mb-2">{t('title')}</h3>
|
||||
<p className="text-neutral-500 font-medium">{t('subtitle')}</p>
|
||||
</div>
|
||||
|
||||
<div className="space-y-8">
|
||||
{data.map((item, index) => {
|
||||
const kabelWidth = (item.kabel / maxVal) * 100;
|
||||
const offenerWidth = (item.offener / maxVal) * 100;
|
||||
const hddWidth = (item.hdd / maxVal) * 100;
|
||||
|
||||
return (
|
||||
<div key={item.year} className="relative">
|
||||
<div className="text-sm font-extrabold text-neutral-dark mb-2 w-16">{item.year}</div>
|
||||
<div className="space-y-2">
|
||||
{/* Kabelverlegung Bar */}
|
||||
<div className="flex items-center gap-4">
|
||||
<div className="flex-1 bg-neutral-100 rounded-full h-6 overflow-hidden relative">
|
||||
<motion.div
|
||||
initial={{ width: 0 }}
|
||||
whileInView={{ width: `${kabelWidth}%` }}
|
||||
viewport={{ once: true, margin: "-50px" }}
|
||||
transition={{ duration: 1.5, delay: 0.1 * index, ease: "easeOut" }}
|
||||
className="absolute top-0 left-0 h-full bg-primary rounded-full"
|
||||
/>
|
||||
</div>
|
||||
<div className="w-24 text-right text-xs font-bold text-neutral-500 uppercase">
|
||||
{t('kabel')}: {new Intl.NumberFormat('de-DE').format(item.kabel)}m
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Offener Tiefbau Bar */}
|
||||
<div className="flex items-center gap-4">
|
||||
<div className="flex-1 bg-neutral-100 rounded-full h-5 overflow-hidden relative">
|
||||
<motion.div
|
||||
initial={{ width: 0 }}
|
||||
whileInView={{ width: `${offenerWidth}%` }}
|
||||
viewport={{ once: true, margin: "-50px" }}
|
||||
transition={{ duration: 1.5, delay: 0.15 + 0.1 * index, ease: "easeOut" }}
|
||||
className="absolute top-0 left-0 h-full bg-neutral-300 rounded-full"
|
||||
/>
|
||||
</div>
|
||||
<div className="w-24 text-right text-xs font-bold text-neutral-500 uppercase">
|
||||
{t('offener')}: {new Intl.NumberFormat('de-DE').format(item.offener)}m
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* HDD Bar */}
|
||||
<div className="flex items-center gap-4">
|
||||
<div className="flex-1 bg-neutral-100 rounded-full h-4 overflow-hidden relative">
|
||||
<motion.div
|
||||
initial={{ width: 0 }}
|
||||
whileInView={{ width: `${hddWidth}%` }}
|
||||
viewport={{ once: true, margin: "-50px" }}
|
||||
transition={{ duration: 1.5, delay: 0.2 + 0.1 * index, ease: "easeOut" }}
|
||||
className="absolute top-0 left-0 h-full bg-neutral-dark rounded-full"
|
||||
/>
|
||||
</div>
|
||||
<div className="w-24 text-right text-xs font-bold text-neutral-500 uppercase">
|
||||
{t('hdd')}: {new Intl.NumberFormat('de-DE').format(item.hdd)}m
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
<div className="mt-8 flex flex-wrap gap-6 border-t border-neutral-100 pt-6">
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="w-3 h-3 rounded-full bg-primary" />
|
||||
<span className="text-xs font-bold text-neutral-500 uppercase">{t('kabel')}</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="w-3 h-3 rounded-full bg-neutral-300" />
|
||||
<span className="text-xs font-bold text-neutral-500 uppercase">{t('offener')}</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="w-3 h-3 rounded-full bg-neutral-dark" />
|
||||
<span className="text-xs font-bold text-neutral-500 uppercase">{t('hdd')}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
import React from 'react';
|
||||
import Image from 'next/image';
|
||||
import { usePathname } from 'next/navigation';
|
||||
import { motion } from 'framer-motion';
|
||||
import { Badge, Container, Heading } from '@/components/ui';
|
||||
import { Button } from '@/components/ui/Button';
|
||||
@@ -39,13 +40,15 @@ const itemVariants = {
|
||||
export const HeroSection: React.FC<HeroSectionProps> = (props) => {
|
||||
const { title, badge, subtitle, backgroundImage, alignment, ctaLabel, ctaHref } = props;
|
||||
const bgSrc = backgroundImage?.sizes?.card?.url || backgroundImage?.url;
|
||||
const pathname = usePathname();
|
||||
|
||||
return (
|
||||
<section
|
||||
className={`relative min-h-[45vh] md:min-h-[55vh] flex items-center pt-32 pb-20 md:pt-36 md:pb-24 overflow-hidden bg-neutral-dark ${alignment === 'center' ? 'justify-center text-center' : ''}`}
|
||||
className={`relative min-h-[35vh] md:min-h-[50vh] flex items-center pt-28 pb-10 md:pt-36 md:pb-24 overflow-hidden bg-neutral-dark ${alignment === 'center' ? 'justify-center text-center' : ''}`}
|
||||
>
|
||||
{bgSrc && (
|
||||
<motion.div
|
||||
key={`hero-bg-${pathname}`}
|
||||
className="absolute inset-0 z-0"
|
||||
initial={{ scale: 1.05, opacity: 0 }}
|
||||
animate={{ scale: 1, opacity: 1 }}
|
||||
@@ -75,6 +78,7 @@ export const HeroSection: React.FC<HeroSectionProps> = (props) => {
|
||||
)}
|
||||
<Container className={`relative z-10 ${alignment === 'center' ? 'max-w-5xl' : ''}`}>
|
||||
<motion.div
|
||||
key={`hero-content-${pathname}`}
|
||||
className={`max-w-4xl ${alignment === 'center' ? 'mx-auto' : ''}`}
|
||||
variants={containerVariants}
|
||||
initial="hidden"
|
||||
@@ -88,7 +92,7 @@ export const HeroSection: React.FC<HeroSectionProps> = (props) => {
|
||||
</motion.div>
|
||||
)}
|
||||
<motion.div variants={itemVariants}>
|
||||
<Heading level={1} variant="white" align={alignment || 'left'} className="mb-4 md:mb-8">
|
||||
<Heading level={1} size="section" variant="white" align={alignment || 'left'} className="mb-4 md:mb-8">
|
||||
{title}
|
||||
</Heading>
|
||||
</motion.div>
|
||||
@@ -100,7 +104,7 @@ export const HeroSection: React.FC<HeroSectionProps> = (props) => {
|
||||
</motion.div>
|
||||
)}
|
||||
{ctaLabel && ctaHref && (
|
||||
<motion.div variants={itemVariants} className={`mt-8 ${alignment === 'center' ? 'flex justify-center' : ''}`}>
|
||||
<motion.div variants={itemVariants} className={`mt-8 ${alignment === 'center' ? 'flex flex-wrap justify-center gap-4' : 'flex flex-wrap gap-4'}`}>
|
||||
<Button
|
||||
href={ctaHref}
|
||||
variant="accent"
|
||||
|
||||
@@ -2,8 +2,8 @@
|
||||
|
||||
import { Button } from '@/components/ui/Button';
|
||||
import Image from 'next/image';
|
||||
import { motion, AnimatePresence } from 'framer-motion';
|
||||
import { useState, useEffect } from 'react';
|
||||
import { usePathname } from 'next/navigation';
|
||||
|
||||
interface HeroVideoProps {
|
||||
data?: any;
|
||||
@@ -22,57 +22,89 @@ interface HeroVideoProps {
|
||||
badge?: string;
|
||||
}
|
||||
|
||||
import placeholders from '@/lib/placeholders.json';
|
||||
|
||||
export function HeroVideo(props: HeroVideoProps) {
|
||||
const { data } = props;
|
||||
const title = props.title || data?.title || 'DIE EXPERTEN FÜR KABELTIEFBAU';
|
||||
const title = props.title || data?.title || 'DIE EXPERTEN FÜR KABELNETZBAU';
|
||||
const subtitle = props.subtitle || props.description || data?.subtitle || 'Wir verbinden Infrastruktur mit Präzision. Von Horizontalbohrungen bis zu komplexen Leitungsnetzen.';
|
||||
const explicitNoVideo = props.videoUrl === 'none' || props.videoUrl === '';
|
||||
const videoUrl = explicitNoVideo ? null : (props.videoUrl || data?.videoUrl || '/assets/dummy-hero.mp4');
|
||||
const pathname = usePathname();
|
||||
|
||||
const posterSrc = props.backgroundImage?.url || props.posterImage?.url || data?.posterImage?.url || "/assets/photos/DJI_0048.JPG";
|
||||
let defaultPoster = "/assets/photos/DJI_0048.JPG";
|
||||
if (videoUrl && videoUrl.endsWith('.mp4')) {
|
||||
defaultPoster = videoUrl.replace('.mp4', '-poster.jpg');
|
||||
}
|
||||
|
||||
const posterSrc = props.backgroundImage?.url || props.posterImage?.url || data?.posterImage?.url || defaultPoster;
|
||||
const posterAlt = props.backgroundImage?.alt || data?.posterImage?.alt || "E-TIB Gruppe Baustelle";
|
||||
const blurDataURL = (placeholders as Record<string, string>)[posterSrc] || undefined;
|
||||
|
||||
const ctaLabel = props.ctaLabel || props.linkText || data?.ctaLabel || 'Unternehmen entdecken';
|
||||
const ctaHref = props.ctaHref || props.linkHref || data?.ctaHref || '#unternehmen';
|
||||
|
||||
let currentLocale = 'de';
|
||||
try {
|
||||
// We can't unconditionally call hooks if they might fail outside context, but HeroVideo is usually in context.
|
||||
// However, to be safe and avoid adding new imports, we can use window.location or default to 'de' if not available.
|
||||
if (typeof window !== 'undefined') {
|
||||
currentLocale = window.location.pathname.startsWith('/en') ? 'en' : 'de';
|
||||
}
|
||||
} catch (e) {
|
||||
// Fallback to default locale if window/location access fails
|
||||
}
|
||||
const currentLocale = pathname?.startsWith('/en') ? 'en' : 'de';
|
||||
|
||||
const secondaryCtaLabel = props.secondaryCtaLabel || data?.secondaryCtaLabel || (currentLocale === 'de' ? 'Projekt anfragen' : 'Inquire Project');
|
||||
const secondaryCtaHref = props.secondaryCtaHref || data?.secondaryCtaHref || `/${currentLocale}/${currentLocale === 'de' ? 'contact' : 'contact'}`;
|
||||
const secondaryCtaHref = props.secondaryCtaHref || data?.secondaryCtaHref || `/${currentLocale}/contact`;
|
||||
|
||||
const [videoSrcLoaded, setVideoSrcLoaded] = useState(false);
|
||||
useEffect(() => {
|
||||
let interactionTriggered = false;
|
||||
|
||||
const handleInteraction = () => {
|
||||
if (interactionTriggered) return;
|
||||
interactionTriggered = true;
|
||||
setVideoSrcLoaded(true);
|
||||
|
||||
// Cleanup listeners
|
||||
['scroll', 'mousemove', 'touchstart', 'keydown'].forEach((event) =>
|
||||
window.removeEventListener(event, handleInteraction)
|
||||
);
|
||||
};
|
||||
|
||||
// Listen for any user interaction
|
||||
['scroll', 'mousemove', 'touchstart', 'keydown'].forEach((event) =>
|
||||
window.addEventListener(event, handleInteraction, { passive: true, once: true })
|
||||
);
|
||||
|
||||
return () => {
|
||||
['scroll', 'mousemove', 'touchstart', 'keydown'].forEach((event) =>
|
||||
window.removeEventListener(event, handleInteraction)
|
||||
);
|
||||
};
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<div className="relative w-full h-[100svh] flex items-center justify-center overflow-hidden bg-neutral-dark">
|
||||
<div className="relative w-full h-[85svh] md:h-[100svh] flex items-center justify-center overflow-hidden bg-neutral-dark">
|
||||
{/* Background color while video loads */}
|
||||
<div className="absolute inset-0 z-0 bg-neutral-dark" />
|
||||
|
||||
{videoUrl ? (
|
||||
{/* ALWAYS render the Next.js optimized Image as the LCP element */}
|
||||
<Image
|
||||
key={`img-${pathname}-${posterSrc}`}
|
||||
src={posterSrc}
|
||||
alt={posterAlt}
|
||||
fill
|
||||
className="object-cover z-[1] pointer-events-none filter contrast-125 saturate-110 brightness-90"
|
||||
sizes="100vw"
|
||||
priority
|
||||
placeholder={blurDataURL ? "blur" : "empty"}
|
||||
blurDataURL={blurDataURL}
|
||||
/>
|
||||
|
||||
{/* Render video on top if available, but defer src to avoid blocking LCP */}
|
||||
{videoUrl && (
|
||||
<video
|
||||
key={videoUrl}
|
||||
className="absolute inset-0 w-full h-full object-cover z-1 pointer-events-none filter contrast-125 saturate-110 brightness-90"
|
||||
src={videoUrl}
|
||||
key={`${pathname}-${videoUrl}`}
|
||||
className="absolute inset-0 w-full h-full object-cover z-[2] pointer-events-none filter contrast-125 saturate-110 brightness-90"
|
||||
src={videoSrcLoaded ? videoUrl : undefined}
|
||||
autoPlay
|
||||
muted
|
||||
loop
|
||||
playsInline
|
||||
preload="auto"
|
||||
/>
|
||||
) : (
|
||||
<Image
|
||||
src={posterSrc}
|
||||
alt={posterAlt}
|
||||
fill
|
||||
className="object-cover z-1 pointer-events-none filter contrast-125 saturate-110 brightness-90"
|
||||
sizes="100vw"
|
||||
priority
|
||||
preload="none"
|
||||
/>
|
||||
)}
|
||||
|
||||
@@ -87,21 +119,20 @@ export function HeroVideo(props: HeroVideoProps) {
|
||||
<div className="absolute top-0 left-0 w-full h-48 bg-gradient-to-b from-black/70 to-transparent z-[3] pointer-events-none" />
|
||||
|
||||
<div className="container relative z-[4] text-center px-4">
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 40 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ duration: 1.2, ease: [0.16, 1, 0.3, 1] }}
|
||||
<div
|
||||
key={`hero-content-${pathname}`}
|
||||
className="animate-in fade-in slide-in-from-bottom-10 duration-1000 ease-out fill-mode-both"
|
||||
>
|
||||
<h1
|
||||
className="font-heading font-bold text-4xl md:text-6xl lg:text-8xl text-white mb-6 uppercase tracking-tight drop-shadow-lg"
|
||||
dangerouslySetInnerHTML={{ __html: title.replace('KABELTIEFBAU', '<span class="text-primary-light">KABELTIEFBAU</span>').replace(/\\n|\n/g, '<br />') }}
|
||||
className="font-heading font-bold text-3xl sm:text-4xl md:text-6xl lg:text-8xl text-white mb-6 uppercase tracking-tight drop-shadow-lg"
|
||||
dangerouslySetInnerHTML={{ __html: title.replace(/KABELNETZBAU|Kabelnetzbau/g, '<span class="text-primary-light">Kabelnetzbau</span>').replace(/KABELNETZBAU|Kabelnetzbau/g, '<span class="text-primary-light">Kabelnetzbau</span>').replace(/\\n|\n/g, '<br />') }}
|
||||
/>
|
||||
|
||||
<p className="text-lg md:text-xl lg:text-2xl text-white/90 max-w-3xl mx-auto mb-12 drop-shadow">
|
||||
{subtitle}
|
||||
</p>
|
||||
|
||||
<div className="flex flex-col sm:flex-row items-center justify-center gap-6">
|
||||
<div className="flex flex-wrap items-center justify-center gap-4 md:gap-6">
|
||||
<Button
|
||||
href={ctaHref}
|
||||
variant="primary"
|
||||
@@ -119,7 +150,7 @@ export function HeroVideo(props: HeroVideoProps) {
|
||||
{secondaryCtaLabel}
|
||||
</Button>
|
||||
</div>
|
||||
</motion.div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -1,17 +1,21 @@
|
||||
'use client';
|
||||
|
||||
import React, { useState } from 'react';
|
||||
import React, { useState, useRef, useCallback, useMemo } from 'react';
|
||||
import { motion, AnimatePresence } from 'framer-motion';
|
||||
import { MapPin, Factory, Zap, CheckCircle2, ArrowUpRight } from 'lucide-react';
|
||||
import { MapPin, CheckCircle2, ArrowUpRight } from 'lucide-react';
|
||||
import Image from 'next/image';
|
||||
import { useRouter } from 'next/navigation';
|
||||
import { usePathname } from 'next/navigation';
|
||||
import Link from 'next/link';
|
||||
import { AnimatedGlossyBorder } from '@/components/ui/AnimatedGlossyBorder';
|
||||
import { Location, defaultLocations } from '@/lib/map-data';
|
||||
import { Location, projectLocations } from '@/lib/map-data';
|
||||
import { standorteLocations } from '@/lib/standorte-data';
|
||||
import { useLocale, useTranslations } from 'next-intl';
|
||||
|
||||
interface Stat {
|
||||
value: string;
|
||||
suffix?: string;
|
||||
label: string;
|
||||
href?: string;
|
||||
}
|
||||
|
||||
interface InteractiveGermanyMapProps {
|
||||
@@ -21,277 +25,303 @@ interface InteractiveGermanyMapProps {
|
||||
stats?: Stat[];
|
||||
locations?: Location[];
|
||||
isHero?: boolean;
|
||||
hideStandorte?: boolean;
|
||||
}
|
||||
|
||||
const MinorNode = React.memo(({ loc, isActive, onEnter, onLeave }: { loc: Location, isActive: boolean, onEnter: (loc: Location) => void, onLeave: () => void }) => (
|
||||
<div
|
||||
className={`absolute group/minor cursor-pointer w-8 h-8 md:w-5 md:h-5 flex items-center justify-center ${isActive ? 'z-[60]' : 'z-10 hover:z-30'}`}
|
||||
style={{
|
||||
left: `${loc.x}%`,
|
||||
top: `${loc.y}%`,
|
||||
transform: 'translate(-50%, -50%)',
|
||||
}}
|
||||
onMouseEnter={() => onEnter(loc)}
|
||||
onMouseLeave={onLeave}
|
||||
onTouchStart={() => onEnter(loc)}
|
||||
onClick={() => onEnter(loc)}
|
||||
>
|
||||
<div className={`w-1.5 h-1.5 rounded-full transition-all duration-300 ${isActive ? 'bg-white scale-150 shadow-[0_0_10px_rgba(130,237,32,1)]' : 'bg-primary/80 group-hover/minor:bg-white group-hover/minor:scale-150'}`} />
|
||||
<div className="absolute inset-0 m-auto w-2 h-2 bg-primary rounded-full opacity-0 group-hover/minor:animate-ping" />
|
||||
</div>
|
||||
));
|
||||
MinorNode.displayName = 'MinorNode';
|
||||
|
||||
const MajorNode = React.memo(({ loc, isActive, idx, onEnter, onLeave }: { loc: Location, isActive: boolean, idx: number, onEnter: (loc: Location) => void, onLeave: () => void }) => {
|
||||
const isHQ = loc.type === 'hq';
|
||||
const isBranch = loc.type === 'branch';
|
||||
return (
|
||||
<div
|
||||
className={`absolute transform -translate-x-1/2 -translate-y-1/2 group/pin cursor-pointer w-16 h-16 flex items-center justify-center ${isActive ? 'z-[60]' : 'z-20 hover:z-40'}`}
|
||||
style={{ left: `${loc.x}%`, top: `${loc.y}%` }}
|
||||
onMouseEnter={() => onEnter(loc)}
|
||||
onMouseLeave={onLeave}
|
||||
onTouchStart={() => onEnter(loc)}
|
||||
onClick={() => onEnter(loc)}
|
||||
>
|
||||
{(isHQ || isBranch) && (
|
||||
<div className="absolute inset-0 m-auto w-6 h-6 rounded-full animate-ping bg-primary/50 scale-150" />
|
||||
)}
|
||||
<motion.div
|
||||
initial={{ scale: 0 }}
|
||||
animate={{ scale: 1 }}
|
||||
transition={{ type: 'spring', delay: idx * 0.1 }}
|
||||
className={`relative flex items-center justify-center rounded-full shadow-[0_0_20px_rgba(130,237,32,0.6)] transition-transform duration-300 ${
|
||||
isActive ? 'scale-125 z-30 ring-4 ring-primary/40' : 'scale-100 group-hover/pin:scale-110'
|
||||
} ${
|
||||
isHQ || isBranch
|
||||
? 'w-7 h-7 bg-primary text-[#050B14]'
|
||||
: 'w-4 h-4 bg-white ring-[3px] ring-primary'
|
||||
}`}
|
||||
>
|
||||
{(isHQ || isBranch) && <MapPin className="w-4 h-4" />}
|
||||
</motion.div>
|
||||
</div>
|
||||
);
|
||||
});
|
||||
MajorNode.displayName = 'MajorNode';
|
||||
|
||||
export function InteractiveGermanyMap({
|
||||
badge = 'Einsatzgebiete',
|
||||
title = <>Deutschlandweit<br/>für Sie im Einsatz.</>,
|
||||
description = 'Von unseren strategischen Standorten in Guben und Bülstedt steuern und realisieren wir komplexe Infrastrukturprojekte im gesamten Bundesgebiet.',
|
||||
stats = [
|
||||
{ value: '100', suffix: '%', label: 'Überregionale Reichweite' },
|
||||
{ value: '2', suffix: '+', label: 'Operative Standorte' },
|
||||
],
|
||||
locations = defaultLocations,
|
||||
isHero = false
|
||||
badge,
|
||||
title,
|
||||
description,
|
||||
stats,
|
||||
locations = projectLocations,
|
||||
isHero = false,
|
||||
hideStandorte = false
|
||||
}: InteractiveGermanyMapProps) {
|
||||
const [activeLocation, setActiveLocation] = useState<Location | null>(null);
|
||||
const router = useRouter();
|
||||
const hoverTimeoutRef = useRef<NodeJS.Timeout | null>(null);
|
||||
|
||||
const hq = locations.find((l) => l.type === 'hq');
|
||||
const branch = locations.find((l) => l.type === 'branch');
|
||||
const projects = locations.filter((l) => l.type === 'project');
|
||||
const handleMouseEnter = useCallback((loc: Location) => {
|
||||
if (hoverTimeoutRef.current) {
|
||||
clearTimeout(hoverTimeoutRef.current);
|
||||
hoverTimeoutRef.current = null;
|
||||
}
|
||||
setActiveLocation(loc);
|
||||
}, []);
|
||||
|
||||
const handleMouseLeave = useCallback(() => {
|
||||
const timeout = setTimeout(() => {
|
||||
setActiveLocation(null);
|
||||
}, 200);
|
||||
hoverTimeoutRef.current = timeout;
|
||||
}, []);
|
||||
|
||||
const pathname = usePathname();
|
||||
const locale = useLocale();
|
||||
const t = useTranslations('InteractiveGermanyMap');
|
||||
const tStandard = useTranslations('StandardPage');
|
||||
|
||||
const finalStats = stats || [
|
||||
{ value: '100', suffix: '%', label: locale === 'en' ? 'Nationwide Reach' : 'Überregionale Reichweite' },
|
||||
{ value: '3', suffix: '', label: locale === 'en' ? 'Operational Locations' : 'Operative Standorte', href: `/${locale}/standorte` },
|
||||
];
|
||||
|
||||
const finalBadge = badge || tStandard('badge');
|
||||
const finalTitle = title || (locale === 'en' ? <>Nationwide<br/>in operation for you.</> : <>Deutschlandweit<br/>für Sie im Einsatz.</>);
|
||||
const finalDescription = description || (locale === 'en' ? 'From our strategic locations in Guben, Kirchheilingen and Bülstedt, we control and implement complex infrastructure projects nationwide.' : 'Von unseren strategischen Standorten in Guben, Kirchheilingen und Bülstedt steuern und realisieren wir komplexe Infrastrukturprojekte im gesamten Bundesgebiet.');
|
||||
|
||||
const finalLocations = useMemo(() => {
|
||||
if (hideStandorte) return locations;
|
||||
const base = [...locations];
|
||||
standorteLocations.forEach(sl => {
|
||||
if (!base.some(l => l.id === sl.id)) {
|
||||
base.push(sl as Location);
|
||||
}
|
||||
});
|
||||
return base;
|
||||
}, [locations, hideStandorte]);
|
||||
|
||||
const mapBackground = useMemo(() => (
|
||||
<div className="absolute inset-0 opacity-[0.25] mix-blend-screen drop-shadow-2xl brightness-200 pointer-events-none transform-gpu">
|
||||
<Image
|
||||
src="/germany-map.svg"
|
||||
alt={locale === 'en' ? "Map of Germany" : "Deutschlandkarte"}
|
||||
fill
|
||||
className="object-cover"
|
||||
priority
|
||||
/>
|
||||
</div>
|
||||
), [locale]);
|
||||
|
||||
return (
|
||||
<div className={isHero ? "relative w-full" : "relative w-full max-w-7xl mx-auto py-16 md:py-24 px-4 sm:px-6 mt-16 md:mt-20"}>
|
||||
<div key={isHero ? `hero-map-${pathname}` : undefined} className={isHero ? "relative w-full" : "relative w-full max-w-7xl mx-auto py-12 md:py-24 px-4 sm:px-6 mt-16 md:mt-20"}>
|
||||
<div className={`bg-[#050B14] ${isHero ? 'pt-32 pb-16 md:pt-40 md:pb-24' : 'rounded-[2.5rem] md:rounded-[3.5rem] shadow-2xl border border-white/5'} overflow-visible relative group/map`}>
|
||||
{/* Animated Border Glow */}
|
||||
{!isHero && <AnimatedGlossyBorder opacity={0.7} className="z-30" />}
|
||||
|
||||
{/* Background Effects */}
|
||||
<div className={`absolute inset-0 bg-gradient-to-br from-[#050B14] via-[#0A1322] to-[#050B14] ${!isHero && 'rounded-[2.5rem] md:rounded-[3.5rem]'} overflow-hidden`} />
|
||||
<div className="absolute top-0 right-0 w-[800px] h-[800px] bg-primary/5 rounded-full blur-[120px] pointer-events-none translate-x-1/3 -translate-y-1/3 overflow-hidden" />
|
||||
|
||||
<div className={`relative z-10 grid grid-cols-1 lg:grid-cols-2 xl:grid-cols-12 gap-12 items-center min-h-[600px] ${isHero ? 'max-w-7xl mx-auto px-4 sm:px-6' : 'p-8 md:p-12 lg:p-16'}`}>
|
||||
|
||||
{/* Content Left */}
|
||||
<div className="xl:col-span-5 text-white z-20">
|
||||
<div className="inline-flex items-center gap-2 px-3 py-1.5 rounded-full bg-primary/10 border border-primary/20 text-primary text-xs font-bold uppercase tracking-wider mb-8">
|
||||
<MapPin className="w-4 h-4" />
|
||||
<span>{badge}</span>
|
||||
<span>{finalBadge}</span>
|
||||
</div>
|
||||
|
||||
{isHero ? (
|
||||
<h1 className="font-heading text-4xl lg:text-5xl xl:text-6xl font-extrabold mb-6 leading-[1.1] text-transparent bg-clip-text bg-gradient-to-r from-white to-white/70 break-words [hyphens:auto]">
|
||||
{title}
|
||||
<h1 className="font-heading text-3xl md:text-4xl lg:text-5xl xl:text-6xl font-extrabold mb-6 leading-[1.1] text-transparent bg-clip-text bg-gradient-to-r from-white to-white/70 text-balance">
|
||||
{finalTitle}
|
||||
</h1>
|
||||
) : (
|
||||
<h3 className="font-heading text-4xl lg:text-5xl xl:text-6xl font-extrabold mb-6 leading-[1.1] text-transparent bg-clip-text bg-gradient-to-r from-white to-white/70 break-words [hyphens:auto]">
|
||||
{title}
|
||||
<h3 className="font-heading text-3xl md:text-4xl lg:text-5xl xl:text-6xl font-extrabold mb-6 leading-[1.1] text-transparent bg-clip-text bg-gradient-to-r from-white to-white/70 text-balance">
|
||||
{finalTitle}
|
||||
</h3>
|
||||
)}
|
||||
|
||||
<p className="text-white/60 text-lg mb-12 leading-relaxed">
|
||||
{description}
|
||||
{finalDescription}
|
||||
</p>
|
||||
|
||||
{/* Industrial Stats Grid */}
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
{stats.map((stat, i) => (
|
||||
<div key={i} className="bg-white/5 border border-white/10 rounded-2xl p-6 backdrop-blur-md relative overflow-hidden group">
|
||||
<div className="absolute inset-0 bg-gradient-to-br from-primary/10 to-transparent opacity-0 group-hover:opacity-100 transition-opacity duration-500" />
|
||||
<div className="relative z-10">
|
||||
<div className="text-4xl font-extrabold text-white mb-1 flex items-baseline gap-1">
|
||||
{stat.value}<span className="text-xl text-primary">{stat.suffix}</span>
|
||||
{finalStats.map((stat, i) => {
|
||||
const StatCard = () => (
|
||||
<div className={`bg-white/5 border border-white/10 rounded-2xl p-6 backdrop-blur-md relative overflow-hidden group h-full ${stat.href ? 'cursor-pointer' : ''}`}>
|
||||
<div className="absolute inset-0 bg-gradient-to-br from-primary/10 to-transparent opacity-0 group-hover:opacity-100 transition-opacity duration-500" />
|
||||
<div className="relative z-10 flex flex-col h-full justify-between">
|
||||
<div className="text-3xl md:text-4xl font-extrabold text-white mb-1 flex items-baseline gap-1">
|
||||
{stat.value}<span className="text-xl text-primary">{stat.suffix}</span>
|
||||
</div>
|
||||
<div className={`text-sm text-white/50 font-medium flex items-center ${stat.href ? 'group-hover:text-primary transition-colors' : ''}`}>
|
||||
{stat.label}
|
||||
{stat.href && <ArrowUpRight className="w-4 h-4 ml-1 opacity-0 -translate-y-1 translate-x-1 group-hover:opacity-100 group-hover:translate-y-0 group-hover:translate-x-0 transition-all duration-300" />}
|
||||
</div>
|
||||
</div>
|
||||
<div className="text-sm text-white/50 font-medium">{stat.label}</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
{/* Map Right */}
|
||||
<div className="xl:col-span-7 relative flex items-center justify-center lg:justify-end mt-8 lg:mt-0 z-10">
|
||||
{/* Map Container - Enforce strict aspect ratio matching the SVG (1024x1024) */}
|
||||
<div className="relative w-full max-w-[600px] aspect-square z-10">
|
||||
{/* Map SVG */}
|
||||
<div className="absolute inset-0 opacity-[0.25] mix-blend-screen drop-shadow-2xl brightness-200">
|
||||
<Image
|
||||
src="/germany-map.svg"
|
||||
alt="Deutschlandkarte"
|
||||
fill
|
||||
className="object-cover"
|
||||
priority
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Connections (Lines) */}
|
||||
<svg className="absolute inset-0 w-full h-full pointer-events-none z-10">
|
||||
{hq && branch && (
|
||||
<motion.line
|
||||
x1={`${hq.x}%`}
|
||||
y1={`${hq.y}%`}
|
||||
x2={`${branch.x}%`}
|
||||
y2={`${branch.y}%`}
|
||||
stroke="rgba(130,237,32,0.6)"
|
||||
strokeWidth="2"
|
||||
strokeDasharray="6 6"
|
||||
initial={{ pathLength: 0, opacity: 0 }}
|
||||
animate={{ pathLength: 1, opacity: 1 }}
|
||||
transition={{ duration: 2, ease: "easeOut" }}
|
||||
/>
|
||||
)}
|
||||
{hq && projects.map((proj, idx) => (
|
||||
<motion.line
|
||||
key={proj.id}
|
||||
x1={`${hq.x}%`}
|
||||
y1={`${hq.y}%`}
|
||||
x2={`${proj.x}%`}
|
||||
y2={`${proj.y}%`}
|
||||
stroke="rgba(130,237,32,0.3)"
|
||||
strokeWidth="1.5"
|
||||
initial={{ pathLength: 0, opacity: 0 }}
|
||||
animate={{ pathLength: 1, opacity: 1 }}
|
||||
transition={{ duration: 1.5, delay: 0.5 + idx * 0.2 }}
|
||||
/>
|
||||
))}
|
||||
</svg>
|
||||
|
||||
{/* Location Pins */}
|
||||
{locations.map((loc, idx) => {
|
||||
const isHQ = loc.type === 'hq';
|
||||
const isBranch = loc.type === 'branch';
|
||||
const isActive = activeLocation?.id === loc.id;
|
||||
|
||||
const isNearLeft = loc.x < 50;
|
||||
const isNearRight = loc.x >= 50;
|
||||
const isNearTop = loc.y < 55; // 55% threshold to strongly avoid the top header
|
||||
|
||||
const tooltipClasses = [
|
||||
'absolute w-[280px] sm:w-64 bg-white text-neutral-dark p-5 rounded-2xl shadow-2xl z-[9999] border border-neutral-100',
|
||||
isNearLeft ? 'left-1/2 sm:left-0 -translate-x-1/2 sm:translate-x-0' : 'left-1/2 sm:left-auto sm:right-0 -translate-x-1/2 sm:translate-x-0',
|
||||
isNearTop
|
||||
? 'top-full mt-4 before:absolute before:-top-6 before:left-0 before:w-full before:h-6'
|
||||
: 'bottom-full mb-4 before:absolute before:-bottom-6 before:left-0 before:w-full before:h-6'
|
||||
].join(' ');
|
||||
|
||||
const arrowClasses = [
|
||||
'absolute border-8 border-transparent drop-shadow-sm hidden sm:block',
|
||||
isNearLeft ? 'left-6' : 'right-6',
|
||||
isNearTop ? '-top-4 border-b-white border-t-0' : '-bottom-4 border-t-white border-b-0'
|
||||
].join(' ');
|
||||
|
||||
return (
|
||||
<div
|
||||
key={loc.id}
|
||||
className={`absolute transform -translate-x-1/2 -translate-y-1/2 group cursor-pointer w-16 h-16 flex items-center justify-center ${isActive ? 'z-[60]' : 'z-20'}`}
|
||||
style={{ left: `${loc.x}%`, top: `${loc.y}%` }}
|
||||
onMouseEnter={() => setActiveLocation(loc)}
|
||||
onMouseLeave={() => setActiveLocation(null)}
|
||||
onClick={() => {
|
||||
if (loc.href) router.push(loc.href);
|
||||
}}
|
||||
>
|
||||
{/* Ping Animation for HQ / Branch */}
|
||||
{(isHQ || isBranch) && (
|
||||
<div className="absolute inset-0 m-auto w-6 h-6 rounded-full animate-ping bg-primary/50 scale-150" />
|
||||
)}
|
||||
|
||||
{/* Marker Dot */}
|
||||
<motion.div
|
||||
initial={{ scale: 0 }}
|
||||
animate={{ scale: 1 }}
|
||||
transition={{ type: 'spring', delay: idx * 0.1 }}
|
||||
className={`relative flex items-center justify-center rounded-full shadow-[0_0_20px_rgba(130,237,32,0.4)] transition-transform duration-300 ${
|
||||
isActive ? 'scale-125 z-30 ring-4 ring-primary/30' : 'scale-100'
|
||||
} ${
|
||||
isHQ || isBranch
|
||||
? 'w-6 h-6 bg-primary text-[#050B14]'
|
||||
: 'w-4 h-4 bg-white ring-[3px] ring-primary'
|
||||
}`}
|
||||
>
|
||||
{(isHQ || isBranch) && <MapPin className="w-3.5 h-3.5" />}
|
||||
</motion.div>
|
||||
|
||||
{/* Tooltip */}
|
||||
<AnimatePresence>
|
||||
{isActive && (
|
||||
<motion.div
|
||||
variants={{
|
||||
hidden: { opacity: 0, y: isNearTop ? -15 : 15, scale: 0.9 },
|
||||
visible: {
|
||||
opacity: 1, y: 0, scale: 1,
|
||||
transition: {
|
||||
type: "spring", stiffness: 350, damping: 25,
|
||||
staggerChildren: 0.05, delayChildren: 0.05
|
||||
}
|
||||
},
|
||||
exit: {
|
||||
opacity: 0, y: isNearTop ? -10 : 10, scale: 0.95,
|
||||
transition: { duration: 0.15, ease: "easeIn" }
|
||||
}
|
||||
}}
|
||||
initial="hidden"
|
||||
animate="visible"
|
||||
exit="exit"
|
||||
className={`${tooltipClasses} overflow-hidden p-0`}
|
||||
>
|
||||
{loc.featuredImage && (
|
||||
<motion.div
|
||||
variants={{ hidden: { opacity: 0 }, visible: { opacity: 1 } }}
|
||||
className="relative w-full h-32 bg-neutral-100 overflow-hidden"
|
||||
>
|
||||
<Image
|
||||
src={loc.featuredImage}
|
||||
alt={loc.name}
|
||||
fill
|
||||
className="object-cover transform transition-transform duration-1000 ease-[cubic-bezier(0.16,1,0.3,1)] group-hover:scale-110"
|
||||
/>
|
||||
<div className="absolute inset-0 bg-gradient-to-t from-black/60 to-transparent opacity-80" />
|
||||
<div className="absolute bottom-3 left-4 flex items-center gap-2">
|
||||
{isHQ ? <Factory className="w-4 h-4 text-primary drop-shadow" /> : <Zap className="w-4 h-4 text-primary drop-shadow" />}
|
||||
<span className="font-extrabold text-sm leading-tight text-white drop-shadow-md">{loc.name}</span>
|
||||
</div>
|
||||
</motion.div>
|
||||
)}
|
||||
|
||||
<div className={loc.featuredImage ? "p-5 pt-4" : "p-5"}>
|
||||
{!loc.featuredImage && (
|
||||
<motion.div
|
||||
variants={{ hidden: { opacity: 0, x: -5 }, visible: { opacity: 1, x: 0 } }}
|
||||
className="flex items-center gap-2 mb-2"
|
||||
>
|
||||
{isHQ ? <Factory className="w-4 h-4 text-primary" /> : <Zap className="w-4 h-4 text-primary" />}
|
||||
<span className="font-extrabold text-sm leading-tight text-neutral-dark">{loc.name}</span>
|
||||
</motion.div>
|
||||
)}
|
||||
|
||||
<motion.p
|
||||
variants={{ hidden: { opacity: 0, y: 5 }, visible: { opacity: 1, y: 0 } }}
|
||||
className="text-xs text-neutral-500 font-medium"
|
||||
>
|
||||
{loc.description}
|
||||
</motion.p>
|
||||
|
||||
{loc.details && (
|
||||
<ul className="mt-4 space-y-2">
|
||||
{loc.details.map((detail, i) => (
|
||||
<motion.li
|
||||
key={i}
|
||||
variants={{ hidden: { opacity: 0, x: -5 }, visible: { opacity: 1, x: 0 } }}
|
||||
className="text-[11px] text-neutral-dark flex items-center gap-2 font-semibold"
|
||||
>
|
||||
<div className="w-4 h-4 rounded-full bg-primary/10 flex items-center justify-center shrink-0">
|
||||
<CheckCircle2 className="w-3 h-3 text-primary" />
|
||||
</div>
|
||||
{detail}
|
||||
</motion.li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
|
||||
{loc.href && (
|
||||
<motion.div
|
||||
variants={{ hidden: { opacity: 0, y: 5 }, visible: { opacity: 1, y: 0 } }}
|
||||
className="mt-4 pt-3 border-t border-neutral-100 text-xs font-bold text-primary flex items-center gap-1 group/link"
|
||||
>
|
||||
Zum Projekt
|
||||
<span className="text-base leading-none translate-y-[1px] transform transition-transform duration-300 group-hover:translate-x-1.5">→</span>
|
||||
</motion.div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Arrow Down/Up */}
|
||||
<div className={arrowClasses} />
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
return stat.href ? (
|
||||
<Link key={i} href={stat.href} className="block group/link hover:-translate-y-1 transition-transform duration-300">
|
||||
<StatCard />
|
||||
</Link>
|
||||
) : (
|
||||
<div key={i}>
|
||||
<StatCard />
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
<div className="xl:col-span-7 relative flex items-center justify-center min-h-[500px] lg:min-h-[700px] z-10 pointer-events-none">
|
||||
<div className="relative w-full max-w-[600px] aspect-square group pointer-events-auto">
|
||||
|
||||
{mapBackground}
|
||||
|
||||
{finalLocations.filter(l => l.type === 'minor_node').map((loc, idx) => (
|
||||
<MinorNode
|
||||
key={`minor-${loc.id || idx}`}
|
||||
loc={loc}
|
||||
isActive={activeLocation === loc}
|
||||
onEnter={handleMouseEnter}
|
||||
onLeave={handleMouseLeave}
|
||||
/>
|
||||
))}
|
||||
|
||||
{finalLocations.filter(l => l.type !== 'minor_node').map((loc, idx) => (
|
||||
<MajorNode
|
||||
key={loc.id}
|
||||
loc={loc}
|
||||
idx={idx}
|
||||
isActive={activeLocation?.id === loc.id}
|
||||
onEnter={handleMouseEnter}
|
||||
onLeave={handleMouseLeave}
|
||||
/>
|
||||
))}
|
||||
|
||||
<AnimatePresence>
|
||||
{activeLocation && (
|
||||
<motion.div
|
||||
key="tooltip"
|
||||
initial={{ opacity: 0, y: 10, scale: 0.95 }}
|
||||
animate={{ opacity: 1, y: 0, scale: 1 }}
|
||||
exit={{ opacity: 0, y: 10, scale: 0.95 }}
|
||||
transition={{ duration: 0.2 }}
|
||||
className="absolute z-[9999] pointer-events-auto bottom-[-60px] left-1/2 -translate-x-1/2 w-[calc(100vw_-_2rem)] max-w-[320px] md:w-max md:bottom-auto md:left-[var(--md-left)] md:top-[var(--md-top)] md:[transform:var(--md-transform)]"
|
||||
style={{
|
||||
'--md-left': `${activeLocation.x}%`,
|
||||
'--md-top': `${activeLocation.y}%`,
|
||||
'--md-transform': activeLocation.x < 50 ? 'translate(20px, -50%)' : 'translate(calc(-100% - 20px), -50%)',
|
||||
} as React.CSSProperties}
|
||||
onMouseEnter={() => handleMouseEnter(activeLocation)}
|
||||
onMouseLeave={handleMouseLeave}
|
||||
>
|
||||
<div className="bg-[#050B14]/95 backdrop-blur-xl border border-white/10 rounded-2xl shadow-2xl w-full text-white overflow-hidden">
|
||||
{activeLocation.featuredImage && (
|
||||
<div className="relative w-full h-32 bg-neutral-900">
|
||||
<Image
|
||||
src={activeLocation.featuredImage}
|
||||
alt={activeLocation.name}
|
||||
fill
|
||||
className="object-cover"
|
||||
/>
|
||||
<div className="absolute inset-0 bg-gradient-to-t from-[#050B14] to-transparent opacity-90" />
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="p-4">
|
||||
<div className="flex items-center gap-2 mb-1">
|
||||
<MapPin className="w-3.5 h-3.5 text-primary" />
|
||||
<span className="text-xs font-bold text-primary uppercase tracking-wider">
|
||||
{t(`types.${activeLocation.type === 'minor_node' ? 'project' : activeLocation.type}`)}
|
||||
</span>
|
||||
</div>
|
||||
<div className="font-heading font-bold text-lg leading-tight mb-2">
|
||||
{activeLocation.name}
|
||||
</div>
|
||||
{activeLocation.type !== 'minor_node' ? (
|
||||
<div className="text-white/60 text-sm line-clamp-3 mb-3">
|
||||
{activeLocation.description}
|
||||
</div>
|
||||
) : (
|
||||
<div className="text-white/60 text-xs mb-3">
|
||||
{t(`descriptions.${['wind','pv','fiber','power','battery'].includes(activeLocation.description || '') ? activeLocation.description : 'default'}`)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{(activeLocation.details || activeLocation.type === 'minor_node') && (
|
||||
<ul className="mb-3 space-y-1.5 border-t border-white/10 pt-3">
|
||||
{activeLocation.type === 'minor_node' ? (
|
||||
<>
|
||||
<li className="text-[11px] text-white/80 flex items-start gap-2 font-medium">
|
||||
<CheckCircle2 className="w-3 h-3 text-primary shrink-0 mt-0.5" />
|
||||
<span>{t(`features.${['wind','pv','fiber','power','battery'].includes(activeLocation.description || '') ? activeLocation.description : 'default'}`)}</span>
|
||||
</li>
|
||||
<li className="text-[11px] text-white/80 flex items-start gap-2 font-medium">
|
||||
<CheckCircle2 className="w-3 h-3 text-primary shrink-0 mt-0.5" />
|
||||
<span>{t('features.method')}</span>
|
||||
</li>
|
||||
</>
|
||||
) : (
|
||||
activeLocation.details?.map((detail, i) => (
|
||||
<li key={i} className="text-[11px] text-white/80 flex items-start gap-2 font-medium">
|
||||
<CheckCircle2 className="w-3 h-3 text-primary shrink-0 mt-0.5" />
|
||||
<span>{detail}</span>
|
||||
</li>
|
||||
))
|
||||
)}
|
||||
</ul>
|
||||
)}
|
||||
|
||||
{activeLocation.href && (activeLocation.type === 'hq' || activeLocation.type === 'branch') && (
|
||||
<Link
|
||||
href={activeLocation.href.startsWith('/') ? `/${locale}${activeLocation.href}` : activeLocation.href}
|
||||
className="inline-flex items-center text-xs font-bold text-primary hover:text-white transition-colors mt-2"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
{t('learnMore')}
|
||||
<ArrowUpRight className="w-3.5 h-3.5 ml-1" />
|
||||
</Link>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -5,6 +5,7 @@ import Link from 'next/link';
|
||||
import { Button } from '@/components/ui/Button';
|
||||
import { HoverShineOverlay } from '@/components/ui/HoverShineOverlay';
|
||||
import { motion } from 'framer-motion';
|
||||
import { useTranslations } from 'next-intl';
|
||||
|
||||
export interface JobListingBlockProps {
|
||||
title?: string;
|
||||
@@ -36,29 +37,32 @@ const containerVariants = {
|
||||
};
|
||||
|
||||
const itemVariants = {
|
||||
hidden: { opacity: 0, y: 30 },
|
||||
hidden: { opacity: 0, y: 20 },
|
||||
visible: {
|
||||
opacity: 1,
|
||||
y: 0,
|
||||
transition: { duration: 0.8, ease: [0.16, 1, 0.3, 1] as const },
|
||||
transition: { duration: 1.0, ease: [0.16, 1, 0.3, 1] as const },
|
||||
},
|
||||
};
|
||||
|
||||
export const JobListingBlock = (props: JobListingBlockProps) => {
|
||||
const t = useTranslations('JobListingBlock');
|
||||
|
||||
const {
|
||||
title,
|
||||
showJobs = true,
|
||||
showFairs,
|
||||
fairsTitle = 'Nächste Messetermine',
|
||||
fairsTitle = t('fairsTitle'),
|
||||
fairs = [
|
||||
{ name: 'Intersolar München', date: 'Termin folgt', type: 'Energie-Messe', location: 'Messe München' },
|
||||
{ name: 'Windenergietage Linstow', date: 'Termin folgt', type: 'Fachkongress', location: 'Linstow' },
|
||||
{ name: 'Kabelwerkstatt Wiesbaden', date: 'Termin folgt', type: 'Fachmesse', location: 'Wiesbaden' }
|
||||
],
|
||||
emptyStateMessage = 'Aktuell sind alle Positionen besetzt. Senden Sie uns gerne eine Initiativbewerbung!',
|
||||
emptyStateLinkText = 'Jetzt Kontakt aufnehmen',
|
||||
emptyStateLinkHref = '/kontakt'
|
||||
emptyStateMessage = t('emptyStateMessage'),
|
||||
emptyStateLinkText = t('emptyStateLinkText'),
|
||||
emptyStateLinkHref = t('emptyStateLinkHref')
|
||||
} = props;
|
||||
|
||||
const title = props.title || t('title');
|
||||
|
||||
// Since Payload is removed, we use an empty array or static data
|
||||
const jobs: any[] = [];
|
||||
@@ -67,13 +71,13 @@ export const JobListingBlock = (props: JobListingBlockProps) => {
|
||||
<div className="py-12 md:py-24">
|
||||
<div className="container mx-auto">
|
||||
{showFairs && (
|
||||
<div className="mb-24 relative">
|
||||
<div className="mb-12 md:mb-24 relative">
|
||||
<motion.h3
|
||||
initial={{ opacity: 0, x: -20 }}
|
||||
whileInView={{ opacity: 1, x: 0 }}
|
||||
viewport={{ once: true, margin: "-100px" }}
|
||||
viewport={{ once: true, margin: "-50px" }}
|
||||
transition={{ duration: 0.8, ease: [0.16, 1, 0.3, 1] as const }}
|
||||
className="font-heading font-extrabold text-4xl text-neutral-dark mb-12 flex items-center gap-4"
|
||||
className="font-heading font-extrabold text-3xl md:text-4xl text-neutral-dark mb-8 md:mb-12 flex items-center gap-4"
|
||||
>
|
||||
<span className="w-12 h-1.5 bg-primary rounded-full" />
|
||||
{fairsTitle}
|
||||
@@ -82,7 +86,7 @@ export const JobListingBlock = (props: JobListingBlockProps) => {
|
||||
variants={containerVariants}
|
||||
initial="hidden"
|
||||
whileInView="visible"
|
||||
viewport={{ once: true, margin: "-100px" }}
|
||||
viewport={{ once: true, margin: "-50px" }}
|
||||
className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-8"
|
||||
>
|
||||
{fairs.map((messe, idx) => {
|
||||
@@ -118,7 +122,7 @@ export const JobListingBlock = (props: JobListingBlockProps) => {
|
||||
<svg xmlns="http://www.w3.org/2000/svg" className="w-4 h-4" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.5" strokeLinecap="round" strokeLinejoin="round"><rect x="3" y="4" width="18" height="18" rx="2" ry="2"></rect><line x1="16" y1="2" x2="16" y2="6"></line><line x1="8" y1="2" x2="8" y2="6"></line><line x1="3" y1="10" x2="21" y2="10"></line></svg>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-xs text-white/40 uppercase tracking-widest font-heading mb-1">Datum</p>
|
||||
<p className="text-xs text-white/40 uppercase tracking-widest font-heading mb-1">{t('date')}</p>
|
||||
<p className="text-white/90 font-medium">{messe.date}</p>
|
||||
</div>
|
||||
</div>
|
||||
@@ -129,7 +133,7 @@ export const JobListingBlock = (props: JobListingBlockProps) => {
|
||||
<svg xmlns="http://www.w3.org/2000/svg" className="w-4 h-4" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.5" strokeLinecap="round" strokeLinejoin="round"><path d="M21 10c0 7-9 13-9 13s-9-6-9-13a9 9 0 0 1 18 0z"></path><circle cx="12" cy="10" r="3"></circle></svg>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-xs text-white/40 uppercase tracking-widest font-heading mb-1">Location</p>
|
||||
<p className="text-xs text-white/40 uppercase tracking-widest font-heading mb-1">{t('location')}</p>
|
||||
<p className="text-white/90 font-medium">{messe.location}</p>
|
||||
</div>
|
||||
</div>
|
||||
@@ -141,7 +145,7 @@ export const JobListingBlock = (props: JobListingBlockProps) => {
|
||||
<svg xmlns="http://www.w3.org/2000/svg" className="w-4 h-4" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.5" strokeLinecap="round" strokeLinejoin="round"><path d="M3 9l9-7 9 7v11a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2z"></path><polyline points="9 22 9 12 15 12 15 22"></polyline></svg>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-xs text-white/40 uppercase tracking-widest font-heading mb-1">Stand</p>
|
||||
<p className="text-xs text-white/40 uppercase tracking-widest font-heading mb-1">{t('booth')}</p>
|
||||
<p className="text-primary-light font-bold text-sm">{messe.booth}</p>
|
||||
</div>
|
||||
</div>
|
||||
@@ -176,11 +180,11 @@ export const JobListingBlock = (props: JobListingBlockProps) => {
|
||||
<motion.h2
|
||||
initial={{ opacity: 0, x: -20 }}
|
||||
whileInView={{ opacity: 1, x: 0 }}
|
||||
viewport={{ once: true, margin: "-100px" }}
|
||||
viewport={{ once: true, margin: "-50px" }}
|
||||
transition={{ duration: 0.8, ease: [0.16, 1, 0.3, 1] as const }}
|
||||
className="font-heading font-extrabold text-4xl text-neutral-dark mb-8"
|
||||
className="font-heading font-extrabold text-3xl md:text-4xl text-neutral-dark mb-6 md:mb-8"
|
||||
>
|
||||
{title || 'Aktuelle Stellenangebote'}
|
||||
{title}
|
||||
</motion.h2>
|
||||
|
||||
{jobs.length > 0 ? (
|
||||
@@ -188,7 +192,7 @@ export const JobListingBlock = (props: JobListingBlockProps) => {
|
||||
variants={containerVariants}
|
||||
initial="hidden"
|
||||
whileInView="visible"
|
||||
viewport={{ once: true, margin: "-100px" }}
|
||||
viewport={{ once: true, margin: "-50px" }}
|
||||
className="grid gap-6"
|
||||
>
|
||||
{jobs.map((job: any) => (
|
||||
@@ -206,7 +210,7 @@ export const JobListingBlock = (props: JobListingBlockProps) => {
|
||||
<p className="text-text-secondary mt-1">{job.location}</p>
|
||||
</div>
|
||||
<div className="flex items-center text-primary font-bold group-hover:translate-x-2 transition-transform">
|
||||
Details ansehen
|
||||
{t('viewDetails')}
|
||||
<svg xmlns="http://www.w3.org/2000/svg" className="w-5 h-5 ml-2" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.5" strokeLinecap="round" strokeLinejoin="round"><line x1="5" y1="12" x2="19" y2="12"></line><polyline points="12 5 19 12 12 19"></polyline></svg>
|
||||
</div>
|
||||
</Link>
|
||||
@@ -217,7 +221,7 @@ export const JobListingBlock = (props: JobListingBlockProps) => {
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
whileInView={{ opacity: 1, y: 0 }}
|
||||
viewport={{ once: true, margin: "-100px" }}
|
||||
viewport={{ once: true, margin: "-50px" }}
|
||||
transition={{ duration: 0.8, ease: [0.16, 1, 0.3, 1] as const }}
|
||||
className="bg-neutral-50 border border-neutral-100 rounded-2xl p-12 text-center"
|
||||
>
|
||||
|
||||
64
components/blocks/ReferencesSlider.test.tsx
Normal file
64
components/blocks/ReferencesSlider.test.tsx
Normal file
@@ -0,0 +1,64 @@
|
||||
import * as React from 'react';
|
||||
import { describe, it, expect, vi } from 'vitest';
|
||||
import { render, screen } from '@testing-library/react';
|
||||
import { ReferencesSlider, Reference } from './ReferencesSlider';
|
||||
|
||||
// Mock next/link to render standard <a> tag
|
||||
vi.mock('next/link', () => ({
|
||||
default: vi.fn(({ children, href, onClick, ...props }: any) => {
|
||||
return (
|
||||
<a href={href} onClick={onClick} {...props}>
|
||||
{children}
|
||||
</a>
|
||||
);
|
||||
})
|
||||
}));
|
||||
|
||||
// Mock next/image to render standard img tag
|
||||
vi.mock('next/image', () => ({
|
||||
default: (props: any) => {
|
||||
return <img {...props} />;
|
||||
}
|
||||
}));
|
||||
|
||||
// Mock next-intl
|
||||
vi.mock('next-intl', () => ({
|
||||
useTranslations: () => (key: string) => key,
|
||||
useLocale: () => 'en',
|
||||
NextIntlClientProvider: ({ children }: any) => <>{children}</>
|
||||
}));
|
||||
|
||||
describe('ReferencesSlider TDD', () => {
|
||||
const mockReferences: Reference[] = [
|
||||
{
|
||||
id: '1',
|
||||
title: 'Referenz Projekt Eins',
|
||||
slug: 'referenz-projekt-eins',
|
||||
category: 'Kabelnetzbau',
|
||||
},
|
||||
{
|
||||
id: '2',
|
||||
title: 'Referenz Projekt Zwei',
|
||||
slug: 'referenz-projekt-zwei',
|
||||
category: 'Bohrtechnik',
|
||||
}
|
||||
];
|
||||
|
||||
it('renders reference tiles correctly', () => {
|
||||
render(<ReferencesSlider references={mockReferences} />);
|
||||
|
||||
expect(screen.getByText('Referenz Projekt Eins')).toBeTruthy();
|
||||
expect(screen.getByText('Referenz Projekt Zwei')).toBeTruthy();
|
||||
});
|
||||
|
||||
it('renders each tile as a static block and NOT wrapped in a link', () => {
|
||||
render(<ReferencesSlider references={mockReferences} />);
|
||||
|
||||
const links = screen.queryAllByTestId('reference-tile-link');
|
||||
expect(links).toHaveLength(0);
|
||||
|
||||
const tiles = screen.getAllByTestId('reference-tile');
|
||||
expect(tiles).toHaveLength(2);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -5,6 +5,7 @@ import { motion } from 'framer-motion';
|
||||
import Link from 'next/link';
|
||||
import Image from 'next/image';
|
||||
import { HoverShineOverlay } from '@/components/ui/HoverShineOverlay';
|
||||
import { useTranslations, useLocale } from 'next-intl';
|
||||
|
||||
export interface Reference {
|
||||
id: string;
|
||||
@@ -34,18 +35,39 @@ interface ReferencesSliderProps {
|
||||
}
|
||||
|
||||
export function ReferencesSlider(props: ReferencesSliderProps) {
|
||||
const t = useTranslations('ReferencesSlider');
|
||||
const locale = useLocale();
|
||||
|
||||
const { data } = props;
|
||||
const references = props.references || [];
|
||||
const containerRef = React.useRef<HTMLDivElement>(null);
|
||||
const dragDistanceRef = React.useRef(0);
|
||||
const startXRef = React.useRef(0);
|
||||
const scrollLeftRef = React.useRef(0);
|
||||
const isDraggingRef = React.useRef(false);
|
||||
const [isDragging, setIsDragging] = React.useState(false);
|
||||
const [startX, setStartX] = React.useState(0);
|
||||
const [scrollLeft, setScrollLeft] = React.useState(0);
|
||||
const [paddingX, setPaddingX] = React.useState(24);
|
||||
|
||||
const badge = props.badge || data?.badge || 'Ausgewählte Projekte';
|
||||
const title = props.title || data?.title || 'Referenzen & Erfolge';
|
||||
const description = props.description || data?.description || 'Ein Auszug unserer erfolgreich abgeschlossenen Projekte im Bereich Kabeltiefbau, Bohrtechnik und Netzinfrastruktur.';
|
||||
const ctaLabel = props.ctaLabel || data?.ctaLabel || 'Alle Referenzen ansehen';
|
||||
const ctaHref = props.ctaHref || data?.ctaHref || '/referenzen';
|
||||
React.useEffect(() => {
|
||||
const updatePadding = () => {
|
||||
const containerEl = document.querySelector('#referenzen .container');
|
||||
if (containerEl) {
|
||||
const rect = containerEl.getBoundingClientRect();
|
||||
const computedStyle = window.getComputedStyle(containerEl);
|
||||
const pLeft = parseFloat(computedStyle.paddingLeft || '0');
|
||||
setPaddingX(rect.left + pLeft);
|
||||
}
|
||||
};
|
||||
updatePadding();
|
||||
window.addEventListener('resize', updatePadding);
|
||||
return () => window.removeEventListener('resize', updatePadding);
|
||||
}, []);
|
||||
|
||||
const badge = props.badge || data?.badge || t('badge');
|
||||
const title = props.title || data?.title || t('title');
|
||||
const description = props.description || data?.description || t('description');
|
||||
const ctaLabel = props.ctaLabel || data?.ctaLabel || t('ctaLabel');
|
||||
const ctaHref = props.ctaHref || data?.ctaHref || `/${locale}/referenzen`;
|
||||
|
||||
if (!references || references.length === 0) return null;
|
||||
|
||||
@@ -60,65 +82,84 @@ export function ReferencesSlider(props: ReferencesSliderProps) {
|
||||
const onMouseDown = (e: React.MouseEvent) => {
|
||||
if (!containerRef.current) return;
|
||||
setIsDragging(true);
|
||||
setStartX(e.pageX - containerRef.current.offsetLeft);
|
||||
setScrollLeft(containerRef.current.scrollLeft);
|
||||
isDraggingRef.current = true;
|
||||
const startXVal = e.clientX - (containerRef.current.offsetLeft || 0);
|
||||
startXRef.current = startXVal;
|
||||
scrollLeftRef.current = containerRef.current.scrollLeft || 0;
|
||||
dragDistanceRef.current = 0;
|
||||
};
|
||||
|
||||
const onMouseLeave = () => {
|
||||
setIsDragging(false);
|
||||
isDraggingRef.current = false;
|
||||
};
|
||||
|
||||
const onMouseUp = () => {
|
||||
setIsDragging(false);
|
||||
isDraggingRef.current = false;
|
||||
};
|
||||
|
||||
const onMouseMove = (e: React.MouseEvent) => {
|
||||
if (!isDragging || !containerRef.current) return;
|
||||
if (!isDraggingRef.current || !containerRef.current) return;
|
||||
e.preventDefault();
|
||||
const x = e.pageX - containerRef.current.offsetLeft;
|
||||
const walk = (x - startX) * 2; // Scroll-fast
|
||||
containerRef.current.scrollLeft = scrollLeft - walk;
|
||||
const x = e.clientX - (containerRef.current.offsetLeft || 0);
|
||||
const walk = (x - startXRef.current) * 2; // Scroll-fast
|
||||
containerRef.current.scrollLeft = scrollLeftRef.current - walk;
|
||||
dragDistanceRef.current = Math.abs(x - startXRef.current);
|
||||
};
|
||||
|
||||
|
||||
return (
|
||||
<section id="referenzen" className="py-24 bg-neutral-dark text-white relative overflow-hidden">
|
||||
<section id="referenzen" className="pt-16 pb-8 md:py-24 lg:py-32 bg-neutral-dark text-white relative overflow-hidden">
|
||||
<div className="absolute inset-0 bg-gradient-to-b from-neutral-dark via-neutral-900 to-neutral-dark z-0" />
|
||||
|
||||
<div className="container relative z-10 mb-12 flex flex-col md:flex-row justify-between items-end gap-6">
|
||||
<div>
|
||||
<div className="container relative z-10 mb-12 flex flex-col md:flex-row justify-between items-start md:items-end gap-6">
|
||||
<div className="text-left">
|
||||
<h2 className="text-primary-light font-bold tracking-wider uppercase text-sm mb-3">{badge}</h2>
|
||||
<h3 className="font-heading text-4xl md:text-5xl font-extrabold">{title}</h3>
|
||||
<h3 className="font-heading text-3xl md:text-5xl font-extrabold">{title}</h3>
|
||||
</div>
|
||||
<p className="text-white/60 max-w-md">
|
||||
<div className="text-left max-w-3xl mx-auto md:mx-0 mb-8 md:mb-16">
|
||||
{description}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="relative z-10">
|
||||
<div className="absolute right-0 top-0 bottom-0 w-16 bg-gradient-to-l from-neutral-dark to-transparent z-20 md:hidden pointer-events-none" />
|
||||
<div
|
||||
ref={containerRef}
|
||||
onMouseDown={onMouseDown}
|
||||
onMouseLeave={onMouseLeave}
|
||||
onMouseUp={onMouseUp}
|
||||
onMouseMove={onMouseMove}
|
||||
className={`select-none flex gap-6 overflow-x-auto pb-12 pt-4 px-[max(1rem,calc((100vw-1280px)/2))] md:px-[max(2rem,calc((100vw-1280px)/2))] lg:px-[max(2.5rem,calc((100vw-1280px)/2))] [scrollbar-width:none] [-ms-overflow-style:none] [&::-webkit-scrollbar]:hidden ${isDragging ? 'cursor-grabbing snap-none' : 'cursor-grab snap-x snap-mandatory'}`}
|
||||
style={{ paddingLeft: paddingX, paddingRight: paddingX }}
|
||||
className={`select-none flex gap-6 overflow-x-auto pb-8 pt-4 [scrollbar-width:none] [-ms-overflow-style:none] [&::-webkit-scrollbar]:hidden ${isDragging ? 'cursor-grabbing snap-none' : 'cursor-grab snap-x snap-mandatory'}`}
|
||||
>
|
||||
{references.map((ref, i) => {
|
||||
const imgSrc = (ref.image && typeof ref.image === 'object' && ref.image.url)
|
||||
? ref.image.url
|
||||
const imgSrc = ref.image
|
||||
? (typeof ref.image === 'string' ? ref.image : ref.image.url)
|
||||
: fallbacks[i % fallbacks.length];
|
||||
|
||||
return (
|
||||
<motion.div
|
||||
key={ref.id}
|
||||
initial={{ opacity: 0, x: 50 }}
|
||||
initial={{ opacity: 0, x: 20 }}
|
||||
whileInView={{ opacity: 1, x: 0 }}
|
||||
viewport={{ once: true }}
|
||||
transition={{ delay: i * 0.1, duration: 0.6, ease: "easeOut" }}
|
||||
className="flex-shrink-0 w-[320px] md:w-[480px] snap-center group pointer-events-auto"
|
||||
viewport={{ once: true, margin: "-50px" }}
|
||||
transition={{ delay: i * 0.1, duration: 1.0, ease: [0.16, 1, 0.3, 1] }}
|
||||
className="flex-shrink-0 w-[320px] md:w-[480px] snap-start group pointer-events-auto"
|
||||
>
|
||||
<div className="relative aspect-[16/10] bg-neutral-800 rounded-2xl overflow-hidden mb-5 border border-white/5 shadow-2xl">
|
||||
<Link
|
||||
href={`/${locale}/referenzen#${ref.slug}`}
|
||||
onClick={(e) => {
|
||||
if (dragDistanceRef.current > 5) {
|
||||
e.preventDefault();
|
||||
}
|
||||
}}
|
||||
data-testid="reference-tile"
|
||||
className="block relative select-none aspect-[16/10] bg-neutral-800 rounded-2xl overflow-hidden mb-5 border border-white/5 shadow-2xl cursor-pointer"
|
||||
>
|
||||
<Image
|
||||
draggable={false}
|
||||
src={imgSrc}
|
||||
alt={ref.title}
|
||||
fill
|
||||
@@ -135,7 +176,7 @@ export function ReferencesSlider(props: ReferencesSliderProps) {
|
||||
</span>
|
||||
<h4 className="font-heading text-xl md:text-2xl font-bold leading-tight break-words">{ref.title}</h4>
|
||||
</div>
|
||||
</div>
|
||||
</Link>
|
||||
</motion.div>
|
||||
);
|
||||
})}
|
||||
|
||||
106
components/blocks/ScaleOfImpact.tsx
Normal file
106
components/blocks/ScaleOfImpact.tsx
Normal file
@@ -0,0 +1,106 @@
|
||||
'use client';
|
||||
|
||||
import React, { useRef } from 'react';
|
||||
import { motion, useScroll, useTransform } from 'framer-motion';
|
||||
import { useLocale } from 'next-intl';
|
||||
|
||||
export function ScaleOfImpact() {
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
const locale = useLocale();
|
||||
|
||||
// Track scroll progress through this container
|
||||
const { scrollYProgress } = useScroll({
|
||||
target: containerRef,
|
||||
offset: ["start end", "end start"]
|
||||
});
|
||||
|
||||
// Map scroll progress to SVG path length
|
||||
const pathLength = useTransform(scrollYProgress, [0.2, 0.8], [0, 1]);
|
||||
|
||||
// Opacity for the massive background numbers
|
||||
const bgOpacity = useTransform(scrollYProgress, [0.1, 0.4, 0.8, 1], [0, 0.1, 0.1, 0]);
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={containerRef}
|
||||
className="relative w-full min-h-[40vh] md:min-h-[70vh] bg-[#050B14] py-12 md:py-0 flex flex-col items-center justify-center overflow-hidden border-y border-neutral-800"
|
||||
>
|
||||
{/* Massive Watermark Numbers */}
|
||||
<motion.div
|
||||
style={{ opacity: bgOpacity }}
|
||||
className="absolute inset-0 flex flex-col items-center justify-center pointer-events-none select-none overflow-hidden mix-blend-overlay"
|
||||
>
|
||||
<div className="font-heading font-black text-[20vw] leading-none text-white opacity-20 whitespace-nowrap">
|
||||
<span className="font-mono tracking-tighter">1.435.223</span>
|
||||
</div>
|
||||
</motion.div>
|
||||
|
||||
{/* Foreground Content */}
|
||||
<div className="relative z-10 container max-w-7xl mx-auto px-4 flex flex-col items-center text-center">
|
||||
|
||||
<h2 className="text-3xl sm:text-4xl md:text-5xl font-heading font-extrabold text-white mb-6 tracking-tight leading-tight">
|
||||
{locale === 'en' ? (
|
||||
<>An infrastructure <br className="md:hidden"/> that connects the country.</>
|
||||
) : (
|
||||
<>Eine Infrastruktur, <br className="md:hidden"/> die das Land verbindet.</>
|
||||
)}
|
||||
</h2>
|
||||
|
||||
<p className="text-white/60 text-lg md:text-xl max-w-2xl font-medium mb-4 md:mb-16">
|
||||
{locale === 'en'
|
||||
? "We don't just talk about the energy transition. We build it. In the last 36 months alone, we have completed a route that stretches from our headquarters in Guben all the way to Munich."
|
||||
: "Wir sprechen nicht nur über die Energiewende. Wir bauen sie. Allein in den letzten 36 Monaten haben wir eine Strecke realisiert, die von unserem Hauptsitz in Guben bis nach München reicht."
|
||||
}
|
||||
</p>
|
||||
|
||||
{/* Abstract SVG Line Animation */}
|
||||
<div className="w-full max-w-4xl h-12 md:h-32 relative">
|
||||
<svg
|
||||
width="100%"
|
||||
height="100%"
|
||||
viewBox="0 0 1000 100"
|
||||
preserveAspectRatio="none"
|
||||
className="absolute inset-0"
|
||||
>
|
||||
{/* Background dim line */}
|
||||
<path
|
||||
d="M0,50 Q250,0 500,50 T1000,50"
|
||||
fill="none"
|
||||
stroke="rgba(255,255,255,0.05)"
|
||||
strokeWidth="2"
|
||||
/>
|
||||
{/* Glowing animated line */}
|
||||
<motion.path
|
||||
d="M0,50 Q250,0 500,50 T1000,50"
|
||||
fill="none"
|
||||
stroke="#ffc107" // warning/yellow color
|
||||
strokeWidth="4"
|
||||
style={{ pathLength }}
|
||||
className="drop-shadow-[0_0_15px_rgba(255,193,7,0.8)]"
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
|
||||
{/* Static Data Grid (No jumping text) */}
|
||||
<div className="grid grid-cols-2 md:grid-cols-4 gap-y-4 gap-x-2 md:gap-12 w-full max-w-5xl mt-4 md:mt-12">
|
||||
<div className="flex flex-col items-center">
|
||||
<div className="font-mono text-3xl md:text-5xl font-black text-white tracking-tighter mb-2">200<span className="text-emerald-400">+</span></div>
|
||||
<div className="text-xs font-bold uppercase tracking-widest text-white/50">{locale === 'en' ? 'Projects' : 'Projekte'}</div>
|
||||
</div>
|
||||
<div className="flex flex-col items-center">
|
||||
<div className="font-mono text-3xl md:text-5xl font-black text-white tracking-tighter mb-2">974<span className="text-emerald-400 text-xl md:text-2xl ml-1">km</span></div>
|
||||
<div className="text-xs font-bold uppercase tracking-widest text-white/50">{locale === 'en' ? 'Cable Laying' : 'Kabelverlegung'}</div>
|
||||
</div>
|
||||
<div className="flex flex-col items-center">
|
||||
<div className="font-mono text-3xl md:text-5xl font-black text-white tracking-tighter mb-2">300<span className="text-emerald-400 text-xl md:text-2xl ml-1">km</span></div>
|
||||
<div className="text-xs font-bold uppercase tracking-widest text-white/50">{locale === 'en' ? 'Open Trenching' : 'Offener Tiefbau'}</div>
|
||||
</div>
|
||||
<div className="flex flex-col items-center">
|
||||
<div className="font-mono text-3xl md:text-5xl font-black text-white tracking-tighter mb-2">160<span className="text-emerald-400 text-xl md:text-2xl ml-1">km</span></div>
|
||||
<div className="text-xs font-bold uppercase tracking-widest text-white/50">{locale === 'en' ? 'HDD Drilling' : 'Spülbohrung'}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -38,8 +38,8 @@ const containerVariants: Variants = {
|
||||
};
|
||||
|
||||
const itemVariants: Variants = {
|
||||
hidden: { opacity: 0, y: 40 },
|
||||
visible: { opacity: 1, y: 0, transition: { duration: 0.7, ease: [0.16, 1, 0.3, 1] } },
|
||||
hidden: { opacity: 0, y: 20 },
|
||||
visible: { opacity: 1, y: 0, transition: { duration: 1.0, ease: [0.16, 1, 0.3, 1] } },
|
||||
};
|
||||
|
||||
const Icons = {
|
||||
@@ -105,17 +105,17 @@ export function ServiceDetailGrid({
|
||||
panels
|
||||
}: ServiceDetailGridProps) {
|
||||
return (
|
||||
<section className="bg-neutral-50 py-24 border-b border-neutral-100 relative overflow-hidden">
|
||||
<section className="bg-neutral-50 py-16 md:py-24 lg:py-32 border-b border-neutral-100 relative overflow-hidden">
|
||||
|
||||
<div className="container relative z-10 px-4 max-w-7xl mx-auto">
|
||||
<div className="max-w-3xl mx-auto text-center mb-20">
|
||||
<div className="max-w-3xl mb-12 md:mb-20 text-left">
|
||||
{badge && <h2 className="text-primary font-bold tracking-wider uppercase text-sm mb-4">{badge}</h2>}
|
||||
{title && (
|
||||
<h3 className="font-heading text-3xl md:text-5xl font-extrabold text-neutral-dark mb-6">
|
||||
<h3 className="font-heading text-2xl md:text-5xl font-extrabold text-neutral-dark mb-6">
|
||||
{title}
|
||||
</h3>
|
||||
)}
|
||||
<div className="w-20 h-2 bg-primary mx-auto mb-8"></div>
|
||||
<div className="w-20 h-2 bg-primary mb-8"></div>
|
||||
|
||||
<div className="space-y-6 text-text-secondary text-lg leading-relaxed">
|
||||
{descriptionParagraphs?.map((p, i) => {
|
||||
@@ -137,11 +137,11 @@ export function ServiceDetailGrid({
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
whileInView={{ opacity: 1, y: 0 }}
|
||||
viewport={{ once: true, margin: "-50px" }}
|
||||
transition={{ duration: 0.7, ease: [0.16, 1, 0.3, 1] }}
|
||||
transition={{ duration: 1.0, ease: [0.16, 1, 0.3, 1] }}
|
||||
className="max-w-4xl mx-auto mb-20 grid md:grid-cols-2 gap-0 bg-white rounded-2xl overflow-hidden shadow-lg border border-neutral-200 text-left"
|
||||
>
|
||||
{problemStatement && (
|
||||
<div className="p-8 md:p-12 border-b md:border-b-0 md:border-r border-neutral-100 bg-red-50/50">
|
||||
<div className="p-6 md:p-12 border-b md:border-b-0 md:border-r border-neutral-100 bg-red-50/50">
|
||||
<div className="flex items-center mb-6 text-red-600">
|
||||
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" className="mr-3">
|
||||
<circle cx="12" cy="12" r="10"></circle>
|
||||
@@ -154,7 +154,7 @@ export function ServiceDetailGrid({
|
||||
</div>
|
||||
)}
|
||||
{solutionStatement && (
|
||||
<div className="p-8 md:p-12 bg-primary/5">
|
||||
<div className="p-6 md:p-12 bg-primary/5">
|
||||
<div className="flex items-center mb-6 text-primary">
|
||||
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" className="mr-3">
|
||||
<path d="M22 11.08V12a10 10 0 1 1-5.93-9.14"></path>
|
||||
@@ -183,7 +183,7 @@ export function ServiceDetailGrid({
|
||||
<motion.div
|
||||
key={panel.id}
|
||||
variants={itemVariants}
|
||||
className={`group relative overflow-hidden bg-white border border-neutral-200 rounded-3xl p-8 md:p-12 transition-all duration-500 hover:border-primary/40 hover:shadow-[0_20px_40px_-15px_rgba(0,0,0,0.05)] hover:-translate-y-1 select-none ${isFullWidth ? 'md:col-span-2' : ''}`}
|
||||
className={`group relative overflow-hidden bg-white border border-neutral-200 rounded-3xl p-6 md:p-12 transition-all duration-500 hover:border-primary/40 hover:shadow-[0_20px_40px_-15px_rgba(0,0,0,0.05)] hover:-translate-y-1 select-none ${isFullWidth ? 'md:col-span-2' : ''}`}
|
||||
>
|
||||
{/* Premium Watermark Number */}
|
||||
<div className="absolute -right-8 -bottom-12 text-[12rem] font-heading font-black text-neutral-50 z-0 pointer-events-none select-none transition-all duration-700 group-hover:scale-105 group-hover:-translate-y-4 group-hover:text-primary/[0.03]">
|
||||
@@ -193,9 +193,9 @@ export function ServiceDetailGrid({
|
||||
{/* On a white card, a very subtle primary/grey shine works best */}
|
||||
<HoverShineOverlay shineColor="via-primary/5" />
|
||||
|
||||
<div className="relative z-10 flex flex-col md:flex-row justify-between items-start mb-12 gap-6">
|
||||
<div className="relative z-10 flex flex-row justify-between items-start mb-12 gap-6">
|
||||
<div className="pt-2 flex-1 min-w-0 pr-4">
|
||||
<h4 className="text-2xl md:text-3xl font-extrabold text-neutral-dark uppercase tracking-wider break-words hyphens-auto" lang="de">{panel.title}</h4>
|
||||
<h4 className="text-lg md:text-2xl font-extrabold text-neutral-dark uppercase tracking-wider text-balance break-words" lang="de">{panel.title}</h4>
|
||||
<div className="h-1 w-12 bg-primary mt-6 transition-all duration-700 group-hover:w-24 rounded-full"></div>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
import * as React from 'react';
|
||||
import { motion, Variants } from 'framer-motion';
|
||||
import Image from 'next/image';
|
||||
import Link from 'next/link';
|
||||
import { HoverShineOverlay } from '@/components/ui/HoverShineOverlay';
|
||||
import { AnimatedGlossyBorder } from '@/components/ui/AnimatedGlossyBorder';
|
||||
|
||||
@@ -67,28 +68,31 @@ export function SubCompanyTiles(props: SubCompanyTilesProps) {
|
||||
})) || defaultCompanies;
|
||||
|
||||
return (
|
||||
<section id="unternehmen" className="py-24 bg-neutral-dark border-b border-neutral-800 relative overflow-hidden">
|
||||
<section id="unternehmen" className="pt-12 pb-16 md:py-24 lg:py-32 bg-neutral-dark border-b border-neutral-800 relative overflow-hidden">
|
||||
<div className="container relative z-10 px-4">
|
||||
|
||||
{(badge || title) && (
|
||||
<div className="text-center max-w-3xl mx-auto mb-16">
|
||||
{badge && <h2 className="text-primary font-bold tracking-wider uppercase text-sm mb-3">{badge}</h2>}
|
||||
{title && <h3 className="font-heading text-4xl md:text-5xl font-extrabold text-white">{title}</h3>}
|
||||
<div className="text-center md:text-left max-w-3xl mx-auto md:mx-0 mb-16">
|
||||
{badge && <h2 className="text-emerald-400 font-bold tracking-wider uppercase text-sm mb-3">{badge}</h2>}
|
||||
{title && <h3 className="font-heading text-3xl md:text-5xl font-extrabold text-white">{title}</h3>}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<motion.div
|
||||
className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-6"
|
||||
variants={containerVariants}
|
||||
initial="hidden"
|
||||
whileInView="visible"
|
||||
viewport={{ once: true, margin: "-50px" }}
|
||||
>
|
||||
<div className="relative -mx-4 md:mx-0">
|
||||
<div className="absolute right-0 top-0 bottom-0 w-16 bg-gradient-to-l from-neutral-dark to-transparent z-20 md:hidden pointer-events-none" />
|
||||
<motion.div
|
||||
className="flex overflow-x-auto md:overflow-visible snap-x snap-mandatory pb-6 md:pb-0 md:grid md:grid-cols-2 lg:grid-cols-4 gap-4 md:gap-6 px-4 md:px-0 [scrollbar-width:none] [-ms-overflow-style:none] [&::-webkit-scrollbar]:hidden"
|
||||
variants={containerVariants}
|
||||
initial="hidden"
|
||||
whileInView="visible"
|
||||
viewport={{ once: true, margin: "-50px" }}
|
||||
>
|
||||
{companiesData.map((company: any, index: number) => {
|
||||
const isCurrent = company.title.toUpperCase() === 'E-TIB GMBH';
|
||||
const hasUrl = !!company.url;
|
||||
const CardWrapper = hasUrl ? 'a' : 'div';
|
||||
const wrapperProps = hasUrl ? { href: company.url, target: "_blank", rel: "noopener noreferrer" } : {};
|
||||
const isExternal = hasUrl && company.url.startsWith('http');
|
||||
const CardWrapper = hasUrl ? (isExternal ? 'a' : Link) : 'div';
|
||||
const wrapperProps = hasUrl ? (isExternal ? { href: company.url, target: "_blank", rel: "noopener noreferrer" } : { href: company.url }) : {};
|
||||
|
||||
// Generate logo representation based on title
|
||||
const isEtib = company.title.includes('E-TIB');
|
||||
@@ -112,33 +116,21 @@ export function SubCompanyTiles(props: SubCompanyTilesProps) {
|
||||
const positionClass = bgPositionClasses[index % bgPositionClasses.length];
|
||||
const animationClass = animationClasses[index % animationClasses.length];
|
||||
|
||||
return (
|
||||
<motion.div
|
||||
key={index}
|
||||
variants={itemVariants}
|
||||
className="h-full"
|
||||
>
|
||||
<CardWrapper
|
||||
{...wrapperProps}
|
||||
className={`group relative overflow-hidden aspect-[4/3] flex flex-col justify-center items-center shadow-xl transition-all duration-500 bg-neutral-dark block w-full h-full sm:rounded-xl select-none ${
|
||||
isCurrent
|
||||
? 'border-2 border-primary shadow-[0_0_30px_rgba(17,124,97,0.15)] cursor-default'
|
||||
: 'border border-white/5 hover:shadow-2xl hover:-translate-y-1 hover:border-white/10 cursor-pointer'
|
||||
}`}
|
||||
>
|
||||
<div className="absolute inset-0 bg-[#0a192f] z-0">
|
||||
<Image
|
||||
src={company.backgroundImage}
|
||||
alt="Background"
|
||||
fill
|
||||
className={`object-cover transition-all duration-1000 ease-out ${
|
||||
isCurrent
|
||||
? 'opacity-30 grayscale-0 scale-105'
|
||||
: 'opacity-40 grayscale-[50%] group-hover:opacity-30 group-hover:grayscale-0 group-hover:scale-110'
|
||||
}`}
|
||||
sizes="(max-width: 768px) 100vw, 33vw"
|
||||
/>
|
||||
</div>
|
||||
const CardContent = (
|
||||
<>
|
||||
<div className="absolute inset-0 bg-[#0a192f] z-0">
|
||||
<Image
|
||||
src={company.backgroundImage}
|
||||
alt="Background"
|
||||
fill
|
||||
className={`object-cover transition-all duration-1000 ease-out ${
|
||||
isCurrent
|
||||
? 'opacity-30 grayscale-0 scale-105'
|
||||
: 'opacity-40 grayscale-[50%] group-hover:opacity-30 group-hover:grayscale-0 group-hover:scale-110'
|
||||
}`}
|
||||
sizes="(max-width: 768px) 100vw, 33vw"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Heavy dark gradient overlay to make logos pop */}
|
||||
<div className={`absolute inset-0 z-0 transition-colors duration-700 mix-blend-multiply ${
|
||||
@@ -182,7 +174,7 @@ export function SubCompanyTiles(props: SubCompanyTilesProps) {
|
||||
{/* LOGO RENDERING */}
|
||||
{isEtib ? (
|
||||
<div className="flex flex-col items-center">
|
||||
<div className="relative w-48 h-14 mb-2">
|
||||
<div className="relative w-32 md:w-40 lg:w-44 h-10 md:h-12 lg:h-14 mb-2 max-w-[85%]">
|
||||
<Image
|
||||
src="/assets/logo-white.png"
|
||||
alt="E-TIB Logo"
|
||||
@@ -191,17 +183,17 @@ export function SubCompanyTiles(props: SubCompanyTilesProps) {
|
||||
/>
|
||||
</div>
|
||||
{isIng && (
|
||||
<div className="text-white/80 tracking-[0.2em] text-[10px] sm:text-xs font-semibold uppercase mt-1 text-center">
|
||||
<div className="text-white/80 tracking-[0.15em] sm:tracking-[0.2em] text-[9px] sm:text-[10px] lg:text-[11px] font-semibold uppercase mt-1 text-center">
|
||||
Ingenieurgesellschaft
|
||||
</div>
|
||||
)}
|
||||
{isVerwaltung && (
|
||||
<div className="text-white/80 tracking-[0.2em] text-[10px] sm:text-xs font-semibold uppercase mt-1 text-center">
|
||||
<div className="text-white/80 tracking-[0.15em] sm:tracking-[0.2em] text-[9px] sm:text-[10px] lg:text-[11px] font-semibold uppercase mt-1 text-center">
|
||||
Verwaltung GmbH
|
||||
</div>
|
||||
)}
|
||||
{isBohrtechnik && (
|
||||
<div className="text-white/80 tracking-[0.2em] text-[10px] sm:text-xs font-semibold uppercase mt-1 text-center">
|
||||
<div className="text-white/80 tracking-[0.15em] sm:tracking-[0.2em] text-[9px] sm:text-[10px] lg:text-[11px] font-semibold uppercase mt-1 text-center">
|
||||
Bohrtechnik GmbH
|
||||
</div>
|
||||
)}
|
||||
@@ -227,17 +219,47 @@ export function SubCompanyTiles(props: SubCompanyTilesProps) {
|
||||
{company.url && (
|
||||
<div className="absolute bottom-6 left-0 right-0 flex justify-center opacity-0 group-hover:opacity-100 transform translate-y-4 group-hover:translate-y-0 transition-all duration-500 z-20">
|
||||
<div className="flex items-center text-white font-bold uppercase text-xs tracking-widest bg-white/10 backdrop-blur-md px-4 py-2 rounded-full border border-white/20">
|
||||
Website Besuchen
|
||||
{isExternal ? (isDe ? 'Website Besuchen' : 'Visit Website') : (isDe ? 'Zum Standort' : 'View Location')}
|
||||
<svg className="w-4 h-4 ml-2" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2"><path d="M5 12h14M12 5l7 7-7 7"/></svg>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
</CardWrapper>
|
||||
|
||||
</>
|
||||
);
|
||||
|
||||
const sharedClass = `group relative overflow-hidden aspect-[4/3] flex flex-col justify-center items-center shadow-xl transition-all duration-500 bg-neutral-dark block w-full h-full rounded-2xl select-none ${
|
||||
isCurrent
|
||||
? 'border border-white/5 hover:shadow-2xl hover:-translate-y-1 hover:border-white/10 cursor-pointer'
|
||||
: 'border border-white/5 hover:shadow-2xl hover:-translate-y-1 hover:border-white/10 cursor-pointer'
|
||||
}`;
|
||||
|
||||
return (
|
||||
<motion.div
|
||||
key={index}
|
||||
variants={itemVariants}
|
||||
className="h-full flex-shrink-0 snap-center w-[85%] sm:w-[70%] md:w-full"
|
||||
>
|
||||
{hasUrl ? (
|
||||
isExternal ? (
|
||||
<a href={company.url} target="_blank" rel="noopener noreferrer" className={sharedClass}>
|
||||
{CardContent}
|
||||
</a>
|
||||
) : (
|
||||
<Link href={company.url as string} className={sharedClass}>
|
||||
{CardContent}
|
||||
</Link>
|
||||
)
|
||||
) : (
|
||||
<div className={sharedClass}>
|
||||
{CardContent}
|
||||
</div>
|
||||
)}
|
||||
</motion.div>
|
||||
);
|
||||
})}
|
||||
</motion.div>
|
||||
</motion.div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
|
||||
@@ -43,8 +43,8 @@ export const SupportCTA: React.FC<SupportCTAProps> = (props) => {
|
||||
variants={containerVariants}
|
||||
initial="hidden"
|
||||
whileInView="visible"
|
||||
viewport={{ once: true, margin: "-100px" }}
|
||||
className="mt-24 p-8 md:p-12 bg-primary-dark rounded-3xl text-white shadow-2xl relative overflow-hidden group"
|
||||
viewport={{ once: true, margin: "-50px" }}
|
||||
className="mt-12 md:mt-24 p-6 md:p-12 bg-primary-dark rounded-3xl text-white shadow-2xl relative overflow-hidden group"
|
||||
>
|
||||
<div className="absolute top-0 right-0 w-64 h-full bg-accent/5 -skew-x-12 translate-x-1/2 transition-transform duration-700 group-hover:translate-x-1/3" />
|
||||
<div className="relative z-10 max-w-2xl">
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
import * as React from 'react';
|
||||
import { motion } from 'framer-motion';
|
||||
import Image from 'next/image';
|
||||
import { useTranslations } from 'next-intl';
|
||||
|
||||
export interface TeamMember {
|
||||
id: string;
|
||||
@@ -22,96 +23,29 @@ interface TeamGridProps {
|
||||
}
|
||||
|
||||
export function TeamGrid({ members }: TeamGridProps) {
|
||||
const t = useTranslations('TeamGrid');
|
||||
|
||||
if (!members || members.length === 0) return null;
|
||||
|
||||
return (
|
||||
<section className="py-24 bg-white relative overflow-hidden">
|
||||
<section className="py-16 md:py-24 lg:py-32 bg-white relative overflow-hidden">
|
||||
<div className="container relative z-10">
|
||||
<div className="mb-12">
|
||||
<h2 className="text-primary font-bold tracking-wider uppercase text-sm mb-3">Persönliche Beratung</h2>
|
||||
<h3 className="font-heading text-4xl font-extrabold text-neutral-dark mb-4">Ihre Ansprechpartner</h3>
|
||||
<h2 className="text-primary font-bold tracking-wider uppercase text-sm mb-3">{t('badge')}</h2>
|
||||
<h3 className="font-heading text-3xl md:text-4xl font-extrabold text-neutral-dark mb-4">{t('title')}</h3>
|
||||
<p className="text-text-secondary max-w-2xl text-lg">
|
||||
Sprechen Sie direkt mit unseren Experten für Ihr regionales Projekt.
|
||||
{t('subtitle')}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{members.length > 0 && (
|
||||
<div className="mb-20">
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 30 }}
|
||||
whileInView={{ opacity: 1, y: 0 }}
|
||||
viewport={{ once: true }}
|
||||
className="relative rounded-[2.5rem] bg-neutral-dark overflow-hidden shadow-2xl border border-white/10"
|
||||
>
|
||||
{/* Premium Background Effects */}
|
||||
<div className="absolute inset-0 bg-[radial-gradient(circle_at_top_right,rgba(238,114,3,0.15),transparent_50%)] pointer-events-none" />
|
||||
<div className="absolute top-0 right-0 w-[500px] h-[500px] bg-primary/20 blur-[120px] mix-blend-screen rounded-full pointer-events-none" />
|
||||
<div className="absolute inset-0 opacity-20 pointer-events-none" style={{ backgroundImage: "url('data:image/svg+xml,%3Csvg width=\\'40\\' height=\\'40\\' viewBox=\\'0 0 40 40\\' xmlns=\\'http://www.w3.org/2000/svg\\'%3E%3Cpath d=\\'M0 0h40v40H0V0zm20 20h20v20H20V20zM0 20h20v20H0V20z\\' fill=\\'%23ffffff\\' fill-opacity=\\'0.02\\' fill-rule=\\'evenodd\\'/%3E%3C/svg%3E')" }} />
|
||||
|
||||
<div className="relative z-10 flex flex-col lg:flex-row items-center justify-between p-8 md:p-12 lg:p-20 gap-12 lg:gap-16">
|
||||
{/* Text Content */}
|
||||
<div className="w-full lg:w-[55%] flex flex-col justify-center text-white">
|
||||
<div className="inline-flex items-center space-x-2 px-4 py-1.5 mb-8 rounded-full border border-primary/30 bg-primary/10 text-primary text-xs font-bold uppercase tracking-widest self-start backdrop-blur-md shadow-[0_0_20px_rgba(238,114,3,0.2)]">
|
||||
<span className="relative flex h-2 w-2 mr-1">
|
||||
<span className="animate-ping absolute inline-flex h-full w-full rounded-full bg-primary opacity-75"></span>
|
||||
<span className="relative inline-flex rounded-full h-2 w-2 bg-primary"></span>
|
||||
</span>
|
||||
Geschäftsführung
|
||||
</div>
|
||||
<h3 className="font-heading font-black text-5xl md:text-6xl lg:text-7xl mb-4 leading-[1.1] tracking-tight">
|
||||
{members[0].name}
|
||||
</h3>
|
||||
<p className="text-xl md:text-2xl text-white/70 font-medium mb-10 leading-relaxed max-w-xl">
|
||||
{members[0].position}
|
||||
</p>
|
||||
|
||||
<div className="flex flex-col sm:flex-row gap-4">
|
||||
{members[0].email && (
|
||||
<a href={`mailto:${members[0].email}`} className="group flex items-center justify-center sm:justify-start gap-4 bg-white/5 hover:bg-primary border border-white/10 hover:border-primary px-6 py-4 rounded-2xl transition-all duration-300 shadow-lg">
|
||||
<svg className="w-5 h-5 text-white/70 group-hover:text-white transition-colors" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2"><path d="M4 4h16c1.1 0 2 .9 2 2v12c0 1.1-.9 2-2 2H4c-1.1 0-2-.9-2-2V6c0-1.1.9-2 2-2z"></path><polyline points="22,6 12,13 2,6"></polyline></svg>
|
||||
<span className="font-semibold text-white/90 group-hover:text-white transition-colors">{members[0].email}</span>
|
||||
</a>
|
||||
)}
|
||||
{members[0].phone && (
|
||||
<a href={`tel:${members[0].phone}`} className="group flex items-center justify-center sm:justify-start gap-4 bg-white/5 hover:bg-white/10 border border-white/10 px-6 py-4 rounded-2xl transition-all duration-300 shadow-lg">
|
||||
<svg className="w-5 h-5 text-white/70 group-hover:text-white transition-colors" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2"><path d="M22 16.92v3a2 2 0 0 1-2.18 2 19.79 19.79 0 0 1-8.63-3.07 19.5 19.5 0 0 1-6-6 19.79 19.79 0 0 1-3.07-8.67A2 2 0 0 1 4.11 2h3a2 2 0 0 1 2 1.72 12.84 12.84 0 0 0 .7 2.81 2 2 0 0 1-.45 2.11L8.09 9.91a16 16 0 0 0 6 6l1.27-1.27a2 2 0 0 1 2.11-.45 12.84 12.84 0 0 0 2.81.7A2 2 0 0 1 22 16.92z"></path></svg>
|
||||
<span className="font-semibold text-white/90 group-hover:text-white transition-colors">{members[0].phone}</span>
|
||||
</a>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Premium Image Block */}
|
||||
<div className="w-full lg:w-[45%] flex justify-center lg:justify-end perspective-1000">
|
||||
<div className="relative w-64 h-64 md:w-80 md:h-80 lg:w-[400px] lg:h-[400px] rounded-[3rem] overflow-hidden border-4 border-white/10 shadow-[0_30px_60px_rgba(0,0,0,0.6)] transform md:rotate-3 hover:rotate-0 transition-all duration-700 bg-neutral-800 group">
|
||||
{members[0].image && (typeof members[0].image === 'string' ? members[0].image : members[0].image.url) ? (
|
||||
<Image
|
||||
src={typeof members[0].image === 'string' ? members[0].image : members[0].image.url}
|
||||
alt={members[0].name}
|
||||
fill
|
||||
sizes="400px"
|
||||
className="object-cover scale-110 group-hover:scale-100 transition-transform duration-1000 ease-out"
|
||||
/>
|
||||
) : (
|
||||
<div className="absolute inset-0 flex items-center justify-center text-white/20">
|
||||
<svg className="w-20 h-20" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1"><path d="M19 21v-2a4 4 0 0 0-4-4H9a4 4 0 0 0-4 4v2"></path><circle cx="12" cy="7" r="4"></circle></svg>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</motion.div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-8 xl:gap-10">
|
||||
{members.slice(1).map((member, i) => (
|
||||
{members.map((member, i) => (
|
||||
<motion.div
|
||||
key={member.id}
|
||||
initial={{ opacity: 0, y: 30 }}
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
whileInView={{ opacity: 1, y: 0 }}
|
||||
viewport={{ once: true, margin: "-50px" }}
|
||||
transition={{ delay: i * 0.1, duration: 0.6, ease: [0.16, 1, 0.3, 1] }}
|
||||
transition={{ delay: i * 0.1, duration: 1.0, ease: [0.16, 1, 0.3, 1] }}
|
||||
className="group flex flex-col bg-white rounded-[2rem] border border-neutral-100 shadow-[0_8px_30px_rgb(0,0,0,0.04)] hover:shadow-[0_20px_40px_rgba(238,114,3,0.1)] transition-all duration-500 hover:-translate-y-2 overflow-hidden"
|
||||
>
|
||||
{/* Card Banner */}
|
||||
@@ -145,7 +79,7 @@ export function TeamGrid({ members }: TeamGridProps) {
|
||||
{member.branch && (
|
||||
<div className="mb-6">
|
||||
<span className="inline-block px-3 py-1 bg-neutral-100 text-neutral-600 rounded-md text-xs uppercase tracking-widest font-semibold border border-neutral-200/60">
|
||||
{member.branch === 'e-tib' ? 'E-TIB GmbH' : member.branch === 'ing' ? 'Ingenieurgesellschaft' : member.branch === 'bohrtechnik' ? 'Bohrtechnik' : member.branch}
|
||||
{member.branch === 'e-tib' ? t('branchETIB') : member.branch === 'ing' ? t('branchIng') : member.branch === 'bohrtechnik' ? t('branchBohr') : member.branch}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -78,7 +78,7 @@ export function CorporateBackground() {
|
||||
const y5 = useTransform(scrollYProgress, [0, 1], [0, -500]);
|
||||
|
||||
return (
|
||||
<div className="absolute inset-0 z-[50] overflow-hidden pointer-events-none" aria-hidden="true">
|
||||
<div className="absolute inset-0 z-[50] overflow-hidden pointer-events-none hidden md:block" aria-hidden="true">
|
||||
{/* 1. Top Right - Medium, rotating moderately */}
|
||||
<motion.div
|
||||
style={{
|
||||
|
||||
41
components/decorations/EUFundingBadge.tsx
Normal file
41
components/decorations/EUFundingBadge.tsx
Normal file
@@ -0,0 +1,41 @@
|
||||
'use client';
|
||||
|
||||
import { motion } from 'framer-motion';
|
||||
import Image from 'next/image';
|
||||
|
||||
export function EUFundingBadge() {
|
||||
return (
|
||||
<motion.div
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
transition={{ duration: 1.5, ease: [0.16, 1, 0.3, 1] }}
|
||||
className="absolute top-0 right-0 w-[260px] md:w-[380px] h-[260px] md:h-[380px] z-0 pointer-events-none select-none overflow-hidden"
|
||||
style={{
|
||||
// Perfekter weicher Fade, der sicherstellt, dass harte Kanten komplett unsichtbar werden
|
||||
maskImage: 'radial-gradient(100% 100% at top right, black 10%, rgba(0,0,0,0.8) 30%, transparent 70%)',
|
||||
WebkitMaskImage: 'radial-gradient(100% 100% at top right, black 10%, rgba(0,0,0,0.8) 30%, transparent 70%)',
|
||||
}}
|
||||
>
|
||||
{/* Background Flag */}
|
||||
<div className="absolute inset-0 w-full h-full opacity-95">
|
||||
<Image
|
||||
src="/assets/eu-flag.webp"
|
||||
alt="European Union Background"
|
||||
fill
|
||||
className="object-cover object-top"
|
||||
priority
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Text overlaid on the flag */}
|
||||
<div className="absolute top-0 right-0 pt-8 pr-8 md:pt-12 md:pr-12 flex flex-col items-end text-right z-10 drop-shadow-[0_2px_8px_rgba(0,0,0,0.9)]">
|
||||
<span className="block text-[9px] md:text-[11px] font-bold text-white/90 tracking-wide leading-snug drop-shadow-md">
|
||||
Kofinanziert von der
|
||||
</span>
|
||||
<span className="block text-[11px] md:text-[13px] font-extrabold text-white tracking-wider leading-snug uppercase drop-shadow-md">
|
||||
Europäischen Union
|
||||
</span>
|
||||
</div>
|
||||
</motion.div>
|
||||
);
|
||||
}
|
||||
@@ -1,145 +0,0 @@
|
||||
import {
|
||||
Body,
|
||||
Container,
|
||||
Head,
|
||||
Heading,
|
||||
Hr,
|
||||
Html,
|
||||
Preview,
|
||||
Section,
|
||||
Text,
|
||||
Button,
|
||||
} from '@react-email/components';
|
||||
import * as React from 'react';
|
||||
|
||||
interface BrochureDeliveryEmailProps {
|
||||
_email: string;
|
||||
brochureUrl: string;
|
||||
locale: 'en' | 'de';
|
||||
}
|
||||
|
||||
export const BrochureDeliveryEmail = ({
|
||||
_email,
|
||||
brochureUrl,
|
||||
locale = 'en',
|
||||
}: BrochureDeliveryEmailProps) => {
|
||||
const t =
|
||||
locale === 'de'
|
||||
? {
|
||||
subject: 'Ihr E-TIB Kabelkatalog',
|
||||
greeting: 'Vielen Dank für Ihr Interesse an E-TIB.',
|
||||
body: 'Anbei erhalten Sie den Link zu unserem aktuellen Produktkatalog. Dieser enthält alle wichtigen technischen Spezifikationen und detaillierten Produktdaten.',
|
||||
button: 'Katalog herunterladen',
|
||||
footer: 'Diese E-Mail wurde von e-tib.com gesendet.',
|
||||
}
|
||||
: {
|
||||
subject: 'Your E-TIB Cable Catalog',
|
||||
greeting: 'Thank you for your interest in E-TIB.',
|
||||
body: 'Below you will find the link to our current product catalog. It contains all key technical specifications and detailed product data.',
|
||||
button: 'Download Catalog',
|
||||
footer: 'This email was sent from e-tib.com.',
|
||||
};
|
||||
|
||||
return (
|
||||
<Html>
|
||||
<Head />
|
||||
<Preview>{t.subject}</Preview>
|
||||
<Body style={main}>
|
||||
<Container style={container}>
|
||||
<Section style={headerSection}>
|
||||
<Heading style={h1}>{t.subject}</Heading>
|
||||
</Section>
|
||||
|
||||
<Section style={section}>
|
||||
<Text style={text}>
|
||||
<strong>{t.greeting}</strong>
|
||||
</Text>
|
||||
<Text style={text}>{t.body}</Text>
|
||||
|
||||
<Section style={buttonContainer}>
|
||||
<Button style={button} href={brochureUrl}>
|
||||
{t.button}
|
||||
</Button>
|
||||
</Section>
|
||||
|
||||
<Hr style={hr} />
|
||||
</Section>
|
||||
<Text style={footer}>{t.footer}</Text>
|
||||
</Container>
|
||||
</Body>
|
||||
</Html>
|
||||
);
|
||||
};
|
||||
|
||||
export default BrochureDeliveryEmail;
|
||||
|
||||
const main = {
|
||||
backgroundColor: '#f6f9fc',
|
||||
fontFamily:
|
||||
'-apple-system,BlinkMacSystemFont,"Segoe UI",Roboto,"Helvetica Neue",Ubuntu,sans-serif',
|
||||
};
|
||||
|
||||
const container = {
|
||||
backgroundColor: '#ffffff',
|
||||
margin: '0 auto',
|
||||
padding: '0 0 48px',
|
||||
marginBottom: '64px',
|
||||
borderRadius: '8px',
|
||||
overflow: 'hidden',
|
||||
border: '1px solid #e6ebf1',
|
||||
};
|
||||
|
||||
const headerSection = {
|
||||
backgroundColor: '#111111',
|
||||
padding: '32px 48px',
|
||||
borderBottom: '4px solid #117c61',
|
||||
};
|
||||
|
||||
const h1 = {
|
||||
color: '#ffffff',
|
||||
fontSize: '24px',
|
||||
fontWeight: 'bold',
|
||||
margin: '0',
|
||||
};
|
||||
|
||||
const section = {
|
||||
padding: '32px 48px 0',
|
||||
};
|
||||
|
||||
const text = {
|
||||
color: '#333',
|
||||
fontSize: '16px',
|
||||
lineHeight: '24px',
|
||||
textAlign: 'left' as const,
|
||||
};
|
||||
|
||||
const buttonContainer = {
|
||||
textAlign: 'center' as const,
|
||||
marginTop: '32px',
|
||||
marginBottom: '32px',
|
||||
};
|
||||
|
||||
const button = {
|
||||
backgroundColor: '#117c61',
|
||||
borderRadius: '4px',
|
||||
color: '#ffffff',
|
||||
fontSize: '16px',
|
||||
fontWeight: 'bold',
|
||||
textDecoration: 'none',
|
||||
textAlign: 'center' as const,
|
||||
display: 'inline-block',
|
||||
padding: '16px 32px',
|
||||
};
|
||||
|
||||
const hr = {
|
||||
borderColor: '#e6ebf1',
|
||||
margin: '20px 0',
|
||||
};
|
||||
|
||||
const footer = {
|
||||
color: '#8898aa',
|
||||
fontSize: '12px',
|
||||
lineHeight: '16px',
|
||||
textAlign: 'center' as const,
|
||||
marginTop: '20px',
|
||||
};
|
||||
@@ -3,6 +3,7 @@
|
||||
import * as React from 'react';
|
||||
import { motion } from 'framer-motion';
|
||||
import { Button } from '@/components/ui/Button';
|
||||
import { sendContactFormAction } from '@/app/actions/contact';
|
||||
|
||||
export function ContactForm() {
|
||||
const [status, setStatus] = React.useState<'idle' | 'loading' | 'success' | 'error'>('idle');
|
||||
@@ -11,10 +12,18 @@ export function ContactForm() {
|
||||
e.preventDefault();
|
||||
setStatus('loading');
|
||||
|
||||
// Simulate server action using @mintel/mail or API route
|
||||
setTimeout(() => {
|
||||
setStatus('success');
|
||||
}, 1500);
|
||||
const formData = new FormData(e.currentTarget);
|
||||
try {
|
||||
const result = await sendContactFormAction(formData);
|
||||
if (result.success) {
|
||||
setStatus('success');
|
||||
} else {
|
||||
setStatus('error');
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
setStatus('error');
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
@@ -41,13 +50,31 @@ export function ContactForm() {
|
||||
<p>Vielen Dank für Ihr Interesse an der E-TIB Gruppe. Ein Ansprechpartner wird sich zeitnah mit Ihnen in Verbindung setzen.</p>
|
||||
</div>
|
||||
</motion.div>
|
||||
) : (
|
||||
) : status === 'error' ? (
|
||||
<motion.div
|
||||
initial={{ opacity: 0, scale: 0.95 }}
|
||||
animate={{ opacity: 1, scale: 1 }}
|
||||
className="bg-red-500/10 border border-red-500 text-red-600 p-6 rounded-xl flex items-start gap-4 mb-6"
|
||||
>
|
||||
<div className="bg-red-500 text-white rounded-full p-1 mt-0.5">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="3" strokeLinecap="round" strokeLinejoin="round"><line x1="18" y1="6" x2="6" y2="18"></line><line x1="6" y1="6" x2="18" y2="18"></line></svg>
|
||||
</div>
|
||||
<div>
|
||||
<h4 className="font-bold text-lg mb-1">Fehler beim Senden</h4>
|
||||
<p>Leider ist ein Fehler aufgetreten. Bitte versuchen Sie es später noch einmal oder kontaktieren Sie uns direkt per E-Mail oder Telefon.</p>
|
||||
<Button variant="outline" size="sm" className="mt-4" onClick={() => setStatus('idle')}>Erneut versuchen</Button>
|
||||
</div>
|
||||
</motion.div>
|
||||
) : null}
|
||||
|
||||
{status !== 'success' && status !== 'error' && (
|
||||
<form onSubmit={handleSubmit} className="space-y-6">
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
|
||||
<div className="space-y-2">
|
||||
<label htmlFor="firstName" className="block text-sm font-semibold text-neutral-dark">Vorname</label>
|
||||
<label htmlFor="name" className="block text-sm font-semibold text-neutral-dark">Vorname</label>
|
||||
<input
|
||||
id="firstName"
|
||||
id="name"
|
||||
name="name"
|
||||
required
|
||||
disabled={status === 'loading'}
|
||||
className="w-full bg-neutral-50 px-4 py-3 rounded-lg border border-neutral-200 focus:border-primary focus:ring-2 focus:ring-primary/20 outline-none transition-all"
|
||||
@@ -57,7 +84,8 @@ export function ContactForm() {
|
||||
<div className="space-y-2">
|
||||
<label htmlFor="lastName" className="block text-sm font-semibold text-neutral-dark">Nachname</label>
|
||||
<input
|
||||
id="lastName"
|
||||
id="lastName"
|
||||
name="lastName"
|
||||
required
|
||||
disabled={status === 'loading'}
|
||||
className="w-full bg-neutral-50 px-4 py-3 rounded-lg border border-neutral-200 focus:border-primary focus:ring-2 focus:ring-primary/20 outline-none transition-all"
|
||||
@@ -70,6 +98,7 @@ export function ContactForm() {
|
||||
<label htmlFor="email" className="block text-sm font-semibold text-neutral-dark">E-Mail Adresse</label>
|
||||
<input
|
||||
id="email"
|
||||
name="email"
|
||||
type="email"
|
||||
required
|
||||
disabled={status === 'loading'}
|
||||
@@ -82,6 +111,7 @@ export function ContactForm() {
|
||||
<label htmlFor="company" className="block text-sm font-semibold text-neutral-dark">Unternehmen (Optional)</label>
|
||||
<input
|
||||
id="company"
|
||||
name="company"
|
||||
disabled={status === 'loading'}
|
||||
className="w-full bg-neutral-50 px-4 py-3 rounded-lg border border-neutral-200 focus:border-primary focus:ring-2 focus:ring-primary/20 outline-none transition-all"
|
||||
placeholder="Firma GmbH"
|
||||
@@ -92,6 +122,7 @@ export function ContactForm() {
|
||||
<label htmlFor="message" className="block text-sm font-semibold text-neutral-dark">Ihre Nachricht</label>
|
||||
<textarea
|
||||
id="message"
|
||||
name="message"
|
||||
required
|
||||
disabled={status === 'loading'}
|
||||
rows={5}
|
||||
@@ -100,6 +131,8 @@ export function ContactForm() {
|
||||
/>
|
||||
</div>
|
||||
|
||||
<input type="text" name="company_website" style={{ display: 'none' }} tabIndex={-1} autoComplete="off" />
|
||||
|
||||
<Button
|
||||
type="submit"
|
||||
disabled={status === 'loading'}
|
||||
|
||||
@@ -7,7 +7,7 @@ export default function CTA({ data }: { data?: any }) {
|
||||
const locale = useLocale();
|
||||
|
||||
return (
|
||||
<Section className="bg-primary text-white py-32 relative overflow-hidden">
|
||||
<Section className="bg-primary text-white relative overflow-hidden">
|
||||
<div className="absolute top-0 right-0 w-1/3 h-full bg-accent/5 -skew-x-12 translate-x-1/2" />
|
||||
<div className="absolute bottom-0 left-0 w-1/4 h-1/2 bg-primary/10 rounded-full blur-3xl -translate-x-1/2 translate-y-1/2" />
|
||||
|
||||
|
||||
@@ -7,7 +7,7 @@ export default function Experience({ data }: { data?: any }) {
|
||||
const t = useTranslations('Home.experience');
|
||||
|
||||
return (
|
||||
<Section className="relative py-32 md:py-48 overflow-hidden text-white">
|
||||
<Section className="relative overflow-hidden text-white">
|
||||
<div className="absolute inset-0 z-0">
|
||||
<Image
|
||||
src="/uploads/2024/12/1694273920124-copy-2.webp"
|
||||
|
||||
@@ -24,13 +24,13 @@ export default function GallerySection({ data }: { data?: any }) {
|
||||
const lightboxIndex = photoParam ? parseInt(photoParam, 10) : 0;
|
||||
|
||||
return (
|
||||
<Section className="bg-white text-white py-32">
|
||||
<Section className="bg-white text-white">
|
||||
<Container>
|
||||
<Heading level={2} subtitle={data?.subtitle || t('subtitle')} align="center">
|
||||
{data?.title || t('title')}
|
||||
</Heading>
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-8">
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4 md:gap-8">
|
||||
{images.map((src, idx) => (
|
||||
<button
|
||||
key={idx}
|
||||
|
||||
@@ -8,6 +8,7 @@ import { useAnalytics } from '../analytics/useAnalytics';
|
||||
import { AnalyticsEvents } from '../analytics/analytics-events';
|
||||
import { useState, useEffect, useRef, useCallback } from 'react';
|
||||
import { ChevronRight } from 'lucide-react';
|
||||
import { usePathname } from 'next/navigation';
|
||||
import { AISearchResults } from '../search/AISearchResults';
|
||||
const HeroIllustration = dynamic(() => import('./HeroIllustration'), { ssr: false });
|
||||
const AIOrb = dynamic(() => import('../search/AIOrb'), { ssr: false });
|
||||
@@ -15,28 +16,29 @@ const AIOrb = dynamic(() => import('../search/AIOrb'), { ssr: false });
|
||||
export default function Hero({ data }: { data?: any }) {
|
||||
const t = useTranslations('Home.hero');
|
||||
const locale = useLocale();
|
||||
const pathname = usePathname();
|
||||
const { trackEvent } = useAnalytics();
|
||||
|
||||
const [searchQuery, setSearchQuery] = useState('');
|
||||
const [isSearchOpen, setIsSearchOpen] = useState(false);
|
||||
const [heroPlaceholder, setHeroPlaceholder] = useState(
|
||||
'Projekt beschreiben oder Kabel suchen...',
|
||||
t('searchPlaceholder')
|
||||
);
|
||||
const typingRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
|
||||
const HERO_PLACEHOLDERS = [
|
||||
'Querschnittsberechnung für 110kV Trasse', // Hochspannung
|
||||
'Wie schwer ist NAYY 4x150?',
|
||||
'Ich plane einen Solarpark, was brauche ich?', // Projekt Solar
|
||||
'Unterschied zwischen N2XSY und NAY2XSY?', // Fach
|
||||
'Mittelspannungskabel für Windkraftanlage', // Windpark
|
||||
'Welches Aluminiumkabel für 20kV?', // Mittelspannung
|
||||
t('placeholder1'),
|
||||
t('placeholder2'),
|
||||
t('placeholder3'),
|
||||
t('placeholder4'),
|
||||
t('placeholder5'),
|
||||
t('placeholder6'),
|
||||
];
|
||||
|
||||
// Typing animation for the hero search placeholder
|
||||
useEffect(() => {
|
||||
if (searchQuery) {
|
||||
setHeroPlaceholder('Projekt beschreiben oder Kabel suchen...');
|
||||
setHeroPlaceholder(t('searchPlaceholder'));
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -85,7 +87,7 @@ export default function Hero({ data }: { data?: any }) {
|
||||
|
||||
return (
|
||||
<>
|
||||
<Section className="relative min-h-[85vh] md:h-[90vh] flex flex-col items-center justify-center overflow-hidden bg-primary py-12 md:py-0 lg:py-0">
|
||||
<Section key={`home-hero-${pathname}`} className="relative min-h-[75vh] md:min-h-[90vh] flex flex-col items-center justify-center overflow-hidden bg-primary py-12 md:py-0 lg:py-0">
|
||||
<Container className="relative z-10 text-center md:text-left text-white w-full order-2 md:order-none">
|
||||
<div className="max-w-5xl mx-auto md:mx-0">
|
||||
<div>
|
||||
@@ -152,7 +154,7 @@ export default function Hero({ data }: { data?: any }) {
|
||||
size="lg"
|
||||
className="rounded-xl px-6 py-4 shrink-0 flex items-center shadow-md font-bold cursor-pointer hover:bg-accent hover:brightness-110"
|
||||
>
|
||||
Fragen
|
||||
{t('ask')}
|
||||
<ChevronRight className="w-5 h-5 ml-2 -mr-1" />
|
||||
</Button>
|
||||
</form>
|
||||
@@ -160,7 +162,7 @@ export default function Hero({ data }: { data?: any }) {
|
||||
<div className="flex flex-col sm:flex-row justify-center md:justify-start gap-4 md:gap-6">
|
||||
<div>
|
||||
<Button
|
||||
href="/contact"
|
||||
href={`/${locale}/${locale === 'de' ? 'kontakt' : 'contact'}`}
|
||||
variant="white"
|
||||
size="lg"
|
||||
className="group w-full sm:w-auto h-14 md:h-16 px-8 md:px-10 text-base md:text-lg hover:scale-105 transition-all outline-none"
|
||||
@@ -197,7 +199,7 @@ export default function Hero({ data }: { data?: any }) {
|
||||
</div>
|
||||
</Container>
|
||||
|
||||
<div className="relative md:absolute inset-0 z-0 w-full h-[40vh] md:h-full order-1 md:order-none mb-[-80px] md:mb-0 mt-[80px] md:mt-0 overflow-visible pointer-events-none animate-in fade-in zoom-in-95 duration-1000 ease-out fill-mode-both">
|
||||
<div className="relative md:absolute inset-0 z-0 w-full h-[35vh] md:h-full order-1 md:order-none mb-[-80px] md:mb-0 mt-[80px] md:mt-0 overflow-visible pointer-events-none animate-in fade-in zoom-in-95 duration-1000 ease-out fill-mode-both">
|
||||
<HeroIllustration />
|
||||
</div>
|
||||
|
||||
|
||||
@@ -9,7 +9,7 @@ export default function MeetTheTeam({ data }: { data?: any }) {
|
||||
const locale = useLocale();
|
||||
|
||||
return (
|
||||
<Section className="relative py-32 md:py-48 overflow-hidden">
|
||||
<Section className="relative overflow-hidden">
|
||||
<div className="absolute inset-0 z-0">
|
||||
<Image
|
||||
src="/uploads/2024/12/DSC08036-Large.webp"
|
||||
@@ -31,7 +31,7 @@ export default function MeetTheTeam({ data }: { data?: any }) {
|
||||
|
||||
<div className="relative mb-12">
|
||||
<div className="absolute -left-8 top-0 bottom-0 w-1 bg-accent rounded-full" />
|
||||
<p className="text-xl md:text-2xl leading-relaxed font-medium italic text-white/90 pl-8">
|
||||
<p className="text-lg md:text-2xl leading-relaxed font-medium italic text-white/90 pl-8">
|
||||
"{data?.description || t('description')}"
|
||||
</p>
|
||||
</div>
|
||||
|
||||
@@ -27,7 +27,7 @@ export default function VideoSection({ data }: { data?: any }) {
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<section ref={sectionRef} className="relative h-[70vh] overflow-hidden bg-primary">
|
||||
<section ref={sectionRef} className="relative h-[50vh] md:h-[70vh] overflow-hidden bg-primary">
|
||||
{isVisible && (
|
||||
<video className="w-full h-full object-cover opacity-60" autoPlay muted loop playsInline>
|
||||
<source
|
||||
|
||||
@@ -3,7 +3,8 @@ import Image from 'next/image';
|
||||
import { useLocale } from 'next-intl';
|
||||
import { ShieldCheck, Leaf, Lock, Accessibility, Zap, Mail, Phone } from 'lucide-react';
|
||||
import { LanguageSwitcher } from './LanguageSwitcher';
|
||||
|
||||
import { Tooltip } from '@/components/ui/Tooltip';
|
||||
import { EUFundingBadge } from '@/components/decorations/EUFundingBadge';
|
||||
interface CompanyInfo {
|
||||
contactEmail: string;
|
||||
contactPhone: string;
|
||||
@@ -18,7 +19,7 @@ export function Footer({ companyInfo }: FooterProps) {
|
||||
const locale = useLocale();
|
||||
|
||||
return (
|
||||
<footer className="bg-[#050B14] text-neutral-light py-10 md:py-16 relative overflow-hidden border-t border-white/5">
|
||||
<footer className="bg-[#050B14] text-neutral-light pt-24 pb-28 md:pt-32 md:pb-16 relative overflow-hidden border-t border-white/5">
|
||||
{/* Subtle background tech grid */}
|
||||
<div className="absolute inset-0 bg-[linear-gradient(to_right,#8080800a_1px,transparent_1px),linear-gradient(to_bottom,#8080800a_1px,transparent_1px)] bg-[size:32px_32px] [mask-image:radial-gradient(ellipse_80%_50%_at_50%_0%,#000_70%,transparent_100%)] pointer-events-none" />
|
||||
|
||||
@@ -26,9 +27,11 @@ export function Footer({ companyInfo }: FooterProps) {
|
||||
<div className="absolute top-0 left-0 w-full h-px bg-gradient-to-r from-transparent via-primary/40 to-transparent" />
|
||||
<div className="absolute top-0 left-1/2 -translate-x-1/2 w-1/3 h-[200px] bg-primary/20 blur-[100px] rounded-full pointer-events-none opacity-50" />
|
||||
|
||||
<div className="container relative z-10 grid grid-cols-1 md:grid-cols-4 lg:grid-cols-12 gap-4 md:gap-6 lg:gap-8 mb-8 md:mb-12">
|
||||
<EUFundingBadge />
|
||||
|
||||
<div className="container relative z-10 grid grid-cols-1 md:grid-cols-4 lg:grid-cols-12 gap-4 md:gap-6 lg:gap-8 mb-4 md:mb-12">
|
||||
{/* Brand Column Bento */}
|
||||
<div className="col-span-1 md:col-span-4 lg:col-span-5 flex flex-col justify-between bg-white/[0.02] border border-white/5 rounded-3xl p-6 md:p-8 lg:p-10 backdrop-blur-md relative group overflow-hidden">
|
||||
<div className="col-span-1 md:col-span-4 lg:col-span-5 flex flex-col justify-between bg-white/[0.02] border border-white/5 rounded-3xl p-5 md:p-8 lg:p-10 backdrop-blur-md relative group overflow-hidden">
|
||||
<div className="absolute inset-0 bg-gradient-to-br from-white/[0.04] to-transparent opacity-0 group-hover:opacity-100 transition-opacity duration-700" />
|
||||
|
||||
<div className="space-y-4 md:space-y-6 relative z-10">
|
||||
@@ -42,8 +45,8 @@ export function Footer({ companyInfo }: FooterProps) {
|
||||
</TransitionLink>
|
||||
<p className="text-neutral-400 text-base leading-relaxed max-w-sm">
|
||||
{locale === 'de'
|
||||
? 'Die Experten für Kabeltiefbau, Horizontalspülbohrungen und umfassende Infrastrukturprojekte. Qualität und Zuverlässigkeit aus Guben.'
|
||||
: 'Experts in cable construction, horizontal directional drilling, and comprehensive infrastructure projects. Quality and reliability from Guben.'}
|
||||
? 'Die Experten für Kabelnetzbau, Horizontalspülbohrungen und umfassende Infrastrukturprojekte. Qualität und Zuverlässigkeit aus Guben.'
|
||||
: 'Experts in cable network construction, horizontal directional drilling, and comprehensive infrastructure projects. Quality and reliability from Guben.'}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
@@ -95,58 +98,120 @@ export function Footer({ companyInfo }: FooterProps) {
|
||||
{/* Navigation Bento Grid */}
|
||||
<div className="col-span-1 md:col-span-4 lg:col-span-7 grid grid-cols-1 sm:grid-cols-2 gap-4 md:gap-6 lg:gap-8">
|
||||
|
||||
{/* Company Column */}
|
||||
<div className="bg-white/[0.02] border border-white/5 rounded-3xl p-6 md:p-8 lg:p-10 backdrop-blur-md relative group overflow-hidden flex flex-col">
|
||||
{/* Links Column */}
|
||||
<div className="bg-white/[0.02] border border-white/5 rounded-3xl p-5 md:p-8 lg:p-10 backdrop-blur-md relative group overflow-hidden flex flex-col">
|
||||
<div className="absolute inset-0 bg-gradient-to-bl from-white/[0.04] to-transparent opacity-0 group-hover:opacity-100 transition-opacity duration-700 pointer-events-none" />
|
||||
<h4 className="font-heading font-bold uppercase tracking-widest text-sm mb-6 md:mb-8 text-white">
|
||||
{locale === 'de' ? 'Unternehmen' : 'Company'}
|
||||
<span className="block w-8 h-1 bg-primary mt-3 rounded-full" />
|
||||
</h4>
|
||||
<ul className="space-y-2 md:space-y-4 relative z-10 flex-grow">
|
||||
{[
|
||||
{ label: locale === 'de' ? 'Kompetenzen' : 'Competencies', href: `/${locale}/kompetenzen` },
|
||||
{ label: locale === 'de' ? 'Über uns' : 'About us', href: `/${locale}/ueber-uns` },
|
||||
{ label: locale === 'de' ? 'Unser Team' : 'Our Team', href: `/${locale}/team` },
|
||||
{ label: locale === 'de' ? 'Zertifikate' : 'Certificates', href: `/${locale}/zertifikate` },
|
||||
{ label: locale === 'de' ? 'Karriere' : 'Career', href: `/${locale}/karriere` },
|
||||
{ label: locale === 'de' ? 'Messen & Events' : 'Fairs & Events', href: `/${locale}/messen` },
|
||||
{ label: locale === 'de' ? 'Kontakt' : 'Contact', href: `/${locale}/${locale === 'de' ? 'kontakt' : 'contact'}` },
|
||||
].map((link) => (
|
||||
<li key={link.href}>
|
||||
<TransitionLink href={link.href} className="group/link flex items-center text-neutral-400 hover:text-white transition-colors py-1 cursor-pointer">
|
||||
<span className="w-0 h-px bg-primary mr-0 group-hover/link:w-4 group-hover/link:mr-3 transition-all duration-300" />
|
||||
<span className="font-medium">{link.label}</span>
|
||||
</TransitionLink>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
|
||||
{/* Desktop View */}
|
||||
<div className="hidden md:block relative z-10 flex-grow">
|
||||
<h4 className="font-heading font-bold uppercase tracking-widest text-sm mb-6 text-white">
|
||||
{locale === 'de' ? 'Navigation' : 'Navigation'}
|
||||
<span className="block w-8 h-1 bg-primary mt-3 rounded-full" />
|
||||
</h4>
|
||||
<ul className="grid grid-cols-2 gap-x-8 gap-y-3 mb-8">
|
||||
{[
|
||||
{ label: locale === 'de' ? 'Unternehmen' : 'Company', href: `/${locale}/${locale === 'de' ? 'ueber-uns' : 'about-us'}` },
|
||||
{ label: locale === 'de' ? 'Standorte' : 'Locations', href: `/${locale}/standorte` },
|
||||
{ label: locale === 'de' ? 'Unser Team' : 'Our Team', href: `/${locale}/team` },
|
||||
{ label: locale === 'de' ? 'Karriere' : 'Career', href: `/${locale}/${locale === 'de' ? 'karriere' : 'career'}` },
|
||||
{ label: locale === 'de' ? 'Zertifikate' : 'Certificates', href: `/${locale}/zertifikate` },
|
||||
{ label: locale === 'de' ? 'Kompetenzen' : 'Competencies', href: `/${locale}/${locale === 'de' ? 'kompetenzen' : 'competencies'}` },
|
||||
{ label: locale === 'de' ? 'Referenzen' : 'References', href: `/${locale}/referenzen` },
|
||||
{ label: locale === 'de' ? 'Messen & Events' : 'Fairs & Events', href: `/${locale}/${locale === 'de' ? 'messen' : 'trade-fairs'}` },
|
||||
{ label: locale === 'de' ? 'Kontakt' : 'Contact', href: `/${locale}/${locale === 'de' ? 'kontakt' : 'contact'}` },
|
||||
].map((link) => (
|
||||
<li key={link.href}>
|
||||
<TransitionLink href={link.href} className="group/link flex items-center text-neutral-400 hover:text-white transition-colors py-1 cursor-pointer">
|
||||
<span className="w-0 h-px bg-primary mr-0 group-hover/link:w-4 group-hover/link:mr-3 transition-all duration-300" />
|
||||
<span className="font-medium">{link.label}</span>
|
||||
</TransitionLink>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
{/* Mobile View (Accordions) */}
|
||||
<div className="md:hidden relative z-10 flex-grow space-y-4">
|
||||
<details className="group/acc">
|
||||
<summary className="font-heading font-bold uppercase tracking-widest text-sm text-white list-none cursor-pointer flex justify-between items-center py-2 outline-none select-none">
|
||||
{locale === 'de' ? 'Navigation' : 'Navigation'}
|
||||
<svg className="w-4 h-4 text-white/50 transition-transform group-open/acc:rotate-180" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2"><path d="M19 9l-7 7-7-7"/></svg>
|
||||
</summary>
|
||||
<div className="pt-2 pb-4">
|
||||
<ul className="space-y-3">
|
||||
{[
|
||||
{ label: locale === 'de' ? 'Unternehmen' : 'Company', href: `/${locale}/${locale === 'de' ? 'ueber-uns' : 'about-us'}` },
|
||||
{ label: locale === 'de' ? 'Standorte' : 'Locations', href: `/${locale}/standorte` },
|
||||
{ label: locale === 'de' ? 'Unser Team' : 'Our Team', href: `/${locale}/team` },
|
||||
{ label: locale === 'de' ? 'Karriere' : 'Career', href: `/${locale}/${locale === 'de' ? 'karriere' : 'career'}` },
|
||||
{ label: locale === 'de' ? 'Zertifikate' : 'Certificates', href: `/${locale}/zertifikate` },
|
||||
{ label: locale === 'de' ? 'Kompetenzen' : 'Competencies', href: `/${locale}/${locale === 'de' ? 'kompetenzen' : 'competencies'}` },
|
||||
{ label: locale === 'de' ? 'Referenzen' : 'References', href: `/${locale}/referenzen` },
|
||||
{ label: locale === 'de' ? 'Messen & Events' : 'Fairs & Events', href: `/${locale}/${locale === 'de' ? 'messen' : 'trade-fairs'}` },
|
||||
{ label: locale === 'de' ? 'Kontakt' : 'Contact', href: `/${locale}/${locale === 'de' ? 'kontakt' : 'contact'}` },
|
||||
].map((link) => (
|
||||
<li key={link.href}>
|
||||
<TransitionLink href={link.href} className="flex items-center text-neutral-400 hover:text-white transition-colors py-1">
|
||||
<span className="font-medium">{link.label}</span>
|
||||
</TransitionLink>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
</details>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Legal Column */}
|
||||
<div className="bg-white/[0.02] border border-white/5 rounded-3xl p-6 md:p-8 lg:p-10 backdrop-blur-md relative group overflow-hidden flex flex-col">
|
||||
<div className="bg-white/[0.02] border border-white/5 rounded-3xl p-5 md:p-8 lg:p-10 backdrop-blur-md relative group overflow-hidden flex flex-col">
|
||||
<div className="absolute inset-0 bg-gradient-to-tr from-white/[0.04] to-transparent opacity-0 group-hover:opacity-100 transition-opacity duration-700 pointer-events-none" />
|
||||
<h4 className="font-heading font-bold uppercase tracking-widest text-sm mb-6 md:mb-8 text-white">
|
||||
{locale === 'de' ? 'Rechtliches' : 'Legal'}
|
||||
<span className="block w-8 h-1 bg-primary mt-3 rounded-full opacity-50" />
|
||||
</h4>
|
||||
<ul className="space-y-2 md:space-y-4 relative z-10 flex-grow">
|
||||
{[
|
||||
{ label: locale === 'de' ? 'Impressum' : 'Imprint', href: `/${locale}/impressum` },
|
||||
{ label: locale === 'de' ? 'Datenschutz' : 'Privacy Policy', href: `/${locale}/datenschutz` },
|
||||
{ label: locale === 'de' ? 'AGB' : 'Terms', href: `/${locale}/${locale === 'de' ? 'agb' : 'terms'}` },
|
||||
].map((link) => (
|
||||
<li key={link.href}>
|
||||
<TransitionLink href={link.href} className="group/link flex items-center text-neutral-400 hover:text-white transition-colors py-1 cursor-pointer">
|
||||
<span className="w-0 h-px bg-primary mr-0 group-hover/link:w-4 group-hover/link:mr-3 transition-all duration-300" />
|
||||
<span className="font-medium">{link.label}</span>
|
||||
</TransitionLink>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
|
||||
{/* Desktop View */}
|
||||
<div className="hidden md:block relative z-10 flex-grow">
|
||||
<h4 className="font-heading font-bold uppercase tracking-widest text-sm mb-6 text-white">
|
||||
{locale === 'de' ? 'Rechtliches' : 'Legal'}
|
||||
<span className="block w-8 h-1 bg-primary mt-3 rounded-full opacity-50" />
|
||||
</h4>
|
||||
<ul className="space-y-2">
|
||||
{[
|
||||
{ label: locale === 'de' ? 'Impressum' : 'Imprint', href: `/${locale}/${locale === 'de' ? 'impressum' : 'imprint'}` },
|
||||
{ label: locale === 'de' ? 'Datenschutz' : 'Privacy Policy', href: `/${locale}/${locale === 'de' ? 'datenschutz' : 'privacy-policy'}` },
|
||||
{ label: locale === 'de' ? 'AGB' : 'Terms', href: `/${locale}/${locale === 'de' ? 'agb' : 'terms'}` },
|
||||
].map((link) => (
|
||||
<li key={link.href}>
|
||||
<TransitionLink href={link.href} className="group/link flex items-center text-neutral-400 hover:text-white transition-colors py-1 cursor-pointer">
|
||||
<span className="w-0 h-px bg-primary mr-0 group-hover/link:w-4 group-hover/link:mr-3 transition-all duration-300" />
|
||||
<span className="font-medium">{link.label}</span>
|
||||
</TransitionLink>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
{/* Built Info */}
|
||||
|
||||
{/* Legal Info removed from here */}
|
||||
{/* Mobile View */}
|
||||
<div className="md:hidden relative z-10 flex-grow">
|
||||
<details className="group/acc">
|
||||
<summary className="font-heading font-bold uppercase tracking-widest text-sm text-white list-none cursor-pointer flex justify-between items-center py-2 outline-none select-none">
|
||||
{locale === 'de' ? 'Rechtliches' : 'Legal'}
|
||||
<svg className="w-4 h-4 text-white/50 transition-transform group-open/acc:rotate-180" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2"><path d="M19 9l-7 7-7-7"/></svg>
|
||||
</summary>
|
||||
<div className="pt-2 pb-4">
|
||||
<ul className="space-y-3">
|
||||
{[
|
||||
{ label: locale === 'de' ? 'Impressum' : 'Imprint', href: `/${locale}/${locale === 'de' ? 'impressum' : 'imprint'}` },
|
||||
{ label: locale === 'de' ? 'Datenschutz' : 'Privacy Policy', href: `/${locale}/${locale === 'de' ? 'datenschutz' : 'privacy-policy'}` },
|
||||
{ label: locale === 'de' ? 'AGB' : 'Terms', href: `/${locale}/${locale === 'de' ? 'agb' : 'terms'}` },
|
||||
].map((link) => (
|
||||
<li key={link.href}>
|
||||
<TransitionLink href={link.href} className="flex items-center text-neutral-400 hover:text-white transition-colors py-1">
|
||||
<span className="font-medium">{link.label}</span>
|
||||
</TransitionLink>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
</details>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
@@ -165,10 +230,18 @@ export function Footer({ companyInfo }: FooterProps) {
|
||||
|
||||
{/* Quality Badges */}
|
||||
<div className="mt-4 md:mt-6 flex flex-wrap justify-center gap-x-4 gap-y-3 md:gap-x-8 md:gap-y-4 text-neutral-400 text-[10px] md:text-xs font-medium uppercase tracking-wider px-2">
|
||||
<div className="flex items-center gap-2"><ShieldCheck className="w-4 h-4 text-neutral-400" /><span>SSL Secured</span></div>
|
||||
<div className="flex items-center gap-2"><Leaf className="w-4 h-4 text-primary" /><span>Green Hosting</span></div>
|
||||
<div className="flex items-center gap-2"><Lock className="w-4 h-4 text-neutral-400" /><span>DSGVO Compliant</span></div>
|
||||
<div className="flex items-center gap-2"><Zap className="w-4 h-4 text-neutral-400" /><span>High Performance</span></div>
|
||||
<Tooltip content={locale === 'de' ? 'Ihre Daten werden sicher und verschlüsselt übertragen.' : 'Your data is transmitted securely and encrypted.'}>
|
||||
<div className="flex items-center gap-2 cursor-help"><ShieldCheck className="w-4 h-4 text-neutral-400" /><span>SSL Secured</span></div>
|
||||
</Tooltip>
|
||||
<Tooltip content={locale === 'de' ? 'Wir nutzen 100% Ökostrom für den Betrieb unserer Website.' : 'We use 100% green energy to run our website.'}>
|
||||
<div className="flex items-center gap-2 cursor-help"><Leaf className="w-4 h-4 text-primary" /><span>Green Hosting</span></div>
|
||||
</Tooltip>
|
||||
<Tooltip content={locale === 'de' ? 'Ihre Privatsphäre ist uns wichtig. Wir halten uns streng an den Datenschutz.' : 'Your privacy is important to us. We strictly adhere to data protection laws.'}>
|
||||
<div className="flex items-center gap-2 cursor-help"><Lock className="w-4 h-4 text-neutral-400" /><span>DSGVO Compliant</span></div>
|
||||
</Tooltip>
|
||||
<Tooltip content={locale === 'de' ? 'Blitzschnelle Ladezeiten für ein optimales Nutzererlebnis.' : 'Lightning-fast loading times for an optimal user experience.'}>
|
||||
<div className="flex items-center gap-2 cursor-help"><Zap className="w-4 h-4 text-neutral-400" /><span>High Performance</span></div>
|
||||
</Tooltip>
|
||||
</div>
|
||||
|
||||
{/* Mintel Design & Version */}
|
||||
|
||||
86
components/layout/Header.test.tsx
Normal file
86
components/layout/Header.test.tsx
Normal file
@@ -0,0 +1,86 @@
|
||||
import * as React from 'react';
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
import { render, screen, fireEvent, act, waitFor } from '@testing-library/react';
|
||||
import { Header, NavLink } from './Header';
|
||||
import { usePathname } from 'next/navigation';
|
||||
|
||||
// Mock next/navigation
|
||||
vi.mock('next/navigation', () => ({
|
||||
usePathname: vi.fn(),
|
||||
}));
|
||||
|
||||
// Mock TransitionLink to render standard anchor tag and forward all props
|
||||
vi.mock('@/components/ui/TransitionLink', () => ({
|
||||
TransitionLink: ({ children, href, onClick, ...props }: any) => (
|
||||
<a href={href} onClick={onClick} {...props}>
|
||||
{children}
|
||||
</a>
|
||||
),
|
||||
}));
|
||||
|
||||
// Mock next/image
|
||||
vi.mock('next/image', () => ({
|
||||
default: ({ src, alt, ...props }: any) => <img src={src} alt={alt} {...props} />,
|
||||
}));
|
||||
|
||||
// Mock LanguageSwitcher
|
||||
vi.mock('./LanguageSwitcher', () => ({
|
||||
LanguageSwitcher: () => <div data-testid="language-switcher" />,
|
||||
}));
|
||||
|
||||
describe('Header Dropdown Navigation TDD', () => {
|
||||
const mockNavLinks: NavLink[] = [
|
||||
{
|
||||
label: 'Home',
|
||||
url: '/',
|
||||
},
|
||||
{
|
||||
label: 'Leistungen',
|
||||
url: '/leistungen',
|
||||
children: [
|
||||
{ label: 'Kabelbau', url: '/leistungen/kabelbau' },
|
||||
{ label: 'Bohrtechnik', url: '/leistungen/bohrtechnik' },
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it('closes the dropdown menu immediately after a page navigation occurs', async () => {
|
||||
// 1. Set initial pathname
|
||||
let currentPath = '/de';
|
||||
vi.mocked(usePathname).mockImplementation(() => currentPath);
|
||||
|
||||
// 2. Render Header
|
||||
const { rerender } = render(<Header navLinks={mockNavLinks} />);
|
||||
|
||||
// 3. Hover over "Leistungen" menu item to open dropdown
|
||||
const leistungenNavItem = screen.getByText('Leistungen');
|
||||
const navItemContainer = leistungenNavItem.closest('.group');
|
||||
expect(navItemContainer).toBeTruthy();
|
||||
|
||||
act(() => {
|
||||
fireEvent.mouseEnter(navItemContainer!);
|
||||
});
|
||||
|
||||
// 4. Assert dropdown is open and children are rendered
|
||||
expect(screen.getByText('Kabelbau')).toBeTruthy();
|
||||
expect(screen.getByText('Bohrtechnik')).toBeTruthy();
|
||||
|
||||
// 5. Simulate page navigation by changing the path
|
||||
currentPath = '/de/leistungen/kabelbau';
|
||||
|
||||
act(() => {
|
||||
// Trigger pathname hook change and force rerender
|
||||
rerender(<Header navLinks={mockNavLinks} />);
|
||||
});
|
||||
|
||||
// 6. Assert dropdown is closed (children are not in the document)
|
||||
await waitFor(() => {
|
||||
expect(screen.queryByText('Kabelbau')).toBeNull();
|
||||
expect(screen.queryByText('Bohrtechnik')).toBeNull();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -11,6 +11,7 @@ export interface NavLink {
|
||||
label: string;
|
||||
url: string;
|
||||
children?: NavLink[];
|
||||
isGroupLabel?: boolean;
|
||||
}
|
||||
|
||||
interface HeaderProps {
|
||||
@@ -21,6 +22,7 @@ export function Header({ navLinks }: HeaderProps) {
|
||||
const [isScrolled, setIsScrolled] = React.useState(false);
|
||||
const [forceSolid, setForceSolid] = React.useState(false);
|
||||
const [headerVariant, setHeaderVariant] = React.useState<'capsule' | 'standard'>('capsule');
|
||||
const [isMobileMenuOpen, setIsMobileMenuOpen] = React.useState(false);
|
||||
const pathname = usePathname() || '/';
|
||||
|
||||
// Scroll bound shine effect (continuous looping as user scrolls)
|
||||
@@ -54,6 +56,7 @@ export function Header({ navLinks }: HeaderProps) {
|
||||
|
||||
// Re-check when pathname changes
|
||||
checkHeaderState();
|
||||
setIsMobileMenuOpen(false);
|
||||
|
||||
return () => {
|
||||
window.removeEventListener('scroll', handleScroll);
|
||||
@@ -71,7 +74,7 @@ export function Header({ navLinks }: HeaderProps) {
|
||||
className={`fixed top-0 left-0 right-0 z-50 flex justify-center transition-all duration-700 ease-[cubic-bezier(0.16,1,0.3,1)] ${
|
||||
isStandard
|
||||
? 'bg-[#050B14]/95 backdrop-blur-xl border-b border-white/10'
|
||||
: (isSolidMode ? 'pt-4 px-4' : 'pt-0 px-0')
|
||||
: (isSolidMode ? 'pt-4 px-4 sm:px-6' : 'pt-0 px-0')
|
||||
}`}
|
||||
>
|
||||
<div
|
||||
@@ -79,7 +82,7 @@ export function Header({ navLinks }: HeaderProps) {
|
||||
isStandard
|
||||
? 'py-4 px-6 md:px-8'
|
||||
: isSolidMode
|
||||
? 'rounded-full bg-white/50 backdrop-blur-xl shadow-[0_8px_32px_rgba(0,0,0,0.08)] border border-white/40 py-2.5 px-6 md:px-8'
|
||||
? 'rounded-full bg-white/50 backdrop-blur-xl shadow-[0_8px_32px_rgba(0,0,0,0.08)] border border-white/40 py-2.5 px-8 md:px-10'
|
||||
: 'rounded-none bg-transparent py-6 px-4 md:px-8 border border-transparent'
|
||||
}`}
|
||||
>
|
||||
@@ -98,6 +101,7 @@ export function Header({ navLinks }: HeaderProps) {
|
||||
fill
|
||||
className="object-contain object-left group-hover:scale-105 transition-transform duration-500 origin-left"
|
||||
priority
|
||||
fetchPriority="high"
|
||||
sizes="192px"
|
||||
/>
|
||||
{/* Masked sweep overlay on hover */}
|
||||
@@ -135,8 +139,54 @@ export function Header({ navLinks }: HeaderProps) {
|
||||
<div className="flex items-center gap-2 md:gap-4 lg:gap-6">
|
||||
<div className={`hidden md:block h-5 w-px transition-colors duration-300 ${isSolidMode ? 'bg-neutral-200' : 'bg-white/20'}`} />
|
||||
|
||||
{/* Language Switcher visible on all devices */}
|
||||
<div className="z-50 relative flex items-center">
|
||||
{/* Quick Actions (Mobile Only) */}
|
||||
<div className="md:hidden flex items-center gap-2">
|
||||
<a
|
||||
href="tel:+492572946760"
|
||||
className={`relative flex items-center justify-center w-9 h-9 rounded-full transition-all duration-300 ${
|
||||
isSolidMode
|
||||
? 'bg-primary text-white shadow-md shadow-primary/20 hover:-translate-y-0.5'
|
||||
: 'bg-white text-primary shadow-md hover:-translate-y-0.5'
|
||||
}`}
|
||||
aria-label="Anrufen"
|
||||
>
|
||||
<svg className="w-[16px] h-[16px]" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2.5} d="M3 5a2 2 0 012-2h3.28a1 1 0 01.948.684l1.498 4.493a1 1 0 01-.502 1.21l-2.257 1.13a11.042 11.042 0 005.516 5.516l1.13-2.257a1 1 0 011.21-.502l4.493 1.498a1 1 0 01.684.949V19a2 2 0 01-2 2h-1C9.716 21 3 14.284 3 6V5z" />
|
||||
</svg>
|
||||
</a>
|
||||
<a
|
||||
href="mailto:info@e-tib.com"
|
||||
className={`relative flex items-center justify-center w-9 h-9 rounded-full transition-all duration-300 ${
|
||||
isSolidMode
|
||||
? 'bg-neutral-100 text-neutral-600 hover:text-primary hover:bg-neutral-200'
|
||||
: 'bg-white/10 text-white backdrop-blur-md border border-white/20 hover:bg-white/20'
|
||||
}`}
|
||||
aria-label="E-Mail"
|
||||
>
|
||||
<svg className="w-[16px] h-[16px]" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2.2} d="M3 8l7.89 5.26a2 2 0 002.22 0L21 8M5 19h14a2 2 0 002-2V7a2 2 0 00-2-2H5a2 2 0 00-2 2v10a2 2 0 002 2z" />
|
||||
</svg>
|
||||
</a>
|
||||
<a
|
||||
href="https://maps.google.com/?q=E-TIB+GmbH,+Greven"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className={`relative flex items-center justify-center w-9 h-9 rounded-full transition-all duration-300 ${
|
||||
isSolidMode
|
||||
? 'bg-neutral-100 text-neutral-600 hover:text-primary hover:bg-neutral-200'
|
||||
: 'bg-white/10 text-white backdrop-blur-md border border-white/20 hover:bg-white/20'
|
||||
}`}
|
||||
aria-label="Route"
|
||||
>
|
||||
<svg className="w-[16px] h-[16px]" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2.2} d="M17.657 16.657L13.414 20.9a1.998 1.998 0 01-2.827 0l-4.244-4.243a8 8 0 1111.314 0z" />
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2.2} d="M15 11a3 3 0 11-6 0 3 3 0 016 0z" />
|
||||
</svg>
|
||||
</a>
|
||||
</div>
|
||||
|
||||
{/* Language Switcher visible on desktop only */}
|
||||
<div className="z-50 relative hidden md:flex items-center">
|
||||
<LanguageSwitcher isSolidMode={isSolidMode} />
|
||||
</div>
|
||||
|
||||
@@ -153,6 +203,7 @@ export function Header({ navLinks }: HeaderProps) {
|
||||
{/* Sleek shine sweep effect */}
|
||||
<span className="absolute top-0 -left-[150%] h-[200%] w-[150%] bg-gradient-to-r from-transparent via-white/30 to-transparent skew-x-[-20deg] group-hover/cta:left-[100%] transition-all duration-1000 ease-[cubic-bezier(0.16,1,0.3,1)] z-30 pointer-events-none" />
|
||||
</TransitionLink>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -164,6 +215,20 @@ export function Header({ navLinks }: HeaderProps) {
|
||||
function NavItem({ link, currentLocale, pathname, isSolidMode }: { link: NavLink, currentLocale: string, pathname: string, isSolidMode: boolean }) {
|
||||
const [isHovered, setIsHovered] = React.useState(false);
|
||||
|
||||
// Close dropdown on navigation
|
||||
React.useEffect(() => {
|
||||
setIsHovered(false);
|
||||
}, [pathname]);
|
||||
|
||||
// Close dropdown on scroll
|
||||
React.useEffect(() => {
|
||||
const handleScroll = () => {
|
||||
if (isHovered) setIsHovered(false);
|
||||
};
|
||||
window.addEventListener('scroll', handleScroll, { passive: true });
|
||||
return () => window.removeEventListener('scroll', handleScroll);
|
||||
}, [isHovered]);
|
||||
|
||||
const mappedUrl = link.url.startsWith('/') && !link.url.match(/^\/(en|de)/)
|
||||
? `/${currentLocale}${link.url}`
|
||||
: link.url;
|
||||
@@ -218,15 +283,25 @@ function NavItem({ link, currentLocale, pathname, isSolidMode }: { link: NavLink
|
||||
{/* Top glowing accent line */}
|
||||
<div className="absolute top-0 left-0 w-full h-[2px] bg-gradient-to-r from-transparent via-primary to-transparent opacity-70" />
|
||||
|
||||
{link.children.map((child) => {
|
||||
{link.children.map((child, idx) => {
|
||||
const childUrl = child.url.startsWith('/') && !child.url.match(/^\/(en|de)/)
|
||||
? `/${currentLocale}${child.url}`
|
||||
: child.url;
|
||||
const isChildActive = pathname === childUrl;
|
||||
|
||||
if (child.isGroupLabel) {
|
||||
return (
|
||||
<div key={`group-${idx}`} className={`mt-2 mb-1 px-4 text-[11px] font-bold uppercase tracking-wider ${isSolidMode ? 'text-neutral-400' : 'text-neutral-500'}`}>
|
||||
{child.label}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<TransitionLink
|
||||
key={child.url}
|
||||
href={childUrl}
|
||||
onClick={() => setIsHovered(false)}
|
||||
className={`group/dropitem relative block px-4 py-3.5 rounded-2xl text-sm font-medium transition-all duration-300 overflow-hidden ${
|
||||
isSolidMode
|
||||
? isChildActive ? 'bg-primary/10 text-primary' : 'text-neutral-600 hover:text-primary hover:bg-neutral-50/80'
|
||||
|
||||
70
components/layout/LanguageSwitcher.test.tsx
Normal file
70
components/layout/LanguageSwitcher.test.tsx
Normal file
@@ -0,0 +1,70 @@
|
||||
import * as React from 'react';
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
import { render, screen } from '@testing-library/react';
|
||||
import { LanguageSwitcher } from './LanguageSwitcher';
|
||||
import { usePathname } from 'next/navigation';
|
||||
|
||||
// Mock next/navigation
|
||||
vi.mock('next/navigation', () => ({
|
||||
usePathname: vi.fn(),
|
||||
}));
|
||||
|
||||
// Mock TransitionLink to render standard anchor tag and forward all props
|
||||
vi.mock('@/components/ui/TransitionLink', () => ({
|
||||
TransitionLink: ({ children, href, onClick, ...props }: any) => (
|
||||
<a href={href} onClick={onClick} {...props}>
|
||||
{children}
|
||||
</a>
|
||||
),
|
||||
}));
|
||||
|
||||
// Mock HoverShineOverlay
|
||||
vi.mock('@/components/ui/HoverShineOverlay', () => ({
|
||||
HoverShineOverlay: () => <div data-testid="hover-shine" />,
|
||||
}));
|
||||
|
||||
describe('LanguageSwitcher TDD', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it('translates /en/trade-fairs to /de/messen when switching to German', () => {
|
||||
(usePathname as any).mockReturnValue('/en/trade-fairs');
|
||||
render(<LanguageSwitcher />);
|
||||
|
||||
const deLink = screen.getByLabelText('Switch to Deutsch');
|
||||
expect(deLink.getAttribute('href')).toBe('/de/messen');
|
||||
});
|
||||
|
||||
it('translates /de/messen to /en/trade-fairs when switching to English', () => {
|
||||
(usePathname as any).mockReturnValue('/de/messen');
|
||||
render(<LanguageSwitcher />);
|
||||
|
||||
const enLink = screen.getByLabelText('Switch to English');
|
||||
expect(enLink.getAttribute('href')).toBe('/en/trade-fairs');
|
||||
});
|
||||
|
||||
it('translates /en/about-us to /de/ueber-uns when switching to German', () => {
|
||||
vi.mocked(usePathname).mockReturnValue('/en/about-us');
|
||||
render(<LanguageSwitcher />);
|
||||
|
||||
const deLink = screen.getByLabelText('Switch to Deutsch');
|
||||
expect(deLink.getAttribute('href')).toBe('/de/ueber-uns');
|
||||
});
|
||||
|
||||
it('translates /de/ueber-uns to /en/about-us when switching to English', () => {
|
||||
vi.mocked(usePathname).mockReturnValue('/de/ueber-uns');
|
||||
render(<LanguageSwitcher />);
|
||||
|
||||
const enLink = screen.getByLabelText('Switch to English');
|
||||
expect(enLink.getAttribute('href')).toBe('/en/about-us');
|
||||
});
|
||||
|
||||
it('preserves nested paths like blog posts', () => {
|
||||
vi.mocked(usePathname).mockReturnValue('/de/blog/some-post');
|
||||
render(<LanguageSwitcher />);
|
||||
|
||||
const enLink = screen.getByLabelText('Switch to English');
|
||||
expect(enLink.getAttribute('href')).toBe('/en/blog/some-post');
|
||||
});
|
||||
});
|
||||
@@ -3,6 +3,41 @@
|
||||
import { TransitionLink } from '@/components/ui/TransitionLink';
|
||||
import { usePathname } from 'next/navigation';
|
||||
import { HoverShineOverlay } from '@/components/ui/HoverShineOverlay';
|
||||
import enMessages from '@/messages/en.json';
|
||||
import deMessages from '@/messages/de.json';
|
||||
|
||||
const getMessages = (locale: string) => {
|
||||
return locale === 'de' ? deMessages : enMessages;
|
||||
};
|
||||
|
||||
// Synchronous version of mapSlugToFileSlug
|
||||
function syncMapSlugToFileSlug(translatedSlug: string, locale: string): string {
|
||||
const messages = getMessages(locale);
|
||||
const slugs = messages.Slugs;
|
||||
|
||||
if (slugs.pages && translatedSlug in slugs.pages) {
|
||||
return slugs.pages[translatedSlug as keyof typeof slugs.pages];
|
||||
}
|
||||
return translatedSlug;
|
||||
}
|
||||
|
||||
// Synchronous version of mapFileSlugToTranslated
|
||||
function syncMapFileSlugToTranslated(fileSlug: string, locale: string): string {
|
||||
const messages = getMessages(locale);
|
||||
const slugs = messages.Slugs;
|
||||
|
||||
const sections = [slugs.pages];
|
||||
for (const sectionData of sections) {
|
||||
if (sectionData && typeof sectionData === 'object') {
|
||||
for (const [translatedSlug, mappedSlug] of Object.entries(sectionData)) {
|
||||
if (mappedSlug === fileSlug) {
|
||||
return translatedSlug;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return fileSlug;
|
||||
}
|
||||
|
||||
interface LanguageSwitcherProps {
|
||||
mobile?: boolean;
|
||||
@@ -14,8 +49,23 @@ export function LanguageSwitcher({ mobile = false, isSolidMode = false }: Langua
|
||||
const currentLocale = pathname.startsWith('/en') ? 'en' : 'de';
|
||||
|
||||
const getSwitchedUrl = (newLocale: string) => {
|
||||
const pathWithoutLocale = pathname.replace(/^\/(en|de)/, '');
|
||||
return `/${newLocale}${pathWithoutLocale === '' ? '' : pathWithoutLocale}`;
|
||||
const segments = pathname.split('/').filter(Boolean);
|
||||
if (segments.length === 0) {
|
||||
return `/${newLocale}`;
|
||||
}
|
||||
|
||||
const currentLoc = segments[0] === 'en' || segments[0] === 'de' ? segments[0] : 'de';
|
||||
const hasLocale = segments[0] === 'en' || segments[0] === 'de';
|
||||
const pathSegments = hasLocale ? segments.slice(1) : segments;
|
||||
|
||||
if (pathSegments.length === 1) {
|
||||
const slug = pathSegments[0];
|
||||
const fileSlug = syncMapSlugToFileSlug(slug, currentLoc);
|
||||
const translatedSlug = syncMapFileSlugToTranslated(fileSlug, newLocale);
|
||||
return `/${newLocale}/${translatedSlug}`;
|
||||
}
|
||||
|
||||
return `/${newLocale}/${pathSegments.join('/')}`;
|
||||
};
|
||||
|
||||
const locales = [
|
||||
|
||||
@@ -3,12 +3,13 @@
|
||||
import * as React from 'react';
|
||||
import { usePathname } from 'next/navigation';
|
||||
import { TransitionLink } from '@/components/ui/TransitionLink';
|
||||
import { Home, Briefcase, Users, Mail } from 'lucide-react';
|
||||
import { motion } from 'framer-motion';
|
||||
import { Home, Layers, Star, Mail, Menu } from 'lucide-react';
|
||||
import { motion, AnimatePresence } from 'framer-motion';
|
||||
|
||||
export interface NavLink {
|
||||
label: string;
|
||||
url: string;
|
||||
children?: NavLink[];
|
||||
}
|
||||
|
||||
interface MobileBottomNavProps {
|
||||
@@ -18,84 +19,213 @@ interface MobileBottomNavProps {
|
||||
|
||||
export function MobileBottomNav({ navLinks, currentLocale }: MobileBottomNavProps) {
|
||||
const pathname = usePathname() || '/';
|
||||
const [isFlyoutOpen, setIsFlyoutOpen] = React.useState(false);
|
||||
|
||||
// Scroll lock when flyout is open
|
||||
React.useEffect(() => {
|
||||
if (isFlyoutOpen) {
|
||||
document.body.style.overflow = 'hidden';
|
||||
document.documentElement.style.overflow = 'hidden';
|
||||
} else {
|
||||
document.body.style.overflow = '';
|
||||
document.documentElement.style.overflow = '';
|
||||
}
|
||||
return () => {
|
||||
document.body.style.overflow = '';
|
||||
document.documentElement.style.overflow = '';
|
||||
};
|
||||
}, [isFlyoutOpen]);
|
||||
|
||||
// Construct our bottom nav items based on the passed links
|
||||
const items = React.useMemo(() => {
|
||||
const homeUrl = `/${currentLocale}`;
|
||||
|
||||
// Find the specific links from the provided navLinks (which might be translated)
|
||||
// We do a simple match based on index or keywords if needed, but since we know the order:
|
||||
// 0: Kompetenzen, 1: Über uns, 2: Karriere, 3: Messen
|
||||
const competenciesUrl = navLinks[0]?.url || `/${currentLocale}/kompetenzen`;
|
||||
const aboutUrl = navLinks[1]?.url || `/${currentLocale}/ueber-uns`;
|
||||
const referencesUrl = navLinks[2]?.url || `/${currentLocale}/referenzen`;
|
||||
const contactUrl = `/${currentLocale}/${currentLocale === 'de' ? 'kontakt' : 'contact'}`;
|
||||
|
||||
return [
|
||||
{
|
||||
id: 'home',
|
||||
label: currentLocale === 'de' ? 'Start' : 'Home',
|
||||
url: homeUrl,
|
||||
icon: <Home className="w-6 h-6 mb-1" strokeWidth={2} />,
|
||||
icon: <Home className="w-[22px] h-[22px] md:w-6 md:h-6" strokeWidth={2.2} />,
|
||||
exactMatch: true
|
||||
},
|
||||
{
|
||||
id: 'kompetenzen',
|
||||
label: navLinks[0]?.label || (currentLocale === 'de' ? 'Kompetenzen' : 'Competencies'),
|
||||
url: competenciesUrl,
|
||||
icon: <Briefcase className="w-6 h-6 mb-1" strokeWidth={2} />
|
||||
icon: <Layers className="w-[22px] h-[22px] md:w-6 md:h-6" strokeWidth={2.2} />
|
||||
},
|
||||
{
|
||||
label: navLinks[1]?.label || (currentLocale === 'de' ? 'Über uns' : 'About Us'),
|
||||
url: aboutUrl,
|
||||
icon: <Users className="w-6 h-6 mb-1" strokeWidth={2} />
|
||||
id: 'referenzen',
|
||||
label: navLinks[2]?.label || (currentLocale === 'de' ? 'Referenzen' : 'References'),
|
||||
url: referencesUrl,
|
||||
icon: <Star className="w-[22px] h-[22px] md:w-6 md:h-6" strokeWidth={2.2} />
|
||||
},
|
||||
{
|
||||
id: 'kontakt',
|
||||
label: currentLocale === 'de' ? 'Kontakt' : 'Contact',
|
||||
url: contactUrl,
|
||||
icon: <Mail className="w-6 h-6 mb-1" strokeWidth={2} />
|
||||
icon: <Mail className="w-[22px] h-[22px] md:w-6 md:h-6" strokeWidth={2.2} />
|
||||
},
|
||||
{
|
||||
id: 'menu',
|
||||
label: currentLocale === 'de' ? 'Mehr' : 'Menu',
|
||||
url: '#',
|
||||
icon: <Menu className="w-[24px] h-[24px] md:w-6 md:h-6" strokeWidth={2.5} />,
|
||||
isFlyoutTrigger: true
|
||||
}
|
||||
];
|
||||
}, [currentLocale, navLinks]);
|
||||
|
||||
return (
|
||||
<div className="md:hidden fixed bottom-0 left-0 right-0 z-50 px-2 pb-6 pt-2 bg-gradient-to-t from-white via-white to-white/90 backdrop-blur-xl border-t border-neutral-200/50 shadow-[0_-8px_32px_rgba(0,0,0,0.05)] safe-area-bottom">
|
||||
<nav className="flex justify-around items-center max-w-md mx-auto">
|
||||
{items.map((item) => {
|
||||
// Determine if active
|
||||
const mappedUrl = item.url.startsWith('/') && !item.url.match(/^\/(en|de)/)
|
||||
? `/${currentLocale}${item.url}`
|
||||
: item.url;
|
||||
|
||||
const isActive = item.exactMatch
|
||||
? pathname === mappedUrl
|
||||
: (pathname === mappedUrl || (mappedUrl !== `/${currentLocale}` && pathname?.startsWith(`${mappedUrl}/`)));
|
||||
const flyoutLinks = React.useMemo(() => {
|
||||
const aboutNode = navLinks[1];
|
||||
const karriereNode = navLinks[3];
|
||||
const messenNode = navLinks[4];
|
||||
|
||||
let links: {label: string, url: string}[] = [];
|
||||
if (aboutNode?.children) {
|
||||
links.push(...aboutNode.children.map(c => ({ label: c.label, url: c.url })));
|
||||
}
|
||||
if (karriereNode) links.push({ label: karriereNode.label, url: karriereNode.url });
|
||||
if (messenNode) links.push({ label: messenNode.label, url: messenNode.url });
|
||||
return links;
|
||||
}, [navLinks]);
|
||||
|
||||
return (
|
||||
<TransitionLink
|
||||
key={item.label}
|
||||
href={mappedUrl}
|
||||
className={`relative flex flex-col items-center justify-center w-full min-h-[44px] py-1 transition-colors duration-300 select-none ${
|
||||
isActive ? 'text-primary' : 'text-neutral-500 hover:text-neutral-900'
|
||||
}`}
|
||||
>
|
||||
<div className="relative flex flex-col items-center justify-center">
|
||||
{/* Active Icon Background */}
|
||||
return (
|
||||
<>
|
||||
<div className="md:hidden fixed bottom-0 left-0 right-0 z-[9999] px-2 pb-[calc(env(safe-area-inset-bottom)_+_12px)] pt-4 pointer-events-none flex justify-center">
|
||||
<nav className="flex justify-between items-center bg-white/75 backdrop-blur-2xl border border-white/60 shadow-[0_8px_32px_rgba(0,0,0,0.12)] rounded-3xl p-1.5 pointer-events-auto w-full max-w-[480px]">
|
||||
{items.map((item) => {
|
||||
const mappedUrl = item.url.startsWith('/') && !item.url.match(/^\/(en|de)/)
|
||||
? `/${currentLocale}${item.url}`
|
||||
: item.url;
|
||||
|
||||
let isActive = false;
|
||||
if (item.isFlyoutTrigger) {
|
||||
isActive = isFlyoutOpen;
|
||||
} else {
|
||||
isActive = item.exactMatch
|
||||
? pathname === mappedUrl
|
||||
: (pathname === mappedUrl || (mappedUrl !== `/${currentLocale}` && pathname?.startsWith(`${mappedUrl}/`)));
|
||||
}
|
||||
|
||||
const innerContent = (
|
||||
<>
|
||||
{isActive && (
|
||||
<motion.div
|
||||
layoutId="mobile-nav-active-bg"
|
||||
className="absolute inset-0 bg-primary/10 rounded-full scale-150"
|
||||
transition={{ type: 'spring', stiffness: 500, damping: 30 }}
|
||||
className="absolute inset-0 bg-primary/10 rounded-2xl shadow-sm border border-primary/10"
|
||||
transition={{ type: 'spring', stiffness: 400, damping: 25 }}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Icon wrapper to ensure it sits above the background */}
|
||||
<div className="relative z-10 transition-transform duration-300 active:scale-90">
|
||||
{item.icon}
|
||||
<div className="relative flex flex-col items-center justify-center z-10 w-full h-full">
|
||||
<motion.div
|
||||
whileTap={{ scale: 0.8 }}
|
||||
animate={{ y: isActive ? -2 : 0 }}
|
||||
transition={{ type: 'spring', stiffness: 400, damping: 17 }}
|
||||
>
|
||||
{item.icon}
|
||||
</motion.div>
|
||||
<motion.span
|
||||
animate={{ y: isActive ? 0 : 2 }}
|
||||
className="text-[9px] font-bold mt-1 tracking-wide"
|
||||
>
|
||||
{item.label}
|
||||
</motion.span>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
|
||||
if (item.isFlyoutTrigger) {
|
||||
return (
|
||||
<button
|
||||
key={item.label}
|
||||
onClick={() => setIsFlyoutOpen(!isFlyoutOpen)}
|
||||
className={`relative flex flex-col items-center justify-center flex-1 h-[62px] transition-colors duration-300 select-none w-full focus:outline-none ${
|
||||
isActive ? 'text-primary' : 'text-neutral-600 hover:text-neutral-900'
|
||||
}`}
|
||||
style={{ WebkitTapHighlightColor: 'transparent', touchAction: 'manipulation' }}
|
||||
>
|
||||
{innerContent}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<TransitionLink
|
||||
key={item.label}
|
||||
href={mappedUrl}
|
||||
onClick={() => setIsFlyoutOpen(false)}
|
||||
className={`relative flex flex-col items-center justify-center flex-1 h-[62px] transition-colors duration-300 select-none ${
|
||||
isActive ? 'text-primary' : 'text-neutral-600 hover:text-neutral-900'
|
||||
}`}
|
||||
style={{ WebkitTapHighlightColor: 'transparent', touchAction: 'manipulation' }}
|
||||
>
|
||||
{innerContent}
|
||||
</TransitionLink>
|
||||
);
|
||||
})}
|
||||
</nav>
|
||||
</div>
|
||||
|
||||
<AnimatePresence>
|
||||
{isFlyoutOpen && (
|
||||
<>
|
||||
<motion.div
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
exit={{ opacity: 0 }}
|
||||
transition={{ duration: 0.3 }}
|
||||
className="fixed inset-0 bg-black/40 backdrop-blur-sm z-[9997] md:hidden"
|
||||
onClick={() => setIsFlyoutOpen(false)}
|
||||
/>
|
||||
<motion.div
|
||||
initial={{ y: '100%', opacity: 0 }}
|
||||
animate={{ y: 0, opacity: 1 }}
|
||||
exit={{ y: '100%', opacity: 0 }}
|
||||
transition={{ type: 'spring', stiffness: 350, damping: 30 }}
|
||||
drag="y"
|
||||
dragConstraints={{ top: 0, bottom: 0 }}
|
||||
dragElastic={0.2}
|
||||
onDragEnd={(e, info) => {
|
||||
if (info.offset.y > 50 || info.velocity.y > 100) {
|
||||
setIsFlyoutOpen(false);
|
||||
}
|
||||
}}
|
||||
className="fixed bottom-0 left-0 right-0 z-[9998] bg-white rounded-t-[32px] pt-4 pb-[calc(env(safe-area-inset-bottom)_+_7rem)] px-6 md:hidden shadow-[0_-20px_40px_rgba(0,0,0,0.1)] border-t border-neutral-100"
|
||||
>
|
||||
<div className="w-16 h-1.5 bg-neutral-200 rounded-full mx-auto mb-6 cursor-grab active:cursor-grabbing" />
|
||||
<h3 className="text-xs font-bold uppercase tracking-widest text-neutral-400 mb-4 px-2">{currentLocale === 'de' ? 'Über uns & Mehr' : 'About us & More'}</h3>
|
||||
<div className="flex flex-col gap-1">
|
||||
{flyoutLinks.map((link, i) => {
|
||||
const childUrl = link.url.startsWith('/') && !link.url.match(/^\/(en|de)/)
|
||||
? `/${currentLocale}${link.url}`
|
||||
: link.url;
|
||||
const isChildActive = pathname === childUrl;
|
||||
|
||||
return (
|
||||
<TransitionLink
|
||||
key={i}
|
||||
href={childUrl}
|
||||
onClick={() => setIsFlyoutOpen(false)}
|
||||
className={`px-4 py-4 rounded-2xl text-[15px] font-semibold transition-all flex items-center justify-between ${
|
||||
isChildActive ? 'bg-primary/10 text-primary' : 'text-neutral-700 hover:bg-neutral-50 active:bg-neutral-100'
|
||||
}`}
|
||||
>
|
||||
{link.label}
|
||||
<svg className={`w-4 h-4 ${isChildActive ? 'text-primary' : 'text-neutral-400'}`} fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2.5} d="M9 5l7 7-7 7" />
|
||||
</svg>
|
||||
</TransitionLink>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
<span className="text-[10px] font-semibold mt-0.5 tracking-wide z-10">{item.label}</span>
|
||||
</TransitionLink>
|
||||
);
|
||||
})}
|
||||
</nav>
|
||||
</div>
|
||||
</motion.div>
|
||||
</>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -4,51 +4,29 @@ import React, { useEffect, useState } from 'react';
|
||||
import { motion, AnimatePresence } from 'framer-motion';
|
||||
import Image from 'next/image';
|
||||
|
||||
const PRELOAD_VIDEOS = [
|
||||
'/assets/videos/web/hero-bohrung.mp4'
|
||||
];
|
||||
|
||||
export function InitialLoader({ shouldShowLoader = true }: { shouldShowLoader?: boolean }) {
|
||||
const [isLoading, setIsLoading] = useState(shouldShowLoader);
|
||||
|
||||
useEffect(() => {
|
||||
if (!shouldShowLoader) return;
|
||||
|
||||
// Fallback Timeout (4 Sekunden), falls Videos hängen
|
||||
const timeout = setTimeout(() => {
|
||||
finishLoading();
|
||||
}, 4000);
|
||||
|
||||
let videosLoaded = 0;
|
||||
|
||||
const checkAllLoaded = () => {
|
||||
videosLoaded++;
|
||||
if (videosLoaded === PRELOAD_VIDEOS.length) {
|
||||
clearTimeout(timeout);
|
||||
// Kleine Verzögerung für die smootheness
|
||||
setTimeout(finishLoading, 800);
|
||||
}
|
||||
};
|
||||
|
||||
PRELOAD_VIDEOS.forEach((src) => {
|
||||
const video = document.createElement('video');
|
||||
video.preload = 'auto';
|
||||
video.oncanplaythrough = checkAllLoaded;
|
||||
video.onerror = checkAllLoaded; // bei Fehler trotzdem weitermachen
|
||||
video.src = src;
|
||||
video.load();
|
||||
});
|
||||
|
||||
// Cleanup
|
||||
return () => clearTimeout(timeout);
|
||||
}, [shouldShowLoader]);
|
||||
|
||||
const finishLoading = () => {
|
||||
// Set a session cookie so the server knows not to render the loader again
|
||||
document.cookie = "etib_initial_loader_v5=true; path=/; samesite=lax";
|
||||
setIsLoading(false);
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (!shouldShowLoader) return;
|
||||
|
||||
// Just show the animation for a short time to allow UI to mount,
|
||||
// we don't preload 10MB videos anymore to save pagespeed!
|
||||
const timeout = setTimeout(() => {
|
||||
finishLoading();
|
||||
}, 1200);
|
||||
|
||||
// Cleanup
|
||||
return () => clearTimeout(timeout);
|
||||
}, [shouldShowLoader]);
|
||||
|
||||
// Wenn wir serverseitig rendern und es nicht zeigen sollen, return null
|
||||
if (!shouldShowLoader) return null;
|
||||
|
||||
|
||||
@@ -15,8 +15,6 @@ export function TransitionProvider({ children }: { children: ReactNode }) {
|
||||
const [isTransitioning, setIsTransitioning] = useState(false);
|
||||
const [transitionMessage, setTransitionMessage] = useState<string | null>(null);
|
||||
const router = useRouter();
|
||||
const pathname = usePathname();
|
||||
const searchParams = useSearchParams();
|
||||
|
||||
// We use a ref to track transition state without triggering the route change effect when it turns true
|
||||
const isTransitioningRef = React.useRef(isTransitioning);
|
||||
@@ -24,18 +22,6 @@ export function TransitionProvider({ children }: { children: ReactNode }) {
|
||||
isTransitioningRef.current = isTransitioning;
|
||||
}, [isTransitioning]);
|
||||
|
||||
// Watch for route changes to complete the transition
|
||||
useEffect(() => {
|
||||
if (isTransitioningRef.current) {
|
||||
// The route has actually changed in the browser, or at least the RSC payload arrived
|
||||
const timer = setTimeout(() => {
|
||||
setIsTransitioning(false);
|
||||
setTimeout(() => setTransitionMessage(null), 300);
|
||||
}, 100);
|
||||
return () => clearTimeout(timer);
|
||||
}
|
||||
}, [pathname, searchParams]); // Run when pathname or search parameters change
|
||||
|
||||
const startTransition = useCallback((href: string, message: string | null = null) => {
|
||||
// Wenn wir bereits navigieren, nichts tun
|
||||
if (isTransitioning) return;
|
||||
@@ -60,7 +46,7 @@ export function TransitionProvider({ children }: { children: ReactNode }) {
|
||||
setTimeout(() => {
|
||||
router.push(href);
|
||||
|
||||
// Fallback: If for some reason the route doesn't change after 5 seconds, open the shutter anyway
|
||||
// Fallback: If for some reason the route doesn't change after 60 seconds (useful for slow dev servers), open the shutter anyway
|
||||
setTimeout(() => {
|
||||
setIsTransitioning((current) => {
|
||||
if (current) {
|
||||
@@ -69,17 +55,41 @@ export function TransitionProvider({ children }: { children: ReactNode }) {
|
||||
}
|
||||
return current;
|
||||
});
|
||||
}, 5000);
|
||||
}, 60000);
|
||||
}, 700);
|
||||
}, [isTransitioning, router]);
|
||||
|
||||
return (
|
||||
<TransitionContext.Provider value={{ isTransitioning, transitionMessage, startTransition }}>
|
||||
<React.Suspense fallback={null}>
|
||||
<TransitionRouteWatcher
|
||||
isTransitioningRef={isTransitioningRef}
|
||||
setIsTransitioning={setIsTransitioning}
|
||||
setTransitionMessage={setTransitionMessage}
|
||||
/>
|
||||
</React.Suspense>
|
||||
{children}
|
||||
</TransitionContext.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
function TransitionRouteWatcher({ isTransitioningRef, setIsTransitioning, setTransitionMessage }: any) {
|
||||
const pathname = usePathname();
|
||||
const searchParams = useSearchParams();
|
||||
|
||||
useEffect(() => {
|
||||
if (isTransitioningRef.current) {
|
||||
const timer = setTimeout(() => {
|
||||
setIsTransitioning(false);
|
||||
setTimeout(() => setTransitionMessage(null), 300);
|
||||
}, 100);
|
||||
return () => clearTimeout(timer);
|
||||
}
|
||||
}, [pathname, searchParams, setIsTransitioning, setTransitionMessage, isTransitioningRef]);
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
export function useTransition() {
|
||||
const context = useContext(TransitionContext);
|
||||
if (!context) {
|
||||
|
||||
61
components/search/AISearchResults.test.tsx
Normal file
61
components/search/AISearchResults.test.tsx
Normal file
@@ -0,0 +1,61 @@
|
||||
import * as React from 'react';
|
||||
import { describe, it, expect, vi } from 'vitest';
|
||||
import { render } from '@testing-library/react';
|
||||
import { AISearchResults } from './AISearchResults';
|
||||
|
||||
// Mock lucide-react
|
||||
vi.mock('lucide-react', () => ({
|
||||
ArrowUp: () => <div data-testid="arrow-up" />,
|
||||
X: () => <div data-testid="close-icon" />,
|
||||
Sparkles: () => <div data-testid="sparkles" />,
|
||||
ChevronRight: () => <div data-testid="chevron-right" />,
|
||||
RotateCcw: () => <div data-testid="rotate-ccw" />,
|
||||
Copy: () => <div data-testid="copy" />,
|
||||
Check: () => <div data-testid="check" />,
|
||||
}));
|
||||
|
||||
// Mock next/link
|
||||
vi.mock('next/link', () => ({
|
||||
default: ({ children, href }: any) => <a href={href}>{children}</a>,
|
||||
}));
|
||||
|
||||
// Mock useAnalytics
|
||||
vi.mock('../analytics/useAnalytics', () => ({
|
||||
useAnalytics: () => ({
|
||||
trackEvent: vi.fn(),
|
||||
}),
|
||||
}));
|
||||
|
||||
// Mock react-markdown
|
||||
vi.mock('react-markdown', () => ({
|
||||
default: ({ children }: any) => <div>{children}</div>,
|
||||
}));
|
||||
|
||||
// Mock remark-gfm
|
||||
vi.mock('remark-gfm', () => ({
|
||||
default: {},
|
||||
}));
|
||||
|
||||
// Mock next-intl
|
||||
vi.mock('next-intl', () => ({
|
||||
useTranslations: () => {
|
||||
const translate = (key: string) => key;
|
||||
translate.raw = (key: string) => {
|
||||
if (key === 'loadingTexts') return ['Lade...', 'Denke nach...'];
|
||||
if (key === 'prompts') return ['Prompt 1', 'Prompt 2'];
|
||||
return [];
|
||||
};
|
||||
return translate;
|
||||
},
|
||||
useLocale: () => 'en',
|
||||
}));
|
||||
|
||||
describe('AISearchResults Component Test (TDD)', () => {
|
||||
it('renders correctly when open', () => {
|
||||
const handleClose = vi.fn();
|
||||
const { container } = render(
|
||||
<AISearchResults isOpen={true} onClose={handleClose} />
|
||||
);
|
||||
expect(container).toBeTruthy();
|
||||
});
|
||||
});
|
||||
@@ -8,14 +8,9 @@ import { AnalyticsEvents } from '../analytics/analytics-events';
|
||||
import ReactMarkdown from 'react-markdown';
|
||||
import remarkGfm from 'remark-gfm';
|
||||
import dynamic from 'next/dynamic';
|
||||
import { useTranslations } from 'next-intl';
|
||||
const AIOrb = dynamic(() => import('./AIOrb'), { ssr: false });
|
||||
|
||||
const LOADING_TEXTS = [
|
||||
'Durchsuche das Kabelhandbuch... 📖',
|
||||
'Frage den Senior-Ingenieur... 👴🔧',
|
||||
'Frage ChatGPTs Cousin 2. Grades... 🤖',
|
||||
];
|
||||
|
||||
interface ProductMatch {
|
||||
id: string;
|
||||
title: string;
|
||||
@@ -51,7 +46,13 @@ export function AISearchResults({
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [copiedIndex, setCopiedIndex] = useState<number | null>(null);
|
||||
const [copiedAll, setCopiedAll] = useState(false);
|
||||
const [loadingText, setLoadingText] = useState(LOADING_TEXTS[0]);
|
||||
const t = useTranslations('AISearch');
|
||||
|
||||
// We load texts dynamically from translations
|
||||
const loadingTexts = t.raw('loadingTexts') as string[];
|
||||
const prompts = t.raw('prompts') as string[];
|
||||
|
||||
const [loadingText, setLoadingText] = useState(loadingTexts[0]);
|
||||
|
||||
const inputRef = useRef<HTMLInputElement>(null);
|
||||
const modalRef = useRef<HTMLDivElement>(null);
|
||||
@@ -141,11 +142,11 @@ export function AISearchResults({
|
||||
setTimeout(() => {
|
||||
setIsLoading(true);
|
||||
// Start rotating loading texts
|
||||
let textIdx = Math.floor(Math.random() * LOADING_TEXTS.length);
|
||||
setLoadingText(LOADING_TEXTS[textIdx]);
|
||||
let textIdx = Math.floor(Math.random() * loadingTexts.length);
|
||||
setLoadingText(loadingTexts[textIdx]);
|
||||
loadingIntervalRef.current = setInterval(() => {
|
||||
textIdx = (textIdx + 1) % LOADING_TEXTS.length;
|
||||
setLoadingText(LOADING_TEXTS[textIdx]);
|
||||
textIdx = (textIdx + 1) % loadingTexts.length;
|
||||
setLoadingText(loadingTexts[textIdx]);
|
||||
}, 2500);
|
||||
}, 400);
|
||||
|
||||
@@ -191,8 +192,8 @@ export function AISearchResults({
|
||||
console.error(err);
|
||||
const msg =
|
||||
err.name === 'AbortError'
|
||||
? 'Anfrage hat zu lange gedauert. Bitte versuche es erneut.'
|
||||
: err.message || 'Ein Fehler ist aufgetreten.';
|
||||
? t('timeoutError')
|
||||
: err.message || t('genericError');
|
||||
|
||||
// Show error as a system message in the chat instead of a separate error banner
|
||||
setMessages((prev) => [
|
||||
@@ -248,7 +249,7 @@ export function AISearchResults({
|
||||
|
||||
const handleCopyChat = () => {
|
||||
const fullChat = messages
|
||||
.map((m) => `${m.role === 'user' ? 'Du' : 'Ohm'}:\n${m.content}`)
|
||||
.map((m) => `${m.role === 'user' ? t('you') : 'Ohm'}:\n${m.content}`)
|
||||
.join('\n\n');
|
||||
handleCopy(fullChat);
|
||||
};
|
||||
@@ -294,7 +295,7 @@ export function AISearchResults({
|
||||
<div>
|
||||
<h2 className="text-white font-bold text-sm tracking-wide">Ohm</h2>
|
||||
<p className="text-[10px] text-white/30 font-medium tracking-wider uppercase">
|
||||
{isLoading ? 'Denkt nach...' : error ? 'Fehler aufgetreten' : 'Online'}
|
||||
{isLoading ? t('thinking') : error ? t('errorStatus') : t('online')}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
@@ -303,14 +304,14 @@ export function AISearchResults({
|
||||
<button
|
||||
onClick={handleCopyChat}
|
||||
className="flex items-center gap-1.5 text-[10px] font-bold text-white/40 hover:text-white/80 transition-all duration-200 hover:bg-white/5 rounded-full px-3 py-1.5 cursor-pointer uppercase tracking-wider"
|
||||
title="gesamten Chat kopieren"
|
||||
title={t('copyChatTitle')}
|
||||
>
|
||||
{copiedAll ? (
|
||||
<Check className="w-3.5 h-3.5 text-accent" />
|
||||
) : (
|
||||
<Copy className="w-3.5 h-3.5" />
|
||||
)}
|
||||
<span>{copiedAll ? 'Kopiert' : 'Chat kopieren'}</span>
|
||||
<span>{copiedAll ? t('copied') : t('copyChat')}</span>
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
@@ -336,16 +337,15 @@ export function AISearchResults({
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-xl md:text-2xl font-bold text-white/80">
|
||||
Wie kann ich helfen?
|
||||
{t('howCanIHelp')}
|
||||
</p>
|
||||
<p className="text-sm text-white/30 mt-2 max-w-md">
|
||||
Beschreibe dein Projekt, frag nach bestimmten Kabeln, oder nenne mir deine
|
||||
Anforderungen.
|
||||
{t('helpDescription')}
|
||||
</p>
|
||||
</div>
|
||||
{/* Quick prompts */}
|
||||
<div className="flex flex-wrap justify-center gap-2 mt-4">
|
||||
{['Windpark 33kV Verkabelung', 'NYCWY 4x185', 'Erdkabel für Solarpark'].map(
|
||||
{prompts.map(
|
||||
(prompt) => (
|
||||
<button
|
||||
key={prompt}
|
||||
@@ -384,7 +384,7 @@ export function AISearchResults({
|
||||
? 'top-2 right-2 bg-primary/10 hover:bg-primary/20 text-primary/60 hover:text-primary'
|
||||
: 'top-2 right-2 bg-white/5 hover:bg-white/10 text-white/40 hover:text-white'
|
||||
}`}
|
||||
title="Nachricht kopieren"
|
||||
title={t('copyMessage')}
|
||||
>
|
||||
{copiedIndex === index ? (
|
||||
<Check className="w-3.5 h-3.5" />
|
||||
@@ -431,7 +431,7 @@ export function AISearchResults({
|
||||
{msg.role === 'assistant' && msg.products && msg.products.length > 0 && (
|
||||
<div className="mt-4 space-y-2 border-t border-white/[0.06] pt-4">
|
||||
<h4 className="text-[10px] font-bold tracking-widest uppercase text-white/30 mb-2">
|
||||
Empfohlene Produkte
|
||||
{t('recommendedProducts')}
|
||||
</h4>
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-2">
|
||||
{msg.products.map((product, idx) => (
|
||||
@@ -509,7 +509,7 @@ export function AISearchResults({
|
||||
<AIOrb isThinking={false} hasError={true} />
|
||||
</div>
|
||||
<div>
|
||||
<h3 className="text-sm font-bold text-red-300">Da ist was schiefgelaufen 😬</h3>
|
||||
<h3 className="text-sm font-bold text-red-300">{t('errorTitle')}</h3>
|
||||
<p className="text-xs text-red-300/60 mt-1">{error}</p>
|
||||
<button
|
||||
onClick={() => {
|
||||
@@ -519,7 +519,7 @@ export function AISearchResults({
|
||||
className="flex items-center gap-1.5 text-[10px] font-bold text-red-300/50 hover:text-red-300 mt-2 transition-colors cursor-pointer"
|
||||
>
|
||||
<RotateCcw className="w-3 h-3" />
|
||||
Nochmal versuchen
|
||||
{t('tryAgain')}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
@@ -544,7 +544,7 @@ export function AISearchResults({
|
||||
value={query}
|
||||
onChange={(e) => setQuery(e.target.value)}
|
||||
onKeyDown={onKeyDown}
|
||||
placeholder="Nachricht eingeben..."
|
||||
placeholder={t('placeholder')}
|
||||
className="flex-1 bg-transparent border-none text-white text-sm md:text-base px-5 py-4 focus:outline-none placeholder:text-white/20"
|
||||
disabled={isLoading}
|
||||
tabIndex={1}
|
||||
@@ -567,20 +567,20 @@ export function AISearchResults({
|
||||
? 'bg-accent text-primary shadow-lg shadow-accent/20 hover:shadow-accent/40 hover:scale-105 active:scale-95'
|
||||
: 'bg-white/5 text-white/20'
|
||||
}`}
|
||||
aria-label="Nachricht senden"
|
||||
aria-label={t('send')}
|
||||
>
|
||||
<ArrowUp className="w-4 h-4" strokeWidth={2.5} />
|
||||
</button>
|
||||
</div>
|
||||
<div className="flex items-center justify-center gap-3 mt-2.5">
|
||||
<span className="text-[9px] uppercase tracking-[0.15em] font-medium text-white/15">
|
||||
Enter zum Senden · Esc zum Schließen
|
||||
{t('footerShortcuts')}
|
||||
</span>
|
||||
<span className="text-[9px] uppercase tracking-[0.15em] font-medium text-white/15">
|
||||
·
|
||||
</span>
|
||||
<span className="text-[9px] uppercase tracking-[0.15em] font-medium text-accent/40 flex items-center gap-1">
|
||||
🛡️ DSGVO-konform · EU-Server
|
||||
{t('footerPrivacy')}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -22,7 +22,7 @@ export default function Gallery() {
|
||||
];
|
||||
|
||||
return (
|
||||
<Section className="bg-primary-dark py-16 md:py-32">
|
||||
<Section className="bg-primary-dark">
|
||||
<Container>
|
||||
<Heading level={2} subtitle={t('gallery.subtitle')} align="center" className="text-white mb-12 md:mb-20">
|
||||
<span className="text-white">{t('gallery.title')}</span>
|
||||
|
||||
48
components/ui/AnimatedCounter.tsx
Normal file
48
components/ui/AnimatedCounter.tsx
Normal file
@@ -0,0 +1,48 @@
|
||||
'use client';
|
||||
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import { motion, useMotionValue, useTransform, animate, useInView } from 'framer-motion';
|
||||
|
||||
interface AnimatedCounterProps {
|
||||
value: number;
|
||||
duration?: number;
|
||||
delay?: number;
|
||||
suffix?: string;
|
||||
prefix?: string;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export function AnimatedCounter({
|
||||
value,
|
||||
duration = 2,
|
||||
delay = 0,
|
||||
suffix = '',
|
||||
prefix = '',
|
||||
className = '',
|
||||
}: AnimatedCounterProps) {
|
||||
const count = useMotionValue(0);
|
||||
const rounded = useTransform(count, (latest) => {
|
||||
// Format with thousands separator
|
||||
return new Intl.NumberFormat('de-DE').format(Math.round(latest));
|
||||
});
|
||||
|
||||
const ref = React.useRef(null);
|
||||
const inView = useInView(ref, { once: true, margin: "-100px" });
|
||||
|
||||
useEffect(() => {
|
||||
if (inView) {
|
||||
const controls = animate(count, value, {
|
||||
duration: duration,
|
||||
delay: delay,
|
||||
ease: 'easeOut',
|
||||
});
|
||||
return controls.stop;
|
||||
}
|
||||
}, [inView, value, duration, delay, count]);
|
||||
|
||||
return (
|
||||
<motion.span ref={ref} className={className}>
|
||||
{prefix}<motion.span>{rounded}</motion.span>{suffix}
|
||||
</motion.span>
|
||||
);
|
||||
}
|
||||
@@ -6,12 +6,14 @@ interface AnimatedGlossyBorderProps {
|
||||
borderWidth?: 1 | 2;
|
||||
opacity?: number;
|
||||
color?: 'white' | 'primary';
|
||||
disableAnimation?: boolean;
|
||||
}
|
||||
|
||||
export function AnimatedGlossyBorder({
|
||||
className,
|
||||
borderWidth = 1,
|
||||
color = 'white'
|
||||
color = 'white',
|
||||
disableAnimation = false
|
||||
}: AnimatedGlossyBorderProps) {
|
||||
const isWhite = color === 'white';
|
||||
const glowColor = isWhite ? 'rgba(255,255,255,0.6)' : 'rgba(17,124,97,0.6)';
|
||||
@@ -35,7 +37,10 @@ export function AnimatedGlossyBorder({
|
||||
}}
|
||||
>
|
||||
<div
|
||||
className="absolute top-1/2 left-1/2 -translate-x-1/2 -translate-y-1/2 w-[250%] aspect-square animate-[spin_8s_linear_infinite]"
|
||||
className={cn(
|
||||
"absolute top-1/2 left-1/2 -translate-x-1/2 -translate-y-1/2 w-[250%] aspect-square",
|
||||
!disableAnimation && "animate-[spin_8s_linear_infinite]"
|
||||
)}
|
||||
style={{
|
||||
background: `conic-gradient(from 0deg, ${gradientColors})`
|
||||
}}
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user