Compare commits
149 Commits
2b79d18a93
...
v2.2.13-rc
| Author | SHA1 | Date | |
|---|---|---|---|
| 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 | |||
| 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
|
# Copy this file to .env for local development
|
||||||
# For production, use .env.production as a template
|
# For production, use .env.production as a template
|
||||||
@@ -20,14 +20,14 @@ NEXT_PUBLIC_RECORD_MODE_ENABLED=true
|
|||||||
# Analytics (Umami)
|
# Analytics (Umami)
|
||||||
# ────────────────────────────────────────────────────────────────────────────
|
# ────────────────────────────────────────────────────────────────────────────
|
||||||
# Optional: Leave empty to disable analytics
|
# 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
|
UMAMI_API_ENDPOINT=https://analytics.infra.mintel.me
|
||||||
|
|
||||||
# ────────────────────────────────────────────────────────────────────────────
|
# ────────────────────────────────────────────────────────────────────────────
|
||||||
# Error Tracking (GlitchTip/Sentry)
|
# Error Tracking (GlitchTip/Sentry)
|
||||||
# ────────────────────────────────────────────────────────────────────────────
|
# ────────────────────────────────────────────────────────────────────────────
|
||||||
# Optional: Leave empty to disable error tracking
|
# 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)
|
# Email Configuration (SMTP)
|
||||||
@@ -37,7 +37,7 @@ MAIL_HOST=smtp.eu.mailgun.org
|
|||||||
MAIL_PORT=587
|
MAIL_PORT=587
|
||||||
MAIL_USERNAME=
|
MAIL_USERNAME=
|
||||||
MAIL_PASSWORD=
|
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
|
MAIL_RECIPIENTS=info@e-tib.com
|
||||||
|
|
||||||
# ────────────────────────────────────────────────────────────────────────────
|
# ────────────────────────────────────────────────────────────────────────────
|
||||||
|
|||||||
@@ -17,9 +17,6 @@ env:
|
|||||||
PUPPETEER_SKIP_DOWNLOAD: "true"
|
PUPPETEER_SKIP_DOWNLOAD: "true"
|
||||||
COREPACK_NPM_REGISTRY: "https://registry.npmmirror.com"
|
COREPACK_NPM_REGISTRY: "https://registry.npmmirror.com"
|
||||||
|
|
||||||
concurrency:
|
|
||||||
group: deploy-pipeline
|
|
||||||
cancel-in-progress: true
|
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
# ──────────────────────────────────────────────────────────────────────────────
|
# ──────────────────────────────────────────────────────────────────────────────
|
||||||
@@ -64,10 +61,17 @@ jobs:
|
|||||||
echo "Pruning Buildkit inside container: $container"
|
echo "Pruning Buildkit inside container: $container"
|
||||||
docker exec -i "$container" buildctl prune --all || true
|
docker exec -i "$container" buildctl prune --all || true
|
||||||
done
|
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..."
|
echo "Purging old build layers, unused volumes and images..."
|
||||||
docker builder prune -a -f || true
|
docker builder prune -a -f || true
|
||||||
docker volume prune -f || true
|
docker volume prune -f || true
|
||||||
docker system prune -a -f || true
|
docker system prune -a -f --volumes || true
|
||||||
echo "=== System Disk Usage After Prune ==="
|
echo "=== System Disk Usage After Prune ==="
|
||||||
df -h || true
|
df -h || true
|
||||||
|
|
||||||
@@ -139,6 +143,13 @@ jobs:
|
|||||||
echo "short_sha=$SHORT_SHA"
|
echo "short_sha=$SHORT_SHA"
|
||||||
} >> "$GITHUB_OUTPUT"
|
} >> "$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)
|
# JOB 2: QA (Lint, Typecheck, Test)
|
||||||
# ──────────────────────────────────────────────────────────────────────────────
|
# ──────────────────────────────────────────────────────────────────────────────
|
||||||
@@ -213,28 +224,33 @@ jobs:
|
|||||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||||
run: bash scripts/registry-auth.sh
|
run: bash scripts/registry-auth.sh
|
||||||
|
|
||||||
- name: 🐳 Set up Docker Buildx
|
|
||||||
uses: docker/setup-buildx-action@v3
|
|
||||||
- name: 🔐 Registry Login
|
- name: 🔐 Registry Login
|
||||||
run: echo "${{ secrets.REGISTRY_PASS }}" | docker login registry.infra.mintel.me -u "${{ secrets.REGISTRY_USER }}" --password-stdin
|
run: echo "${{ secrets.REGISTRY_PASS }}" | docker login registry.infra.mintel.me -u "${{ secrets.REGISTRY_USER }}" --password-stdin
|
||||||
- name: 🏗️ Build and Push
|
- name: 🏗️ Build and Push (Native Docker Buildkit)
|
||||||
uses: docker/build-push-action@v5
|
env:
|
||||||
with:
|
NEXT_PUBLIC_BASE_URL: ${{ needs.prepare.outputs.next_public_url }}
|
||||||
context: .
|
NEXT_PUBLIC_TARGET: ${{ needs.prepare.outputs.target }}
|
||||||
push: true
|
NEXT_PUBLIC_APP_VERSION: ${{ needs.prepare.outputs.image_tag }}
|
||||||
provenance: false
|
UMAMI_WEBSITE_ID: ${{ secrets.UMAMI_WEBSITE_ID || vars.UMAMI_WEBSITE_ID }}
|
||||||
platforms: linux/amd64
|
UMAMI_API_ENDPOINT: ${{ secrets.UMAMI_API_ENDPOINT || vars.UMAMI_API_ENDPOINT || 'https://analytics.infra.mintel.me' }}
|
||||||
build-args: |
|
NPM_TOKEN: ${{ steps.auth.outputs.working_token }}
|
||||||
NEXT_PUBLIC_BASE_URL=${{ needs.prepare.outputs.next_public_url }}
|
NPM_DOMAIN: ${{ steps.auth.outputs.working_domain }}
|
||||||
NEXT_PUBLIC_TARGET=${{ needs.prepare.outputs.target }}
|
IMAGE_TAG: ${{ needs.prepare.outputs.image_tag }}
|
||||||
NEXT_PUBLIC_APP_VERSION=${{ needs.prepare.outputs.image_tag }}
|
run: |
|
||||||
UMAMI_WEBSITE_ID=${{ secrets.UMAMI_WEBSITE_ID || vars.UMAMI_WEBSITE_ID }}
|
echo "$NPM_TOKEN" > npm_token.txt
|
||||||
UMAMI_API_ENDPOINT=${{ secrets.UMAMI_API_ENDPOINT || vars.UMAMI_API_ENDPOINT || 'https://analytics.infra.mintel.me' }}
|
DOCKER_BUILDKIT=1 docker build \
|
||||||
NPM_TOKEN=${{ steps.auth.outputs.working_token }}
|
--secret id=NPM_TOKEN,src=npm_token.txt \
|
||||||
NPM_DOMAIN=${{ steps.auth.outputs.working_domain }}
|
--build-arg NEXT_PUBLIC_BASE_URL="$NEXT_PUBLIC_BASE_URL" \
|
||||||
tags: registry.infra.mintel.me/mintel/e-tib.com:${{ needs.prepare.outputs.image_tag }}
|
--build-arg NEXT_PUBLIC_TARGET="$NEXT_PUBLIC_TARGET" \
|
||||||
secrets: |
|
--build-arg NEXT_PUBLIC_APP_VERSION="$IMAGE_TAG" \
|
||||||
"NPM_TOKEN=${{ steps.auth.outputs.working_token }}"
|
--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
|
# JOB 4: Deploy
|
||||||
@@ -294,6 +310,19 @@ jobs:
|
|||||||
|
|
||||||
GATEKEEPER_ORIGIN="${NEXT_PUBLIC_BASE_URL}/gatekeeper"
|
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 "# Generated by CI - $TARGET"
|
||||||
echo "IMAGE_TAG=$IMAGE_TAG"
|
echo "IMAGE_TAG=$IMAGE_TAG"
|
||||||
@@ -330,6 +359,7 @@ jobs:
|
|||||||
echo "COMPOSE_PROFILES=$COMPOSE_PROFILES"
|
echo "COMPOSE_PROFILES=$COMPOSE_PROFILES"
|
||||||
echo "AUTH_MIDDLEWARE=$AUTH_MIDDLEWARE"
|
echo "AUTH_MIDDLEWARE=$AUTH_MIDDLEWARE"
|
||||||
echo "AUTH_MIDDLEWARE_UNPROTECTED=$AUTH_MIDDLEWARE_UNPROTECTED"
|
echo "AUTH_MIDDLEWARE_UNPROTECTED=$AUTH_MIDDLEWARE_UNPROTECTED"
|
||||||
|
echo "INTERNAL_SUBNET=$INTERNAL_SUBNET"
|
||||||
} > .env.deploy
|
} > .env.deploy
|
||||||
|
|
||||||
- name: 🚀 SSH Deploy
|
- name: 🚀 SSH Deploy
|
||||||
@@ -337,6 +367,7 @@ jobs:
|
|||||||
env:
|
env:
|
||||||
ENV_FILE: ${{ needs.prepare.outputs.env_file }}
|
ENV_FILE: ${{ needs.prepare.outputs.env_file }}
|
||||||
run: |
|
run: |
|
||||||
|
SLUG=$(echo "${{ github.ref_name }}" | tr '[:upper:]' '[:lower:]' | sed 's/[^a-z0-9]/-/g' | sed 's/--*/-/g' | sed 's/^-//;s/-$//')
|
||||||
mkdir -p ~/.ssh
|
mkdir -p ~/.ssh
|
||||||
echo "${{ secrets.ALPHA_SSH_KEY }}" > ~/.ssh/id_ed25519
|
echo "${{ secrets.ALPHA_SSH_KEY }}" > ~/.ssh/id_ed25519
|
||||||
chmod 600 ~/.ssh/id_ed25519
|
chmod 600 ~/.ssh/id_ed25519
|
||||||
@@ -349,7 +380,6 @@ jobs:
|
|||||||
elif [[ "$TARGET" == "staging" ]]; then
|
elif [[ "$TARGET" == "staging" ]]; then
|
||||||
SITE_DIR="/home/deploy/sites/staging.e-tib.com"
|
SITE_DIR="/home/deploy/sites/staging.e-tib.com"
|
||||||
else
|
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"
|
SITE_DIR="/home/deploy/sites/branch.e-tib.com/$SLUG"
|
||||||
fi
|
fi
|
||||||
|
|
||||||
@@ -357,6 +387,8 @@ jobs:
|
|||||||
scp .env.deploy root@alpha.mintel.me:$SITE_DIR/$ENV_FILE
|
scp .env.deploy root@alpha.mintel.me:$SITE_DIR/$ENV_FILE
|
||||||
scp docker-compose.yml root@alpha.mintel.me:$SITE_DIR/docker-compose.yml
|
scp docker-compose.yml root@alpha.mintel.me:$SITE_DIR/docker-compose.yml
|
||||||
|
|
||||||
|
ssh root@alpha.mintel.me "docker system prune -f"
|
||||||
|
ssh root@alpha.mintel.me "docker rm -f \$(docker ps -a -q --filter name=${SLUG}-etib-) 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 && 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' 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 '${{ needs.prepare.outputs.project_name }}' --env-file '$ENV_FILE' up -d --remove-orphans"
|
||||||
|
|||||||
4
.gitignore
vendored
4
.gitignore
vendored
@@ -36,3 +36,7 @@ pnpm-debug.log*
|
|||||||
# typescript
|
# typescript
|
||||||
*.tsbuildinfo
|
*.tsbuildinfo
|
||||||
next-env.d.ts
|
next-env.d.ts
|
||||||
|
|
||||||
|
# turborepo
|
||||||
|
.turbo
|
||||||
|
.turbo-test
|
||||||
|
|||||||
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/0864e4a10f76dcaf-manifest.json
Normal file
1
.turbo/0864e4a10f76dcaf-manifest.json
Normal file
@@ -0,0 +1 @@
|
|||||||
|
{"files":{".turbo/turbo-lint.log":{"size":11972,"mtime_nanos":1779970374061729177,"mode":420,"is_dir":false}},"order":[".turbo/turbo-lint.log"]}
|
||||||
1
.turbo/0864e4a10f76dcaf-meta.json
Normal file
1
.turbo/0864e4a10f76dcaf-meta.json
Normal file
@@ -0,0 +1 @@
|
|||||||
|
{"hash":"0864e4a10f76dcaf","duration":6769,"sha":"68c4a056b70ff215c9ef3252506875dfd3ba4eee","dirty_hash":"29197232f806b237725188c5bfe8d43cb2f12f8bb9e0df915eecf0896e2c04a3"}
|
||||||
BIN
.turbo/0864e4a10f76dcaf.tar.zst
Normal file
BIN
.turbo/0864e4a10f76dcaf.tar.zst
Normal file
Binary file not shown.
1
.turbo/41698960411f6bf6-manifest.json
Normal file
1
.turbo/41698960411f6bf6-manifest.json
Normal file
@@ -0,0 +1 @@
|
|||||||
|
{"files":{".turbo/turbo-test.log":{"size":2776,"mtime_nanos":1781469605328811961,"mode":420,"is_dir":false}},"order":[".turbo/turbo-test.log"]}
|
||||||
1
.turbo/41698960411f6bf6-meta.json
Normal file
1
.turbo/41698960411f6bf6-meta.json
Normal file
@@ -0,0 +1 @@
|
|||||||
|
{"hash":"41698960411f6bf6","duration":4168,"sha":"a3c732dd133b1b8261b19cf64b3bf684e269e839","dirty_hash":"1ef3b75af417f41468b9558671572e75e0fc055f9dbe6e805cfa6f59b1cddf70"}
|
||||||
BIN
.turbo/41698960411f6bf6.tar.zst
Normal file
BIN
.turbo/41698960411f6bf6.tar.zst
Normal file
Binary file not shown.
1
.turbo/7668a73054ba889d-manifest.json
Normal file
1
.turbo/7668a73054ba889d-manifest.json
Normal file
@@ -0,0 +1 @@
|
|||||||
|
{"files":{".turbo/turbo-typecheck.log":{"size":85,"mtime_nanos":1779970373499305257,"mode":420,"is_dir":false}},"order":[".turbo/turbo-typecheck.log"]}
|
||||||
1
.turbo/7668a73054ba889d-meta.json
Normal file
1
.turbo/7668a73054ba889d-meta.json
Normal file
@@ -0,0 +1 @@
|
|||||||
|
{"hash":"7668a73054ba889d","duration":6206,"sha":"68c4a056b70ff215c9ef3252506875dfd3ba4eee","dirty_hash":"29197232f806b237725188c5bfe8d43cb2f12f8bb9e0df915eecf0896e2c04a3"}
|
||||||
BIN
.turbo/7668a73054ba889d.tar.zst
Normal file
BIN
.turbo/7668a73054ba889d.tar.zst
Normal file
Binary file not shown.
1
.turbo/7d27d7d8e3c30797-manifest.json
Normal file
1
.turbo/7d27d7d8e3c30797-manifest.json
Normal file
@@ -0,0 +1 @@
|
|||||||
|
{"files":{".turbo/turbo-typecheck.log":{"size":90,"mtime_nanos":1781782738306875884,"mode":420,"is_dir":false}},"order":[".turbo/turbo-typecheck.log"]}
|
||||||
1
.turbo/7d27d7d8e3c30797-meta.json
Normal file
1
.turbo/7d27d7d8e3c30797-meta.json
Normal file
@@ -0,0 +1 @@
|
|||||||
|
{"hash":"7d27d7d8e3c30797","duration":14130,"sha":"d6eb1c0e7fe99bc7006a396c8eea0228c4723b31","dirty_hash":"b247ccedfcf352a6efb73993a3482b827d5b44922d26bf137f251d0d69a200b6"}
|
||||||
BIN
.turbo/7d27d7d8e3c30797.tar.zst
Normal file
BIN
.turbo/7d27d7d8e3c30797.tar.zst
Normal file
Binary file not shown.
1
.turbo/9c3fa4d9e1993aff-manifest.json
Normal file
1
.turbo/9c3fa4d9e1993aff-manifest.json
Normal file
@@ -0,0 +1 @@
|
|||||||
|
{"files":{".turbo/turbo-test.log":{"size":2150,"mtime_nanos":1779970370282819916,"mode":420,"is_dir":false}},"order":[".turbo/turbo-test.log"]}
|
||||||
1
.turbo/9c3fa4d9e1993aff-meta.json
Normal file
1
.turbo/9c3fa4d9e1993aff-meta.json
Normal file
@@ -0,0 +1 @@
|
|||||||
|
{"hash":"9c3fa4d9e1993aff","duration":2989,"sha":"68c4a056b70ff215c9ef3252506875dfd3ba4eee","dirty_hash":"29197232f806b237725188c5bfe8d43cb2f12f8bb9e0df915eecf0896e2c04a3"}
|
||||||
BIN
.turbo/9c3fa4d9e1993aff.tar.zst
Normal file
BIN
.turbo/9c3fa4d9e1993aff.tar.zst
Normal file
Binary file not shown.
1
.turbo/bb46018d4a7bc516-manifest.json
Normal file
1
.turbo/bb46018d4a7bc516-manifest.json
Normal file
@@ -0,0 +1 @@
|
|||||||
|
{"files":{".turbo/turbo-lint.log":{"size":12987,"mtime_nanos":1781469609469080321,"mode":420,"is_dir":false}},"order":[".turbo/turbo-lint.log"]}
|
||||||
1
.turbo/bb46018d4a7bc516-meta.json
Normal file
1
.turbo/bb46018d4a7bc516-meta.json
Normal file
@@ -0,0 +1 @@
|
|||||||
|
{"hash":"bb46018d4a7bc516","duration":8307,"sha":"a3c732dd133b1b8261b19cf64b3bf684e269e839","dirty_hash":"1ef3b75af417f41468b9558671572e75e0fc055f9dbe6e805cfa6f59b1cddf70"}
|
||||||
BIN
.turbo/bb46018d4a7bc516.tar.zst
Normal file
BIN
.turbo/bb46018d4a7bc516.tar.zst
Normal file
Binary file not shown.
1
.turbo/c3b4dbd60e2d475a-manifest.json
Normal file
1
.turbo/c3b4dbd60e2d475a-manifest.json
Normal file
@@ -0,0 +1 @@
|
|||||||
|
{"files":{".turbo/turbo-lint.log":{"size":15001,"mtime_nanos":1781782739446599688,"mode":420,"is_dir":false}},"order":[".turbo/turbo-lint.log"]}
|
||||||
1
.turbo/c3b4dbd60e2d475a-meta.json
Normal file
1
.turbo/c3b4dbd60e2d475a-meta.json
Normal file
@@ -0,0 +1 @@
|
|||||||
|
{"hash":"c3b4dbd60e2d475a","duration":15270,"sha":"d6eb1c0e7fe99bc7006a396c8eea0228c4723b31","dirty_hash":"b247ccedfcf352a6efb73993a3482b827d5b44922d26bf137f251d0d69a200b6"}
|
||||||
BIN
.turbo/c3b4dbd60e2d475a.tar.zst
Normal file
BIN
.turbo/c3b4dbd60e2d475a.tar.zst
Normal file
Binary file not shown.
1
.turbo/ea9126c55a797120-manifest.json
Normal file
1
.turbo/ea9126c55a797120-manifest.json
Normal file
@@ -0,0 +1 @@
|
|||||||
|
{"files":{".turbo/turbo-test.log":{"size":3396,"mtime_nanos":1781782731048784172,"mode":420,"is_dir":false}},"order":[".turbo/turbo-test.log"]}
|
||||||
1
.turbo/ea9126c55a797120-meta.json
Normal file
1
.turbo/ea9126c55a797120-meta.json
Normal file
@@ -0,0 +1 @@
|
|||||||
|
{"hash":"ea9126c55a797120","duration":6873,"sha":"d6eb1c0e7fe99bc7006a396c8eea0228c4723b31","dirty_hash":"b247ccedfcf352a6efb73993a3482b827d5b44922d26bf137f251d0d69a200b6"}
|
||||||
BIN
.turbo/ea9126c55a797120.tar.zst
Normal file
BIN
.turbo/ea9126c55a797120.tar.zst
Normal file
Binary file not shown.
1
.turbo/eb4eeeb717c1af0c-manifest.json
Normal file
1
.turbo/eb4eeeb717c1af0c-manifest.json
Normal file
@@ -0,0 +1 @@
|
|||||||
|
{"files":{".turbo/turbo-typecheck.log":{"size":85,"mtime_nanos":1781469608726900998,"mode":420,"is_dir":false}},"order":[".turbo/turbo-typecheck.log"]}
|
||||||
1
.turbo/eb4eeeb717c1af0c-meta.json
Normal file
1
.turbo/eb4eeeb717c1af0c-meta.json
Normal file
@@ -0,0 +1 @@
|
|||||||
|
{"hash":"eb4eeeb717c1af0c","duration":7565,"sha":"a3c732dd133b1b8261b19cf64b3bf684e269e839","dirty_hash":"1ef3b75af417f41468b9558671572e75e0fc055f9dbe6e805cfa6f59b1cddf70"}
|
||||||
BIN
.turbo/eb4eeeb717c1af0c.tar.zst
Normal file
BIN
.turbo/eb4eeeb717c1af0c.tar.zst
Normal file
Binary file not shown.
@@ -1,119 +1,5 @@
|
|||||||
|
|
||||||
|
> e-tib-nextjs@2.2.12 lint /Volumes/Alpha SSD/Coding/e-tib.com
|
||||||
> e-tib-nextjs@2.2.12 lint /Volumes/Alpha SSD/Coding/e-tib.com
|
> eslint .
|
||||||
> eslint .
|
|
||||||
|
ELIFECYCLE Command failed.
|
||||||
|
|
||||||
/Volumes/Alpha SSD/Coding/e-tib.com/app/[locale]/[slug]/page.tsx
|
|
||||||
1:20 warning 'redirect' is defined but never used @typescript-eslint/no-unused-vars
|
|
||||||
67:24 warning Using `<img>` could result in slower LCP and higher bandwidth. Consider using `<Image />` from `next/image` or a custom image loader to automatically optimize images. This may incur additional usage or cost from your provider. See: https://nextjs.org/docs/messages/no-img-element @next/next/no-img-element
|
|
||||||
|
|
||||||
/Volumes/Alpha SSD/Coding/e-tib.com/app/[locale]/blog/[slug]/page.tsx
|
|
||||||
1:20 warning 'redirect' is defined but never used @typescript-eslint/no-unused-vars
|
|
||||||
39:24 warning Using `<img>` could result in slower LCP and higher bandwidth. Consider using `<Image />` from `next/image` or a custom image loader to automatically optimize images. This may incur additional usage or cost from your provider. See: https://nextjs.org/docs/messages/no-img-element @next/next/no-img-element
|
|
||||||
|
|
||||||
/Volumes/Alpha SSD/Coding/e-tib.com/app/[locale]/error.tsx
|
|
||||||
7:30 warning 'AlertTriangle' is defined but never used @typescript-eslint/no-unused-vars
|
|
||||||
|
|
||||||
/Volumes/Alpha SSD/Coding/e-tib.com/app/[locale]/not-found.tsx
|
|
||||||
6:10 warning 'Terminal' is defined but never used @typescript-eslint/no-unused-vars
|
|
||||||
|
|
||||||
/Volumes/Alpha SSD/Coding/e-tib.com/app/[locale]/referenzen/[slug]/page.tsx
|
|
||||||
91:26 warning Using `<img>` could result in slower LCP and higher bandwidth. Consider using `<Image />` from `next/image` or a custom image loader to automatically optimize images. This may incur additional usage or cost from your provider. See: https://nextjs.org/docs/messages/no-img-element @next/next/no-img-element
|
|
||||||
|
|
||||||
/Volumes/Alpha SSD/Coding/e-tib.com/app/[locale]/referenzen/page.tsx
|
|
||||||
1:21 warning 'Heading' is defined but never used @typescript-eslint/no-unused-vars
|
|
||||||
1:30 warning 'Badge' is defined but never used @typescript-eslint/no-unused-vars
|
|
||||||
|
|
||||||
/Volumes/Alpha SSD/Coding/e-tib.com/components/JsonLd.tsx
|
|
||||||
1:30 warning 'Graph' is defined but never used @typescript-eslint/no-unused-vars
|
|
||||||
43:31 warning '_' is assigned a value but never used @typescript-eslint/no-unused-vars
|
|
||||||
48:29 warning '_' is assigned a value but never used @typescript-eslint/no-unused-vars
|
|
||||||
|
|
||||||
/Volumes/Alpha SSD/Coding/e-tib.com/components/blocks/CertificatesBlock.tsx
|
|
||||||
6:10 warning 'Button' is defined but never used @typescript-eslint/no-unused-vars
|
|
||||||
9:10 warning 'FileText' is defined but never used @typescript-eslint/no-unused-vars
|
|
||||||
77:5 warning Error: Calling setState synchronously within an effect can trigger cascading renders
|
|
||||||
|
|
||||||
Effects are intended to synchronize state between React and external systems such as manually updating the DOM, state management libraries, or other platform APIs. In general, the body of an effect should do one or both of the following:
|
|
||||||
* Update external systems with the latest state from React.
|
|
||||||
* Subscribe for updates from some external system, calling setState in a callback function when external state changes.
|
|
||||||
|
|
||||||
Calling setState synchronously within an effect body causes cascading renders that can hurt performance, and is not recommended. (https://react.dev/learn/you-might-not-need-an-effect).
|
|
||||||
|
|
||||||
/Volumes/Alpha SSD/Coding/e-tib.com/components/blocks/CertificatesBlock.tsx:77:5
|
|
||||||
75 |
|
|
||||||
76 | useEffect(() => {
|
|
||||||
> 77 | setIsMounted(true);
|
|
||||||
| ^^^^^^^^^^^^ Avoid calling setState() directly within an effect
|
|
||||||
78 | }, []);
|
|
||||||
79 |
|
|
||||||
80 | const badgeText = badge || 'Zertifizierungen & Nachweise'; react-hooks/set-state-in-effect
|
|
||||||
|
|
||||||
/Volumes/Alpha SSD/Coding/e-tib.com/components/blocks/CompetenceBentoGrid.tsx
|
|
||||||
53:5 warning Error: Calling setState synchronously within an effect can trigger cascading renders
|
|
||||||
|
|
||||||
Effects are intended to synchronize state between React and external systems such as manually updating the DOM, state management libraries, or other platform APIs. In general, the body of an effect should do one or both of the following:
|
|
||||||
* Update external systems with the latest state from React.
|
|
||||||
* Subscribe for updates from some external system, calling setState in a callback function when external state changes.
|
|
||||||
|
|
||||||
Calling setState synchronously within an effect body causes cascading renders that can hurt performance, and is not recommended. (https://react.dev/learn/you-might-not-need-an-effect).
|
|
||||||
|
|
||||||
/Volumes/Alpha SSD/Coding/e-tib.com/components/blocks/CompetenceBentoGrid.tsx:53:5
|
|
||||||
51 |
|
|
||||||
52 | useEffect(() => {
|
|
||||||
> 53 | setIsMounted(true);
|
|
||||||
| ^^^^^^^^^^^^ Avoid calling setState() directly within an effect
|
|
||||||
54 | }, []);
|
|
||||||
55 |
|
|
||||||
56 | const badge = props.badge || data?.badge || 'Leistungsspektrum'; react-hooks/set-state-in-effect
|
|
||||||
|
|
||||||
/Volumes/Alpha SSD/Coding/e-tib.com/components/blocks/HeroVideo.tsx
|
|
||||||
5:18 warning 'AnimatePresence' is defined but never used @typescript-eslint/no-unused-vars
|
|
||||||
6:10 warning 'useState' is defined but never used @typescript-eslint/no-unused-vars
|
|
||||||
6:20 warning 'useEffect' is defined but never used @typescript-eslint/no-unused-vars
|
|
||||||
45:12 warning 'e' is defined but never used @typescript-eslint/no-unused-vars
|
|
||||||
|
|
||||||
/Volumes/Alpha SSD/Coding/e-tib.com/components/blocks/InteractiveGermanyMap.tsx
|
|
||||||
5:46 warning 'ArrowUpRight' is defined but never used @typescript-eslint/no-unused-vars
|
|
||||||
147:23 warning 'isNearRight' is assigned a value but never used @typescript-eslint/no-unused-vars
|
|
||||||
|
|
||||||
/Volumes/Alpha SSD/Coding/e-tib.com/components/blocks/TeamGridBlock.tsx
|
|
||||||
20:39 warning 'title' is defined but never used. Allowed unused args must match /^_/u @typescript-eslint/no-unused-vars
|
|
||||||
20:46 warning 'department' is defined but never used. Allowed unused args must match /^_/u @typescript-eslint/no-unused-vars
|
|
||||||
20:58 warning 'showContact' is assigned a value but never used. Allowed unused args must match /^_/u @typescript-eslint/no-unused-vars
|
|
||||||
|
|
||||||
/Volumes/Alpha SSD/Coding/e-tib.com/components/decorations/CorporateBackground.tsx
|
|
||||||
36:13 warning 'distance' is assigned a value but never used @typescript-eslint/no-unused-vars
|
|
||||||
|
|
||||||
/Volumes/Alpha SSD/Coding/e-tib.com/components/home/Hero.tsx
|
|
||||||
9:39 warning 'useCallback' is defined but never used @typescript-eslint/no-unused-vars
|
|
||||||
77:6 warning React Hook useEffect has a missing dependency: 'HERO_PLACEHOLDERS'. Either include it or remove the dependency array react-hooks/exhaustive-deps
|
|
||||||
|
|
||||||
/Volumes/Alpha SSD/Coding/e-tib.com/components/home/MeetTheTeam.tsx
|
|
||||||
8:9 warning 'teamT' is assigned a value but never used @typescript-eslint/no-unused-vars
|
|
||||||
|
|
||||||
/Volumes/Alpha SSD/Coding/e-tib.com/components/layout/Footer.tsx
|
|
||||||
4:35 warning 'Accessibility' is defined but never used @typescript-eslint/no-unused-vars
|
|
||||||
|
|
||||||
/Volumes/Alpha SSD/Coding/e-tib.com/components/providers/InitialLoader.tsx
|
|
||||||
5:8 warning 'Image' is defined but never used @typescript-eslint/no-unused-vars
|
|
||||||
|
|
||||||
/Volumes/Alpha SSD/Coding/e-tib.com/components/providers/PageTransitionShutter.tsx
|
|
||||||
6:8 warning 'Image' is defined but never used @typescript-eslint/no-unused-vars
|
|
||||||
|
|
||||||
/Volumes/Alpha SSD/Coding/e-tib.com/components/search/AIOrb.tsx
|
|
||||||
153:13 warning 'breathe' is assigned a value but never used @typescript-eslint/no-unused-vars
|
|
||||||
212:15 warning 'targetExcite' is assigned a value but never used @typescript-eslint/no-unused-vars
|
|
||||||
|
|
||||||
/Volumes/Alpha SSD/Coding/e-tib.com/components/search/AISearchResults.tsx
|
|
||||||
99:6 warning React Hook useEffect has missing dependencies: 'handleSearch' and 'initialQuery'. Either include them or remove the dependency array react-hooks/exhaustive-deps
|
|
||||||
|
|
||||||
/Volumes/Alpha SSD/Coding/e-tib.com/lib/blog.ts
|
|
||||||
6:7 warning 'BLOG_DIR' is assigned a value but never used @typescript-eslint/no-unused-vars
|
|
||||||
94:7 warning 'next' is never reassigned. Use 'const' instead prefer-const
|
|
||||||
95:7 warning 'prev' is never reassigned. Use 'const' instead prefer-const
|
|
||||||
|
|
||||||
✖ 38 problems (0 errors, 38 warnings)
|
|
||||||
0 errors and 2 warnings potentially fixable with the `--fix` option.
|
|
||||||
|
|
||||||
|
|||||||
@@ -1,31 +1,55 @@
|
|||||||
|
|
||||||
|
> e-tib-nextjs@2.2.12 test /Volumes/Alpha SSD/Coding/e-tib.com
|
||||||
> e-tib-nextjs@2.2.12 test /Volumes/Alpha SSD/Coding/e-tib.com
|
> vitest run --passWithNoTests
|
||||||
> vitest run --passWithNoTests
|
|
||||||
|
|
||||||
[?25l
|
[1m[46m RUN [49m[22m [36mv4.1.2 [39m[90m/Volumes/Alpha SSD/Coding/e-tib.com[39m
|
||||||
RUN v4.1.2 /Volumes/Alpha SSD/Coding/e-tib.com
|
|
||||||
|
(node:64961) [DEP0205] DeprecationWarning: `module.register()` is deprecated. Use `module.registerHooks()` instead.
|
||||||
[?2026h
|
(Use `node --trace-deprecation ...` to show where the warning was created)
|
||||||
❯ lib/mail/mailer.test.ts [queued]
|
[32m✓[39m tests/mdx-links.test.ts [2m([22m[2m1 test[22m[2m)[22m[32m 29[2mms[22m[39m
|
||||||
|
[32m✓[39m tests/homepage-and-competence-content.test.ts [2m([22m[2m4 tests[22m[2m)[22m[32m 14[2mms[22m[39m
|
||||||
Test Files 0 passed (2)
|
[31m❯[39m tests/legal-pages.test.ts [2m([22m[2m2 tests[22m[2m | [22m[31m1 failed[39m[2m)[22m[32m 9[2mms[22m[39m
|
||||||
Tests 0 passed (0)
|
[32m✓[39m should contain highly professional B2B sections in German AGB[32m 5[2mms[22m[39m
|
||||||
Start at 11:47:51
|
[31m [31m×[31m should contain professional corresponding B2B sections in English Terms[39m[32m 2[2mms[22m[39m
|
||||||
Duration 504ms
|
[32m✓[39m tests/task-12-links.test.ts [2m([22m[2m2 tests[22m[2m)[22m[32m 8[2mms[22m[39m
|
||||||
[?2026l[?2026h[K[1A[K[1A[K[1A[K[1A[K[1A[K[1A[K[1A[K ✓ lib/mail/mailer.test.ts (1 test) 2ms
|
[32m✓[39m tests/messen-content.test.ts [2m([22m[2m2 tests[22m[2m)[22m[32m 8[2mms[22m[39m
|
||||||
|
[32m✓[39m lib/mail/mailer.test.ts [2m([22m[2m1 test[22m[2m)[22m[32m 7[2mms[22m[39m
|
||||||
❯ lib/env.test.ts 0/6
|
[90mstderr[2m | components/layout/LanguageSwitcher.test.tsx[2m > [22m[2mLanguageSwitcher TDD[2m > [22m[2mtranslates /en/trade-fairs to /de/messen when switching to German
|
||||||
|
[22m[39mReact does not recognize the `transitionMessage` prop on a DOM element. If you intentionally want it to appear in the DOM as a custom attribute, spell it as lowercase `transitionmessage` instead. If you accidentally passed it from a parent component, remove it from the DOM element.
|
||||||
Test Files 1 passed (2)
|
|
||||||
Tests 1 passed (7)
|
[32m✓[39m components/layout/LanguageSwitcher.test.tsx [2m([22m[2m5 tests[22m[2m)[22m[32m 25[2mms[22m[39m
|
||||||
Start at 11:47:51
|
[32m✓[39m components/search/AISearchResults.test.tsx [2m([22m[2m1 test[22m[2m)[22m[32m 52[2mms[22m[39m
|
||||||
Duration 605ms
|
[90mstderr[2m | components/blocks/ReferencesSlider.test.tsx[2m > [22m[2mReferencesSlider TDD[2m > [22m[2mrenders reference tiles correctly
|
||||||
[?2026l[K[1A[K[1A[K[1A[K[1A[K[1A[K[1A[K[1A[K ✓ lib/env.test.ts (6 tests) 3ms
|
[22m[39mReceived `true` for a non-boolean attribute `fill`.
|
||||||
|
|
||||||
Test Files 2 passed (2)
|
If you want to write it to the DOM, pass a string instead: fill="true" or fill={value.toString()}.
|
||||||
Tests 7 passed (7)
|
|
||||||
Start at 11:47:51
|
[32m✓[39m components/blocks/ReferencesSlider.test.tsx [2m([22m[2m2 tests[22m[2m)[22m[32m 43[2mms[22m[39m
|
||||||
Duration 672ms (transform 113ms, setup 0ms, import 222ms, tests 5ms, environment 813ms)
|
[32m✓[39m lib/env.test.ts [2m([22m[2m6 tests[22m[2m)[22m[32m 4[2mms[22m[39m
|
||||||
|
[90mstdout[2m | app/[locale]/referenzen/[slug]/page.test.tsx
|
||||||
[?25h
|
[22m[39m[Config] Initializing Toggles: { feedbackEnabled: [33mfalse[39m }
|
||||||
|
|
||||||
|
[32m✓[39m app/[locale]/referenzen/[slug]/page.test.tsx [2m([22m[2m1 test[22m[2m)[22m[32m 26[2mms[22m[39m
|
||||||
|
|
||||||
|
[31m⎯⎯⎯⎯⎯⎯⎯[39m[1m[41m Failed Tests 1 [49m[22m[31m⎯⎯⎯⎯⎯⎯⎯[39m
|
||||||
|
|
||||||
|
[41m[1m FAIL [22m[49m tests/legal-pages.test.ts[2m > [22mLegal Pages Content Validation[2m > [22mshould contain professional corresponding B2B sections in English Terms
|
||||||
|
[31m[1mError[22m: ENOENT: no such file or directory, open '/Volumes/Alpha SSD/Coding/e-tib.com/content/en/terms.mdx'[39m
|
||||||
|
[36m [2m❯[22m tests/legal-pages.test.ts:[2m25:24[22m[39m
|
||||||
|
[90m 23|[39m
|
||||||
|
[90m 24|[39m it('should contain professional corresponding B2B sections in Englis…
|
||||||
|
[90m 25|[39m [35mconst[39m content [33m=[39m fs[33m.[39m[34mreadFileSync[39m(enTermsPath[33m,[39m [32m'utf8'[39m)[33m;[39m
|
||||||
|
[90m |[39m [31m^[39m
|
||||||
|
[90m 26|[39m
|
||||||
|
[90m 27|[39m [34mexpect[39m(content)[33m.[39m[34mtoContain[39m([32m'title: "Terms and Conditions"'[39m)[33m;[39m
|
||||||
|
|
||||||
|
[31m[2m⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯[1/1]⎯[22m[39m
|
||||||
|
|
||||||
|
|
||||||
|
[2m Test Files [22m [1m[31m1 failed[39m[22m[2m | [22m[1m[32m10 passed[39m[22m[90m (11)[39m
|
||||||
|
[2m Tests [22m [1m[31m1 failed[39m[22m[2m | [22m[1m[32m26 passed[39m[22m[90m (27)[39m
|
||||||
|
[2m Start at [22m 14:54:33
|
||||||
|
[2m Duration [22m 5.94s[2m (transform 5.27s, setup 0ms, import 9.43s, tests 222ms, environment 13.80s)[22m
|
||||||
|
|
||||||
|
ELIFECYCLE Test failed. See above for more details.
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
|
|
||||||
|
> e-tib-nextjs@2.2.12 typecheck /Volumes/Alpha SSD/Coding/e-tib.com
|
||||||
> e-tib-nextjs@2.2.12 typecheck /Volumes/Alpha SSD/Coding/e-tib.com
|
> tsc --noEmit
|
||||||
> tsc --noEmit
|
|
||||||
|
ELIFECYCLE Command failed.
|
||||||
|
|||||||
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 |
@@ -10,7 +10,6 @@ import TrackedLink from '@/components/analytics/TrackedLink';
|
|||||||
import { getButtonClasses, ButtonOverlay } from '@/components/ui/Button';
|
import { getButtonClasses, ButtonOverlay } from '@/components/ui/Button';
|
||||||
import { getAllReferences } from '@/lib/references';
|
import { getAllReferences } from '@/lib/references';
|
||||||
import { defaultLocations, minorLocations } from '@/lib/map-data';
|
import { defaultLocations, minorLocations } from '@/lib/map-data';
|
||||||
|
|
||||||
// Import components used in MDX
|
// Import components used in MDX
|
||||||
import { HeroVideo as HomeHero } from '@/components/blocks/HeroVideo';
|
import { HeroVideo as HomeHero } from '@/components/blocks/HeroVideo';
|
||||||
import { SubCompanyTiles as HomeSubCompanyTiles } from '@/components/blocks/SubCompanyTiles';
|
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 { ReferencesSlider as HomeReferencesSlider } from '@/components/blocks/ReferencesSlider';
|
||||||
import { CompanyTimeline } from '@/components/blocks/CompanyTimeline';
|
import { CompanyTimeline } from '@/components/blocks/CompanyTimeline';
|
||||||
import { JobListingBlock } from '@/components/blocks/JobListingBlock';
|
import { JobListingBlock } from '@/components/blocks/JobListingBlock';
|
||||||
import { CallToAction } from '@/components/blocks/CallToAction';
|
|
||||||
import { ServiceDetailGrid } from '@/components/blocks/ServiceDetailGrid';
|
import { ServiceDetailGrid } from '@/components/blocks/ServiceDetailGrid';
|
||||||
import { BenefitGrid } from '@/components/blocks/BenefitGrid';
|
import { BenefitGrid } from '@/components/blocks/BenefitGrid';
|
||||||
import { InteractiveGermanyMap } from '@/components/blocks/InteractiveGermanyMap';
|
import { InteractiveGermanyMap } from '@/components/blocks/InteractiveGermanyMap';
|
||||||
@@ -41,7 +39,6 @@ const mdxComponents = {
|
|||||||
HomeReferencesSlider,
|
HomeReferencesSlider,
|
||||||
CompanyTimeline,
|
CompanyTimeline,
|
||||||
JobListingBlock,
|
JobListingBlock,
|
||||||
CallToAction,
|
|
||||||
ServiceDetailGrid,
|
ServiceDetailGrid,
|
||||||
BenefitGrid,
|
BenefitGrid,
|
||||||
InteractiveGermanyMap,
|
InteractiveGermanyMap,
|
||||||
@@ -51,6 +48,7 @@ const mdxComponents = {
|
|||||||
TeamGrid,
|
TeamGrid,
|
||||||
JsonLd,
|
JsonLd,
|
||||||
Button,
|
Button,
|
||||||
|
Badge,
|
||||||
AnimatedCounter,
|
AnimatedCounter,
|
||||||
GrowthChart,
|
GrowthChart,
|
||||||
DeepDrillAnimation,
|
DeepDrillAnimation,
|
||||||
@@ -162,7 +160,7 @@ export default async function Page(props: { params: Promise<{ locale: string; sl
|
|||||||
...loc,
|
...loc,
|
||||||
featuredImage: ref.frontmatter.featuredImage,
|
featuredImage: ref.frontmatter.featuredImage,
|
||||||
details: [
|
details: [
|
||||||
ref.frontmatter.client || 'Kunde',
|
ref.frontmatter.client || t('client'),
|
||||||
ref.frontmatter.dateString || new Date(ref.frontmatter.date).getFullYear().toString(),
|
ref.frontmatter.dateString || new Date(ref.frontmatter.date).getFullYear().toString(),
|
||||||
],
|
],
|
||||||
};
|
};
|
||||||
@@ -187,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'}`}>
|
<div className={`flex flex-col min-h-screen ${isFullBleed ? '' : 'bg-white'}`}>
|
||||||
{/* Generic Hero Section (only for standard pages) */}
|
{/* Generic Hero Section (only for standard pages) */}
|
||||||
{!isFullBleed && (
|
{!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 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 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>
|
</div>
|
||||||
|
|||||||
@@ -99,7 +99,7 @@ export default async function BlogPost({ params }: BlogPostProps) {
|
|||||||
|
|
||||||
{/* Featured Image Header */}
|
{/* Featured Image Header */}
|
||||||
{post.frontmatter.featuredImage ? (
|
{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">
|
<div className="absolute inset-0 transition-transform duration-[3s] ease-out scale-110 group-hover:scale-100">
|
||||||
<Image
|
<Image
|
||||||
src={post.frontmatter.featuredImage.split('?')[0]}
|
src={post.frontmatter.featuredImage.split('?')[0]}
|
||||||
|
|||||||
@@ -59,7 +59,7 @@ export default async function BlogIndex({ params }: BlogIndexProps) {
|
|||||||
<div className="bg-neutral-light min-h-screen">
|
<div className="bg-neutral-light min-h-screen">
|
||||||
{/* Hero Section - Immersive Magazine Feel */}
|
{/* Hero Section - Immersive Magazine Feel */}
|
||||||
<Reveal>
|
<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 && (
|
{featuredPost && featuredPost.frontmatter.featuredImage && (
|
||||||
<>
|
<>
|
||||||
<Image
|
<Image
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ import { getMessages } from 'next-intl/server';
|
|||||||
import '../../styles/globals.css';
|
import '../../styles/globals.css';
|
||||||
import { SITE_URL } from '@/lib/schema';
|
import { SITE_URL } from '@/lib/schema';
|
||||||
import FeedbackClientWrapper from '@/components/FeedbackClientWrapper';
|
import FeedbackClientWrapper from '@/components/FeedbackClientWrapper';
|
||||||
|
import AnnotatorClientWrapper from '@/components/AnnotatorClientWrapper';
|
||||||
import { setRequestLocale } from 'next-intl/server';
|
import { setRequestLocale } from 'next-intl/server';
|
||||||
import { Inter } from 'next/font/google';
|
import { Inter } from 'next/font/google';
|
||||||
import { mapFileSlugToTranslated } from '@/lib/slugs';
|
import { mapFileSlugToTranslated } from '@/lib/slugs';
|
||||||
@@ -19,7 +20,6 @@ import { TransitionProvider } from '@/components/providers/TransitionProvider';
|
|||||||
import { PageTransitionShutter } from '@/components/providers/PageTransitionShutter';
|
import { PageTransitionShutter } from '@/components/providers/PageTransitionShutter';
|
||||||
import { InitialLoader } from '@/components/providers/InitialLoader';
|
import { InitialLoader } from '@/components/providers/InitialLoader';
|
||||||
import { CorporateBackground } from '@/components/decorations/CorporateBackground';
|
import { CorporateBackground } from '@/components/decorations/CorporateBackground';
|
||||||
|
|
||||||
const inter = Inter({
|
const inter = Inter({
|
||||||
subsets: ['latin'],
|
subsets: ['latin'],
|
||||||
display: 'swap',
|
display: 'swap',
|
||||||
@@ -36,7 +36,7 @@ export async function generateMetadata(props: {
|
|||||||
return {
|
return {
|
||||||
title: {
|
title: {
|
||||||
template: '%s | E-TIB',
|
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 Kabelleitungstiefbau, Horizontalspülbohrungen, Planung und Vermessung in Guben und überregional.',
|
||||||
metadataBase: new URL(baseUrl),
|
metadataBase: new URL(baseUrl),
|
||||||
@@ -93,13 +93,18 @@ export default async function Layout(props: {
|
|||||||
children: [
|
children: [
|
||||||
{ label: safeLocale === 'de' ? 'Firma' : 'Company', url: `/${safeLocale}/${await mapFileSlugToTranslated('ueber-uns', safeLocale)}` },
|
{ 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' ? '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',
|
label: safeLocale === 'de' ? 'Referenzen' : 'References',
|
||||||
url: `/${safeLocale}/referenzen`
|
url: `/${safeLocale}/referenzen`
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
label: safeLocale === 'de' ? 'Karriere' : 'Career',
|
||||||
|
url: `/${safeLocale}/${safeLocale === 'de' ? 'karriere' : 'career'}`
|
||||||
|
},
|
||||||
{
|
{
|
||||||
label: safeLocale === 'de' ? 'Messen' : 'Events',
|
label: safeLocale === 'de' ? 'Messen' : 'Events',
|
||||||
url: `/${safeLocale}/${await mapFileSlugToTranslated('messen', safeLocale)}`
|
url: `/${safeLocale}/${await mapFileSlugToTranslated('messen', safeLocale)}`
|
||||||
@@ -123,6 +128,14 @@ export default async function Layout(props: {
|
|||||||
'Error',
|
'Error',
|
||||||
'StandardPage',
|
'StandardPage',
|
||||||
'Brochure',
|
'Brochure',
|
||||||
|
'JobListingBlock',
|
||||||
|
'CallToAction',
|
||||||
|
'InteractiveGermanyMap',
|
||||||
|
'ReferencesSlider',
|
||||||
|
'CompanyTimeline',
|
||||||
|
'TeamGrid',
|
||||||
|
'AISearch',
|
||||||
|
'GrowthChart',
|
||||||
];
|
];
|
||||||
const clientMessages: Record<string, any> = {};
|
const clientMessages: Record<string, any> = {};
|
||||||
for (const key of clientKeys) {
|
for (const key of clientKeys) {
|
||||||
@@ -202,6 +215,7 @@ export default async function Layout(props: {
|
|||||||
<Footer companyInfo={companyInfo} />
|
<Footer companyInfo={companyInfo} />
|
||||||
<JsonLd />
|
<JsonLd />
|
||||||
<AnalyticsShell />
|
<AnalyticsShell />
|
||||||
|
{process.env.TARGET !== 'production' && <AnnotatorClientWrapper />}
|
||||||
{feedbackEnabled && <FeedbackClientWrapper feedbackEnabled={feedbackEnabled} />}
|
{feedbackEnabled && <FeedbackClientWrapper feedbackEnabled={feedbackEnabled} />}
|
||||||
</TransitionProvider>
|
</TransitionProvider>
|
||||||
</NextIntlClientProvider>
|
</NextIntlClientProvider>
|
||||||
|
|||||||
@@ -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,11 +3,12 @@ import { getTranslations, setRequestLocale } from 'next-intl/server';
|
|||||||
import { Metadata } from 'next';
|
import { Metadata } from 'next';
|
||||||
import { getAllReferences } from '@/lib/references';
|
import { getAllReferences } from '@/lib/references';
|
||||||
import TrackedLink from '@/components/analytics/TrackedLink';
|
import TrackedLink from '@/components/analytics/TrackedLink';
|
||||||
import { MapPin, Calendar, Briefcase, ArrowUpRight } from 'lucide-react';
|
import { MapPin, Calendar, Briefcase } from 'lucide-react';
|
||||||
import { getButtonClasses, ButtonOverlay } from '@/components/ui/Button';
|
import { getButtonClasses, ButtonOverlay } from '@/components/ui/Button';
|
||||||
import { SITE_URL } from '@/lib/schema';
|
import { SITE_URL } from '@/lib/schema';
|
||||||
import Image from 'next/image';
|
import Image from 'next/image';
|
||||||
import { InteractiveGermanyMap } from '@/components/blocks/InteractiveGermanyMap';
|
import { InteractiveGermanyMap } from '@/components/blocks/InteractiveGermanyMap';
|
||||||
|
import { HeroSection } from '@/components/blocks/HeroSection';
|
||||||
import { defaultLocations, minorLocations } from '@/lib/map-data';
|
import { defaultLocations, minorLocations } from '@/lib/map-data';
|
||||||
|
|
||||||
interface PageProps {
|
interface PageProps {
|
||||||
@@ -22,7 +23,7 @@ export async function generateMetadata({ params }: PageProps): Promise<Metadata>
|
|||||||
|
|
||||||
return {
|
return {
|
||||||
title: 'Referenzen | E-TIB Gruppe',
|
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: {
|
alternates: {
|
||||||
canonical: `${SITE_URL}/${locale}/referenzen`,
|
canonical: `${SITE_URL}/${locale}/referenzen`,
|
||||||
languages: {
|
languages: {
|
||||||
@@ -72,27 +73,26 @@ export default async function ReferenzenOverview(props: { params: Promise<{ loca
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-col min-h-screen bg-neutral-50 pb-16 md:pb-24">
|
<div className="flex flex-col min-h-screen bg-neutral-50 pb-16 md:pb-24">
|
||||||
{/* Map Hero Section */}
|
{/* Map Section */}
|
||||||
<InteractiveGermanyMap
|
<InteractiveGermanyMap
|
||||||
isHero={true}
|
isHero={true}
|
||||||
badge={locale === 'en' ? 'Our References' : 'Unsere Referenzen'}
|
badge={locale === 'en' ? 'Our References' : 'Unsere Referenzen'}
|
||||||
title={locale === 'en' ? <>Successfully realized<br/>projects.</> : <>Erfolgreich umgesetzte<br/>Projekte.</>}
|
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. Discover our locations.' : '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.'}
|
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}
|
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 */}
|
{/* Main Content Area */}
|
||||||
<Container className="relative z-20 mt-16 md:mt-24">
|
<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">
|
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6 md:gap-8">
|
||||||
{references.map((ref) => (
|
{references.map((ref) => (
|
||||||
<TrackedLink
|
<div
|
||||||
key={ref.slug}
|
key={ref.slug}
|
||||||
href={`/${locale}/referenzen/${ref.slug}`}
|
className="flex flex-col bg-white border border-neutral-100 rounded-3xl overflow-hidden shadow-sm block"
|
||||||
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,
|
|
||||||
}}
|
|
||||||
>
|
>
|
||||||
<div className="flex flex-col h-full relative">
|
<div className="flex flex-col h-full relative">
|
||||||
{/* Image Section */}
|
{/* Image Section */}
|
||||||
@@ -102,14 +102,14 @@ export default async function ReferenzenOverview(props: { params: Promise<{ loca
|
|||||||
src={ref.frontmatter.featuredImage}
|
src={ref.frontmatter.featuredImage}
|
||||||
alt={ref.frontmatter.title}
|
alt={ref.frontmatter.title}
|
||||||
fill
|
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">
|
<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" />
|
<Image src="/assets/logo.png" alt="E-TIB Logo" width={80} height={80} className="opacity-20 grayscale" />
|
||||||
</div>
|
</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 */}
|
{/* Location Badge */}
|
||||||
<div className="absolute top-4 left-4 z-10">
|
<div className="absolute top-4 left-4 z-10">
|
||||||
@@ -122,21 +122,21 @@ export default async function ReferenzenOverview(props: { params: Promise<{ loca
|
|||||||
|
|
||||||
{/* Content Section */}
|
{/* Content Section */}
|
||||||
<div className="flex flex-col flex-grow p-6 md:p-8 bg-white">
|
<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}
|
{ref.frontmatter.title}
|
||||||
</h3>
|
</h3>
|
||||||
|
|
||||||
{/* Technical Meta Data */}
|
{/* 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">
|
<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">
|
<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]" />
|
<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>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="min-w-0">
|
<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">
|
<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]" />
|
<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>
|
<span className="line-clamp-2">{ref.frontmatter.dateString || new Date(ref.frontmatter.date).getFullYear()}</span>
|
||||||
@@ -144,13 +144,8 @@ export default async function ReferenzenOverview(props: { params: Promise<{ loca
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</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>
|
</div>
|
||||||
</TrackedLink>
|
</div>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -158,8 +153,8 @@ export default async function ReferenzenOverview(props: { params: Promise<{ loca
|
|||||||
<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-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="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">
|
<div className="relative z-10 max-w-2xl">
|
||||||
<h3 className="text-2xl md:text-3xl font-bold mb-4">Ihr nächstes Projekt?</h3>
|
<h3 className="text-2xl md:text-3xl font-bold mb-4">{t('nextProjectTitle')}</h3>
|
||||||
<p className="text-lg text-white/70 mb-8">Lassen Sie uns gemeinsam herausragende Infrastruktur bauen.</p>
|
<p className="text-lg text-white/70 mb-8">{t('nextProjectDesc')}</p>
|
||||||
<TrackedLink
|
<TrackedLink
|
||||||
href={`/${locale}/${locale === 'de' ? 'kontakt' : 'contact'}`}
|
href={`/${locale}/${locale === 'de' ? 'kontakt' : 'contact'}`}
|
||||||
className={getButtonClasses('accent', 'lg')}
|
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-12 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-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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -99,7 +99,7 @@ export async function sendContactFormAction(formData: FormData) {
|
|||||||
const confirmationHtml = await render(
|
const confirmationHtml = await render(
|
||||||
React.createElement(ConfirmationMessage, {
|
React.createElement(ConfirmationMessage, {
|
||||||
name,
|
name,
|
||||||
clientName: 'KLZ Cables',
|
clientName: 'E-TIB GmbH',
|
||||||
// brandColor: '#82ed20', // Optional: could be KLZ specific
|
// brandColor: '#82ed20', // Optional: could be KLZ specific
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
|
|||||||
35
app/actions/getAnnotatorAssets.ts
Normal file
35
app/actions/getAnnotatorAssets.ts
Normal file
@@ -0,0 +1,35 @@
|
|||||||
|
'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 = (dir: string): string[] => {
|
||||||
|
let results: string[] = [];
|
||||||
|
if (!fs.existsSync(dir)) return results;
|
||||||
|
const list = fs.readdirSync(dir);
|
||||||
|
list.forEach((file) => {
|
||||||
|
const filePath = path.join(dir, file);
|
||||||
|
const stat = fs.statSync(filePath);
|
||||||
|
if (stat && stat.isDirectory()) {
|
||||||
|
results = results.concat(walk(filePath));
|
||||||
|
} else {
|
||||||
|
if (filePath.match(/\.(png|jpg|jpeg|webp|mp4|svg)$/i)) {
|
||||||
|
results.push(filePath.replace(publicDir, ''));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
return results;
|
||||||
|
};
|
||||||
|
const results = walk(publicDir);
|
||||||
|
console.log('[Annotator] publicDir:', publicDir);
|
||||||
|
console.log('[Annotator] Assets found:', results.length);
|
||||||
|
console.log('[Annotator] First 5 paths:', results.slice(0, 5));
|
||||||
|
|
||||||
|
return results;
|
||||||
|
}
|
||||||
54
app/actions/submitAnnotations.ts
Normal file
54
app/actions/submitAnnotations.ts
Normal file
@@ -0,0 +1,54 @@
|
|||||||
|
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 im JSON-Anhang.</i></p>
|
||||||
|
`;
|
||||||
|
|
||||||
|
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,
|
||||||
|
attachments: [
|
||||||
|
{
|
||||||
|
filename: 'corrections.json',
|
||||||
|
content: jsonContent,
|
||||||
|
contentType: 'application/json',
|
||||||
|
}
|
||||||
|
]
|
||||||
|
});
|
||||||
|
|
||||||
|
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 {
|
.hide-scrollbar {
|
||||||
-ms-overflow-style: none; /* IE and Edge */
|
-ms-overflow-style: none !important; /* IE and Edge */
|
||||||
scrollbar-width: none; /* Firefox */
|
scrollbar-width: none !important; /* Firefox */
|
||||||
}
|
}
|
||||||
.hide-scrollbar::-webkit-scrollbar {
|
.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 {
|
@keyframes bg-pulse-15 {
|
||||||
|
|||||||
30
components/AnnotatorClientWrapper.tsx
Normal file
30
components/AnnotatorClientWrapper.tsx
Normal file
@@ -0,0 +1,30 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { Annotator } from '@mintel/annotator';
|
||||||
|
import { useEffect, useState } from 'react';
|
||||||
|
import { getAnnotatorAssets } from '@/app/actions/getAnnotatorAssets';
|
||||||
|
import { submitAnnotations } from '@/app/actions/submitAnnotations';
|
||||||
|
|
||||||
|
export default function AnnotatorClientWrapper() {
|
||||||
|
const [assets, setAssets] = useState<string[]>([]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
getAnnotatorAssets().then(fetchedAssets => {
|
||||||
|
console.log('[AnnotatorClientWrapper] fetchedAssets:', fetchedAssets);
|
||||||
|
if (Array.isArray(fetchedAssets)) {
|
||||||
|
setAssets(fetchedAssets);
|
||||||
|
}
|
||||||
|
}).catch(err => {
|
||||||
|
console.error('[AnnotatorClientWrapper] Action 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 {
|
interface ContactMapProps {
|
||||||
address: string;
|
address?: string;
|
||||||
lat: number;
|
lat?: number;
|
||||||
lng: number;
|
lng?: number;
|
||||||
|
locations?: Array<{name: string, address: string, lat: number, lng: number}>;
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function ContactMap({ address, lat, lng }: ContactMapProps) {
|
export default function ContactMap({ address, lat, lng, locations }: ContactMapProps) {
|
||||||
return <LeafletMap locations={[{ name: 'E-TIB Gruppe', address, lat, lng }]} />;
|
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 firstLoc = locations[0];
|
||||||
const map = L.map(mapRef.current, {
|
const map = L.map(mapRef.current, {
|
||||||
center: [firstLoc.lat, firstLoc.lng],
|
center: [firstLoc.lat, firstLoc.lng],
|
||||||
zoom: 6, // Zoom out to see both or more
|
zoom: locations.length === 1 ? 16 : 6,
|
||||||
scrollWheelZoom: false,
|
scrollWheelZoom: false,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -12,6 +12,8 @@ interface TrackedLinkProps {
|
|||||||
className?: string;
|
className?: string;
|
||||||
children: React.ReactNode;
|
children: React.ReactNode;
|
||||||
onClick?: () => void;
|
onClick?: () => void;
|
||||||
|
target?: string;
|
||||||
|
rel?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -25,6 +27,8 @@ export default function TrackedLink({
|
|||||||
className,
|
className,
|
||||||
children,
|
children,
|
||||||
onClick,
|
onClick,
|
||||||
|
target,
|
||||||
|
rel,
|
||||||
}: TrackedLinkProps) {
|
}: TrackedLinkProps) {
|
||||||
const { trackEvent } = useAnalytics();
|
const { trackEvent } = useAnalytics();
|
||||||
|
|
||||||
@@ -41,7 +45,7 @@ export default function TrackedLink({
|
|||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Link href={href} className={className} onClick={handleClick}>
|
<Link href={href} className={className} onClick={handleClick} target={target} rel={rel}>
|
||||||
{children}
|
{children}
|
||||||
</Link>
|
</Link>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -75,13 +75,13 @@ const Icons = {
|
|||||||
|
|
||||||
export function BenefitGrid({ badge, title, description, benefits }: BenefitGridProps) {
|
export function BenefitGrid({ badge, title, description, benefits }: BenefitGridProps) {
|
||||||
return (
|
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="container px-4 max-w-7xl mx-auto relative z-10">
|
||||||
|
|
||||||
<div className="max-w-3xl mx-auto text-center mb-16">
|
<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>}
|
{badge && <h2 className="text-primary font-bold tracking-wider uppercase text-sm mb-4">{badge}</h2>}
|
||||||
{title && (
|
{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}
|
{title}
|
||||||
</h3>
|
</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 {
|
interface Certificate {
|
||||||
title: string;
|
title: string;
|
||||||
description: string;
|
description: string;
|
||||||
pdfUrl: string;
|
pdfUrl?: string;
|
||||||
type: 'iso' | 'tax' | 'general';
|
type: 'iso' | 'tax' | 'general';
|
||||||
date?: string;
|
date?: string;
|
||||||
}
|
}
|
||||||
@@ -29,45 +29,46 @@ const defaultCertificates: Certificate[] = [
|
|||||||
{
|
{
|
||||||
title: 'ISO 14001:2015',
|
title: 'ISO 14001:2015',
|
||||||
description: 'Umweltmanagementsystem',
|
description: 'Umweltmanagementsystem',
|
||||||
pdfUrl: '/assets/certificates/231214_Zertifikat ISO 14001 Umweltmanagement.pdf',
|
pdfUrl: '/assets/certificates/iso14001.pdf',
|
||||||
type: 'iso',
|
type: 'iso',
|
||||||
date: '14.12.2023',
|
date: '14.12.2023',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
title: 'ISO 9001:2015',
|
title: 'ISO 9001:2015',
|
||||||
description: 'Qualitätsmanagementsystem',
|
description: 'Qualitätsmanagementsystem',
|
||||||
pdfUrl: '/assets/certificates/231214_Zertifikat ISO 9001 Qualitätsmanagement.pdf',
|
pdfUrl: '/assets/certificates/iso9001.pdf',
|
||||||
type: 'iso',
|
type: 'iso',
|
||||||
date: '14.12.2023',
|
date: '14.12.2023',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
title: 'DIN EN ISO 45001:2018',
|
title: 'DIN EN ISO 45001:2018',
|
||||||
description: 'Arbeits- und Gesundheitsschutz',
|
description: 'Arbeits- und Gesundheitsschutz',
|
||||||
pdfUrl: '/assets/certificates/Zertifizierung DIN EN ISO 45001 bis 05122028.pdf',
|
pdfUrl: '/assets/certificates/iso45001.pdf',
|
||||||
type: 'iso',
|
type: 'iso',
|
||||||
date: '05.12.2025', // Assuming valid till
|
date: '05.12.2025',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
title: 'Freistellungsbescheinigung',
|
title: 'Freistellungsbescheinigung',
|
||||||
description: 'Nach § 48 b EStG',
|
description: 'Nach § 48 b EStG',
|
||||||
pdfUrl: '/assets/certificates/240209_Freistellungsbescheinigung § 48 b.pdf',
|
pdfUrl: '/assets/certificates/freistellung.pdf',
|
||||||
type: 'tax',
|
type: 'tax',
|
||||||
date: '09.02.2024',
|
date: '09.02.2024',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
title: 'Nachweis § 13b UStG',
|
title: 'Nachweis § 13b UStG',
|
||||||
description: 'Steuerschuldnerschaft des Leistungsempfängers',
|
description: 'Steuerschuldnerschaft des Leistungsempfängers',
|
||||||
pdfUrl: '/assets/certificates/240209_Nachweis § 13 b.pdf',
|
pdfUrl: '/assets/certificates/nachweis13b.pdf',
|
||||||
type: 'tax',
|
type: 'tax',
|
||||||
date: '09.02.2024',
|
date: '09.02.2024',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
title: 'Bescheinigung in Steuersachen',
|
title: 'Bescheinigung in Steuersachen',
|
||||||
description: 'Zertifikat des Finanzamtes',
|
description: 'Zertifikat des Finanzamtes',
|
||||||
pdfUrl: '/assets/certificates/250213_Bescheinigung in Steuersachen.pdf',
|
pdfUrl: '/assets/certificates/bescheinigung.pdf',
|
||||||
type: 'tax',
|
type: 'tax',
|
||||||
date: '13.02.2025',
|
date: '13.02.2025',
|
||||||
},
|
},
|
||||||
|
|
||||||
];
|
];
|
||||||
|
|
||||||
export function CertificatesBlock({ badge, title, description, certificates = defaultCertificates, hideHeader = false }: CertificatesBlockProps) {
|
export function CertificatesBlock({ badge, title, description, certificates = defaultCertificates, hideHeader = false }: CertificatesBlockProps) {
|
||||||
@@ -98,12 +99,12 @@ export function CertificatesBlock({ badge, title, description, certificates = de
|
|||||||
|
|
||||||
if (!isMounted) {
|
if (!isMounted) {
|
||||||
return (
|
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>
|
<Container>
|
||||||
{!hideHeader && (
|
{!hideHeader && (
|
||||||
<div className="text-center max-w-3xl mx-auto mb-16">
|
<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>
|
<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>
|
<p className="text-lg text-neutral-600">{descriptionText}</p>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
@@ -113,7 +114,7 @@ export function CertificatesBlock({ badge, title, description, certificates = de
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
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 */}
|
{/* 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 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" />
|
<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" />
|
||||||
@@ -128,7 +129,7 @@ export function CertificatesBlock({ badge, title, description, certificates = de
|
|||||||
transition={{ duration: 0.5 }}
|
transition={{ duration: 0.5 }}
|
||||||
>
|
>
|
||||||
<h2 className="text-primary font-bold tracking-wider uppercase text-sm mb-3">{badgeText}</h2>
|
<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>
|
<p className="text-lg text-neutral-600">{descriptionText}</p>
|
||||||
</motion.div>
|
</motion.div>
|
||||||
</div>
|
</div>
|
||||||
@@ -143,15 +144,19 @@ export function CertificatesBlock({ badge, title, description, certificates = de
|
|||||||
>
|
>
|
||||||
{certificates.map((cert, index) => {
|
{certificates.map((cert, index) => {
|
||||||
const isIso = cert.type === 'iso';
|
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 (
|
return (
|
||||||
<motion.a
|
<Wrapper
|
||||||
key={index}
|
key={index}
|
||||||
href={cert.pdfUrl}
|
{...wrapperProps as any}
|
||||||
target="_blank"
|
|
||||||
rel="noopener noreferrer"
|
|
||||||
variants={itemVariants}
|
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
|
isIso
|
||||||
? 'bg-neutral-dark text-white border-neutral-800 shadow-lg'
|
? 'bg-neutral-dark text-white border-neutral-800 shadow-lg'
|
||||||
: 'bg-white text-neutral-dark border-neutral-200 shadow-md'
|
: 'bg-white text-neutral-dark border-neutral-200 shadow-md'
|
||||||
@@ -196,14 +201,22 @@ export function CertificatesBlock({ badge, title, description, certificates = de
|
|||||||
</p>
|
</p>
|
||||||
</div>
|
</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'
|
isIso ? 'text-primary-light' : 'text-primary'
|
||||||
}`}>
|
}`}>
|
||||||
<span>Download PDF</span>
|
{cert.pdfUrl ? (
|
||||||
<Download size={16} className="ml-2 group-hover:translate-y-0.5 transition-transform" />
|
<>
|
||||||
|
<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>
|
||||||
</div>
|
</div>
|
||||||
</motion.a>
|
</Wrapper>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
</motion.div>
|
</motion.div>
|
||||||
|
|||||||
@@ -80,18 +80,18 @@ export function CompanyTimeline({
|
|||||||
const lineHeight = useTransform(scrollYProgress, [0, 1], ["0%", "100%"]);
|
const lineHeight = useTransform(scrollYProgress, [0, 1], ["0%", "100%"]);
|
||||||
|
|
||||||
return (
|
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 */}
|
{/* 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 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="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="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">
|
<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" />
|
<span className="w-2 h-2 rounded-full bg-primary animate-pulse" />
|
||||||
{finalBadge}
|
{finalBadge}
|
||||||
</div>
|
</div>
|
||||||
<h3 className="font-heading text-4xl md:text-5xl lg:text-6xl font-extrabold text-neutral-dark tracking-tight">
|
<h3 className="font-heading text-3xl md:text-5xl lg:text-6xl font-extrabold text-neutral-dark tracking-tight">
|
||||||
{finalTitle}
|
{finalTitle}
|
||||||
</h3>
|
</h3>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -62,12 +62,12 @@ export function CompetenceBentoGrid(props: CompetenceBentoGridProps) {
|
|||||||
// Static fallback for SSR
|
// Static fallback for SSR
|
||||||
if (!isMounted) {
|
if (!isMounted) {
|
||||||
return (
|
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>
|
<Container>
|
||||||
<div className="flex flex-col md:flex-row justify-between items-end mb-12">
|
<div className="flex flex-col md:flex-row justify-between items-end mb-12">
|
||||||
<div className="max-w-2xl">
|
<div className="max-w-2xl">
|
||||||
<h2 className="text-primary font-bold tracking-wider uppercase text-sm mb-3">{badge}</h2>
|
<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>
|
</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-[220px]">
|
||||||
@@ -79,12 +79,12 @@ export function CompetenceBentoGrid(props: CompetenceBentoGridProps) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
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">
|
<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="flex flex-col md:flex-row justify-between items-start md:items-end mb-12 px-4 md:px-0">
|
||||||
<div className="max-w-2xl">
|
<div className="max-w-2xl text-left">
|
||||||
<h2 className="text-primary font-bold tracking-wider uppercase text-sm mb-3">{badge}</h2>
|
<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>
|
||||||
<Button href={ctaHref} variant="ghost" className="mt-6 md:mt-0 bg-primary/5 hover:bg-primary/10 mx-4 md:mx-0">
|
<Button href={ctaHref} variant="ghost" className="mt-6 md:mt-0 bg-primary/5 hover:bg-primary/10 mx-4 md:mx-0">
|
||||||
{ctaLabel}
|
{ctaLabel}
|
||||||
@@ -92,7 +92,7 @@ export function CompetenceBentoGrid(props: CompetenceBentoGridProps) {
|
|||||||
</Button>
|
</Button>
|
||||||
</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-[220px] px-4 md:px-0">
|
||||||
{items.map((item, idx) => {
|
{items.map((item, idx) => {
|
||||||
const isLarge = item.size === 'large';
|
const isLarge = item.size === 'large';
|
||||||
const isMedium = item.size === 'medium';
|
const isMedium = item.size === 'medium';
|
||||||
@@ -177,7 +177,7 @@ export function CompetenceBentoGrid(props: CompetenceBentoGridProps) {
|
|||||||
{item.tag}
|
{item.tag}
|
||||||
</span>
|
</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 ${isLarge ? 'text-2xl md:text-5xl' : 'text-xl md:text-2xl'}`}>
|
||||||
{item.title}
|
{item.title}
|
||||||
</h4>
|
</h4>
|
||||||
{item.description && (
|
{item.description && (
|
||||||
|
|||||||
@@ -41,7 +41,7 @@ export const ContactSection: React.FC<ContactSectionProps> = (props) => {
|
|||||||
const { showForm, showMap } = props;
|
const { showForm, showMap } = props;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Section className="bg-neutral-light py-12 md:py-28 relative overflow-hidden">
|
<Section className="bg-neutral-light relative overflow-hidden">
|
||||||
<Container>
|
<Container>
|
||||||
<div className="grid grid-cols-1 lg:grid-cols-12 gap-8 md:gap-16">
|
<div className="grid grid-cols-1 lg:grid-cols-12 gap-8 md:gap-16">
|
||||||
<motion.div
|
<motion.div
|
||||||
@@ -52,7 +52,7 @@ export const ContactSection: React.FC<ContactSectionProps> = (props) => {
|
|||||||
viewport={{ once: true, margin: "-50px" }}
|
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.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">
|
<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.
|
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>
|
</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" />}
|
fallback={<div className="h-full w-full bg-neutral-medium animate-pulse" />}
|
||||||
>
|
>
|
||||||
<ContactMap
|
<ContactMap
|
||||||
address="Gewerbestraße 22\n03172 Guben"
|
locations={[
|
||||||
lat={51.9547}
|
{ name: 'E-TIB GmbH (Hauptsitz)', address: 'Gewerbestraße 22\n03172 Guben', lat: 51.9547, lng: 14.7214 },
|
||||||
lng={14.7214}
|
{ name: 'E-TIB Bohrtechnik GmbH', address: 'Bahnhofstraße 180a\n99947 Kirchheilingen', lat: 51.1852, lng: 10.6865 }
|
||||||
|
]}
|
||||||
/>
|
/>
|
||||||
</Suspense>
|
</Suspense>
|
||||||
</motion.section>
|
</motion.section>
|
||||||
|
|||||||
@@ -7,13 +7,13 @@ import { useLocale } from 'next-intl';
|
|||||||
export function DataGridPulse() {
|
export function DataGridPulse() {
|
||||||
const locale = useLocale();
|
const locale = useLocale();
|
||||||
return (
|
return (
|
||||||
<div className="relative w-full py-32 bg-[#050B14] overflow-hidden flex flex-col items-center border-y border-primary/20 -mx-[50vw] px-[50vw] mb-16">
|
<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 */}
|
{/* Blueprint grid background */}
|
||||||
<div
|
<div
|
||||||
className="absolute inset-0 opacity-20 pointer-events-none"
|
className="absolute inset-0 opacity-[0.03] pointer-events-none"
|
||||||
style={{
|
style={{
|
||||||
backgroundImage: 'linear-gradient(#82ED20 1px, transparent 1px), linear-gradient(90deg, #82ED20 1px, transparent 1px)',
|
backgroundImage: 'linear-gradient(#117C61 1px, transparent 1px), linear-gradient(90deg, #117C61 1px, transparent 1px)',
|
||||||
backgroundSize: '40px 40px'
|
backgroundSize: '40px 40px'
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
@@ -21,25 +21,25 @@ export function DataGridPulse() {
|
|||||||
{/* Pulsing horizontal lines (Data Flow) */}
|
{/* Pulsing horizontal lines (Data Flow) */}
|
||||||
<motion.div
|
<motion.div
|
||||||
animate={{ x: ["-100%", "100%"] }}
|
animate={{ x: ["-100%", "100%"] }}
|
||||||
transition={{ duration: 3, repeat: Infinity, ease: "linear" }}
|
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 to-transparent opacity-50 blur-[2px]"
|
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
|
<motion.div
|
||||||
animate={{ x: ["100%", "-100%"] }}
|
animate={{ x: ["100%", "-100%"] }}
|
||||||
transition={{ duration: 4, repeat: Infinity, ease: "linear" }}
|
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-white to-transparent opacity-30 blur-[2px]"
|
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-4xl mx-auto flex flex-col items-center text-center">
|
<div className="relative z-10 w-full max-w-2xl px-6 mx-auto flex flex-col items-center text-center">
|
||||||
<h3 className="text-sm font-bold uppercase tracking-widest text-primary mb-6">
|
<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'}
|
{locale === 'en' ? 'Network Expansion in Numbers' : 'Netzausbau in Zahlen'}
|
||||||
</h3>
|
</span>
|
||||||
|
|
||||||
<div className="bg-black/40 backdrop-blur-md p-12 md:p-16 rounded-3xl border border-white/10 shadow-2xl">
|
<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-6xl md:text-8xl font-black text-white tracking-tighter mb-4 leading-none" style={{ fontVariantNumeric: 'tabular-nums' }}>
|
<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-3xl md:text-5xl ml-2">m</span>
|
372.161<span className="text-primary text-2xl md:text-4xl ml-1 font-sans">m</span>
|
||||||
</div>
|
</div>
|
||||||
<p className="text-white/70 text-lg md:text-xl font-medium max-w-lg mx-auto">
|
<p className="text-text-secondary text-base md:text-lg font-medium max-w-md mx-auto leading-relaxed">
|
||||||
{locale === 'en'
|
{locale === 'en'
|
||||||
? 'Cable routes laid since 2023. A massive infrastructure achievement, driven by our own machinery.'
|
? '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.'}
|
: 'Verlegte Kabeltrassen seit 2023. Eine massive Infrastrukturleistung, getragen von unserem eigenen Maschinenpark.'}
|
||||||
|
|||||||
@@ -16,13 +16,13 @@ export function DeepDrillAnimation() {
|
|||||||
const drillDepth = useTransform(scrollYProgress, [0, 1], ["0%", "100%"]);
|
const drillDepth = useTransform(scrollYProgress, [0, 1], ["0%", "100%"]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div ref={containerRef} className="relative w-full py-32 flex flex-col items-center">
|
<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="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">
|
<div className="relative z-10 w-full max-w-3xl mx-auto flex flex-col items-center text-center text-white">
|
||||||
|
|
||||||
<h3 className="text-3xl font-heading font-extrabold mb-8 tracking-tight">
|
<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'}
|
{locale === 'en' ? 'Precision in Depth' : 'Präzision in der Tiefe'}
|
||||||
</h3>
|
</h3>
|
||||||
<p className="text-white/60 text-lg mb-16 max-w-xl">
|
<p className="text-white/60 text-lg mb-16 max-w-xl">
|
||||||
@@ -50,8 +50,8 @@ export function DeepDrillAnimation() {
|
|||||||
transition={{ duration: 0.8 }}
|
transition={{ duration: 0.8 }}
|
||||||
className="flex flex-col items-center"
|
className="flex flex-col items-center"
|
||||||
>
|
>
|
||||||
<div className="font-mono text-6xl md:text-8xl font-black text-white tracking-tighter mb-4" style={{ fontVariantNumeric: 'tabular-nums' }}>
|
<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-3xl md:text-5xl ml-2">m</span>
|
102.771<span className="text-primary text-2xl md:text-5xl ml-2">m</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="text-sm font-bold uppercase tracking-widest text-primary">
|
<div className="text-sm font-bold uppercase tracking-widest text-primary">
|
||||||
{locale === 'en' ? 'Drilled distance since 2023' : 'Gebohrte Strecke seit 2023'}
|
{locale === 'en' ? 'Drilled distance since 2023' : 'Gebohrte Strecke seit 2023'}
|
||||||
|
|||||||
@@ -21,7 +21,7 @@ export const FaqBlock: React.FC<FaqBlockProps> = ({
|
|||||||
if (!questions || questions.length === 0) return null;
|
if (!questions || questions.length === 0) return null;
|
||||||
|
|
||||||
return (
|
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="max-w-3xl mx-auto">
|
||||||
<div className="mb-10 text-center">
|
<div className="mb-10 text-center">
|
||||||
{subtitle && (
|
{subtitle && (
|
||||||
@@ -29,7 +29,7 @@ export const FaqBlock: React.FC<FaqBlockProps> = ({
|
|||||||
{subtitle}
|
{subtitle}
|
||||||
</span>
|
</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}
|
{title}
|
||||||
</h2>
|
</h2>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -2,27 +2,38 @@
|
|||||||
|
|
||||||
import React from 'react';
|
import React from 'react';
|
||||||
import { motion } from 'framer-motion';
|
import { motion } from 'framer-motion';
|
||||||
|
import { useTranslations } from 'next-intl';
|
||||||
|
|
||||||
export function GrowthChart() {
|
export function GrowthChart() {
|
||||||
|
const t = useTranslations('GrowthChart');
|
||||||
|
|
||||||
const data = [
|
const data = [
|
||||||
{ year: '2023', hdd: 29436, kabel: 76727 },
|
{ year: '2016', offener: 5000, hdd: 1200, kabel: 16064 },
|
||||||
{ year: '2024', hdd: 28680, kabel: 131964 },
|
{ year: '2017', offener: 33401, hdd: 8363, kabel: 84675 },
|
||||||
{ year: '2025', hdd: 44655, kabel: 163470 },
|
{ 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
|
const maxVal = 163470; // Highest value for scaling
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="w-full bg-white rounded-3xl p-8 md:p-12 shadow-sm border border-neutral-100 my-16">
|
<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">
|
<div className="mb-10">
|
||||||
<h3 className="text-3xl font-heading font-extrabold text-neutral-dark mb-2">Bewiesene Leistungsfähigkeit</h3>
|
<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">Unsere verlässliche Baukapazität der letzten drei Jahre (in Metern)</p>
|
<p className="text-neutral-500 font-medium">{t('subtitle')}</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="space-y-8">
|
<div className="space-y-8">
|
||||||
{data.map((item, index) => {
|
{data.map((item, index) => {
|
||||||
const hddWidth = (item.hdd / maxVal) * 100;
|
|
||||||
const kabelWidth = (item.kabel / maxVal) * 100;
|
const kabelWidth = (item.kabel / maxVal) * 100;
|
||||||
|
const offenerWidth = (item.offener / maxVal) * 100;
|
||||||
|
const hddWidth = (item.hdd / maxVal) * 100;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div key={item.year} className="relative">
|
<div key={item.year} className="relative">
|
||||||
@@ -40,7 +51,23 @@ export function GrowthChart() {
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div className="w-24 text-right text-xs font-bold text-neutral-500 uppercase">
|
<div className="w-24 text-right text-xs font-bold text-neutral-500 uppercase">
|
||||||
Kabel: {new Intl.NumberFormat('de-DE').format(item.kabel)}m
|
{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>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -56,7 +83,7 @@ export function GrowthChart() {
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div className="w-24 text-right text-xs font-bold text-neutral-500 uppercase">
|
<div className="w-24 text-right text-xs font-bold text-neutral-500 uppercase">
|
||||||
HDD: {new Intl.NumberFormat('de-DE').format(item.hdd)}m
|
{t('hdd')}: {new Intl.NumberFormat('de-DE').format(item.hdd)}m
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -65,14 +92,18 @@ export function GrowthChart() {
|
|||||||
})}
|
})}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="mt-8 flex gap-6 border-t border-neutral-100 pt-6">
|
<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="flex items-center gap-2">
|
||||||
<div className="w-3 h-3 rounded-full bg-primary" />
|
<div className="w-3 h-3 rounded-full bg-primary" />
|
||||||
<span className="text-xs font-bold text-neutral-500 uppercase">Kabelverlegung</span>
|
<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>
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<div className="w-3 h-3 rounded-full bg-neutral-dark" />
|
<div className="w-3 h-3 rounded-full bg-neutral-dark" />
|
||||||
<span className="text-xs font-bold text-neutral-500 uppercase">HDD Spülbohrung</span>
|
<span className="text-xs font-bold text-neutral-500 uppercase">{t('hdd')}</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -2,6 +2,7 @@
|
|||||||
|
|
||||||
import React from 'react';
|
import React from 'react';
|
||||||
import Image from 'next/image';
|
import Image from 'next/image';
|
||||||
|
import { usePathname } from 'next/navigation';
|
||||||
import { motion } from 'framer-motion';
|
import { motion } from 'framer-motion';
|
||||||
import { Badge, Container, Heading } from '@/components/ui';
|
import { Badge, Container, Heading } from '@/components/ui';
|
||||||
import { Button } from '@/components/ui/Button';
|
import { Button } from '@/components/ui/Button';
|
||||||
@@ -39,13 +40,15 @@ const itemVariants = {
|
|||||||
export const HeroSection: React.FC<HeroSectionProps> = (props) => {
|
export const HeroSection: React.FC<HeroSectionProps> = (props) => {
|
||||||
const { title, badge, subtitle, backgroundImage, alignment, ctaLabel, ctaHref } = props;
|
const { title, badge, subtitle, backgroundImage, alignment, ctaLabel, ctaHref } = props;
|
||||||
const bgSrc = backgroundImage?.sizes?.card?.url || backgroundImage?.url;
|
const bgSrc = backgroundImage?.sizes?.card?.url || backgroundImage?.url;
|
||||||
|
const pathname = usePathname();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<section
|
<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 && (
|
{bgSrc && (
|
||||||
<motion.div
|
<motion.div
|
||||||
|
key={`hero-bg-${pathname}`}
|
||||||
className="absolute inset-0 z-0"
|
className="absolute inset-0 z-0"
|
||||||
initial={{ scale: 1.05, opacity: 0 }}
|
initial={{ scale: 1.05, opacity: 0 }}
|
||||||
animate={{ scale: 1, opacity: 1 }}
|
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' : ''}`}>
|
<Container className={`relative z-10 ${alignment === 'center' ? 'max-w-5xl' : ''}`}>
|
||||||
<motion.div
|
<motion.div
|
||||||
|
key={`hero-content-${pathname}`}
|
||||||
className={`max-w-4xl ${alignment === 'center' ? 'mx-auto' : ''}`}
|
className={`max-w-4xl ${alignment === 'center' ? 'mx-auto' : ''}`}
|
||||||
variants={containerVariants}
|
variants={containerVariants}
|
||||||
initial="hidden"
|
initial="hidden"
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import { Button } from '@/components/ui/Button';
|
|||||||
import Image from 'next/image';
|
import Image from 'next/image';
|
||||||
import { motion, AnimatePresence } from 'framer-motion';
|
import { motion, AnimatePresence } from 'framer-motion';
|
||||||
import { useState, useEffect } from 'react';
|
import { useState, useEffect } from 'react';
|
||||||
|
import { usePathname } from 'next/navigation';
|
||||||
|
|
||||||
interface HeroVideoProps {
|
interface HeroVideoProps {
|
||||||
data?: any;
|
data?: any;
|
||||||
@@ -24,10 +25,11 @@ interface HeroVideoProps {
|
|||||||
|
|
||||||
export function HeroVideo(props: HeroVideoProps) {
|
export function HeroVideo(props: HeroVideoProps) {
|
||||||
const { data } = props;
|
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 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 explicitNoVideo = props.videoUrl === 'none' || props.videoUrl === '';
|
||||||
const videoUrl = explicitNoVideo ? null : (props.videoUrl || data?.videoUrl || '/assets/dummy-hero.mp4');
|
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";
|
const posterSrc = props.backgroundImage?.url || props.posterImage?.url || data?.posterImage?.url || "/assets/photos/DJI_0048.JPG";
|
||||||
const posterAlt = props.backgroundImage?.alt || data?.posterImage?.alt || "E-TIB Gruppe Baustelle";
|
const posterAlt = props.backgroundImage?.alt || data?.posterImage?.alt || "E-TIB Gruppe Baustelle";
|
||||||
@@ -35,28 +37,19 @@ export function HeroVideo(props: HeroVideoProps) {
|
|||||||
const ctaLabel = props.ctaLabel || props.linkText || data?.ctaLabel || 'Unternehmen entdecken';
|
const ctaLabel = props.ctaLabel || props.linkText || data?.ctaLabel || 'Unternehmen entdecken';
|
||||||
const ctaHref = props.ctaHref || props.linkHref || data?.ctaHref || '#unternehmen';
|
const ctaHref = props.ctaHref || props.linkHref || data?.ctaHref || '#unternehmen';
|
||||||
|
|
||||||
let currentLocale = 'de';
|
const currentLocale = pathname?.startsWith('/en') ? 'en' : '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 secondaryCtaLabel = props.secondaryCtaLabel || data?.secondaryCtaLabel || (currentLocale === 'de' ? 'Projekt anfragen' : 'Inquire Project');
|
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`;
|
||||||
|
|
||||||
return (
|
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 */}
|
{/* Background color while video loads */}
|
||||||
<div className="absolute inset-0 z-0 bg-neutral-dark" />
|
<div className="absolute inset-0 z-0 bg-neutral-dark" />
|
||||||
|
|
||||||
{videoUrl ? (
|
{videoUrl ? (
|
||||||
<video
|
<video
|
||||||
key={videoUrl}
|
key={`${pathname}-${videoUrl}`}
|
||||||
className="absolute inset-0 w-full h-full object-cover z-1 pointer-events-none filter contrast-125 saturate-110 brightness-90"
|
className="absolute inset-0 w-full h-full object-cover z-1 pointer-events-none filter contrast-125 saturate-110 brightness-90"
|
||||||
src={videoUrl}
|
src={videoUrl}
|
||||||
autoPlay
|
autoPlay
|
||||||
@@ -67,6 +60,7 @@ export function HeroVideo(props: HeroVideoProps) {
|
|||||||
/>
|
/>
|
||||||
) : (
|
) : (
|
||||||
<Image
|
<Image
|
||||||
|
key={`img-${pathname}-${posterSrc}`}
|
||||||
src={posterSrc}
|
src={posterSrc}
|
||||||
alt={posterAlt}
|
alt={posterAlt}
|
||||||
fill
|
fill
|
||||||
@@ -88,13 +82,14 @@ export function HeroVideo(props: HeroVideoProps) {
|
|||||||
|
|
||||||
<div className="container relative z-[4] text-center px-4">
|
<div className="container relative z-[4] text-center px-4">
|
||||||
<motion.div
|
<motion.div
|
||||||
|
key={`hero-content-${pathname}`}
|
||||||
initial={{ opacity: 0, y: 40 }}
|
initial={{ opacity: 0, y: 40 }}
|
||||||
animate={{ opacity: 1, y: 0 }}
|
animate={{ opacity: 1, y: 0 }}
|
||||||
transition={{ duration: 1.2, ease: [0.16, 1, 0.3, 1] }}
|
transition={{ duration: 1.2, ease: [0.16, 1, 0.3, 1] }}
|
||||||
>
|
>
|
||||||
<h1
|
<h1
|
||||||
className="font-heading font-bold text-4xl md:text-6xl lg:text-8xl text-white mb-6 uppercase tracking-tight drop-shadow-lg"
|
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('KABELTIEFBAU', '<span class="text-primary-light">KABELTIEFBAU</span>').replace(/\\n|\n/g, '<br />') }}
|
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">
|
<p className="text-lg md:text-xl lg:text-2xl text-white/90 max-w-3xl mx-auto mb-12 drop-shadow">
|
||||||
|
|||||||
@@ -1,20 +1,21 @@
|
|||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import React, { useState } from 'react';
|
import React, { useState, useRef } from 'react';
|
||||||
import { motion, AnimatePresence } from 'framer-motion';
|
import { motion, AnimatePresence } from 'framer-motion';
|
||||||
import { MapPin, Factory, Zap, CheckCircle2, ArrowUpRight } from 'lucide-react';
|
import { MapPin, Factory, Zap, CheckCircle2, ArrowUpRight } from 'lucide-react';
|
||||||
import Image from 'next/image';
|
import Image from 'next/image';
|
||||||
import { useRouter } from 'next/navigation';
|
import { useRouter, usePathname } from 'next/navigation';
|
||||||
|
import Link from 'next/link';
|
||||||
import { AnimatedGlossyBorder } from '@/components/ui/AnimatedGlossyBorder';
|
import { AnimatedGlossyBorder } from '@/components/ui/AnimatedGlossyBorder';
|
||||||
import { Location, defaultLocations, minorLocations } from '@/lib/map-data';
|
import { Location, defaultLocations, minorLocations, projectLocations } from '@/lib/map-data';
|
||||||
|
import { standorteLocations } from '@/lib/standorte-data';
|
||||||
import { useLocale, useTranslations } from 'next-intl';
|
import { useLocale, useTranslations } from 'next-intl';
|
||||||
|
|
||||||
const allLocations = [...defaultLocations, ...minorLocations];
|
|
||||||
|
|
||||||
interface Stat {
|
interface Stat {
|
||||||
value: string;
|
value: string;
|
||||||
suffix?: string;
|
suffix?: string;
|
||||||
label: string;
|
label: string;
|
||||||
|
href?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface InteractiveGermanyMapProps {
|
interface InteractiveGermanyMapProps {
|
||||||
@@ -24,37 +25,67 @@ interface InteractiveGermanyMapProps {
|
|||||||
stats?: Stat[];
|
stats?: Stat[];
|
||||||
locations?: Location[];
|
locations?: Location[];
|
||||||
isHero?: boolean;
|
isHero?: boolean;
|
||||||
|
hideStandorte?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function InteractiveGermanyMap({
|
export function InteractiveGermanyMap({
|
||||||
badge,
|
badge,
|
||||||
title,
|
title,
|
||||||
description,
|
description,
|
||||||
stats = [
|
stats,
|
||||||
{ value: '100', suffix: '%', label: 'Überregionale Reichweite' },
|
locations = projectLocations,
|
||||||
{ value: '2', suffix: '+', label: 'Operative Standorte' },
|
isHero = false,
|
||||||
],
|
hideStandorte = false
|
||||||
locations = allLocations,
|
|
||||||
isHero = false
|
|
||||||
}: InteractiveGermanyMapProps) {
|
}: InteractiveGermanyMapProps) {
|
||||||
const [activeLocation, setActiveLocation] = useState<Location | null>(null);
|
const [activeLocation, setActiveLocation] = useState<Location | null>(null);
|
||||||
|
const hoverTimeoutRef = useRef<NodeJS.Timeout | null>(null);
|
||||||
|
|
||||||
|
const handleMouseEnter = (loc: Location) => {
|
||||||
|
if (hoverTimeoutRef.current) {
|
||||||
|
clearTimeout(hoverTimeoutRef.current);
|
||||||
|
hoverTimeoutRef.current = null;
|
||||||
|
}
|
||||||
|
setActiveLocation(loc);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleMouseLeave = () => {
|
||||||
|
const timeout = setTimeout(() => {
|
||||||
|
setActiveLocation(null);
|
||||||
|
}, 200);
|
||||||
|
hoverTimeoutRef.current = timeout;
|
||||||
|
};
|
||||||
|
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
|
const pathname = usePathname();
|
||||||
const locale = useLocale();
|
const locale = useLocale();
|
||||||
const t = useTranslations('InteractiveGermanyMap');
|
const t = useTranslations('InteractiveGermanyMap');
|
||||||
const tStandard = useTranslations('StandardPage');
|
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 finalBadge = badge || tStandard('badge');
|
||||||
// the map is mostly used with specific props, so we leave title & description to fallbacks if not provided
|
// the map is mostly used with specific props, so we leave title & description to fallbacks if not provided
|
||||||
const finalTitle = title || (locale === 'en' ? <>Nationwide<br/>in operation for you.</> : <>Deutschlandweit<br/>für Sie im Einsatz.</>);
|
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 and Bülstedt, we control and implement complex infrastructure projects nationwide.' : 'Von unseren strategischen Standorten in Guben und Bülstedt steuern und realisieren wir komplexe Infrastrukturprojekte im gesamten Bundesgebiet.');
|
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 hq = locations.find((l) => l.type === 'hq');
|
const finalLocations = React.useMemo(() => {
|
||||||
const branch = locations.find((l) => l.type === 'branch');
|
if (hideStandorte) return locations;
|
||||||
const projects = locations.filter((l) => l.type === 'project');
|
|
||||||
const minorNodes = locations.filter((l) => l.type === 'minor_node');
|
// Ensure the 3 standorte are always included, without duplicating if already present by ID
|
||||||
|
const base = [...locations];
|
||||||
|
standorteLocations.forEach(sl => {
|
||||||
|
if (!base.some(l => l.id === sl.id)) {
|
||||||
|
base.push(sl as Location);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
return base;
|
||||||
|
}, [locations, hideStandorte]);
|
||||||
|
|
||||||
return (
|
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`}>
|
<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 */}
|
{/* Animated Border Glow */}
|
||||||
{!isHero && <AnimatedGlossyBorder opacity={0.7} className="z-30" />}
|
{!isHero && <AnimatedGlossyBorder opacity={0.7} className="z-30" />}
|
||||||
@@ -73,11 +104,11 @@ export function InteractiveGermanyMap({
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{isHero ? (
|
{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]">
|
<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}
|
{finalTitle}
|
||||||
</h1>
|
</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]">
|
<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}
|
{finalTitle}
|
||||||
</h3>
|
</h3>
|
||||||
)}
|
)}
|
||||||
@@ -88,17 +119,32 @@ export function InteractiveGermanyMap({
|
|||||||
|
|
||||||
{/* Industrial Stats Grid */}
|
{/* Industrial Stats Grid */}
|
||||||
<div className="grid grid-cols-2 gap-4">
|
<div className="grid grid-cols-2 gap-4">
|
||||||
{stats.map((stat, i) => (
|
{finalStats.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">
|
const StatCard = () => (
|
||||||
<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={`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="relative z-10">
|
<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="text-4xl font-extrabold text-white mb-1 flex items-baseline gap-1">
|
<div className="relative z-10 flex flex-col h-full justify-between">
|
||||||
{stat.value}<span className="text-xl text-primary">{stat.suffix}</span>
|
<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>
|
||||||
<div className="text-sm text-white/50 font-medium">{stat.label}</div>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
);
|
||||||
))}
|
|
||||||
|
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>
|
</div>
|
||||||
|
|
||||||
@@ -119,25 +165,29 @@ export function InteractiveGermanyMap({
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Minor Location Markers (The 100+ generic projects) */}
|
{/* Minor Location Markers (The 100+ generic projects) */}
|
||||||
{locations.filter(l => l.type === 'minor_node').map((loc, idx) => (
|
{finalLocations.filter(l => l.type === 'minor_node').map((loc, idx) => {
|
||||||
|
const isActive = activeLocation === loc;
|
||||||
|
return (
|
||||||
<div
|
<div
|
||||||
key={`minor-${idx}`}
|
key={`minor-${idx}`}
|
||||||
className="absolute z-10 group/minor cursor-pointer w-10 h-10 flex items-center justify-center"
|
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={{
|
style={{
|
||||||
left: `${loc.x}%`,
|
left: `${loc.x}%`,
|
||||||
top: `${loc.y}%`,
|
top: `${loc.y}%`,
|
||||||
transform: 'translate(-50%, -50%)',
|
transform: 'translate(-50%, -50%)',
|
||||||
}}
|
}}
|
||||||
onMouseEnter={() => setActiveLocation(loc)}
|
onMouseEnter={() => handleMouseEnter(loc)}
|
||||||
onMouseLeave={() => setActiveLocation(null)}
|
onMouseLeave={handleMouseLeave}
|
||||||
|
onTouchStart={() => handleMouseEnter(loc)}
|
||||||
|
onClick={() => handleMouseEnter(loc)}
|
||||||
>
|
>
|
||||||
<div className="w-1.5 h-1.5 bg-primary/80 rounded-full group-hover/minor:bg-white group-hover/minor:scale-150 transition-all duration-300" />
|
<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 className="absolute inset-0 m-auto w-2 h-2 bg-primary rounded-full opacity-0 group-hover/minor:animate-ping" />
|
||||||
</div>
|
</div>
|
||||||
))}
|
)})}
|
||||||
|
|
||||||
{/* Major & Reference Location Markers (HQ, Branch, Project) */}
|
{/* Major Location Markers (HQ, Branch, Project) */}
|
||||||
{locations.filter(l => l.type !== 'minor_node').map((loc, idx) => {
|
{finalLocations.filter(l => l.type !== 'minor_node').map((loc, idx) => {
|
||||||
const isHQ = loc.type === 'hq';
|
const isHQ = loc.type === 'hq';
|
||||||
const isBranch = loc.type === 'branch';
|
const isBranch = loc.type === 'branch';
|
||||||
const isActive = activeLocation?.id === loc.id;
|
const isActive = activeLocation?.id === loc.id;
|
||||||
@@ -145,13 +195,12 @@ export function InteractiveGermanyMap({
|
|||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
key={loc.id}
|
key={loc.id}
|
||||||
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'}`}
|
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}%` }}
|
style={{ left: `${loc.x}%`, top: `${loc.y}%` }}
|
||||||
onMouseEnter={() => setActiveLocation(loc)}
|
onMouseEnter={() => handleMouseEnter(loc)}
|
||||||
onMouseLeave={() => setActiveLocation(null)}
|
onMouseLeave={handleMouseLeave}
|
||||||
onClick={() => {
|
onTouchStart={() => handleMouseEnter(loc)}
|
||||||
if (loc.href) router.push(loc.href);
|
onClick={() => handleMouseEnter(loc)}
|
||||||
}}
|
|
||||||
>
|
>
|
||||||
{/* Ping Animation for HQ / Branch */}
|
{/* Ping Animation for HQ / Branch */}
|
||||||
{(isHQ || isBranch) && (
|
{(isHQ || isBranch) && (
|
||||||
@@ -186,12 +235,14 @@ export function InteractiveGermanyMap({
|
|||||||
animate={{ opacity: 1, y: 0, scale: 1 }}
|
animate={{ opacity: 1, y: 0, scale: 1 }}
|
||||||
exit={{ opacity: 0, y: 10, scale: 0.95 }}
|
exit={{ opacity: 0, y: 10, scale: 0.95 }}
|
||||||
transition={{ duration: 0.2 }}
|
transition={{ duration: 0.2 }}
|
||||||
className="absolute z-[9999] pointer-events-none"
|
className="absolute z-[9999] pointer-events-auto"
|
||||||
style={{
|
style={{
|
||||||
left: `${activeLocation.x}%`,
|
left: `${activeLocation.x}%`,
|
||||||
top: `${activeLocation.y}%`,
|
top: `${activeLocation.y}%`,
|
||||||
transform: activeLocation.x < 50 ? 'translate(20px, -50%)' : 'translate(calc(-100% - 20px), -50%)',
|
transform: activeLocation.x < 50 ? 'translate(20px, -50%)' : 'translate(calc(-100% - 20px), -50%)',
|
||||||
}}
|
}}
|
||||||
|
onMouseEnter={() => handleMouseEnter(activeLocation)}
|
||||||
|
onMouseLeave={handleMouseLeave}
|
||||||
>
|
>
|
||||||
<div className="bg-[#050B14]/95 backdrop-blur-xl border border-white/10 rounded-2xl shadow-2xl min-w-[200px] w-max max-w-[300px] text-white overflow-hidden">
|
<div className="bg-[#050B14]/95 backdrop-blur-xl border border-white/10 rounded-2xl shadow-2xl min-w-[200px] w-max max-w-[300px] text-white overflow-hidden">
|
||||||
|
|
||||||
@@ -251,11 +302,15 @@ export function InteractiveGermanyMap({
|
|||||||
</ul>
|
</ul>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{activeLocation.href && (
|
{activeLocation.href && (activeLocation.type === 'hq' || activeLocation.type === 'branch') && (
|
||||||
<div className="inline-flex items-center text-xs font-bold text-primary">
|
<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')}
|
{t('learnMore')}
|
||||||
<ArrowUpRight className="w-3.5 h-3.5 ml-1" />
|
<ArrowUpRight className="w-3.5 h-3.5 ml-1" />
|
||||||
</div>
|
</Link>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import Link from 'next/link';
|
|||||||
import { Button } from '@/components/ui/Button';
|
import { Button } from '@/components/ui/Button';
|
||||||
import { HoverShineOverlay } from '@/components/ui/HoverShineOverlay';
|
import { HoverShineOverlay } from '@/components/ui/HoverShineOverlay';
|
||||||
import { motion } from 'framer-motion';
|
import { motion } from 'framer-motion';
|
||||||
|
import { useTranslations } from 'next-intl';
|
||||||
|
|
||||||
export interface JobListingBlockProps {
|
export interface JobListingBlockProps {
|
||||||
title?: string;
|
title?: string;
|
||||||
@@ -45,20 +46,23 @@ const itemVariants = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
export const JobListingBlock = (props: JobListingBlockProps) => {
|
export const JobListingBlock = (props: JobListingBlockProps) => {
|
||||||
|
const t = useTranslations('JobListingBlock');
|
||||||
|
|
||||||
const {
|
const {
|
||||||
title,
|
|
||||||
showJobs = true,
|
showJobs = true,
|
||||||
showFairs,
|
showFairs,
|
||||||
fairsTitle = 'Nächste Messetermine',
|
fairsTitle = t('fairsTitle'),
|
||||||
fairs = [
|
fairs = [
|
||||||
{ name: 'Intersolar München', date: 'Termin folgt', type: 'Energie-Messe', location: 'Messe München' },
|
{ 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: 'Windenergietage Linstow', date: 'Termin folgt', type: 'Fachkongress', location: 'Linstow' },
|
||||||
{ name: 'Kabelwerkstatt Wiesbaden', date: 'Termin folgt', type: 'Fachmesse', location: 'Wiesbaden' }
|
{ name: 'Kabelwerkstatt Wiesbaden', date: 'Termin folgt', type: 'Fachmesse', location: 'Wiesbaden' }
|
||||||
],
|
],
|
||||||
emptyStateMessage = 'Aktuell sind alle Positionen besetzt. Senden Sie uns gerne eine Initiativbewerbung!',
|
emptyStateMessage = t('emptyStateMessage'),
|
||||||
emptyStateLinkText = 'Jetzt Kontakt aufnehmen',
|
emptyStateLinkText = t('emptyStateLinkText'),
|
||||||
emptyStateLinkHref = '/kontakt'
|
emptyStateLinkHref = t('emptyStateLinkHref')
|
||||||
} = props;
|
} = props;
|
||||||
|
|
||||||
|
const title = props.title || t('title');
|
||||||
|
|
||||||
// Since Payload is removed, we use an empty array or static data
|
// Since Payload is removed, we use an empty array or static data
|
||||||
const jobs: any[] = [];
|
const jobs: any[] = [];
|
||||||
@@ -67,13 +71,13 @@ export const JobListingBlock = (props: JobListingBlockProps) => {
|
|||||||
<div className="py-12 md:py-24">
|
<div className="py-12 md:py-24">
|
||||||
<div className="container mx-auto">
|
<div className="container mx-auto">
|
||||||
{showFairs && (
|
{showFairs && (
|
||||||
<div className="mb-24 relative">
|
<div className="mb-12 md:mb-24 relative">
|
||||||
<motion.h3
|
<motion.h3
|
||||||
initial={{ opacity: 0, x: -20 }}
|
initial={{ opacity: 0, x: -20 }}
|
||||||
whileInView={{ opacity: 1, x: 0 }}
|
whileInView={{ opacity: 1, x: 0 }}
|
||||||
viewport={{ once: true, margin: "-100px" }}
|
viewport={{ once: true, margin: "-100px" }}
|
||||||
transition={{ duration: 0.8, ease: [0.16, 1, 0.3, 1] as const }}
|
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" />
|
<span className="w-12 h-1.5 bg-primary rounded-full" />
|
||||||
{fairsTitle}
|
{fairsTitle}
|
||||||
@@ -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>
|
<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>
|
||||||
<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>
|
<p className="text-white/90 font-medium">{messe.date}</p>
|
||||||
</div>
|
</div>
|
||||||
</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>
|
<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>
|
||||||
<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>
|
<p className="text-white/90 font-medium">{messe.location}</p>
|
||||||
</div>
|
</div>
|
||||||
</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>
|
<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>
|
||||||
<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>
|
<p className="text-primary-light font-bold text-sm">{messe.booth}</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -178,9 +182,9 @@ export const JobListingBlock = (props: JobListingBlockProps) => {
|
|||||||
whileInView={{ opacity: 1, x: 0 }}
|
whileInView={{ opacity: 1, x: 0 }}
|
||||||
viewport={{ once: true, margin: "-100px" }}
|
viewport={{ once: true, margin: "-100px" }}
|
||||||
transition={{ duration: 0.8, ease: [0.16, 1, 0.3, 1] as const }}
|
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>
|
</motion.h2>
|
||||||
|
|
||||||
{jobs.length > 0 ? (
|
{jobs.length > 0 ? (
|
||||||
@@ -206,7 +210,7 @@ export const JobListingBlock = (props: JobListingBlockProps) => {
|
|||||||
<p className="text-text-secondary mt-1">{job.location}</p>
|
<p className="text-text-secondary mt-1">{job.location}</p>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center text-primary font-bold group-hover:translate-x-2 transition-transform">
|
<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>
|
<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>
|
</div>
|
||||||
</Link>
|
</Link>
|
||||||
|
|||||||
@@ -1,8 +1,7 @@
|
|||||||
import * as React from 'react';
|
import * as React from 'react';
|
||||||
import { describe, it, expect, vi } from 'vitest';
|
import { describe, it, expect, vi } from 'vitest';
|
||||||
import { render, screen, fireEvent } from '@testing-library/react';
|
import { render, screen } from '@testing-library/react';
|
||||||
import { ReferencesSlider, Reference } from './ReferencesSlider';
|
import { ReferencesSlider, Reference } from './ReferencesSlider';
|
||||||
import { NextIntlClientProvider } from 'next-intl';
|
|
||||||
|
|
||||||
// Mock next/link to render standard <a> tag
|
// Mock next/link to render standard <a> tag
|
||||||
vi.mock('next/link', () => ({
|
vi.mock('next/link', () => ({
|
||||||
@@ -35,7 +34,7 @@ describe('ReferencesSlider TDD', () => {
|
|||||||
id: '1',
|
id: '1',
|
||||||
title: 'Referenz Projekt Eins',
|
title: 'Referenz Projekt Eins',
|
||||||
slug: 'referenz-projekt-eins',
|
slug: 'referenz-projekt-eins',
|
||||||
category: 'Kabeltiefbau',
|
category: 'Kabelnetzbau',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: '2',
|
id: '2',
|
||||||
@@ -52,45 +51,14 @@ describe('ReferencesSlider TDD', () => {
|
|||||||
expect(screen.getByText('Referenz Projekt Zwei')).toBeTruthy();
|
expect(screen.getByText('Referenz Projekt Zwei')).toBeTruthy();
|
||||||
});
|
});
|
||||||
|
|
||||||
it('wraps each tile in a link to /referenzen/[slug]', () => {
|
it('renders each tile as a static block and NOT wrapped in a link', () => {
|
||||||
render(<ReferencesSlider references={mockReferences} />);
|
render(<ReferencesSlider references={mockReferences} />);
|
||||||
|
|
||||||
const links = screen.getAllByTestId('reference-tile-link');
|
const links = screen.queryAllByTestId('reference-tile-link');
|
||||||
expect(links).toHaveLength(2);
|
expect(links).toHaveLength(0);
|
||||||
expect(links[0].getAttribute('href')).toBe('/en/referenzen/referenz-projekt-eins');
|
|
||||||
expect(links[1].getAttribute('href')).toBe('/en/referenzen/referenz-projekt-zwei');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('allows navigation on simple click (no drag)', () => {
|
const tiles = screen.getAllByTestId('reference-tile');
|
||||||
render(<ReferencesSlider references={mockReferences} />);
|
expect(tiles).toHaveLength(2);
|
||||||
|
|
||||||
const firstLink = screen.getAllByTestId('reference-tile-link')[0];
|
|
||||||
|
|
||||||
// Simulate simple click sequence
|
|
||||||
fireEvent.mouseDown(firstLink, { pageX: 100, clientX: 100 });
|
|
||||||
fireEvent.mouseUp(firstLink);
|
|
||||||
const clickEvent = new MouseEvent('click', { bubbles: true, cancelable: true });
|
|
||||||
|
|
||||||
firstLink.dispatchEvent(clickEvent);
|
|
||||||
|
|
||||||
expect(clickEvent.defaultPrevented).toBe(false);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('prevents navigation if mouse was dragged', () => {
|
|
||||||
render(<ReferencesSlider references={mockReferences} />);
|
|
||||||
|
|
||||||
const firstLink = screen.getAllByTestId('reference-tile-link')[0];
|
|
||||||
|
|
||||||
// Simulate drag sequence
|
|
||||||
fireEvent.mouseDown(firstLink, { pageX: 100, clientX: 100 });
|
|
||||||
// Simulate mouse move of 20px
|
|
||||||
fireEvent.mouseMove(firstLink, { pageX: 120, clientX: 120 });
|
|
||||||
fireEvent.mouseUp(firstLink);
|
|
||||||
|
|
||||||
const clickEvent = new MouseEvent('click', { bubbles: true, cancelable: true });
|
|
||||||
firstLink.dispatchEvent(clickEvent);
|
|
||||||
|
|
||||||
expect(clickEvent.defaultPrevented).toBe(true);
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -92,27 +92,23 @@ export function ReferencesSlider(props: ReferencesSliderProps) {
|
|||||||
dragDistanceRef.current = Math.abs(x - startXRef.current);
|
dragDistanceRef.current = Math.abs(x - startXRef.current);
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleTileClick = (e: React.MouseEvent) => {
|
|
||||||
if (dragDistanceRef.current > 10) {
|
|
||||||
e.preventDefault();
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<section id="referenzen" className="py-24 bg-neutral-dark text-white relative overflow-hidden">
|
<section id="referenzen" className="py-16 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="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 className="container relative z-10 mb-12 flex flex-col md:flex-row justify-between items-end gap-6">
|
||||||
<div>
|
<div>
|
||||||
<h2 className="text-primary-light font-bold tracking-wider uppercase text-sm mb-3">{badge}</h2>
|
<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>
|
</div>
|
||||||
<p className="text-white/60 max-w-md">
|
<div className="text-center md:text-left max-w-3xl mx-auto md:mx-0 mb-16">
|
||||||
{description}
|
{description}
|
||||||
</p>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="relative z-10">
|
<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
|
<div
|
||||||
ref={containerRef}
|
ref={containerRef}
|
||||||
onMouseDown={onMouseDown}
|
onMouseDown={onMouseDown}
|
||||||
@@ -135,13 +131,12 @@ export function ReferencesSlider(props: ReferencesSliderProps) {
|
|||||||
transition={{ delay: i * 0.1, duration: 0.6, ease: "easeOut" }}
|
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"
|
className="flex-shrink-0 w-[320px] md:w-[480px] snap-center group pointer-events-auto"
|
||||||
>
|
>
|
||||||
<Link
|
<div
|
||||||
href={`/${locale}/referenzen/${ref.slug}`}
|
data-testid="reference-tile"
|
||||||
onClick={handleTileClick}
|
className="block relative select-none aspect-[16/10] bg-neutral-800 rounded-2xl overflow-hidden mb-5 border border-white/5 shadow-2xl"
|
||||||
data-testid="reference-tile-link"
|
|
||||||
className="block relative aspect-[16/10] bg-neutral-800 rounded-2xl overflow-hidden mb-5 border border-white/5 shadow-2xl"
|
|
||||||
>
|
>
|
||||||
<Image
|
<Image
|
||||||
|
draggable={false}
|
||||||
src={imgSrc}
|
src={imgSrc}
|
||||||
alt={ref.title}
|
alt={ref.title}
|
||||||
fill
|
fill
|
||||||
@@ -158,7 +153,7 @@ export function ReferencesSlider(props: ReferencesSliderProps) {
|
|||||||
</span>
|
</span>
|
||||||
<h4 className="font-heading text-xl md:text-2xl font-bold leading-tight break-words">{ref.title}</h4>
|
<h4 className="font-heading text-xl md:text-2xl font-bold leading-tight break-words">{ref.title}</h4>
|
||||||
</div>
|
</div>
|
||||||
</Link>
|
</div>
|
||||||
</motion.div>
|
</motion.div>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
|
|||||||
@@ -23,7 +23,7 @@ export function ScaleOfImpact() {
|
|||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
ref={containerRef}
|
ref={containerRef}
|
||||||
className="relative w-full min-h-[70vh] bg-[#050B14] flex flex-col items-center justify-center overflow-hidden border-y border-neutral-800"
|
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 */}
|
{/* Massive Watermark Numbers */}
|
||||||
<motion.div
|
<motion.div
|
||||||
@@ -31,14 +31,14 @@ export function ScaleOfImpact() {
|
|||||||
className="absolute inset-0 flex flex-col items-center justify-center pointer-events-none select-none overflow-hidden mix-blend-overlay"
|
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">
|
<div className="font-heading font-black text-[20vw] leading-none text-white opacity-20 whitespace-nowrap">
|
||||||
<span className="font-mono tracking-tighter">474.932</span>
|
<span className="font-mono tracking-tighter">1.435.223</span>
|
||||||
</div>
|
</div>
|
||||||
</motion.div>
|
</motion.div>
|
||||||
|
|
||||||
{/* Foreground Content */}
|
{/* Foreground Content */}
|
||||||
<div className="relative z-10 container max-w-7xl mx-auto px-4 flex flex-col items-center text-center">
|
<div className="relative z-10 container max-w-7xl mx-auto px-4 flex flex-col items-center text-center">
|
||||||
|
|
||||||
<h2 className="text-3xl md:text-5xl font-heading font-extrabold text-white mb-6 tracking-tight">
|
<h2 className="text-2xl md:text-5xl font-heading font-extrabold text-white mb-6 tracking-tight">
|
||||||
{locale === 'en' ? (
|
{locale === 'en' ? (
|
||||||
<>An infrastructure <br className="md:hidden"/> that connects the country.</>
|
<>An infrastructure <br className="md:hidden"/> that connects the country.</>
|
||||||
) : (
|
) : (
|
||||||
@@ -46,7 +46,7 @@ export function ScaleOfImpact() {
|
|||||||
)}
|
)}
|
||||||
</h2>
|
</h2>
|
||||||
|
|
||||||
<p className="text-white/60 text-lg md:text-xl max-w-2xl font-medium mb-16">
|
<p className="text-white/60 text-lg md:text-xl max-w-2xl font-medium mb-4 md:mb-16">
|
||||||
{locale === 'en'
|
{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."
|
? "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."
|
: "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."
|
||||||
@@ -54,7 +54,7 @@ export function ScaleOfImpact() {
|
|||||||
</p>
|
</p>
|
||||||
|
|
||||||
{/* Abstract SVG Line Animation */}
|
{/* Abstract SVG Line Animation */}
|
||||||
<div className="w-full max-w-4xl h-32 relative">
|
<div className="w-full max-w-4xl h-12 md:h-32 relative">
|
||||||
<svg
|
<svg
|
||||||
width="100%"
|
width="100%"
|
||||||
height="100%"
|
height="100%"
|
||||||
@@ -73,26 +73,30 @@ export function ScaleOfImpact() {
|
|||||||
<motion.path
|
<motion.path
|
||||||
d="M0,50 Q250,0 500,50 T1000,50"
|
d="M0,50 Q250,0 500,50 T1000,50"
|
||||||
fill="none"
|
fill="none"
|
||||||
stroke="#82ED20" // primary color
|
stroke="#ffc107" // warning/yellow color
|
||||||
strokeWidth="4"
|
strokeWidth="4"
|
||||||
style={{ pathLength }}
|
style={{ pathLength }}
|
||||||
className="drop-shadow-[0_0_15px_rgba(130,237,32,0.8)]"
|
className="drop-shadow-[0_0_15px_rgba(255,193,7,0.8)]"
|
||||||
/>
|
/>
|
||||||
</svg>
|
</svg>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Static Data Grid (No jumping text) */}
|
{/* Static Data Grid (No jumping text) */}
|
||||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-8 md:gap-16 w-full max-w-4xl mt-12">
|
<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="flex flex-col items-center">
|
||||||
<div className="font-mono text-5xl md:text-6xl font-black text-white tracking-tighter mb-2">100<span className="text-primary">+</span></div>
|
<div className="font-mono text-3xl md:text-5xl font-black text-white tracking-tighter mb-2">200<span className="text-primary">+</span></div>
|
||||||
<div className="text-xs font-bold uppercase tracking-widest text-white/50">{locale === 'en' ? 'Major Projects' : 'Großprojekte'}</div>
|
<div className="text-xs font-bold uppercase tracking-widest text-white/50">{locale === 'en' ? 'Projects' : 'Projekte'}</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex flex-col items-center">
|
<div className="flex flex-col items-center">
|
||||||
<div className="font-mono text-5xl md:text-6xl font-black text-white tracking-tighter mb-2">372<span className="text-primary text-3xl">km</span></div>
|
<div className="font-mono text-3xl md:text-5xl font-black text-white tracking-tighter mb-2">974<span className="text-primary 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 className="text-xs font-bold uppercase tracking-widest text-white/50">{locale === 'en' ? 'Cable Laying' : 'Kabelverlegung'}</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex flex-col items-center">
|
<div className="flex flex-col items-center">
|
||||||
<div className="font-mono text-5xl md:text-6xl font-black text-white tracking-tighter mb-2">102<span className="text-primary text-3xl">km</span></div>
|
<div className="font-mono text-3xl md:text-5xl font-black text-white tracking-tighter mb-2">300<span className="text-primary 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-primary 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 className="text-xs font-bold uppercase tracking-widest text-white/50">{locale === 'en' ? 'HDD Drilling' : 'Spülbohrung'}</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -105,17 +105,17 @@ export function ServiceDetailGrid({
|
|||||||
panels
|
panels
|
||||||
}: ServiceDetailGridProps) {
|
}: ServiceDetailGridProps) {
|
||||||
return (
|
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="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>}
|
{badge && <h2 className="text-primary font-bold tracking-wider uppercase text-sm mb-4">{badge}</h2>}
|
||||||
{title && (
|
{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}
|
{title}
|
||||||
</h3>
|
</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">
|
<div className="space-y-6 text-text-secondary text-lg leading-relaxed">
|
||||||
{descriptionParagraphs?.map((p, i) => {
|
{descriptionParagraphs?.map((p, i) => {
|
||||||
@@ -141,7 +141,7 @@ export function ServiceDetailGrid({
|
|||||||
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"
|
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 && (
|
{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">
|
<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">
|
<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>
|
<circle cx="12" cy="12" r="10"></circle>
|
||||||
@@ -154,7 +154,7 @@ export function ServiceDetailGrid({
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
{solutionStatement && (
|
{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">
|
<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">
|
<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>
|
<path d="M22 11.08V12a10 10 0 1 1-5.93-9.14"></path>
|
||||||
@@ -183,7 +183,7 @@ export function ServiceDetailGrid({
|
|||||||
<motion.div
|
<motion.div
|
||||||
key={panel.id}
|
key={panel.id}
|
||||||
variants={itemVariants}
|
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 */}
|
{/* 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]">
|
<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]">
|
||||||
@@ -195,7 +195,7 @@ export function ServiceDetailGrid({
|
|||||||
|
|
||||||
<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-col md:flex-row justify-between items-start mb-12 gap-6">
|
||||||
<div className="pt-2 flex-1 min-w-0 pr-4">
|
<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 className="h-1 w-12 bg-primary mt-6 transition-all duration-700 group-hover:w-24 rounded-full"></div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@@ -3,6 +3,7 @@
|
|||||||
import * as React from 'react';
|
import * as React from 'react';
|
||||||
import { motion, Variants } from 'framer-motion';
|
import { motion, Variants } from 'framer-motion';
|
||||||
import Image from 'next/image';
|
import Image from 'next/image';
|
||||||
|
import Link from 'next/link';
|
||||||
import { HoverShineOverlay } from '@/components/ui/HoverShineOverlay';
|
import { HoverShineOverlay } from '@/components/ui/HoverShineOverlay';
|
||||||
import { AnimatedGlossyBorder } from '@/components/ui/AnimatedGlossyBorder';
|
import { AnimatedGlossyBorder } from '@/components/ui/AnimatedGlossyBorder';
|
||||||
|
|
||||||
@@ -67,28 +68,31 @@ export function SubCompanyTiles(props: SubCompanyTilesProps) {
|
|||||||
})) || defaultCompanies;
|
})) || defaultCompanies;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<section id="unternehmen" className="py-24 bg-neutral-dark border-b border-neutral-800 relative overflow-hidden">
|
<section id="unternehmen" className="py-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">
|
<div className="container relative z-10 px-4">
|
||||||
|
|
||||||
{(badge || title) && (
|
{(badge || title) && (
|
||||||
<div className="text-center max-w-3xl mx-auto mb-16">
|
<div className="text-center md:text-left max-w-3xl mx-auto md:mx-0 mb-16">
|
||||||
{badge && <h2 className="text-primary font-bold tracking-wider uppercase text-sm mb-3">{badge}</h2>}
|
{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>}
|
{title && <h3 className="font-heading text-3xl md:text-5xl font-extrabold text-white">{title}</h3>}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<motion.div
|
<div className="relative -mx-4 md:mx-0">
|
||||||
className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-6"
|
<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" />
|
||||||
variants={containerVariants}
|
<motion.div
|
||||||
initial="hidden"
|
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"
|
||||||
whileInView="visible"
|
variants={containerVariants}
|
||||||
viewport={{ once: true, margin: "-50px" }}
|
initial="hidden"
|
||||||
>
|
whileInView="visible"
|
||||||
|
viewport={{ once: true, margin: "-50px" }}
|
||||||
|
>
|
||||||
{companiesData.map((company: any, index: number) => {
|
{companiesData.map((company: any, index: number) => {
|
||||||
const isCurrent = company.title.toUpperCase() === 'E-TIB GMBH';
|
const isCurrent = company.title.toUpperCase() === 'E-TIB GMBH';
|
||||||
const hasUrl = !!company.url;
|
const hasUrl = !!company.url;
|
||||||
const CardWrapper = hasUrl ? 'a' : 'div';
|
const isExternal = hasUrl && company.url.startsWith('http');
|
||||||
const wrapperProps = hasUrl ? { href: company.url, target: "_blank", rel: "noopener noreferrer" } : {};
|
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
|
// Generate logo representation based on title
|
||||||
const isEtib = company.title.includes('E-TIB');
|
const isEtib = company.title.includes('E-TIB');
|
||||||
@@ -112,33 +116,21 @@ export function SubCompanyTiles(props: SubCompanyTilesProps) {
|
|||||||
const positionClass = bgPositionClasses[index % bgPositionClasses.length];
|
const positionClass = bgPositionClasses[index % bgPositionClasses.length];
|
||||||
const animationClass = animationClasses[index % animationClasses.length];
|
const animationClass = animationClasses[index % animationClasses.length];
|
||||||
|
|
||||||
return (
|
const CardContent = (
|
||||||
<motion.div
|
<>
|
||||||
key={index}
|
<div className="absolute inset-0 bg-[#0a192f] z-0">
|
||||||
variants={itemVariants}
|
<Image
|
||||||
className="h-full"
|
src={company.backgroundImage}
|
||||||
>
|
alt="Background"
|
||||||
<CardWrapper
|
fill
|
||||||
{...wrapperProps}
|
className={`object-cover transition-all duration-1000 ease-out ${
|
||||||
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
|
||||||
isCurrent
|
? 'opacity-30 grayscale-0 scale-105'
|
||||||
? 'border-2 border-primary shadow-[0_0_30px_rgba(17,124,97,0.15)] cursor-default'
|
: 'opacity-40 grayscale-[50%] group-hover:opacity-30 group-hover:grayscale-0 group-hover:scale-110'
|
||||||
: 'border border-white/5 hover:shadow-2xl hover:-translate-y-1 hover:border-white/10 cursor-pointer'
|
}`}
|
||||||
}`}
|
sizes="(max-width: 768px) 100vw, 33vw"
|
||||||
>
|
/>
|
||||||
<div className="absolute inset-0 bg-[#0a192f] z-0">
|
</div>
|
||||||
<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 */}
|
{/* Heavy dark gradient overlay to make logos pop */}
|
||||||
<div className={`absolute inset-0 z-0 transition-colors duration-700 mix-blend-multiply ${
|
<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 */}
|
{/* LOGO RENDERING */}
|
||||||
{isEtib ? (
|
{isEtib ? (
|
||||||
<div className="flex flex-col items-center">
|
<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
|
<Image
|
||||||
src="/assets/logo-white.png"
|
src="/assets/logo-white.png"
|
||||||
alt="E-TIB Logo"
|
alt="E-TIB Logo"
|
||||||
@@ -191,17 +183,17 @@ export function SubCompanyTiles(props: SubCompanyTilesProps) {
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
{isIng && (
|
{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
|
Ingenieurgesellschaft
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
{isVerwaltung && (
|
{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
|
Verwaltung GmbH
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
{isBohrtechnik && (
|
{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
|
Bohrtechnik GmbH
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
@@ -227,17 +219,47 @@ export function SubCompanyTiles(props: SubCompanyTilesProps) {
|
|||||||
{company.url && (
|
{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="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">
|
<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>
|
<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>
|
||||||
</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 sm:rounded-xl 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>
|
</motion.div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -44,7 +44,7 @@ export const SupportCTA: React.FC<SupportCTAProps> = (props) => {
|
|||||||
initial="hidden"
|
initial="hidden"
|
||||||
whileInView="visible"
|
whileInView="visible"
|
||||||
viewport={{ once: true, margin: "-100px" }}
|
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"
|
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="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">
|
<div className="relative z-10 max-w-2xl">
|
||||||
|
|||||||
@@ -3,6 +3,7 @@
|
|||||||
import * as React from 'react';
|
import * as React from 'react';
|
||||||
import { motion } from 'framer-motion';
|
import { motion } from 'framer-motion';
|
||||||
import Image from 'next/image';
|
import Image from 'next/image';
|
||||||
|
import { useTranslations } from 'next-intl';
|
||||||
|
|
||||||
export interface TeamMember {
|
export interface TeamMember {
|
||||||
id: string;
|
id: string;
|
||||||
@@ -22,90 +23,23 @@ interface TeamGridProps {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function TeamGrid({ members }: TeamGridProps) {
|
export function TeamGrid({ members }: TeamGridProps) {
|
||||||
|
const t = useTranslations('TeamGrid');
|
||||||
|
|
||||||
if (!members || members.length === 0) return null;
|
if (!members || members.length === 0) return null;
|
||||||
|
|
||||||
return (
|
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="container relative z-10">
|
||||||
<div className="mb-12">
|
<div className="mb-12">
|
||||||
<h2 className="text-primary font-bold tracking-wider uppercase text-sm mb-3">Persönliche Beratung</h2>
|
<h2 className="text-primary font-bold tracking-wider uppercase text-sm mb-3">{t('badge')}</h2>
|
||||||
<h3 className="font-heading text-4xl font-extrabold text-neutral-dark mb-4">Ihre Ansprechpartner</h3>
|
<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">
|
<p className="text-text-secondary max-w-2xl text-lg">
|
||||||
Sprechen Sie direkt mit unseren Experten für Ihr regionales Projekt.
|
{t('subtitle')}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</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">
|
<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
|
<motion.div
|
||||||
key={member.id}
|
key={member.id}
|
||||||
initial={{ opacity: 0, y: 30 }}
|
initial={{ opacity: 0, y: 30 }}
|
||||||
@@ -145,7 +79,7 @@ export function TeamGrid({ members }: TeamGridProps) {
|
|||||||
{member.branch && (
|
{member.branch && (
|
||||||
<div className="mb-6">
|
<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">
|
<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>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -78,7 +78,7 @@ export function CorporateBackground() {
|
|||||||
const y5 = useTransform(scrollYProgress, [0, 1], [0, -500]);
|
const y5 = useTransform(scrollYProgress, [0, 1], [0, -500]);
|
||||||
|
|
||||||
return (
|
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 */}
|
{/* 1. Top Right - Medium, rotating moderately */}
|
||||||
<motion.div
|
<motion.div
|
||||||
style={{
|
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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -3,6 +3,7 @@
|
|||||||
import * as React from 'react';
|
import * as React from 'react';
|
||||||
import { motion } from 'framer-motion';
|
import { motion } from 'framer-motion';
|
||||||
import { Button } from '@/components/ui/Button';
|
import { Button } from '@/components/ui/Button';
|
||||||
|
import { sendContactFormAction } from '@/app/actions/contact';
|
||||||
|
|
||||||
export function ContactForm() {
|
export function ContactForm() {
|
||||||
const [status, setStatus] = React.useState<'idle' | 'loading' | 'success' | 'error'>('idle');
|
const [status, setStatus] = React.useState<'idle' | 'loading' | 'success' | 'error'>('idle');
|
||||||
@@ -11,10 +12,18 @@ export function ContactForm() {
|
|||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
setStatus('loading');
|
setStatus('loading');
|
||||||
|
|
||||||
// Simulate server action using @mintel/mail or API route
|
const formData = new FormData(e.currentTarget);
|
||||||
setTimeout(() => {
|
try {
|
||||||
setStatus('success');
|
const result = await sendContactFormAction(formData);
|
||||||
}, 1500);
|
if (result.success) {
|
||||||
|
setStatus('success');
|
||||||
|
} else {
|
||||||
|
setStatus('error');
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error(error);
|
||||||
|
setStatus('error');
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
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>
|
<p>Vielen Dank für Ihr Interesse an der E-TIB Gruppe. Ein Ansprechpartner wird sich zeitnah mit Ihnen in Verbindung setzen.</p>
|
||||||
</div>
|
</div>
|
||||||
</motion.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">
|
<form onSubmit={handleSubmit} className="space-y-6">
|
||||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
|
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<label htmlFor="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
|
<input
|
||||||
id="firstName"
|
id="name"
|
||||||
|
name="name"
|
||||||
required
|
required
|
||||||
disabled={status === 'loading'}
|
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"
|
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">
|
<div className="space-y-2">
|
||||||
<label htmlFor="lastName" className="block text-sm font-semibold text-neutral-dark">Nachname</label>
|
<label htmlFor="lastName" className="block text-sm font-semibold text-neutral-dark">Nachname</label>
|
||||||
<input
|
<input
|
||||||
id="lastName"
|
id="lastName"
|
||||||
|
name="lastName"
|
||||||
required
|
required
|
||||||
disabled={status === 'loading'}
|
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"
|
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>
|
<label htmlFor="email" className="block text-sm font-semibold text-neutral-dark">E-Mail Adresse</label>
|
||||||
<input
|
<input
|
||||||
id="email"
|
id="email"
|
||||||
|
name="email"
|
||||||
type="email"
|
type="email"
|
||||||
required
|
required
|
||||||
disabled={status === 'loading'}
|
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>
|
<label htmlFor="company" className="block text-sm font-semibold text-neutral-dark">Unternehmen (Optional)</label>
|
||||||
<input
|
<input
|
||||||
id="company"
|
id="company"
|
||||||
|
name="company"
|
||||||
disabled={status === 'loading'}
|
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"
|
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"
|
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>
|
<label htmlFor="message" className="block text-sm font-semibold text-neutral-dark">Ihre Nachricht</label>
|
||||||
<textarea
|
<textarea
|
||||||
id="message"
|
id="message"
|
||||||
|
name="message"
|
||||||
required
|
required
|
||||||
disabled={status === 'loading'}
|
disabled={status === 'loading'}
|
||||||
rows={5}
|
rows={5}
|
||||||
@@ -100,6 +131,8 @@ export function ContactForm() {
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<input type="text" name="company_website" style={{ display: 'none' }} tabIndex={-1} autoComplete="off" />
|
||||||
|
|
||||||
<Button
|
<Button
|
||||||
type="submit"
|
type="submit"
|
||||||
disabled={status === 'loading'}
|
disabled={status === 'loading'}
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ export default function CTA({ data }: { data?: any }) {
|
|||||||
const locale = useLocale();
|
const locale = useLocale();
|
||||||
|
|
||||||
return (
|
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 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" />
|
<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');
|
const t = useTranslations('Home.experience');
|
||||||
|
|
||||||
return (
|
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">
|
<div className="absolute inset-0 z-0">
|
||||||
<Image
|
<Image
|
||||||
src="/uploads/2024/12/1694273920124-copy-2.webp"
|
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;
|
const lightboxIndex = photoParam ? parseInt(photoParam, 10) : 0;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Section className="bg-white text-white py-32">
|
<Section className="bg-white text-white">
|
||||||
<Container>
|
<Container>
|
||||||
<Heading level={2} subtitle={data?.subtitle || t('subtitle')} align="center">
|
<Heading level={2} subtitle={data?.subtitle || t('subtitle')} align="center">
|
||||||
{data?.title || t('title')}
|
{data?.title || t('title')}
|
||||||
</Heading>
|
</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) => (
|
{images.map((src, idx) => (
|
||||||
<button
|
<button
|
||||||
key={idx}
|
key={idx}
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import { useAnalytics } from '../analytics/useAnalytics';
|
|||||||
import { AnalyticsEvents } from '../analytics/analytics-events';
|
import { AnalyticsEvents } from '../analytics/analytics-events';
|
||||||
import { useState, useEffect, useRef, useCallback } from 'react';
|
import { useState, useEffect, useRef, useCallback } from 'react';
|
||||||
import { ChevronRight } from 'lucide-react';
|
import { ChevronRight } from 'lucide-react';
|
||||||
|
import { usePathname } from 'next/navigation';
|
||||||
import { AISearchResults } from '../search/AISearchResults';
|
import { AISearchResults } from '../search/AISearchResults';
|
||||||
const HeroIllustration = dynamic(() => import('./HeroIllustration'), { ssr: false });
|
const HeroIllustration = dynamic(() => import('./HeroIllustration'), { ssr: false });
|
||||||
const AIOrb = dynamic(() => import('../search/AIOrb'), { 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 }) {
|
export default function Hero({ data }: { data?: any }) {
|
||||||
const t = useTranslations('Home.hero');
|
const t = useTranslations('Home.hero');
|
||||||
const locale = useLocale();
|
const locale = useLocale();
|
||||||
|
const pathname = usePathname();
|
||||||
const { trackEvent } = useAnalytics();
|
const { trackEvent } = useAnalytics();
|
||||||
|
|
||||||
const [searchQuery, setSearchQuery] = useState('');
|
const [searchQuery, setSearchQuery] = useState('');
|
||||||
const [isSearchOpen, setIsSearchOpen] = useState(false);
|
const [isSearchOpen, setIsSearchOpen] = useState(false);
|
||||||
const [heroPlaceholder, setHeroPlaceholder] = useState(
|
const [heroPlaceholder, setHeroPlaceholder] = useState(
|
||||||
'Projekt beschreiben oder Kabel suchen...',
|
t('searchPlaceholder')
|
||||||
);
|
);
|
||||||
const typingRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
const typingRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||||
|
|
||||||
const HERO_PLACEHOLDERS = [
|
const HERO_PLACEHOLDERS = [
|
||||||
'Querschnittsberechnung für 110kV Trasse', // Hochspannung
|
t('placeholder1'),
|
||||||
'Wie schwer ist NAYY 4x150?',
|
t('placeholder2'),
|
||||||
'Ich plane einen Solarpark, was brauche ich?', // Projekt Solar
|
t('placeholder3'),
|
||||||
'Unterschied zwischen N2XSY und NAY2XSY?', // Fach
|
t('placeholder4'),
|
||||||
'Mittelspannungskabel für Windkraftanlage', // Windpark
|
t('placeholder5'),
|
||||||
'Welches Aluminiumkabel für 20kV?', // Mittelspannung
|
t('placeholder6'),
|
||||||
];
|
];
|
||||||
|
|
||||||
// Typing animation for the hero search placeholder
|
// Typing animation for the hero search placeholder
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (searchQuery) {
|
if (searchQuery) {
|
||||||
setHeroPlaceholder('Projekt beschreiben oder Kabel suchen...');
|
setHeroPlaceholder(t('searchPlaceholder'));
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -85,7 +87,7 @@ export default function Hero({ data }: { data?: any }) {
|
|||||||
|
|
||||||
return (
|
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">
|
<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 className="max-w-5xl mx-auto md:mx-0">
|
||||||
<div>
|
<div>
|
||||||
@@ -152,7 +154,7 @@ export default function Hero({ data }: { data?: any }) {
|
|||||||
size="lg"
|
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"
|
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" />
|
<ChevronRight className="w-5 h-5 ml-2 -mr-1" />
|
||||||
</Button>
|
</Button>
|
||||||
</form>
|
</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 className="flex flex-col sm:flex-row justify-center md:justify-start gap-4 md:gap-6">
|
||||||
<div>
|
<div>
|
||||||
<Button
|
<Button
|
||||||
href="/contact"
|
href={`/${locale}/${locale === 'de' ? 'kontakt' : 'contact'}`}
|
||||||
variant="white"
|
variant="white"
|
||||||
size="lg"
|
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"
|
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>
|
</div>
|
||||||
</Container>
|
</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 />
|
<HeroIllustration />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@@ -9,7 +9,7 @@ export default function MeetTheTeam({ data }: { data?: any }) {
|
|||||||
const locale = useLocale();
|
const locale = useLocale();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Section className="relative py-32 md:py-48 overflow-hidden">
|
<Section className="relative overflow-hidden">
|
||||||
<div className="absolute inset-0 z-0">
|
<div className="absolute inset-0 z-0">
|
||||||
<Image
|
<Image
|
||||||
src="/uploads/2024/12/DSC08036-Large.webp"
|
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="relative mb-12">
|
||||||
<div className="absolute -left-8 top-0 bottom-0 w-1 bg-accent rounded-full" />
|
<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')}"
|
"{data?.description || t('description')}"
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -27,7 +27,7 @@ export default function VideoSection({ data }: { data?: any }) {
|
|||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
return (
|
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 && (
|
{isVisible && (
|
||||||
<video className="w-full h-full object-cover opacity-60" autoPlay muted loop playsInline>
|
<video className="w-full h-full object-cover opacity-60" autoPlay muted loop playsInline>
|
||||||
<source
|
<source
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import { useLocale } from 'next-intl';
|
|||||||
import { ShieldCheck, Leaf, Lock, Accessibility, Zap, Mail, Phone } from 'lucide-react';
|
import { ShieldCheck, Leaf, Lock, Accessibility, Zap, Mail, Phone } from 'lucide-react';
|
||||||
import { LanguageSwitcher } from './LanguageSwitcher';
|
import { LanguageSwitcher } from './LanguageSwitcher';
|
||||||
import { Tooltip } from '@/components/ui/Tooltip';
|
import { Tooltip } from '@/components/ui/Tooltip';
|
||||||
|
import { EUFundingBadge } from '@/components/decorations/EUFundingBadge';
|
||||||
interface CompanyInfo {
|
interface CompanyInfo {
|
||||||
contactEmail: string;
|
contactEmail: string;
|
||||||
contactPhone: string;
|
contactPhone: string;
|
||||||
@@ -18,7 +19,7 @@ export function Footer({ companyInfo }: FooterProps) {
|
|||||||
const locale = useLocale();
|
const locale = useLocale();
|
||||||
|
|
||||||
return (
|
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-40 pb-10 md:pt-40 md:pb-16 relative overflow-hidden border-t border-white/5">
|
||||||
{/* Subtle background tech grid */}
|
{/* 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" />
|
<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-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="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 */}
|
{/* 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="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">
|
<div className="space-y-4 md:space-y-6 relative z-10">
|
||||||
@@ -42,7 +45,7 @@ export function Footer({ companyInfo }: FooterProps) {
|
|||||||
</TransitionLink>
|
</TransitionLink>
|
||||||
<p className="text-neutral-400 text-base leading-relaxed max-w-sm">
|
<p className="text-neutral-400 text-base leading-relaxed max-w-sm">
|
||||||
{locale === 'de'
|
{locale === 'de'
|
||||||
? 'Die Experten für Kabeltiefbau, Horizontalspülbohrungen und umfassende Infrastrukturprojekte. Qualität und Zuverlässigkeit aus Guben.'
|
? 'Die Experten für Kabelnetzbau, 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.'}
|
: 'Experts in cable construction, horizontal directional drilling, and comprehensive infrastructure projects. Quality and reliability from Guben.'}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
@@ -95,58 +98,158 @@ export function Footer({ companyInfo }: FooterProps) {
|
|||||||
{/* Navigation Bento Grid */}
|
{/* 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">
|
<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 */}
|
{/* Links 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-bl from-white/[0.04] to-transparent opacity-0 group-hover:opacity-100 transition-opacity duration-700 pointer-events-none" />
|
<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'}
|
{/* Desktop View */}
|
||||||
<span className="block w-8 h-1 bg-primary mt-3 rounded-full" />
|
<div className="hidden md:block relative z-10 flex-grow">
|
||||||
</h4>
|
<h4 className="font-heading font-bold uppercase tracking-widest text-sm mb-6 text-white">
|
||||||
<ul className="space-y-2 md:space-y-4 relative z-10 flex-grow">
|
{locale === 'de' ? 'Navigation' : 'Navigation'}
|
||||||
{[
|
<span className="block w-8 h-1 bg-primary mt-3 rounded-full" />
|
||||||
{ label: locale === 'de' ? 'Kompetenzen' : 'Competencies', href: `/${locale}/kompetenzen` },
|
</h4>
|
||||||
{ label: locale === 'de' ? 'Über uns' : 'About us', href: `/${locale}/ueber-uns` },
|
<ul className="space-y-2 mb-8">
|
||||||
{ label: locale === 'de' ? 'Unser Team' : 'Our Team', href: `/${locale}/team` },
|
{[
|
||||||
{ label: locale === 'de' ? 'Zertifikate' : 'Certificates', href: `/${locale}/zertifikate` },
|
{ label: locale === 'de' ? 'Kompetenzen' : 'Competencies', href: `/${locale}/${locale === 'de' ? 'kompetenzen' : 'competencies'}` },
|
||||||
{ label: locale === 'de' ? 'Karriere' : 'Career', href: `/${locale}/karriere` },
|
{ label: locale === 'de' ? 'Referenzen' : 'References', href: `/${locale}/referenzen` },
|
||||||
{ label: locale === 'de' ? 'Messen & Events' : 'Fairs & Events', href: `/${locale}/messen` },
|
{ 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'}` },
|
{ label: locale === 'de' ? 'Kontakt' : 'Contact', href: `/${locale}/${locale === 'de' ? 'kontakt' : 'contact'}` },
|
||||||
].map((link) => (
|
].map((link) => (
|
||||||
<li key={link.href}>
|
<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">
|
<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="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>
|
<span className="font-medium">{link.label}</span>
|
||||||
</TransitionLink>
|
</TransitionLink>
|
||||||
</li>
|
</li>
|
||||||
))}
|
))}
|
||||||
</ul>
|
</ul>
|
||||||
|
|
||||||
|
<h4 className="font-heading font-bold uppercase tracking-widest text-sm mb-4 text-white">
|
||||||
|
<TransitionLink href={`/${locale}/${locale === 'de' ? 'ueber-uns' : 'about-us'}`} className="hover:text-primary transition-colors cursor-pointer inline-block">
|
||||||
|
{locale === 'de' ? 'Über uns' : 'About us'}
|
||||||
|
</TransitionLink>
|
||||||
|
<span className="block w-8 h-1 bg-primary mt-3 rounded-full" />
|
||||||
|
</h4>
|
||||||
|
<ul className="space-y-2 border-l border-white/10 pl-4 ml-1">
|
||||||
|
{[
|
||||||
|
{ label: locale === 'de' ? 'Standorte' : 'Locations', href: `/${locale}/standorte` },
|
||||||
|
{ 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}/${locale === 'de' ? 'karriere' : 'career'}` },
|
||||||
|
].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' ? '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 className="h-px bg-white/10 w-full"></div>
|
||||||
|
|
||||||
|
<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' ? 'Über uns' : 'About us'}
|
||||||
|
<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' ? 'Zertifikate' : 'Certificates', href: `/${locale}/zertifikate` },
|
||||||
|
{ label: locale === 'de' ? 'Karriere' : 'Career', href: `/${locale}/${locale === 'de' ? 'karriere' : 'career'}` },
|
||||||
|
].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>
|
||||||
|
|
||||||
{/* Legal Column */}
|
{/* 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" />
|
<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'}
|
{/* Desktop View */}
|
||||||
<span className="block w-8 h-1 bg-primary mt-3 rounded-full opacity-50" />
|
<div className="hidden md:block relative z-10 flex-grow">
|
||||||
</h4>
|
<h4 className="font-heading font-bold uppercase tracking-widest text-sm mb-6 text-white">
|
||||||
<ul className="space-y-2 md:space-y-4 relative z-10 flex-grow">
|
{locale === 'de' ? 'Rechtliches' : 'Legal'}
|
||||||
{[
|
<span className="block w-8 h-1 bg-primary mt-3 rounded-full opacity-50" />
|
||||||
{ label: locale === 'de' ? 'Impressum' : 'Imprint', href: `/${locale}/impressum` },
|
</h4>
|
||||||
{ label: locale === 'de' ? 'Datenschutz' : 'Privacy Policy', href: `/${locale}/datenschutz` },
|
<ul className="space-y-2">
|
||||||
{ label: locale === 'de' ? 'AGB' : 'Terms', href: `/${locale}/${locale === 'de' ? 'agb' : 'terms'}` },
|
{[
|
||||||
].map((link) => (
|
{ label: locale === 'de' ? 'Impressum' : 'Imprint', href: `/${locale}/${locale === 'de' ? 'impressum' : 'imprint'}` },
|
||||||
<li key={link.href}>
|
{ label: locale === 'de' ? 'Datenschutz' : 'Privacy Policy', href: `/${locale}/${locale === 'de' ? 'datenschutz' : 'privacy-policy'}` },
|
||||||
<TransitionLink href={link.href} className="group/link flex items-center text-neutral-400 hover:text-white transition-colors py-1 cursor-pointer">
|
{ label: locale === 'de' ? 'AGB' : 'Terms', href: `/${locale}/${locale === 'de' ? 'agb' : 'terms'}` },
|
||||||
<span className="w-0 h-px bg-primary mr-0 group-hover/link:w-4 group-hover/link:mr-3 transition-all duration-300" />
|
].map((link) => (
|
||||||
<span className="font-medium">{link.label}</span>
|
<li key={link.href}>
|
||||||
</TransitionLink>
|
<TransitionLink href={link.href} className="group/link flex items-center text-neutral-400 hover:text-white transition-colors py-1 cursor-pointer">
|
||||||
</li>
|
<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>
|
||||||
</ul>
|
</TransitionLink>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
|
||||||
{/* Built Info */}
|
{/* Mobile View */}
|
||||||
|
<div className="md:hidden relative z-10 flex-grow">
|
||||||
{/* Legal Info removed from here */}
|
<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>
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
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;
|
label: string;
|
||||||
url: string;
|
url: string;
|
||||||
children?: NavLink[];
|
children?: NavLink[];
|
||||||
|
isGroupLabel?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface HeaderProps {
|
interface HeaderProps {
|
||||||
@@ -21,6 +22,7 @@ export function Header({ navLinks }: HeaderProps) {
|
|||||||
const [isScrolled, setIsScrolled] = React.useState(false);
|
const [isScrolled, setIsScrolled] = React.useState(false);
|
||||||
const [forceSolid, setForceSolid] = React.useState(false);
|
const [forceSolid, setForceSolid] = React.useState(false);
|
||||||
const [headerVariant, setHeaderVariant] = React.useState<'capsule' | 'standard'>('capsule');
|
const [headerVariant, setHeaderVariant] = React.useState<'capsule' | 'standard'>('capsule');
|
||||||
|
const [isMobileMenuOpen, setIsMobileMenuOpen] = React.useState(false);
|
||||||
const pathname = usePathname() || '/';
|
const pathname = usePathname() || '/';
|
||||||
|
|
||||||
// Scroll bound shine effect (continuous looping as user scrolls)
|
// Scroll bound shine effect (continuous looping as user scrolls)
|
||||||
@@ -54,6 +56,7 @@ export function Header({ navLinks }: HeaderProps) {
|
|||||||
|
|
||||||
// Re-check when pathname changes
|
// Re-check when pathname changes
|
||||||
checkHeaderState();
|
checkHeaderState();
|
||||||
|
setIsMobileMenuOpen(false);
|
||||||
|
|
||||||
return () => {
|
return () => {
|
||||||
window.removeEventListener('scroll', handleScroll);
|
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)] ${
|
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
|
isStandard
|
||||||
? 'bg-[#050B14]/95 backdrop-blur-xl border-b border-white/10'
|
? '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
|
<div
|
||||||
@@ -79,7 +82,7 @@ export function Header({ navLinks }: HeaderProps) {
|
|||||||
isStandard
|
isStandard
|
||||||
? 'py-4 px-6 md:px-8'
|
? 'py-4 px-6 md:px-8'
|
||||||
: isSolidMode
|
: 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'
|
: 'rounded-none bg-transparent py-6 px-4 md:px-8 border border-transparent'
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
@@ -153,10 +156,82 @@ export function Header({ navLinks }: HeaderProps) {
|
|||||||
{/* Sleek shine sweep effect */}
|
{/* 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" />
|
<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>
|
</TransitionLink>
|
||||||
|
|
||||||
|
{/* Hamburger Menu (Mobile Only) */}
|
||||||
|
<button
|
||||||
|
className={`md:hidden ml-2 p-2 transition-colors focus:outline-none ${isSolidMode && !isMobileMenuOpen ? 'text-text-primary hover:text-primary' : 'text-white hover:text-primary z-[60] relative'}`}
|
||||||
|
onClick={() => setIsMobileMenuOpen(!isMobileMenuOpen)}
|
||||||
|
aria-label="Toggle Menu"
|
||||||
|
>
|
||||||
|
<div className="w-6 flex flex-col gap-[6px] relative items-center justify-center">
|
||||||
|
<span className={`w-full h-[2px] rounded-full bg-current transform transition-all duration-300 ease-in-out origin-center ${isMobileMenuOpen ? 'rotate-45 translate-y-[8px]' : ''}`} />
|
||||||
|
<span className={`w-full h-[2px] rounded-full bg-current transition-all duration-300 ease-in-out ${isMobileMenuOpen ? 'opacity-0 translate-x-3' : 'opacity-100'}`} />
|
||||||
|
<span className={`w-full h-[2px] rounded-full bg-current transform transition-all duration-300 ease-in-out origin-center ${isMobileMenuOpen ? '-rotate-45 -translate-y-[8px]' : ''}`} />
|
||||||
|
</div>
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
|
{/* Mobile Menu Overlay */}
|
||||||
|
<AnimatePresence>
|
||||||
|
{isMobileMenuOpen && (
|
||||||
|
<motion.div
|
||||||
|
initial={{ opacity: 0, y: -20 }}
|
||||||
|
animate={{ opacity: 1, y: 0 }}
|
||||||
|
exit={{ opacity: 0, y: -20 }}
|
||||||
|
transition={{ duration: 0.3 }}
|
||||||
|
className="fixed inset-0 z-[40] bg-[#050B14]/95 backdrop-blur-xl md:hidden pt-24 pb-24 px-6 overflow-y-auto"
|
||||||
|
>
|
||||||
|
<nav className="flex flex-col gap-6 mt-8">
|
||||||
|
{navLinks && navLinks.length > 0 && navLinks.map((link) => {
|
||||||
|
const mappedUrl = link.url.startsWith('/') && !link.url.match(/^\/(en|de)/)
|
||||||
|
? `/${currentLocale}${link.url}`
|
||||||
|
: link.url;
|
||||||
|
const isActive = pathname === mappedUrl || (mappedUrl !== `/${currentLocale}` && pathname?.startsWith(`${mappedUrl}/`));
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div key={link.url} className="flex flex-col">
|
||||||
|
<TransitionLink
|
||||||
|
href={mappedUrl}
|
||||||
|
onClick={() => setIsMobileMenuOpen(false)}
|
||||||
|
className={`text-2xl font-bold uppercase tracking-widest transition-colors ${isActive ? 'text-primary' : 'text-white'}`}
|
||||||
|
>
|
||||||
|
{link.label}
|
||||||
|
</TransitionLink>
|
||||||
|
|
||||||
|
{link.children && (
|
||||||
|
<div className="flex flex-col gap-3 mt-3 pl-4 border-l-2 border-white/10">
|
||||||
|
{link.children.map((child, idx) => {
|
||||||
|
if (child.isGroupLabel) {
|
||||||
|
return <div key={idx} className="text-sm font-bold text-neutral-500 uppercase tracking-widest mt-2">{child.label}</div>;
|
||||||
|
}
|
||||||
|
const childUrl = child.url.startsWith('/') && !child.url.match(/^\/(en|de)/)
|
||||||
|
? `/${currentLocale}${child.url}`
|
||||||
|
: child.url;
|
||||||
|
const isChildActive = pathname === childUrl;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<TransitionLink
|
||||||
|
key={child.url}
|
||||||
|
href={childUrl}
|
||||||
|
onClick={() => setIsMobileMenuOpen(false)}
|
||||||
|
className={`text-lg font-medium transition-colors ${isChildActive ? 'text-primary' : 'text-white/70'}`}
|
||||||
|
>
|
||||||
|
{child.label}
|
||||||
|
</TransitionLink>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</nav>
|
||||||
|
</motion.div>
|
||||||
|
)}
|
||||||
|
</AnimatePresence>
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -164,6 +239,20 @@ export function Header({ navLinks }: HeaderProps) {
|
|||||||
function NavItem({ link, currentLocale, pathname, isSolidMode }: { link: NavLink, currentLocale: string, pathname: string, isSolidMode: boolean }) {
|
function NavItem({ link, currentLocale, pathname, isSolidMode }: { link: NavLink, currentLocale: string, pathname: string, isSolidMode: boolean }) {
|
||||||
const [isHovered, setIsHovered] = React.useState(false);
|
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)/)
|
const mappedUrl = link.url.startsWith('/') && !link.url.match(/^\/(en|de)/)
|
||||||
? `/${currentLocale}${link.url}`
|
? `/${currentLocale}${link.url}`
|
||||||
: link.url;
|
: link.url;
|
||||||
@@ -218,15 +307,25 @@ function NavItem({ link, currentLocale, pathname, isSolidMode }: { link: NavLink
|
|||||||
{/* Top glowing accent line */}
|
{/* 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" />
|
<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)/)
|
const childUrl = child.url.startsWith('/') && !child.url.match(/^\/(en|de)/)
|
||||||
? `/${currentLocale}${child.url}`
|
? `/${currentLocale}${child.url}`
|
||||||
: child.url;
|
: child.url;
|
||||||
const isChildActive = pathname === childUrl;
|
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 (
|
return (
|
||||||
<TransitionLink
|
<TransitionLink
|
||||||
key={child.url}
|
key={child.url}
|
||||||
href={childUrl}
|
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 ${
|
className={`group/dropitem relative block px-4 py-3.5 rounded-2xl text-sm font-medium transition-all duration-300 overflow-hidden ${
|
||||||
isSolidMode
|
isSolidMode
|
||||||
? isChildActive ? 'bg-primary/10 text-primary' : 'text-neutral-600 hover:text-primary hover:bg-neutral-50/80'
|
? isChildActive ? 'bg-primary/10 text-primary' : 'text-neutral-600 hover:text-primary hover:bg-neutral-50/80'
|
||||||
|
|||||||
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 ReactMarkdown from 'react-markdown';
|
||||||
import remarkGfm from 'remark-gfm';
|
import remarkGfm from 'remark-gfm';
|
||||||
import dynamic from 'next/dynamic';
|
import dynamic from 'next/dynamic';
|
||||||
|
import { useTranslations } from 'next-intl';
|
||||||
const AIOrb = dynamic(() => import('./AIOrb'), { ssr: false });
|
const AIOrb = dynamic(() => import('./AIOrb'), { ssr: false });
|
||||||
|
|
||||||
const LOADING_TEXTS = [
|
|
||||||
'Durchsuche das Kabelhandbuch... 📖',
|
|
||||||
'Frage den Senior-Ingenieur... 👴🔧',
|
|
||||||
'Frage ChatGPTs Cousin 2. Grades... 🤖',
|
|
||||||
];
|
|
||||||
|
|
||||||
interface ProductMatch {
|
interface ProductMatch {
|
||||||
id: string;
|
id: string;
|
||||||
title: string;
|
title: string;
|
||||||
@@ -51,7 +46,13 @@ export function AISearchResults({
|
|||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
const [copiedIndex, setCopiedIndex] = useState<number | null>(null);
|
const [copiedIndex, setCopiedIndex] = useState<number | null>(null);
|
||||||
const [copiedAll, setCopiedAll] = useState(false);
|
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 inputRef = useRef<HTMLInputElement>(null);
|
||||||
const modalRef = useRef<HTMLDivElement>(null);
|
const modalRef = useRef<HTMLDivElement>(null);
|
||||||
@@ -141,11 +142,11 @@ export function AISearchResults({
|
|||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
setIsLoading(true);
|
setIsLoading(true);
|
||||||
// Start rotating loading texts
|
// Start rotating loading texts
|
||||||
let textIdx = Math.floor(Math.random() * LOADING_TEXTS.length);
|
let textIdx = Math.floor(Math.random() * loadingTexts.length);
|
||||||
setLoadingText(LOADING_TEXTS[textIdx]);
|
setLoadingText(loadingTexts[textIdx]);
|
||||||
loadingIntervalRef.current = setInterval(() => {
|
loadingIntervalRef.current = setInterval(() => {
|
||||||
textIdx = (textIdx + 1) % LOADING_TEXTS.length;
|
textIdx = (textIdx + 1) % loadingTexts.length;
|
||||||
setLoadingText(LOADING_TEXTS[textIdx]);
|
setLoadingText(loadingTexts[textIdx]);
|
||||||
}, 2500);
|
}, 2500);
|
||||||
}, 400);
|
}, 400);
|
||||||
|
|
||||||
@@ -191,8 +192,8 @@ export function AISearchResults({
|
|||||||
console.error(err);
|
console.error(err);
|
||||||
const msg =
|
const msg =
|
||||||
err.name === 'AbortError'
|
err.name === 'AbortError'
|
||||||
? 'Anfrage hat zu lange gedauert. Bitte versuche es erneut.'
|
? t('timeoutError')
|
||||||
: err.message || 'Ein Fehler ist aufgetreten.';
|
: err.message || t('genericError');
|
||||||
|
|
||||||
// Show error as a system message in the chat instead of a separate error banner
|
// Show error as a system message in the chat instead of a separate error banner
|
||||||
setMessages((prev) => [
|
setMessages((prev) => [
|
||||||
@@ -248,7 +249,7 @@ export function AISearchResults({
|
|||||||
|
|
||||||
const handleCopyChat = () => {
|
const handleCopyChat = () => {
|
||||||
const fullChat = messages
|
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');
|
.join('\n\n');
|
||||||
handleCopy(fullChat);
|
handleCopy(fullChat);
|
||||||
};
|
};
|
||||||
@@ -294,7 +295,7 @@ export function AISearchResults({
|
|||||||
<div>
|
<div>
|
||||||
<h2 className="text-white font-bold text-sm tracking-wide">Ohm</h2>
|
<h2 className="text-white font-bold text-sm tracking-wide">Ohm</h2>
|
||||||
<p className="text-[10px] text-white/30 font-medium tracking-wider uppercase">
|
<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>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -303,14 +304,14 @@ export function AISearchResults({
|
|||||||
<button
|
<button
|
||||||
onClick={handleCopyChat}
|
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"
|
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 ? (
|
{copiedAll ? (
|
||||||
<Check className="w-3.5 h-3.5 text-accent" />
|
<Check className="w-3.5 h-3.5 text-accent" />
|
||||||
) : (
|
) : (
|
||||||
<Copy className="w-3.5 h-3.5" />
|
<Copy className="w-3.5 h-3.5" />
|
||||||
)}
|
)}
|
||||||
<span>{copiedAll ? 'Kopiert' : 'Chat kopieren'}</span>
|
<span>{copiedAll ? t('copied') : t('copyChat')}</span>
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
<button
|
<button
|
||||||
@@ -336,16 +337,15 @@ export function AISearchResults({
|
|||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<p className="text-xl md:text-2xl font-bold text-white/80">
|
<p className="text-xl md:text-2xl font-bold text-white/80">
|
||||||
Wie kann ich helfen?
|
{t('howCanIHelp')}
|
||||||
</p>
|
</p>
|
||||||
<p className="text-sm text-white/30 mt-2 max-w-md">
|
<p className="text-sm text-white/30 mt-2 max-w-md">
|
||||||
Beschreibe dein Projekt, frag nach bestimmten Kabeln, oder nenne mir deine
|
{t('helpDescription')}
|
||||||
Anforderungen.
|
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
{/* Quick prompts */}
|
{/* Quick prompts */}
|
||||||
<div className="flex flex-wrap justify-center gap-2 mt-4">
|
<div className="flex flex-wrap justify-center gap-2 mt-4">
|
||||||
{['Windpark 33kV Verkabelung', 'NYCWY 4x185', 'Erdkabel für Solarpark'].map(
|
{prompts.map(
|
||||||
(prompt) => (
|
(prompt) => (
|
||||||
<button
|
<button
|
||||||
key={prompt}
|
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-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'
|
: '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 ? (
|
{copiedIndex === index ? (
|
||||||
<Check className="w-3.5 h-3.5" />
|
<Check className="w-3.5 h-3.5" />
|
||||||
@@ -431,7 +431,7 @@ export function AISearchResults({
|
|||||||
{msg.role === 'assistant' && msg.products && msg.products.length > 0 && (
|
{msg.role === 'assistant' && msg.products && msg.products.length > 0 && (
|
||||||
<div className="mt-4 space-y-2 border-t border-white/[0.06] pt-4">
|
<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">
|
<h4 className="text-[10px] font-bold tracking-widest uppercase text-white/30 mb-2">
|
||||||
Empfohlene Produkte
|
{t('recommendedProducts')}
|
||||||
</h4>
|
</h4>
|
||||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-2">
|
<div className="grid grid-cols-1 sm:grid-cols-2 gap-2">
|
||||||
{msg.products.map((product, idx) => (
|
{msg.products.map((product, idx) => (
|
||||||
@@ -509,7 +509,7 @@ export function AISearchResults({
|
|||||||
<AIOrb isThinking={false} hasError={true} />
|
<AIOrb isThinking={false} hasError={true} />
|
||||||
</div>
|
</div>
|
||||||
<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>
|
<p className="text-xs text-red-300/60 mt-1">{error}</p>
|
||||||
<button
|
<button
|
||||||
onClick={() => {
|
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"
|
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" />
|
<RotateCcw className="w-3 h-3" />
|
||||||
Nochmal versuchen
|
{t('tryAgain')}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -544,7 +544,7 @@ export function AISearchResults({
|
|||||||
value={query}
|
value={query}
|
||||||
onChange={(e) => setQuery(e.target.value)}
|
onChange={(e) => setQuery(e.target.value)}
|
||||||
onKeyDown={onKeyDown}
|
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"
|
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}
|
disabled={isLoading}
|
||||||
tabIndex={1}
|
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-accent text-primary shadow-lg shadow-accent/20 hover:shadow-accent/40 hover:scale-105 active:scale-95'
|
||||||
: 'bg-white/5 text-white/20'
|
: 'bg-white/5 text-white/20'
|
||||||
}`}
|
}`}
|
||||||
aria-label="Nachricht senden"
|
aria-label={t('send')}
|
||||||
>
|
>
|
||||||
<ArrowUp className="w-4 h-4" strokeWidth={2.5} />
|
<ArrowUp className="w-4 h-4" strokeWidth={2.5} />
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center justify-center gap-3 mt-2.5">
|
<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">
|
<span className="text-[9px] uppercase tracking-[0.15em] font-medium text-white/15">
|
||||||
Enter zum Senden · Esc zum Schließen
|
{t('footerShortcuts')}
|
||||||
</span>
|
</span>
|
||||||
<span className="text-[9px] uppercase tracking-[0.15em] font-medium text-white/15">
|
<span className="text-[9px] uppercase tracking-[0.15em] font-medium text-white/15">
|
||||||
·
|
·
|
||||||
</span>
|
</span>
|
||||||
<span className="text-[9px] uppercase tracking-[0.15em] font-medium text-accent/40 flex items-center gap-1">
|
<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>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -22,7 +22,7 @@ export default function Gallery() {
|
|||||||
];
|
];
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Section className="bg-primary-dark py-16 md:py-32">
|
<Section className="bg-primary-dark">
|
||||||
<Container>
|
<Container>
|
||||||
<Heading level={2} subtitle={t('gallery.subtitle')} align="center" className="text-white mb-12 md:mb-20">
|
<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>
|
<span className="text-white">{t('gallery.title')}</span>
|
||||||
|
|||||||
@@ -6,12 +6,14 @@ interface AnimatedGlossyBorderProps {
|
|||||||
borderWidth?: 1 | 2;
|
borderWidth?: 1 | 2;
|
||||||
opacity?: number;
|
opacity?: number;
|
||||||
color?: 'white' | 'primary';
|
color?: 'white' | 'primary';
|
||||||
|
disableAnimation?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function AnimatedGlossyBorder({
|
export function AnimatedGlossyBorder({
|
||||||
className,
|
className,
|
||||||
borderWidth = 1,
|
borderWidth = 1,
|
||||||
color = 'white'
|
color = 'white',
|
||||||
|
disableAnimation = false
|
||||||
}: AnimatedGlossyBorderProps) {
|
}: AnimatedGlossyBorderProps) {
|
||||||
const isWhite = color === 'white';
|
const isWhite = color === 'white';
|
||||||
const glowColor = isWhite ? 'rgba(255,255,255,0.6)' : 'rgba(17,124,97,0.6)';
|
const glowColor = isWhite ? 'rgba(255,255,255,0.6)' : 'rgba(17,124,97,0.6)';
|
||||||
@@ -35,7 +37,10 @@ export function AnimatedGlossyBorder({
|
|||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<div
|
<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={{
|
style={{
|
||||||
background: `conic-gradient(from 0deg, ${gradientColors})`
|
background: `conic-gradient(from 0deg, ${gradientColors})`
|
||||||
}}
|
}}
|
||||||
|
|||||||
@@ -32,9 +32,9 @@ export function Heading({
|
|||||||
const effectiveSize = size || String(level) as HeadingSize;
|
const effectiveSize = size || String(level) as HeadingSize;
|
||||||
|
|
||||||
const sizes: Record<string, string> = {
|
const sizes: Record<string, string> = {
|
||||||
'hero': 'font-heading text-4xl md:text-5xl lg:text-6xl font-black tracking-tight !leading-[0.9]',
|
'hero': 'font-heading text-3xl md:text-5xl lg:text-6xl font-black tracking-tight !leading-[0.9]',
|
||||||
'section': 'font-heading text-3xl md:text-4xl lg:text-5xl font-extrabold tracking-tight !leading-[0.95]',
|
'section': 'font-heading text-3xl md:text-4xl lg:text-5xl font-extrabold tracking-tight !leading-[0.95]',
|
||||||
'subsection': 'font-heading text-2xl md:text-3xl lg:text-4xl font-bold tracking-tight leading-tight',
|
'subsection': 'font-heading text-xl md:text-3xl lg:text-4xl font-bold tracking-tight leading-tight',
|
||||||
'card': 'text-xl md:text-2xl font-bold leading-snug',
|
'card': 'text-xl md:text-2xl font-bold leading-snug',
|
||||||
'small': 'text-lg font-semibold leading-snug',
|
'small': 'text-lg font-semibold leading-snug',
|
||||||
// Legacy support
|
// Legacy support
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ import { cn } from './utils';
|
|||||||
|
|
||||||
export function Section({ className, children, ...props }: React.HTMLAttributes<HTMLElement>) {
|
export function Section({ className, children, ...props }: React.HTMLAttributes<HTMLElement>) {
|
||||||
return (
|
return (
|
||||||
<section className={cn('py-16 md:py-28 lg:py-36 overflow-hidden content-visibility-auto', className)} {...props}>
|
<section className={cn('py-16 md:py-24 lg:py-32 overflow-hidden content-visibility-auto', className)} {...props}>
|
||||||
{children}
|
{children}
|
||||||
</section>
|
</section>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -24,6 +24,11 @@ export function TransitionLink({ children, href, onClick, transitionMessage, ...
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Hash links on the same page should not trigger a transition
|
||||||
|
if (href.toString().startsWith('#')) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
// Default Link Navigation blockieren
|
// Default Link Navigation blockieren
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
|
|
||||||
|
|||||||
1
contact-page-auth-debug.html
Normal file
1
contact-page-auth-debug.html
Normal file
File diff suppressed because one or more lines are too long
1
contact-page-debug.html
Normal file
1
contact-page-debug.html
Normal file
File diff suppressed because one or more lines are too long
BIN
contact.png
Normal file
BIN
contact.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 288 KiB |
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user