Compare commits

..

3 Commits

Author SHA1 Message Date
8652dd722e perf(ci): optimize pipeline via parallelization, caching and conditional audits
Some checks failed
Build & Deploy / 🔍 Prepare (push) Successful in 20s
Build & Deploy / 🏗️ Build (push) Successful in 6m35s
Build & Deploy / 🧪 QA (push) Successful in 15m18s
Build & Deploy / 🚀 Deploy (push) Successful in 36s
Build & Deploy / 🧪 Smoke Test (push) Failing after 10s
Build & Deploy / 🔔 Notify (push) Has been cancelled
Build & Deploy / ⚡ Lighthouse (push) Has been cancelled
2026-02-23 12:42:05 +01:00
5e48c75a83 fix(routing): resolve 404 on German product pages via rewrites and localized links
Some checks failed
Build & Deploy / 🔍 Prepare (push) Successful in 18s
Build & Deploy / 🧪 QA (push) Successful in 3m7s
Build & Deploy / 🚀 Deploy (push) Has been cancelled
Build & Deploy / 🧪 Smoke Test (push) Has been cancelled
Build & Deploy / ⚡ Lighthouse (push) Has been cancelled
Build & Deploy / 🔔 Notify (push) Has been cancelled
Build & Deploy / 🏗️ Build (push) Has started running
2026-02-23 12:36:37 +01:00
50fc8a0554 fix(analytics): remove conflicting next config rewrite and enable proxy client
All checks were successful
Build & Deploy / 🔍 Prepare (push) Successful in 9s
Build & Deploy / 🧪 QA (push) Successful in 1m53s
Build & Deploy / 🏗️ Build (push) Successful in 7m35s
Build & Deploy / 🚀 Deploy (push) Successful in 37s
Build & Deploy / 🧪 Smoke Test (push) Successful in 1m8s
Build & Deploy / ⚡ Lighthouse (push) Successful in 3m25s
Build & Deploy / 🔔 Notify (push) Successful in 1s
2026-02-20 15:00:18 +01:00
1123 changed files with 231514 additions and 43851 deletions

View File

@@ -1,13 +1,5 @@
node_modules
.next
.DS_Store
.git
.gitignore
.gitea
.github
.turbo
reference/
.next
!.next/cache
.git
.DS_Store
@@ -16,5 +8,3 @@ reference/
docs
reference
public/datasheets/*.pdf
.pnpm-store
.gitea

40
.env
View File

@@ -7,7 +7,6 @@ SENTRY_DSN=https://c10957d0182245b1a2a806ac2d34a197@errors.infra.mintel.me/1
LOG_LEVEL=info
NEXT_PUBLIC_FEEDBACK_ENABLED=false
NEXT_PUBLIC_RECORD_MODE_ENABLED=false
NPM_TOKEN=263e7f75d8ada27f3a2e71fd6bd9d95298d48a4d
# SMTP Configuration
MAIL_HOST=smtp.eu.mailgun.org
@@ -17,22 +16,23 @@ MAIL_PASSWORD=4592fcb94599ee1a45b4ac2386fd0a64-102c75d8-ca2870e6
MAIL_FROM="KLZ Cables <postmaster@mg.mintel.me>"
MAIL_RECIPIENTS=marc@cablecreations.de,info@klz-cables.com
# ────────────────────────────────────────────────────────────────────────────
# Payload Infrastructure (Dockerized)
# ────────────────────────────────────────────────────────────────────────────
# The POSTGRES_URI and PAYLOAD_SECRET are automatically constructed and injected
# by docker-compose.yml using these base DB credentials, so you don't need to
# manually write the connection strings here.
PAYLOAD_DB_NAME=payload
PAYLOAD_DB_USER=payload
PAYLOAD_DB_PASSWORD=120in09oenaoinsd9iaidon
# ────────────────────────────────────────────────────────────────────────────
# Hetzner S3 Object Storage
# ────────────────────────────────────────────────────────────────────────────
S3_ENDPOINT=https://fsn1.your-objectstorage.com
S3_ACCESS_KEY=ROB3MSWMEIGRL7N94ZKS
S3_SECRET_KEY=9QJV3NE8xeLxhyufhNU7lsUB0RffJxPhGuEuFSH3
S3_BUCKET=mintel
S3_REGION=fsn1
S3_PREFIX=klz-cables
# Directus
DIRECTUS_URL=http://klz-cms:8055
DIRECTUS_KEY=59fb8f4c1a51b18fe28ad947f713914e
DIRECTUS_SECRET=7459038d41401dfb11254cf7f1ef2d0f
DIRECTUS_ADMIN_EMAIL=marc@mintel.me
DIRECTUS_ADMIN_PASSWORD=Tim300493.
DIRECTUS_API_TOKEN=59fb8f4c1a51b18fe28ad947f713914e
DIRECTUS_DB_NAME=directus
DIRECTUS_DB_USER=klz_db_user
DIRECTUS_DB_PASSWORD=klz_db_pass
# Local Development
PROJECT_NAME=klz-cables
GATEKEEPER_BYPASS_ENABLED=true
TRAEFIK_HOST=klz.localhost
DIRECTUS_HOST=cms.klz.localhost
GATEKEEPER_PASSWORD=klz2026
COOKIE_DOMAIN=localhost
INFRA_DIRECTUS_URL=http://localhost:8059
INFRA_DIRECTUS_TOKEN=59fb8f4c1a51b18fe28ad947f713914e
GATEKEEPER_ORIGIN=http://klz.localhost

View File

@@ -10,6 +10,7 @@
# ────────────────────────────────────────────────────────────────────────────
NODE_ENV=development
NEXT_PUBLIC_BASE_URL=http://localhost:3000
DIRECTUS_PORT=8055
# TARGET is used to differentiate between environments (testing, staging, production)
# NEXT_PUBLIC_TARGET makes this information available to the frontend
TARGET=development
@@ -46,18 +47,9 @@ MAIL_RECIPIENTS=info@klz-cables.com
LOG_LEVEL=info
GATEKEEPER_PASSWORD=klz2026
SENTRY_DSN=
# For Directus Error Tracking
# SENTRY_ENVIRONMENT is set automatically by CI
# ────────────────────────────────────────────────────────────────────────────
# Payload Infrastructure (Dockerized)
# ────────────────────────────────────────────────────────────────────────────
# The POSTGRES_URI and PAYLOAD_SECRET are automatically constructed and injected
# by docker-compose.yml using these base DB credentials, so you don't need to
# manually write the connection strings here.
PAYLOAD_DB_NAME=payload
PAYLOAD_DB_USER=payload
PAYLOAD_DB_PASSWORD=120in09oenaoinsd9iaidon
# ────────────────────────────────────────────────────────────────────────────
# Deployment Configuration (CI/CD only)
# ────────────────────────────────────────────────────────────────────────────

View File

@@ -3,10 +3,6 @@ name: CI - Lint, Typecheck & Test
on:
pull_request:
concurrency:
group: deploy-pipeline
cancel-in-progress: true
jobs:
quality-assurance:
runs-on: docker
@@ -14,31 +10,34 @@ jobs:
- name: Checkout repository
uses: actions/checkout@v4
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: 20
cache: 'pnpm'
- name: Setup pnpm
uses: pnpm/action-setup@v3
with:
version: 10
run_install: false
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: 20
- name: 🔐 Configure Private Registry
run: |
echo "@mintel:registry=https://git.infra.mintel.me/api/packages/mmintel/npm" > .npmrc
echo "//git.infra.mintel.me/api/packages/mmintel/npm/:_authToken=${{ secrets.NPM_TOKEN }}" >> .npmrc
REGISTRY="${{ vars.REGISTRY_HOST || 'npm.infra.mintel.me' }}"
echo "@mintel:registry=https://$REGISTRY" > .npmrc
echo "//$REGISTRY/:_authToken=${{ secrets.REGISTRY_PASS }}" >> .npmrc
- name: Install dependencies
run: pnpm install --no-frozen-lockfile
run: pnpm install
env:
NPM_TOKEN: ${{ secrets.NPM_TOKEN }}
NPM_TOKEN: ${{ secrets.REGISTRY_PASS }}
- name: 🧪 QA Checks
env:
TURBO_TELEMETRY_DISABLED: "1"
run: npx turbo run check:mdx lint typecheck test --cache-dir=".turbo"
run: pnpm check:mdx && pnpm lint && pnpm typecheck && pnpm test
- name: 🏗️ Build
run: pnpm build
- name: ♿ Accessibility Check
run: pnpm check:a11y

View File

@@ -13,12 +13,8 @@ on:
required: false
default: 'false'
env:
PUPPETEER_SKIP_DOWNLOAD: "true"
COREPACK_NPM_REGISTRY: "https://registry.npmmirror.com"
concurrency:
group: deploy-pipeline
group: ${{ github.workflow }}-${{ (github.ref_type == 'tag' && !contains(github.ref_name, '-')) && 'prod' || (github.ref_name == 'main' && 'testing' || github.ref_name) }}
cancel-in-progress: true
jobs:
@@ -35,6 +31,7 @@ jobs:
traefik_host: ${{ steps.determine.outputs.traefik_host }}
traefik_rule: ${{ steps.determine.outputs.traefik_rule }}
next_public_url: ${{ steps.determine.outputs.next_public_url }}
directus_url: ${{ steps.determine.outputs.directus_url }}
project_name: ${{ steps.determine.outputs.project_name }}
short_sha: ${{ steps.determine.outputs.short_sha }}
container:
@@ -86,16 +83,14 @@ jobs:
TRAEFIK_HOST="${SLUG}.branch.mintel.me"
fi
# Standardize Traefik Rule (escaped backticks for Traefik v3)
# Standardize Traefik Rule
if [[ "$TRAEFIK_HOST" == *","* ]]; then
TRAEFIK_RULE=$(echo "$TRAEFIK_HOST" | sed 's/,/ /g' | awk '{for(i=1;i<=NF;i++) printf "Host(\x60%s\x60)%s", $i, (i==NF?"":" || ")}')
TRAEFIK_RULE=$(echo "$TRAEFIK_HOST" | sed 's/,/ /g' | awk '{for(i=1;i<=NF;i++) printf "Host(\"%s\")%s", $i, (i==NF?"":" || ")}')
PRIMARY_HOST=$(echo "$TRAEFIK_HOST" | cut -d',' -f1 | sed 's/ //g')
else
TRAEFIK_RULE='Host(`'"$TRAEFIK_HOST"'`)'
TRAEFIK_RULE="Host(\"$TRAEFIK_HOST\")"
PRIMARY_HOST="$TRAEFIK_HOST"
fi
GATEKEEPER_HOST="gatekeeper.$PRIMARY_HOST"
{
echo "target=$TARGET"
@@ -103,12 +98,10 @@ jobs:
echo "env_file=$ENV_FILE"
echo "traefik_host=$PRIMARY_HOST"
echo "traefik_rule=$TRAEFIK_RULE"
echo "gatekeeper_host=$GATEKEEPER_HOST"
echo "next_public_url=https://$PRIMARY_HOST"
echo "directus_url=https://cms.$PRIMARY_HOST"
if [[ "$TARGET" == "production" ]]; then
echo "project_name=klz-cablescom"
elif [[ "$TARGET" == "branch" ]]; then
echo "project_name=$PRJ-branch-$SLUG"
else
echo "project_name=$PRJ-$TARGET"
fi
@@ -124,13 +117,13 @@ jobs:
if [[ -n "$UPSTREAM_VERSION" && "$UPSTREAM_VERSION" != "workspace:"* ]]; then
# 1. Discovery (Works without token for public repositories)
UPSTREAM_SHA=$(git ls-remote --tags https://git.infra.mintel.me/mmintel/at-mintel.git "$TAG_TO_WAIT" 2>/dev/null | grep "$TAG_TO_WAIT" | awk '{print $1}' | tail -n1 || echo "")
UPSTREAM_SHA=$(git ls-remote --tags https://git.infra.mintel.me/mmintel/at-mintel.git "$TAG_TO_WAIT" | grep "$TAG_TO_WAIT" | tail -n1 | awk '{print $1}')
if [[ -z "$UPSTREAM_SHA" ]]; then
echo "⚠️ Warning: Tag $TAG_TO_WAIT not found in mmintel/at-mintel."
else
echo "✅ Tag verified: Found upstream SHA $UPSTREAM_SHA for $TAG_TO_WAIT"
echo "❌ Error: Tag $TAG_TO_WAIT not found in mmintel/at-mintel."
exit 1
fi
echo "✅ Tag verified: Found upstream SHA $UPSTREAM_SHA for $TAG_TO_WAIT"
# 2. Status Check (Requires GITEA_PAT for cross-repo API access)
POLL_TOKEN="${{ secrets.GITEA_PAT || secrets.MINTEL_PRIVATE_TOKEN }}"
@@ -161,38 +154,34 @@ jobs:
steps:
- name: Checkout repository
uses: actions/checkout@v4
- name: Setup pnpm
uses: pnpm/action-setup@v3
with:
version: 10
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: 20
cache: 'pnpm'
- name: Setup pnpm
uses: pnpm/action-setup@v3
with:
version: 10
- name: 🔐 Registry Auth
run: |
echo "@mintel:registry=https://git.infra.mintel.me/api/packages/mmintel/npm" > .npmrc
echo "//git.infra.mintel.me/api/packages/mmintel/npm/:_authToken=${{ secrets.NPM_TOKEN }}" >> .npmrc
echo "@mintel:registry=https://${{ vars.REGISTRY_HOST || 'npm.infra.mintel.me' }}" > .npmrc
echo "//${{ vars.REGISTRY_HOST || 'npm.infra.mintel.me' }}/:_authToken=${{ secrets.REGISTRY_PASS }}" >> .npmrc
- name: Install dependencies
run: |
pnpm store prune
pnpm install --no-frozen-lockfile
- name: 🔒 Security Audit
run: pnpm audit --audit-level high || echo "⚠️ Audit found vulnerabilities (non-blocking)"
run: pnpm install --frozen-lockfile
- name: 🧪 QA Checks
if: github.event.inputs.skip_checks != 'true'
env:
TURBO_TELEMETRY_DISABLED: "1"
run: npx turbo run lint typecheck test --cache-dir=".turbo"
run: |
pnpm lint
pnpm typecheck
pnpm test
# ──────────────────────────────────────────────────────────────────────────────
# JOB 3: Build & Push
# ──────────────────────────────────────────────────────────────────────────────
build:
name: 🏗️ Build
needs: [prepare, qa]
needs: [prepare]
if: needs.prepare.outputs.target != 'skip'
runs-on: docker
container:
@@ -209,17 +198,19 @@ jobs:
with:
context: .
push: true
provenance: false
platforms: linux/amd64
platforms: linux/arm64
build-args: |
NEXT_PUBLIC_BASE_URL=${{ needs.prepare.outputs.next_public_url }}
NEXT_PUBLIC_TARGET=${{ needs.prepare.outputs.target }}
DIRECTUS_URL=${{ needs.prepare.outputs.directus_url }}
UMAMI_WEBSITE_ID=${{ secrets.UMAMI_WEBSITE_ID || vars.UMAMI_WEBSITE_ID }}
UMAMI_API_ENDPOINT=${{ secrets.UMAMI_API_ENDPOINT || vars.UMAMI_API_ENDPOINT || 'https://analytics.infra.mintel.me' }}
NPM_TOKEN=${{ secrets.NPM_TOKEN }}
tags: registry.infra.mintel.me/mintel/klz-2026:${{ needs.prepare.outputs.image_tag }}
NPM_TOKEN=${{ secrets.REGISTRY_PASS }}
tags: registry.infra.mintel.me/mintel/klz-cables.com:${{ needs.prepare.outputs.image_tag }}
cache-from: type=registry,ref=registry.infra.mintel.me/mintel/klz-cables.com:buildcache
cache-to: type=registry,ref=registry.infra.mintel.me/mintel/klz-cables.com:buildcache,mode=max
secrets: |
"NPM_TOKEN=${{ secrets.NPM_TOKEN }}"
"NPM_TOKEN=${{ secrets.REGISTRY_PASS }}"
# ──────────────────────────────────────────────────────────────────────────────
# JOB 4: Deploy
@@ -235,17 +226,27 @@ jobs:
IMAGE_TAG: ${{ needs.prepare.outputs.image_tag }}
PROJECT_NAME: ${{ needs.prepare.outputs.project_name }}
NEXT_PUBLIC_BASE_URL: ${{ needs.prepare.outputs.next_public_url }}
DIRECTUS_URL: ${{ needs.prepare.outputs.directus_url }}
DIRECTUS_HOST: cms.${{ needs.prepare.outputs.traefik_host }}
TRAEFIK_HOST: ${{ needs.prepare.outputs.traefik_host }}
GATEKEEPER_HOST: ${{ needs.prepare.outputs.gatekeeper_host }}
# Secrets mapping (Directus)
DIRECTUS_KEY: ${{ (needs.prepare.outputs.target == 'testing' && secrets.TESTING_DIRECTUS_KEY) || (needs.prepare.outputs.target == 'staging' && secrets.STAGING_DIRECTUS_KEY) || secrets.DIRECTUS_KEY || vars.DIRECTUS_KEY }}
DIRECTUS_SECRET: ${{ (needs.prepare.outputs.target == 'testing' && secrets.TESTING_DIRECTUS_SECRET) || (needs.prepare.outputs.target == 'staging' && secrets.STAGING_DIRECTUS_SECRET) || secrets.DIRECTUS_SECRET || vars.DIRECTUS_SECRET }}
DIRECTUS_ADMIN_EMAIL: ${{ (needs.prepare.outputs.target == 'testing' && secrets.TESTING_DIRECTUS_ADMIN_EMAIL) || (needs.prepare.outputs.target == 'staging' && secrets.STAGING_DIRECTUS_ADMIN_EMAIL) || secrets.DIRECTUS_ADMIN_EMAIL || vars.DIRECTUS_ADMIN_EMAIL || 'admin@mintel.me' }}
DIRECTUS_ADMIN_PASSWORD: ${{ (needs.prepare.outputs.target == 'testing' && secrets.TESTING_DIRECTUS_ADMIN_PASSWORD) || (needs.prepare.outputs.target == 'staging' && secrets.STAGING_DIRECTUS_ADMIN_PASSWORD) || secrets.DIRECTUS_ADMIN_PASSWORD || vars.DIRECTUS_ADMIN_PASSWORD }}
DIRECTUS_DB_NAME: ${{ secrets.DIRECTUS_DB_NAME || vars.DIRECTUS_DB_NAME || 'directus' }}
DIRECTUS_DB_USER: ${{ secrets.DIRECTUS_DB_USER || vars.DIRECTUS_DB_USER || 'directus' }}
DIRECTUS_DB_PASSWORD: ${{ (needs.prepare.outputs.target == 'testing' && secrets.TESTING_DIRECTUS_DB_PASSWORD) || (needs.prepare.outputs.target == 'staging' && secrets.STAGING_DIRECTUS_DB_PASSWORD) || secrets.DIRECTUS_DB_PASSWORD || vars.DIRECTUS_DB_PASSWORD || 'directus' }}
DIRECTUS_API_TOKEN: ${{ (needs.prepare.outputs.target == 'testing' && secrets.TESTING_DIRECTUS_API_TOKEN) || (needs.prepare.outputs.target == 'staging' && secrets.STAGING_DIRECTUS_API_TOKEN) || secrets.DIRECTUS_API_TOKEN || vars.DIRECTUS_API_TOKEN }}
# Secrets mapping (Mail)
MAIL_HOST: ${{ secrets.SMTP_HOST || vars.SMTP_HOST }}
MAIL_PORT: ${{ secrets.SMTP_PORT || vars.SMTP_PORT || '587' }}
MAIL_USERNAME: ${{ secrets.SMTP_USER || vars.SMTP_USER }}
MAIL_PASSWORD: ${{ secrets.SMTP_PASS || vars.SMTP_PASS }}
MAIL_FROM: ${{ secrets.SMTP_FROM || vars.SMTP_FROM || 'noreply@klz-cables.com' }}
MAIL_RECIPIENTS: ${{ secrets.CONTACT_RECIPIENT || vars.CONTACT_RECIPIENT || 'info@klz-cables.com' }}
MAIL_FROM: ${{ secrets.SMTP_FROM || vars.SMTP_FROM }}
MAIL_RECIPIENTS: ${{ secrets.CONTACT_RECIPIENT || vars.CONTACT_RECIPIENT }}
# Monitoring
SENTRY_DSN: ${{ secrets.SENTRY_DSN || vars.SENTRY_DSN }}
@@ -256,10 +257,6 @@ jobs:
# Analytics
UMAMI_WEBSITE_ID: ${{ secrets.UMAMI_WEBSITE_ID || vars.UMAMI_WEBSITE_ID }}
UMAMI_API_ENDPOINT: ${{ secrets.UMAMI_API_ENDPOINT || vars.UMAMI_API_ENDPOINT || 'https://analytics.infra.mintel.me' }}
# Notifications
GOTIFY_URL: ${{ secrets.GOTIFY_URL }}
GOTIFY_TOKEN: ${{ secrets.GOTIFY_TOKEN }}
steps:
- name: Checkout repository
uses: actions/checkout@v4
@@ -287,7 +284,7 @@ jobs:
AUTH_MIDDLEWARE_UNPROTECTED="$STD_MW"
# Gatekeeper Origin
GATEKEEPER_ORIGIN="${NEXT_PUBLIC_BASE_URL}/gatekeeper"
GATEKEEPER_ORIGIN="$NEXT_PUBLIC_BASE_URL/gatekeeper"
{
echo "# Generated by CI - $TARGET"
@@ -303,6 +300,21 @@ jobs:
echo "MAIL_FROM=$MAIL_FROM"
echo "MAIL_RECIPIENTS=$MAIL_RECIPIENTS"
echo ""
echo "# Directus"
echo "DIRECTUS_URL=$DIRECTUS_URL"
echo "DIRECTUS_HOST=$DIRECTUS_HOST"
echo "DIRECTUS_KEY=$DIRECTUS_KEY"
echo "DIRECTUS_SECRET=$DIRECTUS_SECRET"
echo "DIRECTUS_ADMIN_EMAIL=$DIRECTUS_ADMIN_EMAIL"
echo "DIRECTUS_ADMIN_PASSWORD=$DIRECTUS_ADMIN_PASSWORD"
echo "DIRECTUS_DB_NAME=$DIRECTUS_DB_NAME"
echo "DIRECTUS_DB_USER=$DIRECTUS_DB_USER"
echo "DIRECTUS_DB_PASSWORD=$DIRECTUS_DB_PASSWORD"
echo "DIRECTUS_DB_CLIENT=pg"
echo "DIRECTUS_DB_HOST=directus-db"
echo "DIRECTUS_DB_PORT=5432"
echo "DIRECTUS_API_TOKEN=$DIRECTUS_API_TOKEN"
echo "INTERNAL_DIRECTUS_URL=http://directus:8055"
echo ""
echo "# Gatekeeper"
echo "GATEKEEPER_PASSWORD=$GATEKEEPER_PASSWORD"
@@ -313,16 +325,11 @@ jobs:
echo "UMAMI_WEBSITE_ID=$UMAMI_WEBSITE_ID"
echo "UMAMI_API_ENDPOINT=$UMAMI_API_ENDPOINT"
echo ""
echo "# Notifications"
echo "GOTIFY_URL=$GOTIFY_URL"
echo "GOTIFY_TOKEN=$GOTIFY_TOKEN"
echo ""
echo "TARGET=$TARGET"
echo "SENTRY_ENVIRONMENT=$TARGET"
echo "PROJECT_NAME=$PROJECT_NAME"
printf 'TRAEFIK_HOST_RULE=%s\n' "$TRAEFIK_RULE"
echo "TRAEFIK_HOST=$TRAEFIK_HOST"
echo "GATEKEEPER_HOST=$GATEKEEPER_HOST"
echo "TRAEFIK_ENTRYPOINT=websecure"
echo "TRAEFIK_TLS=true"
echo "TRAEFIK_CERT_RESOLVER=le"
@@ -347,25 +354,20 @@ jobs:
ssh-keyscan -H alpha.mintel.me >> ~/.ssh/known_hosts 2>/dev/null
# Transfer and Restart
if [[ "$TARGET" == "production" ]]; then
SITE_DIR="/home/deploy/sites/klz-cables.com"
elif [[ "$TARGET" == "testing" ]]; then
SITE_DIR="/home/deploy/sites/testing.klz-cables.com"
elif [[ "$TARGET" == "staging" ]]; then
SITE_DIR="/home/deploy/sites/staging.klz-cables.com"
else
SITE_DIR="/home/deploy/sites/branch.klz-cables.com/${SLUG:-unknown}"
fi
ssh root@alpha.mintel.me "mkdir -p $SITE_DIR"
SITE_DIR="/home/deploy/sites/klz-cables.com"
ssh root@alpha.mintel.me "mkdir -p $SITE_DIR/directus/schema $SITE_DIR/directus/uploads $SITE_DIR/directus/extensions"
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 -r directus/schema root@alpha.mintel.me:$SITE_DIR/directus/
ssh root@alpha.mintel.me "cd $SITE_DIR && echo '${{ secrets.REGISTRY_PASS }}' | docker login registry.infra.mintel.me -u '${{ secrets.REGISTRY_USER }}' --password-stdin"
ssh root@alpha.mintel.me "cd $SITE_DIR && docker compose -p '${{ needs.prepare.outputs.project_name }}' --env-file '$ENV_FILE' pull"
ssh root@alpha.mintel.me "cd $SITE_DIR && docker compose -p '${{ needs.prepare.outputs.project_name }}' --env-file '$ENV_FILE' up -d --remove-orphans"
ssh root@alpha.mintel.me "docker network prune -f"
# Apply Directus Schema Snapshot if available
ssh root@alpha.mintel.me "cd $SITE_DIR && if docker compose -p '${{ needs.prepare.outputs.project_name }}' --env-file '$ENV_FILE' exec -T directus ls /directus/schema/snapshot.yaml >/dev/null 2>&1; then echo '→ Applying Directus Schema Snapshot...' && docker compose -p '${{ needs.prepare.outputs.project_name }}' --env-file '$ENV_FILE' exec -T directus npx directus schema apply /directus/schema/snapshot.yaml --yes; fi"
ssh root@alpha.mintel.me "docker system prune -f --filter 'until=24h'"
- name: 🧹 Post-Deploy Cleanup (Runner)
@@ -373,10 +375,10 @@ jobs:
run: docker builder prune -f --filter "until=1h"
# ──────────────────────────────────────────────────────────────────────────────
# JOB 5: Post-Deploy Verification (Smoke Tests + Quality Gates)
# JOB 5: Smoke Test (OG Images)
# ──────────────────────────────────────────────────────────────────────────────
post_deploy_checks:
name: 🧪 Post-Deploy Verification
smoke_test:
name: 🧪 Smoke Test
needs: [prepare, deploy]
if: needs.deploy.result == 'success' && needs.prepare.outputs.target != 'branch'
runs-on: docker
@@ -385,93 +387,113 @@ jobs:
steps:
- name: Checkout repository
uses: actions/checkout@v4
- name: Setup pnpm
uses: pnpm/action-setup@v3
with:
version: 10
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: 20
cache: 'pnpm'
- name: Setup pnpm
uses: pnpm/action-setup@v3
with:
version: 10
- name: 🔐 Registry Auth
run: |
echo "@mintel:registry=https://git.infra.mintel.me/api/packages/mmintel/npm" > .npmrc
echo "//git.infra.mintel.me/api/packages/mmintel/npm/:_authToken=${{ secrets.NPM_TOKEN }}" >> .npmrc
echo "@mintel:registry=https://${{ vars.REGISTRY_HOST || 'npm.infra.mintel.me' }}" > .npmrc
echo "//${{ vars.REGISTRY_HOST || 'npm.infra.mintel.me' }}/:_authToken=${{ secrets.REGISTRY_PASS }}" >> .npmrc
- name: Install dependencies
id: deps
run: |
pnpm store prune
pnpm install --no-frozen-lockfile
- name: Setup Chrome
id: setup-chrome
uses: browser-actions/setup-chrome@v1
with:
install-dependencies: true
run: pnpm install --frozen-lockfile
- name: 🚀 Run OG Image Check
env:
TEST_URL: ${{ needs.prepare.outputs.next_public_url }}
run: pnpm run check:og
# ── Minimalist Smoke Test ──────────────────────────────────────────
- name: 🌐 Smoke Test (Essential Flow)
if: always() && steps.deps.outcome == 'success'
# ──────────────────────────────────────────────────────────────────────────────
# JOB 6: Lighthouse (Performance & Accessibility)
# ──────────────────────────────────────────────────────────────────────────────
lighthouse:
name: ⚡ Lighthouse
needs: [prepare, deploy]
if: success() && needs.prepare.outputs.target != 'skip' && needs.prepare.outputs.target != 'branch'
runs-on: docker
container:
image: catthehacker/ubuntu:act-latest
steps:
- name: Checkout repository
uses: actions/checkout@v4
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: 20
cache: 'pnpm'
- name: Setup pnpm
uses: pnpm/action-setup@v3
with:
version: 10
- name: 🔐 Registry Auth
run: |
echo "@mintel:registry=https://${{ vars.REGISTRY_HOST || 'npm.infra.mintel.me' }}" > .npmrc
echo "//${{ vars.REGISTRY_HOST || 'npm.infra.mintel.me' }}/:_authToken=${{ secrets.REGISTRY_PASS }}" >> .npmrc
- name: Install dependencies
run: pnpm install --frozen-lockfile
- name: 🔍 Install Chromium (Native & ARM64)
run: |
apt-get update
apt-get install -y gnupg wget ca-certificates
# Detect OS
OS_ID=$(. /etc/os-release && echo $ID)
CODENAME=$(. /etc/os-release && echo $VERSION_CODENAME)
if [ "$OS_ID" = "debian" ]; then
echo "🎯 Debian detected - installing native chromium"
apt-get install -y chromium
else
echo "🎯 Ubuntu detected - adding xtradeb PPA"
mkdir -p /etc/apt/keyrings
KEY_ID="82BB6851C64F6880"
# Fetch PPA key
wget -qO- "https://keyserver.ubuntu.com/pks/lookup?op=get&search=0x$KEY_ID" | gpg --dearmor > /etc/apt/keyrings/xtradeb.gpg
# Add PPA repository
echo "deb [signed-by=/etc/apt/keyrings/xtradeb.gpg] http://ppa.launchpad.net/xtradeb/apps/ubuntu $CODENAME main" > /etc/apt/sources.list.d/xtradeb-ppa.list
# PRIORITY PINNING: Force PPA over Snap-dummy
printf "Package: *\nPin: release o=LP-PPA-xtradeb-apps\nPin-Priority: 1001\n" > /etc/apt/preferences.d/xtradeb
apt-get update
apt-get install -y --allow-downgrades chromium
fi
# Standardize binary paths
[ -f /usr/bin/chromium ] && ln -sf /usr/bin/chromium /usr/bin/google-chrome
[ -f /usr/bin/chromium ] && ln -sf /usr/bin/chromium /usr/bin/chromium-browser
- name: ⚡ Run Lighthouse CI
env:
NEXT_PUBLIC_BASE_URL: ${{ needs.prepare.outputs.next_public_url }}
GATEKEEPER_PASSWORD: ${{ secrets.GATEKEEPER_PASSWORD || 'klz2026' }}
PUPPETEER_EXECUTABLE_PATH: ${{ steps.setup-chrome.outputs.chrome-path }}
run: npx tsx scripts/smoke.ts
CHROME_PATH: /usr/bin/chromium
PAGESPEED_LIMIT: 8
run: pnpm run pagespeed:test
# ──────────────────────────────────────────────────────────────────────────────
# JOB 7: Notifications
# ──────────────────────────────────────────────────────────────────────────────
notifications:
name: 🔔 Notify
needs: [prepare, deploy, post_deploy_checks]
needs: [prepare, deploy, smoke_test, lighthouse]
if: always()
runs-on: docker
container:
image: catthehacker/ubuntu:act-latest
steps:
- name: 🔔 Gotify
shell: bash
run: |
DEPLOY="${{ needs.deploy.result }}"
SMOKE="${{ needs.post_deploy_checks.result }}"
PERF="${{ needs.post_deploy_checks.result }}"
TARGET="${{ needs.prepare.outputs.target }}"
VERSION="${{ needs.prepare.outputs.image_tag }}"
URL="${{ needs.prepare.outputs.next_public_url }}"
# Gotify priority scale:
# 1-3 = low (silent/info)
# 4-5 = normal
# 6-7 = high (warning)
# 8-10 = critical (alarm)
if [[ "$DEPLOY" != "success" ]]; then
PRIORITY=10
EMOJI="🚨"
STATUS_LINE="DEPLOY FAILED"
elif [[ "$SMOKE" != "success" ]]; then
PRIORITY=8
EMOJI="⚠️"
STATUS_LINE="Smoke tests failed"
elif [[ "$PERF" != "success" ]]; then
PRIORITY=5
EMOJI="📉"
STATUS_LINE="Performance degraded"
else
PRIORITY=2
EMOJI="✅"
STATUS_LINE="All checks passed"
fi
TITLE="$EMOJI klz-cables.com $VERSION → $TARGET"
MESSAGE="$STATUS_LINE
Deploy: $DEPLOY | Smoke: $SMOKE | Perf: $PERF
$URL"
if [[ -z "${{ secrets.GOTIFY_URL }}" || -z "${{ secrets.GOTIFY_TOKEN }}" ]]; then
echo "⚠️ Gotify credentials missing, skipping notification."
exit 0
fi
STATUS="${{ needs.deploy.result }}"
TITLE="klz-cables.com: $STATUS"
[[ "$STATUS" == "success" ]] && PRIORITY=5 || PRIORITY=8
curl -s -k -X POST "${{ secrets.GOTIFY_URL }}/message?token=${{ secrets.GOTIFY_TOKEN }}" \
-F "title=$TITLE" \
-F "message=$MESSAGE" \
-F "message=Deploy to ${{ needs.prepare.outputs.target }} finished with status $STATUS.\nVersion: ${{ needs.prepare.outputs.image_tag }}" \
-F "priority=$PRIORITY" || true

View File

@@ -1,56 +0,0 @@
name: Nightly QA
on:
schedule:
- cron: '0 3 * * *'
workflow_dispatch:
env:
TARGET_URL: 'https://testing.klz-cables.com'
PROJECT_NAME: 'klz-2026'
jobs:
smoke:
name: 💨 Smoke & Health
runs-on: docker
container:
image: catthehacker/ubuntu:act-latest
steps:
- uses: actions/checkout@v4
- uses: pnpm/action-setup@v3
with:
version: 10
- uses: actions/setup-node@v4
with:
node-version: 20
- name: 🔐 Registry Auth
run: |
echo "@mintel:registry=https://git.infra.mintel.me/api/packages/mmintel/npm" > .npmrc
echo "//git.infra.mintel.me/api/packages/mmintel/npm/:_authToken=${{ secrets.NPM_TOKEN }}" >> .npmrc
- name: 📦 Cache node_modules
uses: actions/cache@v4
id: cache-deps
with:
path: node_modules
key: pnpm-${{ hashFiles('pnpm-lock.yaml') }}
- name: Install
if: steps.cache-deps.outputs.cache-hit != 'true'
run: pnpm install --no-frozen-lockfile
- name: 🌐 Install Chrome & Dependencies
run: |
apt-get update && apt-get install -y libnss3 libnspr4 libatk1.0-0 libatk-bridge2.0-0 libcups2 libdrm2 libxkbcommon0 libxcomposite1 libxdamage1 libxext6 libxfixes3 libxrandr2 libgbm1 libasound2t64 libpango-1.0-0 libcairo2
npx puppeteer browsers install chrome
- name: 🚀 Run Smoke Test
env:
NEXT_PUBLIC_BASE_URL: ${{ env.TARGET_URL }}
GATEKEEPER_PASSWORD: ${{ secrets.GATEKEEPER_PASSWORD }}
run: npx tsx scripts/smoke.ts
- name: 🔔 Notify
if: always()
run: |
STATUS="${{ job.status }}"
TITLE="💨 ${{ env.PROJECT_NAME }} Nightly Smoke $STATUS"
curl -s -k -X POST "${{ secrets.GOTIFY_URL }}/message?token=${{ secrets.GOTIFY_TOKEN }}" \
-F "title=$TITLE" \
-F "message=Status: $STATUS | URL: ${{ env.TARGET_URL }}" \
-F "priority=5" || true

28
.gitignore vendored
View File

@@ -1,30 +1,14 @@
node_modules
.next
.DS_Store
.pnpm-store
# lighthouse CI
# Lighthouse CI
.lighthouseci/
lighthouserc.cjs
.lighthouserc.json
# Legacy (Directus) cleanup
# Directus
directus/uploads
.next-docker
# Pa11y CI
.pa11yci/
.htmlvalidate-tmp
# Turborepo
.turbo
# Test Outputs
html-errors*.json
reference/
# Database backups
backups/
# Local data / backups
.data/
!directus/extensions/
!directus/schema/
!directus/migrations/

View File

@@ -1,26 +0,0 @@
{
"extends": ["html-validate:recommended", "html-validate:document"],
"rules": {
"require-sri": "off",
"meta-refresh": "off",
"heading-level": "warn",
"no-trailing-whitespace": "off",
"wcag/h37": "warn",
"no-inline-style": "off",
"svg-focusable": "off",
"attribute-boolean-style": "off",
"attr-case": "off",
"void-style": "off",
"no-implicit-button-type": "off",
"unique-landmark": "off",
"long-title": "off",
"valid-id": "off",
"element-required-attributes": "off",
"attribute-empty-style": "off",
"element-permitted-content": "off",
"element-required-content": "off",
"element-permitted-parent": "off",
"no-implicit-close": "off",
"close-order": "off"
}
}

2
.npmrc
View File

@@ -1,2 +0,0 @@
@mintel:registry=https://git.infra.mintel.me/api/packages/mmintel/npm/
//git.infra.mintel.me/api/packages/mmintel/npm/:_authToken=${NPM_TOKEN}

View File

@@ -1,9 +1,5 @@
# Stage 1: Builder
FROM node:20-alpine AS base
RUN apk add --no-cache libc6-compat curl
# Enable pnpm
RUN corepack enable && corepack prepare pnpm@10.3.0 --activate
FROM registry.infra.mintel.me/mintel/nextjs:v1.7.10 AS base
WORKDIR /app
# Arguments for build-time configuration
@@ -12,6 +8,7 @@ ARG NEXT_PUBLIC_TARGET
ARG DIRECTUS_URL
ARG UMAMI_WEBSITE_ID
ARG UMAMI_API_ENDPOINT
ARG NPM_TOKEN
# Environment variables for Next.js build
ENV NEXT_PUBLIC_BASE_URL=$NEXT_PUBLIC_BASE_URL
@@ -28,11 +25,10 @@ COPY pnpm-lock.yaml package.json .npmrc* ./
# Configure private registry and install dependencies
RUN --mount=type=cache,id=pnpm,target=/pnpm/store \
--mount=type=secret,id=NPM_TOKEN \
export NPM_TOKEN=$(cat /run/secrets/NPM_TOKEN) && \
echo "@mintel:registry=https://git.infra.mintel.me/api/packages/mmintel/npm" > .npmrc && \
echo "//git.infra.mintel.me/api/packages/mmintel/npm/:_authToken=\${NPM_TOKEN}" >> .npmrc && \
pnpm store prune && \
pnpm install --no-frozen-lockfile && \
export NPM_TOKEN=$(cat /run/secrets/NPM_TOKEN 2>/dev/null || echo $NPM_TOKEN) && \
echo "@mintel:registry=https://npm.infra.mintel.me" > .npmrc && \
echo "//npm.infra.mintel.me/:_authToken=\${NPM_TOKEN}" >> .npmrc && \
pnpm install --frozen-lockfile && \
rm .npmrc
# Copy source code
@@ -46,37 +42,25 @@ CMD ["pnpm", "dev:local"]
# Build application
# Stage 3: Builder (Production)
FROM base AS builder
# Limit memory to 1GB to prevent ResourceExhausted in combination with worker limits
ENV NODE_OPTIONS="--max-old-space-size=1024"
# Force Turbopack (Rust/Rayon) and Node.js to use strictly 3 threads to avoid starving the Gitea Runner VPS CPU
ENV RAYON_NUM_THREADS=3
ENV UV_THREADPOOL_SIZE=3
RUN pnpm build
# Stage 2: Runner
FROM node:20-alpine AS runner
# Stage 3: Runner
FROM registry.infra.mintel.me/mintel/runtime:v1.7.10 AS runner
WORKDIR /app
# Install dependencies for health checks and system compatibility
RUN apk add --no-cache libc6-compat curl
# Create nextjs user and group
RUN addgroup --system --gid 1001 nodejs && \
adduser --system --uid 1001 nextjs && \
chown -R nextjs:nodejs /app
# Create nextjs user and group (standardized in runtime image but ensuring local ownership)
USER root
RUN chown -R nextjs:nodejs /app
USER nextjs
ENV HOSTNAME="0.0.0.0"
ENV PORT=3000
ENV NODE_ENV=production
# Copy standalone output
COPY --from=builder --chown=nextjs:nodejs /app/.next/standalone ./
# Copy public and static files
# Copy standalone output and static files
COPY --from=builder --chown=nextjs:nodejs /app/public ./public
COPY --from=builder --chown=nextjs:nodejs /app/.next/standalone ./
COPY --from=builder --chown=nextjs:nodejs /app/.next/static ./.next/static
COPY --from=builder --chown=nextjs:nodejs /app/.next/cache ./.next/cache
CMD ["node", "server.js"]

View File

@@ -1,18 +0,0 @@
FROM node:20-alpine
# Install essential build tools if needed (e.g., for node-gyp)
RUN apk add --no-cache libc6-compat python3 make g++
WORKDIR /app
# Enable corepack for pnpm
RUN corepack enable
# Pre-set the pnpm store directory
ENV PNPM_HOME="/pnpm"
ENV PATH="$PNPM_HOME:$PATH"
# Set up pnpm store configuration
RUN pnpm config set store-dir /pnpm/store
EXPOSE 3000

View File

@@ -462,4 +462,3 @@ Proprietary - KLZ Cables
**Status**: ✅ **READY FOR DEPLOYMENT**
**Version**: 1.0.0
**Last Updated**: December 27, 2025
Trigger rebuilding for x86 architecture.

View File

@@ -1,7 +1,7 @@
import { ImageResponse } from 'next/og';
import { getPageBySlug } from '@/lib/pages';
import { OGImageTemplate } from '@/components/OGImageTemplate';
import { getOgFonts, OG_IMAGE_SIZE, getOgAssets } from '@/lib/og-helper';
import { getOgFonts, OG_IMAGE_SIZE } from '@/lib/og-helper';
export const size = OG_IMAGE_SIZE;
export const contentType = 'image/png';
@@ -20,12 +20,9 @@ export default async function Image({
}
const fonts = await getOgFonts();
const { logo, bgImage } = getOgAssets();
return new ImageResponse(
<OGImageTemplate
image={bgImage}
logo={logo}
title={pageData.frontmatter.title}
description={pageData.frontmatter.excerpt}
label="Information"

View File

@@ -1,10 +1,10 @@
import { notFound, redirect, permanentRedirect } from 'next/navigation';
import { notFound } from 'next/navigation';
import { MDXRemote } from 'next-mdx-remote/rsc';
import { Container, Badge, Heading } from '@/components/ui';
import { getTranslations, setRequestLocale } from 'next-intl/server';
import { Metadata } from 'next';
import { getPageBySlug, getAllPages } from '@/lib/pages';
import { mapSlugToFileSlug, mapFileSlugToTranslated } from '@/lib/slugs';
import MDXContent from '@/components/MDXContent';
import { mdxComponents } from '@/components/blog/MDXComponents';
import { SITE_URL } from '@/lib/schema';
import TrackedLink from '@/components/analytics/TrackedLink';
@@ -15,34 +15,41 @@ interface PageProps {
}>;
}
export async function generateStaticParams() {
const locales = ['en', 'de'];
const params = [];
for (const locale of locales) {
const pages = await getAllPages(locale);
for (const page of pages) {
params.push({ locale, slug: page.slug });
}
}
return params;
}
export async function generateMetadata({ params }: PageProps): Promise<Metadata> {
const { locale, slug } = await params;
const pageData = await getPageBySlug(slug, locale);
if (!pageData) return {};
const fileSlug = await mapSlugToFileSlug(pageData.slug || slug, locale);
const deSlug = await mapFileSlugToTranslated(fileSlug, 'de');
const enSlug = await mapFileSlugToTranslated(fileSlug, 'en');
// Determine correct localized slug based on current locale
const currentLocaleSlug = locale === 'de' ? deSlug : enSlug;
return {
title: pageData.frontmatter.title,
description: pageData.frontmatter.excerpt || '',
alternates: {
canonical: `${SITE_URL}/${locale}/${currentLocaleSlug}`,
canonical: `${SITE_URL}/${locale}/${slug}`,
languages: {
de: `${SITE_URL}/de/${deSlug}`,
en: `${SITE_URL}/en/${enSlug}`,
'x-default': `${SITE_URL}/en/${enSlug}`,
de: `${SITE_URL}/de/${slug}`,
en: `${SITE_URL}/en/${slug}`,
'x-default': `${SITE_URL}/en/${slug}`,
},
},
openGraph: {
title: `${pageData.frontmatter.title} | KLZ Cables`,
description: pageData.frontmatter.excerpt || '',
url: `${SITE_URL}/${locale}/${currentLocaleSlug}`,
url: `${SITE_URL}/${locale}/${slug}`,
},
twitter: {
card: 'summary_large_image',
@@ -62,32 +69,6 @@ export default async function StandardPage({ params }: PageProps) {
notFound();
}
// Handle explicit CMS redirects (e.g. /en/terms -> /de/terms)
if (pageData.redirectUrl) {
if (pageData.redirectPermanent) {
permanentRedirect(pageData.redirectUrl);
} else {
redirect(pageData.redirectUrl);
}
}
// Redirect if accessed via a different locale's slug
const fileSlug = await mapSlugToFileSlug(pageData.slug || slug, locale);
const correctSlug = await mapFileSlugToTranslated(fileSlug, locale);
if (correctSlug && correctSlug !== slug) {
redirect(`/${locale}/${correctSlug}`);
}
// Full-bleed pages render blocks edge-to-edge without the generic article wrapper
if (pageData.frontmatter.layout === 'fullBleed') {
return (
<div className="flex flex-col min-h-screen">
<MDXContent data={pageData.content} className="" />
</div>
);
}
// Default article layout with hero, content, and support CTA
return (
<div className="flex flex-col min-h-screen bg-white">
{/* Hero Section */}
@@ -96,7 +77,7 @@ export default async function StandardPage({ params }: PageProps) {
<div className="absolute top-0 left-0 w-full h-full bg-[radial-gradient(circle_at_center,_var(--tw-gradient-stops))] from-accent via-transparent to-transparent" />
</div>
<Container className="relative z-10">
<div className="max-w-4xl">
<div className="max-w-4xl animate-slide-up">
<Badge variant="accent" className="mb-4 md:mb-6">
{t('badge')}
</Badge>
@@ -112,7 +93,7 @@ export default async function StandardPage({ params }: PageProps) {
<div className="max-w-4xl mx-auto">
{/* Excerpt/Lead paragraph if available */}
{pageData.frontmatter.excerpt && (
<div className="mb-16">
<div className="mb-16 animate-slight-fade-in-from-bottom">
<p className="text-xl md:text-2xl text-text-primary leading-relaxed font-medium border-l-4 border-primary pl-8 py-2 italic">
{pageData.frontmatter.excerpt}
</p>
@@ -120,8 +101,8 @@ export default async function StandardPage({ params }: PageProps) {
)}
{/* Main content with shared blog components */}
<div className="prose prose-lg md:prose-xl max-w-none prose-headings:font-bold prose-headings:text-text-primary prose-p:text-text-secondary prose-p:leading-relaxed prose-a:text-primary prose-a:no-underline hover:prose-a:underline prose-img:rounded-2xl prose-img:shadow-2xl prose-blockquote:border-primary prose-blockquote:bg-primary/5 prose-blockquote:rounded-r-2xl prose-strong:text-primary">
<MDXContent data={pageData.content} />
<div className="prose prose-lg md:prose-xl max-w-none prose-headings:font-bold prose-headings:text-text-primary prose-p:text-text-secondary prose-p:leading-relaxed prose-a:text-primary prose-a:no-underline hover:prose-a:underline prose-img:rounded-2xl prose-img:shadow-2xl prose-blockquote:border-primary prose-blockquote:bg-primary/5 prose-blockquote:rounded-r-2xl prose-strong:text-primary animate-slight-fade-in-from-bottom">
<MDXRemote source={pageData.content} components={mdxComponents} />
</div>
{/* Support Section */}
@@ -131,7 +112,7 @@ export default async function StandardPage({ params }: PageProps) {
<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'}`}
href={`/${locale}/contact`}
className="inline-flex items-center px-8 py-4 bg-accent text-primary-dark font-bold rounded-full hover:bg-white transition-all duration-300 group/link"
eventProperties={{
location: 'generic_page_support_cta',

View File

@@ -1,5 +1,5 @@
import { ImageResponse } from 'next/og';
import { getProductBySlug } from '@/lib/products';
import { getProductBySlug } from '@/lib/mdx';
import { getTranslations } from 'next-intl/server';
import { OGImageTemplate } from '@/components/OGImageTemplate';
import { NextRequest } from 'next/server';

View File

@@ -1,27 +1,13 @@
import { ImageResponse } from 'next/og';
import { getPostBySlug } from '@/lib/blog';
import { OGImageTemplate } from '@/components/OGImageTemplate';
import { getOgFonts, OG_IMAGE_SIZE, getOgAssets } from '@/lib/og-helper';
import { getOgFonts, OG_IMAGE_SIZE } from '@/lib/og-helper';
import { SITE_URL } from '@/lib/schema';
export const size = OG_IMAGE_SIZE;
export const contentType = 'image/png';
export const runtime = 'nodejs';
async function fetchImageAsBase64(url: string) {
try {
const res = await fetch(url);
if (!res.ok) return undefined;
const arrayBuffer = await res.arrayBuffer();
const buffer = Buffer.from(arrayBuffer);
const contentType = res.headers.get('content-type') || 'image/jpeg';
return `data:${contentType};base64,${buffer.toString('base64')}`;
} catch (error) {
console.error('Failed to fetch OG image:', url, error);
return undefined;
}
}
export default async function Image({
params,
}: {
@@ -35,7 +21,6 @@ export default async function Image({
}
const fonts = await getOgFonts();
const { logo, bgImage } = getOgAssets();
// We don't have request.url here, but we can assume the domain from SITE_URL or config
// For local images during dev, relative paths in <img> might not work in Satori
@@ -47,20 +32,12 @@ export default async function Image({
: `${SITE_URL}${post.frontmatter.featuredImage}`
: undefined;
// Fetch image explicitly and convert to base64 because Satori sometimes struggles
// fetching remote URLs directly inside ImageResponse correctly in various environments.
let base64Image: string | undefined = undefined;
if (featuredImage) {
base64Image = await fetchImageAsBase64(featuredImage);
}
return new ImageResponse(
<OGImageTemplate
logo={logo}
title={post.frontmatter.title}
description={post.frontmatter.excerpt}
label={post.frontmatter.category || 'Blog'}
image={base64Image || featuredImage || bgImage}
image={featuredImage}
/>,
{
...OG_IMAGE_SIZE,

View File

@@ -1,26 +1,18 @@
import { notFound, redirect } from 'next/navigation';
import { notFound } from 'next/navigation';
import JsonLd from '@/components/JsonLd';
import { SITE_URL } from '@/lib/schema';
import {
getPostBySlug,
getAdjacentPosts,
getReadingTime,
extractLexicalHeadings,
getPostSlugs,
} from '@/lib/blog';
import { MDXRemote } from 'next-mdx-remote/rsc';
import { getPostBySlug, getAdjacentPosts, getReadingTime, getHeadings } from '@/lib/blog';
import { Metadata } from 'next';
import Link from 'next/link';
import Image from 'next/image';
import PostNavigation from '@/components/blog/PostNavigation';
import PowerCTA from '@/components/blog/PowerCTA';
import TableOfContents from '@/components/blog/TableOfContents';
import { mdxComponents } from '@/components/blog/MDXComponents';
import { Heading } from '@/components/ui';
import { setRequestLocale } from 'next-intl/server';
import BlogEngagementTracker from '@/components/analytics/BlogEngagementTracker';
// Payload CMS Imports
import MDXContent from '@/components/MDXContent';
interface BlogPostProps {
params: Promise<{
locale: string;
@@ -34,21 +26,12 @@ export async function generateMetadata({ params }: BlogPostProps): Promise<Metad
if (!post) return {};
const slugs = await getPostSlugs(slug, locale);
const deSlug = slugs?.de || post.slug;
const enSlug = slugs?.en || post.slug;
const description = post.frontmatter.excerpt || '';
return {
title: post.frontmatter.title,
description: description,
alternates: {
canonical: `${SITE_URL}/${locale}/blog/${post.slug}`,
languages: {
de: `${SITE_URL}/de/blog/${deSlug}`,
en: `${SITE_URL}/en/blog/${enSlug}`,
'x-default': `${SITE_URL}/en/blog/${enSlug}`,
},
canonical: `${SITE_URL}/${locale}/blog/${slug}`,
},
openGraph: {
title: `${post.frontmatter.title} | KLZ Cables`,
@@ -56,7 +39,7 @@ export async function generateMetadata({ params }: BlogPostProps): Promise<Metad
type: 'article',
publishedTime: post.frontmatter.date,
authors: ['KLZ Cables'],
url: `${SITE_URL}/${locale}/blog/${post.slug}`,
url: `${SITE_URL}/${locale}/blog/${slug}`,
},
twitter: {
card: 'summary_large_image',
@@ -70,25 +53,13 @@ export default async function BlogPost({ params }: BlogPostProps) {
const { locale, slug } = await params;
setRequestLocale(locale);
const post = await getPostBySlug(slug, locale);
const { prev, next } = await getAdjacentPosts(slug, locale);
if (!post) {
notFound();
}
// If the user accessed this post using a slug from a different locale
// (e.g. via the generic language switcher), redirect them to the correct localized slug URL
if (post.slug && post.slug !== slug) {
redirect(`/${locale}/blog/${post.slug}`);
}
const { prev, next, isPrevRandom, isNextRandom } = await getAdjacentPosts(post.slug, locale);
// Convert Lexical content into a plain string to estimate reading time roughly
// Extract headings for TOC
const headings = extractLexicalHeadings(post.content?.root || post.content);
// Convert Lexical content into a plain string to estimate reading time roughly
const rawTextContent = JSON.stringify(post.content);
const headings = getHeadings(post.content);
return (
<article className="bg-white min-h-screen font-sans selection:bg-primary/10 selection:text-primary">
@@ -96,27 +67,16 @@ export default async function BlogPost({ params }: BlogPostProps) {
title={post.frontmatter.title}
slug={slug}
category={post.frontmatter.category}
readingTime={getReadingTime(rawTextContent)}
readingTime={getReadingTime(post.content)}
/>
{/* Featured Image Header */}
{post.frontmatter.featuredImage ? (
<div className="relative w-full h-[70vh] min-h-[500px] overflow-hidden group">
<div className="absolute inset-0 transition-transform duration-[3s] ease-out scale-110 group-hover:scale-100">
<Image
src={post.frontmatter.featuredImage.split('?')[0]}
alt={post.frontmatter.title}
fill
priority
quality={100}
className="object-cover"
sizes="100vw"
style={{
objectPosition: `${post.frontmatter.focalX ?? 50}% ${post.frontmatter.focalY ?? 50}%`,
}}
/>
</div>
<div className="absolute inset-0 bg-gradient-to-t from-neutral-dark/90 via-neutral-dark/40 to-transparent" />
<div
className="absolute inset-0 bg-cover bg-center transition-transform duration-[3s] ease-out scale-110 group-hover:scale-100"
style={{ backgroundImage: `url(${post.frontmatter.featuredImage})` }}
/>
<div className="absolute inset-0 bg-gradient-to-t from-neutral-dark via-neutral-dark/40 to-transparent" />
{/* Title overlay on image */}
<div className="absolute inset-0 flex flex-col justify-end pb-16 md:pb-24">
@@ -124,36 +84,27 @@ export default async function BlogPost({ params }: BlogPostProps) {
<div className="max-w-4xl">
{post.frontmatter.category && (
<div className="overflow-hidden mb-6">
<span className="inline-block px-4 py-1.5 bg-accent text-neutral-dark text-xs font-bold uppercase tracking-[0.2em] rounded-sm">
<span className="inline-block px-4 py-1.5 bg-accent text-neutral-dark text-xs font-bold uppercase tracking-[0.2em] rounded-sm animate-slight-fade-in-from-bottom">
{post.frontmatter.category}
</span>
</div>
)}
<Heading level={1} className="text-white mb-8 drop-shadow-2xl">
<Heading
level={1}
className="text-white mb-8 drop-shadow-2xl animate-slight-fade-in-from-bottom [animation-delay:200ms]"
>
{post.frontmatter.title}
</Heading>
<div className="flex flex-wrap items-center gap-6 text-white/80 text-sm md:text-base font-medium">
<time dateTime={post.frontmatter.date} suppressHydrationWarning>
{new Date(post.frontmatter.date).toLocaleDateString(
locale === 'en' ? 'en-US' : 'de-DE',
{
year: 'numeric',
month: 'long',
day: 'numeric',
},
)}
<div className="flex flex-wrap items-center gap-6 text-white/80 text-sm md:text-base font-medium animate-slight-fade-in-from-bottom [animation-delay:400ms]">
<time dateTime={post.frontmatter.date}>
{new Date(post.frontmatter.date).toLocaleDateString(locale, {
year: 'numeric',
month: 'long',
day: 'numeric',
})}
</time>
<span className="w-1 h-1 bg-white/30 rounded-full" />
<span>{getReadingTime(rawTextContent)} min read</span>
{(new Date(post.frontmatter.date) > new Date() ||
post.frontmatter.public === false) && (
<>
<span className="w-1 h-1 bg-white/30 rounded-full" />
<span className="px-2 py-0.5 border border-white/40 text-white/80 rounded uppercase tracking-widest text-[10px] md:text-xs font-bold">
Draft Preview
</span>
</>
)}
<span>{getReadingTime(post.content)} min read</span>
</div>
</div>
</div>
@@ -172,28 +123,16 @@ export default async function BlogPost({ params }: BlogPostProps) {
<Heading level={1} className="mb-8">
{post.frontmatter.title}
</Heading>
<div className="flex items-center gap-6 text-text-primary/80 font-medium">
<time dateTime={post.frontmatter.date} suppressHydrationWarning>
{new Date(post.frontmatter.date).toLocaleDateString(
locale === 'en' ? 'en-US' : 'de-DE',
{
year: 'numeric',
month: 'long',
day: 'numeric',
},
)}
<div className="flex items-center gap-6 text-text-secondary font-medium">
<time dateTime={post.frontmatter.date}>
{new Date(post.frontmatter.date).toLocaleDateString(locale, {
year: 'numeric',
month: 'long',
day: 'numeric',
})}
</time>
<span className="w-1 h-1 bg-neutral-400 rounded-full" />
<span>{getReadingTime(rawTextContent)} min read</span>
{(new Date(post.frontmatter.date) > new Date() ||
post.frontmatter.public === false) && (
<>
<span className="w-1 h-1 bg-neutral-300 rounded-full" />
<span className="px-2 py-0.5 border border-orange-500/50 text-orange-600 rounded uppercase tracking-widest text-[10px] md:text-xs font-bold">
Draft Preview
</span>
</>
)}
<span className="w-1 h-1 bg-neutral-300 rounded-full" />
<span>{getReadingTime(post.content)} min read</span>
</div>
</div>
</header>
@@ -206,16 +145,16 @@ export default async function BlogPost({ params }: BlogPostProps) {
<div className="sticky-narrative-content">
{/* Excerpt/Lead paragraph if available */}
{post.frontmatter.excerpt && (
<div className="mb-16">
<div className="mb-16 animate-slight-fade-in-from-bottom [animation-delay:600ms]">
<p className="text-xl md:text-2xl text-text-primary leading-relaxed font-medium border-l-4 border-primary pl-8 py-2 italic">
{post.frontmatter.excerpt}
</p>
</div>
)}
{/* Main content with enhanced styling rendering Payload Lexical */}
<div className="prose prose-lg md:prose-xl max-w-none prose-headings:font-bold prose-headings:text-text-primary prose-p:text-text-secondary prose-p:leading-relaxed prose-a:text-primary prose-a:no-underline hover:prose-a:underline prose-img:rounded-2xl prose-img:shadow-2xl prose-blockquote:border-primary prose-blockquote:bg-primary/5 prose-blockquote:rounded-r-2xl prose-strong:text-primary">
<MDXContent data={post.content} />
{/* Main content with enhanced styling */}
<div className="prose prose-lg md:prose-xl max-w-none prose-headings:font-bold prose-headings:text-text-primary prose-p:text-text-secondary prose-p:leading-relaxed prose-a:text-primary prose-a:no-underline hover:prose-a:underline prose-img:rounded-2xl prose-img:shadow-2xl prose-blockquote:border-primary prose-blockquote:bg-primary/5 prose-blockquote:rounded-r-2xl prose-strong:text-primary animate-slight-fade-in-from-bottom [animation-delay:800ms]">
<MDXRemote source={post.content} components={mdxComponents} />
</div>
{/* Power CTA */}
@@ -225,13 +164,7 @@ export default async function BlogPost({ params }: BlogPostProps) {
{/* Post Navigation */}
<div className="mt-16">
<PostNavigation
prev={prev}
next={next}
isPrevRandom={isPrevRandom}
isNextRandom={isNextRandom}
locale={locale}
/>
<PostNavigation prev={prev} next={next} locale={locale} />
</div>
{/* Back to blog link */}
@@ -258,9 +191,9 @@ export default async function BlogPost({ params }: BlogPostProps) {
</div>
</div>
{/* Right Column: Sticky Sidebar - TOC */}
{/* Right Column: Sticky Sidebar */}
<aside className="sticky-narrative-sidebar hidden lg:block">
<div className="space-y-12 lg:sticky lg:top-32">
<div className="space-y-12">
<TableOfContents headings={headings} locale={locale} />
</div>
</aside>
@@ -300,8 +233,8 @@ export default async function BlogPost({ params }: BlogPostProps) {
'@id': `${SITE_URL}/${locale}/blog/${slug}`,
},
articleSection: post.frontmatter.category,
wordCount: rawTextContent.split(/\s+/).length,
timeRequired: `PT${getReadingTime(rawTextContent)}M`,
wordCount: post.content.split(/\s+/).length,
timeRequired: `PT${getReadingTime(post.content)}M`,
} as any
}
/>

View File

@@ -1,7 +1,7 @@
import { ImageResponse } from 'next/og';
import { getTranslations } from 'next-intl/server';
import { OGImageTemplate } from '@/components/OGImageTemplate';
import { getOgFonts, OG_IMAGE_SIZE, getOgAssets } from '@/lib/og-helper';
import { getOgFonts, OG_IMAGE_SIZE } from '@/lib/og-helper';
export const size = OG_IMAGE_SIZE;
export const contentType = 'image/png';
@@ -11,16 +11,9 @@ export default async function Image({ params }: { params: Promise<{ locale: stri
const { locale } = await params;
const t = await getTranslations({ locale, namespace: 'Blog.meta' });
const fonts = await getOgFonts();
const { logo, bgImage } = getOgAssets();
return new ImageResponse(
<OGImageTemplate
image={bgImage}
logo={logo}
title={t('title')}
description={t('description')}
label="Blog"
/>,
<OGImageTemplate title={t('title')} description={t('description')} label="Blog" />,
{
...OG_IMAGE_SIZE,
fonts,

View File

@@ -3,16 +3,14 @@ import Image from 'next/image';
import { getAllPosts } from '@/lib/blog';
import { Section, Container, Heading, Card, Badge, Button } from '@/components/ui';
import Reveal from '@/components/Reveal';
import { Metadata } from 'next';
import { getTranslations, setRequestLocale } from 'next-intl/server';
import { SITE_URL } from '@/lib/schema';
import { BlogPaginationKeyboardObserver } from '@/components/blog/BlogPaginationKeyboardObserver';
interface BlogIndexProps {
params: Promise<{
locale: string;
}>;
searchParams: Promise<{ [key: string]: string | string[] | undefined }>;
}
export async function generateMetadata({ params }: BlogIndexProps) {
@@ -42,10 +40,8 @@ export async function generateMetadata({ params }: BlogIndexProps) {
};
}
export default async function BlogIndex({ params, searchParams }: BlogIndexProps) {
export default async function BlogIndex({ params }: BlogIndexProps) {
const { locale } = await params;
const resolvedSearchParams = await searchParams;
const categoryParam = resolvedSearchParams.category as string | undefined;
setRequestLocale(locale);
const t = await getTranslations('Blog');
const posts = await getAllPosts(locale);
@@ -55,14 +51,8 @@ export default async function BlogIndex({ params, searchParams }: BlogIndexProps
(a, b) => new Date(b.frontmatter.date).getTime() - new Date(a.frontmatter.date).getTime(),
);
const filteredPosts = categoryParam
? sortedPosts.filter(
(post) => post.frontmatter.category?.toLowerCase() === categoryParam.toLowerCase(),
)
: sortedPosts;
const featuredPost = filteredPosts[0];
const remainingPosts = filteredPosts.slice(1);
const featuredPost = sortedPosts[0];
const remainingPosts = sortedPosts.slice(1);
return (
<div className="bg-neutral-light min-h-screen">
@@ -72,38 +62,28 @@ export default async function BlogIndex({ params, searchParams }: BlogIndexProps
{featuredPost && featuredPost.frontmatter.featuredImage && (
<>
<Image
src={featuredPost.frontmatter.featuredImage.split('?')[0]}
src={featuredPost.frontmatter.featuredImage}
alt={featuredPost.frontmatter.title}
fill
className="absolute inset-0 w-full h-full object-cover opacity-40 md:opacity-60"
style={{
objectPosition: `${featuredPost.frontmatter.focalX ?? 50}% ${featuredPost.frontmatter.focalY ?? 50}%`,
}}
className="absolute inset-0 w-full h-full object-cover scale-105 animate-slow-zoom opacity-40 md:opacity-60"
sizes="100vw"
priority
/>
<div className="absolute inset-0 bg-neutral-dark/20" />
<div className="absolute inset-0 image-overlay-gradient" />
</>
)}
<Container className="relative z-10">
<div className="max-w-4xl">
<div className="flex flex-wrap items-center gap-3 mb-4 md:mb-6">
<Badge variant="saturated">{t('featuredPost')}</Badge>
{featuredPost &&
(new Date(featuredPost.frontmatter.date) > new Date() ||
featuredPost.frontmatter.public === false) && (
<span className="px-2 py-0.5 border border-white/40 text-white/80 rounded uppercase tracking-widest text-[10px] md:text-xs font-bold">
Draft Preview
</span>
)}
</div>
<div className="max-w-4xl animate-slide-up">
<Badge variant="saturated" className="mb-4 md:mb-6">
{t('featuredPost')}
</Badge>
{featuredPost && (
<>
<Heading level={1} className="text-white mb-4 md:mb-8">
{featuredPost.frontmatter.title}
</Heading>
<p className="text-base md:text-xl text-white/80 mb-6 md:mb-10 line-clamp-3 md:line-clamp-4 max-w-2xl">
<p className="text-base md:text-xl text-white/80 mb-6 md:mb-10 line-clamp-2 md:line-clamp-2 max-w-2xl">
{featuredPost.frontmatter.excerpt}
</p>
<Button
@@ -132,116 +112,85 @@ export default async function BlogIndex({ params, searchParams }: BlogIndexProps
{t('allArticles')}
</Heading>
<div className="flex flex-wrap gap-2 md:gap-4">
<Link href={`/${locale}/blog`}>
<Badge
variant={!categoryParam ? 'primary' : 'neutral'}
className="cursor-pointer hover:bg-primary hover:text-white transition-colors touch-target px-3 md:px-4"
>
{t('categories.all')}
</Badge>
</Link>
<Link href={`/${locale}/blog?category=industry`}>
<Badge
variant={categoryParam === 'industry' ? 'primary' : 'neutral'}
className="cursor-pointer hover:bg-primary hover:text-white transition-colors touch-target px-3 md:px-4"
>
{t('categories.industry')}
</Badge>
</Link>
<Link href={`/${locale}/blog?category=technical`}>
<Badge
variant={categoryParam === 'technical' ? 'primary' : 'neutral'}
className="cursor-pointer hover:bg-primary hover:text-white transition-colors touch-target px-3 md:px-4"
>
{t('categories.technical')}
</Badge>
</Link>
<Link href={`/${locale}/blog?category=sustainability`}>
<Badge
variant={categoryParam === 'sustainability' ? 'primary' : 'neutral'}
className="cursor-pointer hover:bg-primary hover:text-white transition-colors touch-target px-3 md:px-4"
>
{t('categories.sustainability')}
</Badge>
</Link>
{/* Category filters could go here */}
<Badge
variant="primary"
className="cursor-pointer hover:bg-primary hover:text-white transition-colors touch-target px-3 md:px-4"
>
{t('categories.all')}
</Badge>
<Badge
variant="neutral"
className="cursor-pointer hover:bg-primary hover:text-white transition-colors touch-target px-3 md:px-4"
>
{t('categories.industry')}
</Badge>
<Badge
variant="neutral"
className="cursor-pointer hover:bg-primary hover:text-white transition-colors touch-target px-3 md:px-4"
>
{t('categories.technical')}
</Badge>
<Badge
variant="neutral"
className="cursor-pointer hover:bg-primary hover:text-white transition-colors touch-target px-3 md:px-4"
>
{t('categories.sustainability')}
</Badge>
</div>
</div>
</Reveal>
{/* Grid for remaining posts */}
<div className="grid grid-cols-1 gap-12">
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6 md:gap-12">
{remainingPosts.map((post, idx) => (
<Reveal key={post.slug} delay={idx * 50}>
<Link
href={`/${locale}/blog/${post.slug}`}
className="group block focus:outline-none"
>
<Reveal key={post.slug} delay={idx * 100}>
<Link href={`/${locale}/blog/${post.slug}`} className="group block">
<Card
tag="article"
className="relative flex flex-col justify-end border-none shadow-lg hover:shadow-2xl transition-all duration-500 rounded-3xl overflow-hidden min-h-[450px] md:min-h-[500px]"
className="h-full flex flex-col border-none shadow-lg hover:shadow-2xl transition-all duration-500 rounded-2xl md:rounded-3xl overflow-hidden"
>
{post.frontmatter.featuredImage && (
<>
<div className="relative h-48 md:h-72 overflow-hidden">
<Image
src={post.frontmatter.featuredImage.split('?')[0]}
src={post.frontmatter.featuredImage}
alt={post.frontmatter.title}
fill
className="absolute inset-0 w-full h-full object-cover transition-transform duration-1000 group-hover:scale-105"
style={{
objectPosition: `${post.frontmatter.focalX ?? 50}% ${post.frontmatter.focalY ?? 50}%`,
}}
sizes="(max-width: 768px) 100vw, 100vw"
loading="lazy"
className="w-full h-full object-cover transition-transform duration-1000 group-hover:scale-110"
sizes="(max-width: 768px) 100vw, (max-width: 1024px) 50vw, 33vw"
/>
<div className="absolute inset-0 bg-neutral-dark/10 group-hover:bg-neutral-dark/5 transition-colors duration-500" />
</>
)}
<div className="relative z-10 w-full p-6 md:p-10 bg-gradient-to-t from-neutral-dark/95 via-neutral-dark/70 to-transparent flex flex-col pt-40">
<div className="flex flex-wrap items-center gap-4 mb-4">
<div className="absolute inset-0 image-overlay-gradient opacity-0 group-hover:opacity-100 transition-opacity duration-500" />
{post.frontmatter.category && (
<Badge variant="accent" className="shadow-md">
<Badge
variant="accent"
className="absolute top-3 left-3 md:top-6 md:left-6 shadow-lg"
>
{post.frontmatter.category}
</Badge>
)}
{(new Date(post.frontmatter.date) > new Date() ||
post.frontmatter.public === false) && (
<span className="px-2 py-0.5 border border-white/40 text-white/90 rounded uppercase tracking-widest text-[10px] md:text-xs font-bold bg-neutral-dark/40 shadow-sm">
Draft Preview
</span>
)}
</div>
<div className="flex items-center gap-3 text-xs md:text-sm font-bold text-white/80 mb-3 tracking-widest uppercase">
<time dateTime={post.frontmatter.date} suppressHydrationWarning>
{new Date(post.frontmatter.date).toLocaleDateString(
locale === 'en' ? 'en-US' : 'de-DE',
{
year: 'numeric',
month: 'long',
day: 'numeric',
},
)}
</time>
)}
<div className="p-5 md:p-10 flex flex-col flex-1">
<div className="text-[10px] md:text-sm font-bold text-accent-dark mb-2 md:mb-4 tracking-widest uppercase">
{new Date(post.frontmatter.date).toLocaleDateString(locale, {
year: 'numeric',
month: 'long',
day: 'numeric',
})}
</div>
<h3 className="text-xl md:text-3xl font-bold text-white mb-4 group-hover:text-accent transition-colors drop-shadow-md leading-tight max-w-4xl">
<h3 className="text-lg md:text-2xl font-bold text-primary mb-3 md:mb-6 group-hover:text-accent-dark transition-colors line-clamp-2 leading-tight">
{post.frontmatter.title}
</h3>
{post.frontmatter.excerpt && (
<p className="text-white/90 text-sm md:text-lg line-clamp-3 mb-6 max-w-4xl drop-shadow-sm leading-relaxed">
{post.frontmatter.excerpt}
</p>
)}
<div className="mt-auto flex items-center justify-between border-t border-white/20 pt-6">
<span className="text-accent text-sm md:text-base font-extrabold group-hover:text-white transition-colors">
<p className="text-text-secondary text-sm md:text-lg line-clamp-2 md:line-clamp-3 mb-4 md:mb-8 leading-relaxed">
{post.frontmatter.excerpt}
</p>
<div className="mt-auto pt-4 md:pt-8 border-t border-neutral-medium flex items-center justify-between">
<span className="text-saturated text-sm md:text-base font-extrabold group-hover:text-accent-dark transition-colors">
{t('readMore')}
</span>
<div className="w-10 h-10 rounded-full bg-white/10 flex items-center justify-center text-accent group-hover:bg-accent group-hover:text-primary-dark transition-all duration-300 backdrop-blur-sm border border-white/20">
<div className="w-8 h-8 md:w-10 md:h-10 rounded-full bg-primary-light flex items-center justify-center text-saturated group-hover:bg-accent group-hover:text-primary-dark transition-all duration-300">
<svg
className="w-5 h-5 transition-transform group-hover:translate-x-1"
className="w-4 h-4 md:w-5 md:h-5 transition-transform group-hover:translate-x-1"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
@@ -262,47 +211,21 @@ export default async function BlogIndex({ params, searchParams }: BlogIndexProps
))}
</div>
{/* Pagination */}
{/* Pagination Placeholder */}
<div className="mt-12 md:mt-24 flex justify-center gap-2 md:gap-4">
<Button
href="#"
variant="outline"
size="sm"
className="md:h-11 md:px-6 md:text-base pointer-events-none opacity-50"
aria-disabled="true"
aria-keyshortcuts="ArrowLeft"
tabIndex={-1}
>
<Button variant="outline" size="sm" className="md:h-11 md:px-6 md:text-base" disabled>
{t('prev')}
</Button>
<Button
href={`/${locale}/blog?page=1${categoryParam ? `&category=${categoryParam}` : ''}`}
variant="primary"
size="sm"
className="md:h-11 md:px-6 md:text-base"
aria-current="page"
>
<Button variant="primary" size="sm" className="md:h-11 md:px-6 md:text-base">
1
</Button>
<Button
href={`/${locale}/blog?page=2${categoryParam ? `&category=${categoryParam}` : ''}`}
variant="outline"
size="sm"
className="md:h-11 md:px-6 md:text-base"
>
<Button variant="outline" size="sm" className="md:h-11 md:px-6 md:text-base">
2
</Button>
<Button
href={`/${locale}/blog?page=2${categoryParam ? `&category=${categoryParam}` : ''}`}
variant="outline"
size="sm"
className="md:h-11 md:px-6 md:text-base"
aria-keyshortcuts="ArrowRight"
>
<Button variant="outline" size="sm" className="md:h-11 md:px-6 md:text-base">
{t('next')}
</Button>
</div>
<BlogPaginationKeyboardObserver currentPage={1} totalPages={2} locale={locale} />
</Container>
</Section>
</div>

View File

@@ -1,7 +1,7 @@
import { ImageResponse } from 'next/og';
import { getTranslations } from 'next-intl/server';
import { OGImageTemplate } from '@/components/OGImageTemplate';
import { getOgFonts, OG_IMAGE_SIZE, getOgAssets } from '@/lib/og-helper';
import { getOgFonts, OG_IMAGE_SIZE } from '@/lib/og-helper';
export const size = OG_IMAGE_SIZE;
export const contentType = 'image/png';
@@ -11,19 +11,12 @@ export default async function Image({ params }: { params: Promise<{ locale: stri
const { locale } = await params;
const t = await getTranslations({ locale, namespace: 'Contact' });
const fonts = await getOgFonts();
const { logo, bgImage } = getOgAssets();
const title = t('meta.title') || t('title');
const description = t('meta.description') || t('subtitle');
return new ImageResponse(
<OGImageTemplate
image={bgImage}
logo={logo}
title={title}
description={description}
label="Contact"
/>,
<OGImageTemplate title={title} description={description} label="Contact" />,
{
...OG_IMAGE_SIZE,
fonts,

View File

@@ -8,7 +8,6 @@ import { SITE_URL } from '@/lib/schema';
import { getOGImageMetadata } from '@/lib/metadata';
import { Suspense } from 'react';
import ContactMap from '@/components/ContactMap';
import ObfuscatedEmail from '@/components/ObfuscatedEmail';
interface ContactPageProps {
params: Promise<{
@@ -25,9 +24,9 @@ export async function generateMetadata({ params }: ContactPageProps): Promise<Me
title,
description,
alternates: {
canonical: `${SITE_URL}/${locale}/${locale === 'de' ? 'kontakt' : 'contact'}`,
canonical: `${SITE_URL}/${locale}/contact`,
languages: {
de: `${SITE_URL}/de/kontakt`,
de: `${SITE_URL}/de/contact`,
en: `${SITE_URL}/en/contact`,
'x-default': `${SITE_URL}/en/contact`,
},
@@ -35,7 +34,7 @@ export async function generateMetadata({ params }: ContactPageProps): Promise<Me
openGraph: {
title: `${title} | KLZ Cables`,
description,
url: `${SITE_URL}/${locale}/${locale === 'de' ? 'kontakt' : 'contact'}`,
url: `${SITE_URL}/${locale}/contact`,
siteName: 'KLZ Cables',
locale: `${locale.toUpperCase()}_DE`,
type: 'website',
@@ -60,21 +59,6 @@ export default async function ContactPage({ params }: ContactPageProps) {
const { locale } = await params;
setRequestLocale(locale);
const t = await getTranslations({ locale, namespace: 'Contact' });
// Get translated slug to redirect if user used incorrect static slug
const { headers } = await import('next/headers');
const headersList = await headers();
const urlPath = headersList.get('x-invoke-path') || '';
const currentSlug = urlPath.split('/').pop();
if (currentSlug) {
const contactSlugDe = locale === 'de' ? 'kontakt' : 'contact';
if (currentSlug !== contactSlugDe && (currentSlug === 'kontakt' || currentSlug === 'contact')) {
const { redirect } = await import('next/navigation');
redirect(`/${locale}/${contactSlugDe}`);
}
}
return (
<div className="flex flex-col min-h-screen bg-neutral-light">
<JsonLd
@@ -205,10 +189,12 @@ export default async function ContactPage({ params }: ContactPageProps) {
<h4 className="text-base md:text-xl font-bold text-primary mb-1 md:mb-2">
{t('info.email')}
</h4>
<ObfuscatedEmail
email="info@klz-cables.com"
<a
href="mailto:info@klz-cables.com"
className="text-sm md:text-lg text-text-secondary hover:text-primary transition-colors font-medium touch-target"
/>
>
info@klz-cables.com
</a>
</div>
</div>
</address>

View File

@@ -16,18 +16,12 @@ export default function Error({
const t = useTranslations('Error');
useEffect(() => {
// Treat "Failed to find Server Action" as a deployment sync issue and reload
if (error?.message?.includes('Failed to find Server Action')) {
window.location.reload();
return;
}
const services = getAppServices();
services.errors.captureException(error);
services.logger.error('Application error caught by boundary', {
message: error?.message || 'Unknown error',
stack: error?.stack,
digest: error?.digest,
message: error.message,
stack: error.stack,
digest: error.digest
});
}, [error]);
@@ -42,14 +36,19 @@ export default function Error({
<Heading level={1} className="text-6xl md:text-8xl font-bold mb-2 text-saturated">
500
</Heading>
<Scribble variant="underline" className="w-full h-6 -bottom-2 left-0 text-saturated/40" />
<Scribble
variant="underline"
className="w-full h-6 -bottom-2 left-0 text-saturated/40"
/>
</div>
<Heading level={2} className="text-2xl md:text-3xl font-bold mb-4">
{t('title')}
</Heading>
<p className="text-text-secondary mb-10 max-w-md text-lg">{t('description')}</p>
<p className="text-white/60 mb-10 max-w-md text-lg">
{t('description')}
</p>
<div className="flex flex-col sm:flex-row gap-4">
<Button onClick={() => reset()} variant="saturated" size="lg">

View File

@@ -2,15 +2,19 @@ import Footer from '@/components/Footer';
import Header from '@/components/Header';
import JsonLd from '@/components/JsonLd';
import SkipLink from '@/components/SkipLink';
import CMSConnectivityNotice from '@/components/CMSConnectivityNotice';
import { RecordModeProvider } from '@/components/record-mode/RecordModeContext';
import { RecordModeVisuals } from '@/components/record-mode/RecordModeVisuals';
import { ToolCoordinator } from '@/components/record-mode/ToolCoordinator';
import AnalyticsShell from '@/components/analytics/AnalyticsShell';
import { Metadata, Viewport } from 'next';
import { NextIntlClientProvider } from 'next-intl';
import { getMessages } from 'next-intl/server';
import dynamic from 'next/dynamic';
import { Suspense } from 'react';
import '../../styles/globals.css';
import { SITE_URL } from '@/lib/schema';
import { config } from '@/lib/config';
import FeedbackClientWrapper from '@/components/FeedbackClientWrapper';
import { setRequestLocale } from 'next-intl/server';
import { Inter } from 'next/font/google';
@@ -26,14 +30,16 @@ export async function generateMetadata(props: {
const params = await props.params;
const { locale } = params;
const baseUrl = process.env.CI ? 'http://klz.localhost' : SITE_URL;
return {
title: {
template: '%s | KLZ Cables',
default: 'KLZ Cables | Ihr Partner für Kabel & Leitungen',
},
metadataBase: new URL(baseUrl),
metadataBase: new URL(SITE_URL),
manifest: '/manifest.webmanifest',
alternates: {
canonical: locale === 'en' ? '/' : `/${locale}`,
languages: {
de: '/de',
en: '/en',
},
},
icons: {
icon: [
{ url: '/favicon.ico', sizes: 'any' },
@@ -66,31 +72,14 @@ export default async function Layout(props: {
setRequestLocale(safeLocale);
let messages: Record<string, any> = {};
let messages = {};
try {
messages = await getMessages();
} catch (error) {
console.error(`Failed to load messages for locale '${safeLocale}':`, error);
messages = {};
}
// Pick only the namespaces required by client components to reduce the hydration payload size
const clientKeys = [
'Footer',
'Navigation',
'Contact',
'Products',
'Team',
'Home',
'Error',
'StandardPage',
];
const clientMessages: Record<string, any> = {};
for (const key of clientKeys) {
if (messages[key]) {
clientMessages[key] = messages[key];
}
}
const { getServerAppServices } = await import('@/lib/services/create-services.server');
const serverServices = getServerAppServices();
@@ -98,10 +87,7 @@ export default async function Layout(props: {
const { headers } = await import('next/headers');
const requestHeaders = await headers();
// Disable analytics in CI to prevent console noise/score penalties
if (process.env.NEXT_PUBLIC_CI === 'true') {
// Skip setting server context for analytics in CI
} else if ('setServerContext' in serverServices.analytics) {
if ('setServerContext' in serverServices.analytics) {
(serverServices.analytics as any).setServerContext({
userAgent: requestHeaders.get('user-agent') || undefined,
language: requestHeaders.get('accept-language')?.split(',')[0] || undefined,
@@ -121,30 +107,38 @@ export default async function Layout(props: {
}
// Read directly from process.env — bypasses all abstraction to guarantee correctness
const recordModeEnabled = process.env.NEXT_PUBLIC_RECORD_MODE_ENABLED === 'true';
const feedbackEnabled = process.env.NEXT_PUBLIC_FEEDBACK_ENABLED === 'true';
return (
<html lang={safeLocale} className={`overflow-x-hidden ${inter.variable}`}>
<html lang={safeLocale} className={`scroll-smooth overflow-x-hidden ${inter.variable}`}>
<head>
<link rel="preconnect" href="https://fonts.googleapis.com" />
<link rel="preconnect" href="https://fonts.gstatic.com" crossOrigin="anonymous" />
<link rel="preconnect" href="https://img.infra.mintel.me" />
</head>
<body className="flex flex-col min-h-screen font-sans selection:bg-accent selection:text-primary-dark antialiased overflow-x-hidden">
<NextIntlClientProvider messages={clientMessages} locale={safeLocale}>
<SkipLink />
<JsonLd />
<Header />
<main
id="main-content"
className="flex-grow animate-fade-in overflow-visible"
tabIndex={-1}
>
{children}
</main>
<Footer />
<AnalyticsShell />
<FeedbackClientWrapper feedbackEnabled={feedbackEnabled} />
<NextIntlClientProvider messages={messages} locale={safeLocale}>
<RecordModeProvider isEnabled={recordModeEnabled}>
<RecordModeVisuals>
<SkipLink />
<JsonLd />
<Header />
<main
id="main-content"
className="flex-grow animate-fade-in overflow-visible"
tabIndex={-1}
>
{children}
</main>
<Footer />
</RecordModeVisuals>
<CMSConnectivityNotice />
<AnalyticsShell />
<ToolCoordinator feedbackEnabled={feedbackEnabled} />
</RecordModeProvider>
</NextIntlClientProvider>
</body>
</html>

View File

@@ -1,93 +1,56 @@
import { getTranslations } from 'next-intl/server';
'use client';
import { useTranslations } from 'next-intl';
import { Container, Button, Heading } from '@/components/ui';
import Scribble from '@/components/Scribble';
import { headers } from 'next/headers';
import ClientNotFoundTracker from '@/components/analytics/ClientNotFoundTracker';
import { useEffect } from 'react';
import { useAnalytics } from '@/components/analytics/useAnalytics';
import { AnalyticsEvents } from '@/components/analytics/analytics-events';
export default async function NotFound() {
const t = await getTranslations('Error.notFound');
export default function NotFound() {
const t = useTranslations('Error.notFound');
const { trackEvent } = useAnalytics();
// Try to determine the requested path
const headersList = await headers();
const urlPath = headersList.get('x-invoke-path') || '';
let suggestedUrl = null;
let suggestedLang = null;
// If we have a path, try to see if the last segment (slug) exists in ANY locale
if (urlPath) {
const slug = urlPath.split('/').filter(Boolean).pop();
if (slug) {
// Logic for alternative locale checking is tricky without DB.
// We'll just provide a simple fallback if the user is in wrong locale
const currentLocale = urlPath.startsWith('/en') ? 'en' : 'de';
const alternativeLocale = currentLocale === 'de' ? 'en' : 'de';
suggestedLang = alternativeLocale === 'de' ? 'Deutsch' : 'English';
const pathParts = urlPath.split('/').filter(Boolean);
if (pathParts.length > 0 && (pathParts[0] === 'en' || pathParts[0] === 'de')) {
pathParts[0] = alternativeLocale;
} else {
pathParts.unshift(alternativeLocale);
}
suggestedUrl = '/' + pathParts.join('/');
}
}
useEffect(() => {
trackEvent(AnalyticsEvents.ERROR, {
type: '404_not_found',
path: typeof window !== 'undefined' ? window.location.pathname : 'unknown',
});
}, [trackEvent]);
return (
<>
<ClientNotFoundTracker path={urlPath} />
<Container className="relative py-24 flex flex-col items-center justify-center text-center min-h-[70vh] overflow-hidden">
{/* Industrial Background Element */}
<div className="absolute inset-0 -z-10 opacity-[0.03] pointer-events-none flex items-center justify-center">
<span className="text-[20rem] font-bold select-none">404</span>
</div>
<Container className="relative py-24 flex flex-col items-center justify-center text-center min-h-[70vh] overflow-hidden">
{/* Industrial Background Element */}
<div className="absolute inset-0 -z-10 opacity-[0.03] pointer-events-none flex items-center justify-center">
<span className="text-[20rem] font-bold select-none">404</span>
</div>
<div className="relative mb-8">
<Heading level={1} className="text-6xl md:text-8xl font-bold mb-2">
404
</Heading>
<Scribble
variant="circle"
className="w-[150%] h-[150%] -top-[25%] -left-[25%] text-accent/40"
/>
</div>
<Heading level={2} className="text-2xl md:text-3xl font-bold mb-4 text-primary">
{t('title')}
<div className="relative mb-8">
<Heading level={1} className="text-6xl md:text-8xl font-bold mb-2">
404
</Heading>
<Scribble
variant="circle"
className="w-[150%] h-[150%] -top-[25%] -left-[25%] text-accent/40"
/>
</div>
<p className="text-text-secondary mb-10 max-w-md text-lg">{t('description')}</p>
<Heading level={2} className="text-2xl md:text-3xl font-bold mb-4 text-primary">
{t('title')}
</Heading>
{suggestedUrl && (
<div className="mb-12 p-6 bg-accent/10 border border-accent/20 rounded-2xl animate-fade-in shadow-lg relative overflow-hidden group">
<div className="absolute inset-0 bg-accent/5 -skew-x-12 translate-x-full group-hover:translate-x-0 transition-transform duration-700" />
<div className="relative z-10">
<h3 className="text-primary font-bold mb-2 text-lg">
Did you mean to visit the {suggestedLang} version?
</h3>
<p className="text-text-secondary text-sm mb-4">
This page exists, but in another language.
</p>
<Button href={suggestedUrl} variant="accent" size="md" className="w-full sm:w-auto">
Go to {suggestedLang} Version
</Button>
</div>
</div>
)}
<p className="text-white/60 mb-10 max-w-md text-lg">{t('description')}</p>
<div className="flex flex-col sm:flex-row gap-4">
<Button href="/" variant={suggestedUrl ? 'outline' : 'accent'} size="lg">
{t('cta')}
</Button>
<Button href="/contact" variant={suggestedUrl ? 'ghost' : 'outline'} size="lg">
Contact Support
</Button>
</div>
<div className="flex flex-col sm:flex-row gap-4">
<Button href="/" variant="accent" size="lg">
{t('cta')}
</Button>
<Button href="/contact" variant="outline" size="lg">
Contact Support
</Button>
</div>
{/* Decorative Industrial Line */}
<div className="absolute bottom-0 left-1/2 -translate-x-1/2 w-px h-24 bg-gradient-to-t from-accent/50 to-transparent" />
</Container>
</>
{/* Decorative Industrial Line */}
<div className="absolute bottom-0 left-1/2 -translate-x-1/2 w-px h-24 bg-gradient-to-t from-accent/50 to-transparent" />
</Container>
);
}

View File

@@ -1,22 +1,20 @@
import { ImageResponse } from 'next/og';
import { getTranslations } from 'next-intl/server';
import { OGImageTemplate } from '@/components/OGImageTemplate';
import { getOgFonts, OG_IMAGE_SIZE, getOgAssets } from '@/lib/og-helper';
import { getOgFonts, OG_IMAGE_SIZE } from '@/lib/og-helper';
export const size = OG_IMAGE_SIZE;
export const contentType = 'image/png';
export const runtime = 'nodejs';
export default async function Image({ params }: { params: Promise<{ locale: string }> }) {
console.log('🖼️ OG Image Handler Called');
const { locale } = await params;
const t = await getTranslations({ locale, namespace: 'Index.meta' });
const fonts = await getOgFonts();
const { logo, bgImage } = getOgAssets();
return new ImageResponse(
<OGImageTemplate
image={bgImage}
logo={logo}
title={t('title')}
description={t('description')}
label="Reliable Energy Infrastructure"

View File

@@ -1,12 +1,11 @@
import Hero from '@/components/home/Hero';
import JsonLd from '@/components/JsonLd';
import { getBreadcrumbSchema, SITE_URL } from '@/lib/schema';
import ProductCategories from '@/components/home/ProductCategories';
import WhatWeDo from '@/components/home/WhatWeDo';
import dynamic from 'next/dynamic';
import Reveal from '@/components/Reveal';
const ProductCategories = dynamic(() => import('@/components/home/ProductCategories'));
const WhatWeDo = dynamic(() => import('@/components/home/WhatWeDo'));
const RecentPosts = dynamic(() => import('@/components/home/RecentPosts'));
const Experience = dynamic(() => import('@/components/home/Experience'));
const WhyChooseUs = dynamic(() => import('@/components/home/WhyChooseUs'));
@@ -27,13 +26,6 @@ export default async function HomePage({ params }: { params: Promise<{ locale: s
id="breadcrumb-home"
data={getBreadcrumbSchema([{ name: 'Home', item: `/${locale}` }])}
/>
{/*
The instruction refers to changing a class within the Hero component's paragraph.
Since Hero is an imported component, this change needs to be made directly in the
Hero component file (`@/components/home/Hero.tsx`) itself, not in this page file.
This file (`app/[locale]/page.tsx`) only renders the Hero component.
Therefore, no change is applied here.
*/}
<Hero />
<Reveal>
<ProductCategories />

View File

@@ -7,16 +7,16 @@ import RelatedProducts from '@/components/RelatedProducts';
import DatasheetDownload from '@/components/DatasheetDownload';
import { Badge, Card, Container, Heading, Section } from '@/components/ui';
import { getDatasheetPath } from '@/lib/datasheets';
import { getAllProducts, getProductBySlug } from '@/lib/products';
import { getAllProducts, getProductBySlug } from '@/lib/mdx';
import { mapFileSlugToTranslated, mapSlugToFileSlug } from '@/lib/slugs';
import { Metadata } from 'next';
import { getTranslations, setRequestLocale } from 'next-intl/server';
import { getProductOGImageMetadata } from '@/lib/metadata';
import { MDXRemote } from 'next-mdx-remote/rsc';
import Image from 'next/image';
import Link from 'next/link';
import { notFound, redirect } from 'next/navigation';
import { notFound } from 'next/navigation';
import ProductEngagementTracker from '@/components/analytics/ProductEngagementTracker';
import MDXContent from '@/components/MDXContent';
interface ProductPageProps {
params: Promise<{
@@ -53,82 +53,135 @@ export async function generateMetadata({ params }: ProductPageProps): Promise<Me
title: categoryTitle,
description: categoryDesc,
alternates: {
canonical: `${SITE_URL}/${locale}/${await mapFileSlugToTranslated('products', locale)}/${await mapFileSlugToTranslated(fileSlug, locale)}`,
canonical: `${SITE_URL}/${locale}/${await mapFileSlugToTranslated('products', locale)}/${productSlug}`,
languages: {
de: `${SITE_URL}/de/${await mapFileSlugToTranslated('products', 'de')}/${await mapFileSlugToTranslated(fileSlug, 'de')}`,
en: `${SITE_URL}/en/${await mapFileSlugToTranslated('products', 'en')}/${await mapFileSlugToTranslated(fileSlug, 'en')}`,
'x-default': `${SITE_URL}/en/${await mapFileSlugToTranslated('products', 'en')}/${await mapFileSlugToTranslated(fileSlug, 'en')}`,
de: `${SITE_URL}/de/${await mapFileSlugToTranslated('products', 'de')}/${await mapFileSlugToTranslated(productSlug, 'de')}`,
en: `${SITE_URL}/en/${await mapFileSlugToTranslated('products', 'en')}/${await mapFileSlugToTranslated(productSlug, 'en')}`,
'x-default': `${SITE_URL}/en/${await mapFileSlugToTranslated('products', 'en')}/${await mapFileSlugToTranslated(productSlug, 'en')}`,
},
},
openGraph: {
title: `${categoryTitle} | KLZ Cables`,
description: categoryDesc,
url: `${SITE_URL}/${locale}/${await mapFileSlugToTranslated('products', locale)}/${productSlug}`,
images: getProductOGImageMetadata(fileSlug, categoryTitle, locale),
},
twitter: {
card: 'summary_large_image',
title: `${categoryTitle} | KLZ Cables`,
description: categoryDesc,
},
};
}
const fileSlugs = await Promise.all(slug.map((s) => mapSlugToFileSlug(s, locale)));
const getLocalizedPath = async (lang: string) => {
const parts = await Promise.all([
mapFileSlugToTranslated('products', lang),
...fileSlugs.map((fs) => mapFileSlugToTranslated(fs, lang)),
]);
return parts.join('/');
};
const product = await getProductBySlug(productSlug, locale);
if (!product) return {};
const currentLocalePath = await getLocalizedPath(locale);
return {
title: product.frontmatter.title,
description: product.frontmatter.description,
alternates: {
canonical: `${SITE_URL}/${locale}/${currentLocalePath}`,
canonical: `${SITE_URL}/${locale}/${await mapFileSlugToTranslated('products', locale)}/${slug.join('/')}`,
languages: {
de: `${SITE_URL}/de/${await getLocalizedPath('de')}`,
en: `${SITE_URL}/en/${await getLocalizedPath('en')}`,
'x-default': `${SITE_URL}/en/${await getLocalizedPath('en')}`,
de: `${SITE_URL}/de/${await mapFileSlugToTranslated('products', 'de')}/${await mapFileSlugToTranslated(slug[0], 'de')}/${await mapFileSlugToTranslated(productSlug, 'de')}`,
en: `${SITE_URL}/en/${await mapFileSlugToTranslated('products', 'en')}/${await mapFileSlugToTranslated(slug[0], 'en')}/${await mapFileSlugToTranslated(productSlug, 'en')}`,
'x-default': `${SITE_URL}/en/${await mapFileSlugToTranslated('products', 'en')}/${await mapFileSlugToTranslated(slug[0], 'en')}/${await mapFileSlugToTranslated(productSlug, 'en')}`,
},
},
openGraph: {
title: product.frontmatter.title,
title: `${product.frontmatter.title} | KLZ Cables`,
description: product.frontmatter.description,
type: 'website',
url: `${SITE_URL}/${locale}/${currentLocalePath}`,
url: `${SITE_URL}/${locale}/${await mapFileSlugToTranslated('products', locale)}/${slug.join('/')}`,
images: getProductOGImageMetadata(productSlug, product.frontmatter.title, locale),
},
twitter: {
card: 'summary_large_image',
title: product.frontmatter.title,
title: `${product.frontmatter.title} | KLZ Cables`,
description: product.frontmatter.description,
},
};
}
const components = {
ProductTechnicalData,
ProductTabs,
p: (props: any) => (
<p
{...props}
className="text-lg md:text-xl text-text-secondary leading-relaxed mb-8 font-medium"
/>
),
h2: (props: any) => (
<div className="relative mb-16">
<h2
{...props}
className="text-3xl md:text-4xl font-black text-primary tracking-tighter uppercase mb-6"
/>
<div className="w-20 h-1.5 bg-accent rounded-full" />
</div>
),
h3: (props: any) => (
<h3
{...props}
className="text-2xl md:text-3xl font-black text-primary mb-10 tracking-tight uppercase"
/>
),
ul: (props: any) => <ul {...props} className="list-none pl-0 mb-10" />,
section: (props: any) => <div {...props} className="block" />,
li: (props: any) => (
<li className="flex items-start gap-4 group mb-4 last:mb-0">
<div className="mt-2.5 w-2 h-2 rounded-full bg-accent flex-shrink-0 group-hover:scale-125 transition-transform" />
<span
{...props}
className="text-lg md:text-xl text-text-secondary leading-relaxed font-medium"
/>
</li>
),
strong: (props: any) => <strong {...props} className="font-black text-primary" />,
table: (props: any) => (
<div className="overflow-x-auto my-20 rounded-[32px] border border-neutral-dark/10 shadow-xl bg-white p-1">
<table {...props} className="min-w-full divide-y divide-neutral-dark/10" />
</div>
),
th: (props: any) => (
<th
{...props}
className="px-8 py-6 bg-neutral-light/50 text-left text-[10px] font-black uppercase tracking-[0.25em] text-primary/60"
/>
),
td: (props: any) => (
<td
{...props}
className="px-8 py-6 text-text-secondary border-t border-neutral-dark/5 text-lg md:text-xl font-medium"
/>
),
hr: () => <hr className="my-24 border-t-2 border-neutral-dark/5" />,
blockquote: (props: any) => (
<div className="my-20 p-10 md:p-16 bg-primary-dark rounded-[40px] relative overflow-hidden group">
<div className="absolute top-0 right-0 w-64 h-64 bg-accent/10 rounded-full -translate-y-1/2 translate-x-1/2 blur-3xl group-hover:bg-accent/20 transition-colors duration-700" />
<div
className="relative z-10 italic text-2xl md:text-3xl text-white/90 leading-relaxed font-black tracking-tight"
{...props}
/>
</div>
),
};
export default async function ProductPage({ params }: ProductPageProps) {
const { locale, slug } = await params;
setRequestLocale(locale);
const productSlug = slug[slug.length - 1];
const t = await getTranslations('Products');
const productsSlug = await mapFileSlugToTranslated('products', locale);
// Check if it's a category page
const categories = [
'low-voltage-cables',
'medium-voltage-cables',
'high-voltage-cables',
'solar-cables',
];
const fileSlugs = await Promise.all(slug.map((s) => mapSlugToFileSlug(s, locale)));
const translatedSlugsForLocale = await Promise.all(
fileSlugs.map((fs) => mapFileSlugToTranslated(fs, locale)),
);
// If the requested slugs don't exactly match the translated slugs for the current locale
// (i.e. if the user used the static language switcher but kept the original locale's slugs)
if (slug.join('/') !== translatedSlugsForLocale.join('/')) {
redirect(`/${locale}/${productsSlug}/${translatedSlugsForLocale.join('/')}`);
}
const fileSlug = fileSlugs[fileSlugs.length - 1];
const fileSlug = await mapSlugToFileSlug(productSlug, locale);
if (categories.includes(fileSlug)) {
const allProducts = await getAllProducts(locale);
@@ -139,27 +192,14 @@ export default async function ProductPage({ params }: ProductPageProps) {
? t(`categories.${categoryKey}.title`)
: fileSlug;
const filteredProducts = allProducts.filter((p) => {
const firstCat = p.frontmatter.categories[0] || '';
const normalizedCat = firstCat.toLowerCase().replace(/\s+/g, '-');
let pFileSlug = 'low-voltage-cables';
if (normalizedCat === 'hochspannungskabel' || normalizedCat === 'high-voltage-cables')
pFileSlug = 'high-voltage-cables';
else if (
normalizedCat === 'mittelspannungskabel' ||
normalizedCat === 'medium-voltage-cables'
)
pFileSlug = 'medium-voltage-cables';
else if (
normalizedCat === 'solarkabel' ||
normalizedCat === 'solar-cables' ||
normalizedCat === 'solar'
)
pFileSlug = 'solar-cables';
return pFileSlug === fileSlug;
});
// Filter products for this category
const filteredProducts = allProducts.filter((p) =>
p.frontmatter.categories.some(
(cat) => cat.toLowerCase().replace(/\s+/g, '-') === fileSlug || cat === categoryTitle,
),
);
// Get translated product slugs
const productsWithTranslatedSlugs = await Promise.all(
filteredProducts.map(async (p) => ({
...p,
@@ -174,10 +214,10 @@ export default async function ProductPage({ params }: ProductPageProps) {
<div className="max-w-4xl animate-slide-up">
<nav className="flex items-center mb-8 text-white/40 text-sm font-bold uppercase tracking-widest">
<Link
href={`/${locale}/${productsSlug}`}
href={`/${locale}/${await mapFileSlugToTranslated('products', locale)}`}
className="hover:text-accent transition-colors"
>
{t.has('breadcrumb') ? t('breadcrumb') : 'Products'}
{t.has('breadcrumb') ? t('breadcrumb') : t('title').replace(/<[^>]*>/g, '')}
</Link>
<span className="mx-3 opacity-30">/</span>
<span className="text-white/90">{categoryTitle}</span>
@@ -196,7 +236,7 @@ export default async function ProductPage({ params }: ProductPageProps) {
{productsWithTranslatedSlugs.map((product) => (
<Link
key={product.slug}
href={`/${locale}/${productsSlug}/${productSlug}/${product.translatedSlug}`}
href={`/${locale}/${productSlug}/${product.translatedSlug}`}
className="group block bg-white rounded-[32px] overflow-hidden shadow-sm hover:shadow-2xl transition-all duration-500 border border-neutral-dark/5"
>
<Card tag="article" className="premium-card-reset">
@@ -210,6 +250,7 @@ export default async function ProductPage({ params }: ProductPageProps) {
className="object-contain p-8 transition-transform duration-700 group-hover:scale-110 z-10"
sizes="(max-width: 768px) 100vw, (max-width: 1024px) 50vw, 33vw"
/>
{/* Subtle reflection/shadow effect */}
<div className="absolute bottom-4 left-1/2 -translate-x-1/2 w-2/3 h-4 bg-black/5 blur-xl rounded-full" />
</>
)}
@@ -266,17 +307,18 @@ export default async function ProductPage({ params }: ProductPageProps) {
notFound();
}
// Extract technical data for schema
const technicalDataMatch = product.content.match(
/technicalData=\{<ProductTechnicalData data=\{(.*?)\}\s*\/>\}/s,
);
let technicalItems = [];
try {
const tabMatch =
typeof product.content === 'string' &&
product.content.match(/<Block type="productTabs" data={({.*?})}\s*\/>/s);
if (tabMatch && tabMatch[1]) {
const data = JSON.parse(tabMatch[1]);
if (technicalDataMatch) {
try {
const data = JSON.parse(technicalDataMatch[1]);
technicalItems = data.technicalItems || [];
} catch (e) {
console.error('Failed to parse technical data for schema', e);
}
} catch (e) {
// Ignore JSON parse errors for AST
}
const datasheetPath = getDatasheetPath(productSlug, locale);
@@ -290,17 +332,6 @@ export default async function ProductPage({ params }: ProductPageProps) {
? t(`categories.${categoryKey}.title`)
: categoryFileSlug;
let descriptionContent = typeof product.content === 'string' ? product.content : '';
let technicalContent = '';
if (typeof product.content === 'string') {
const tabBlockMatch = product.content.match(/<Block type="productTabs".*?\/>/s);
if (tabBlockMatch) {
descriptionContent = product.content.replace(tabBlockMatch[0], '');
technicalContent = tabBlockMatch[0];
}
}
const sidebar = (
<ProductSidebar
productName={product.frontmatter.title}
@@ -309,6 +340,22 @@ export default async function ProductPage({ params }: ProductPageProps) {
/>
);
const productComponents = {
...components,
ProductTabs: (props: any) => <ProductTabs {...props} sidebar={sidebar} />,
};
// Pre-process content to convert raw HTML tags to Markdown so they use our custom components
const processedContent = product.content
.replace(/<h2[^>]*>(.*?)<\/h2>/g, '\n## $1\n')
.replace(/<h3[^>]*>(.*?)<\/h3>/g, '\n### $1\n')
.replace(/<p[^>]*>(.*?)<\/p>/g, '\n$1\n')
.replace(/<ul[^>]*>(.*?)<\/ul>/gs, '\n$1\n')
.replace(/<li[^>]*>(.*?)<\/li>/g, '\n- $1\n')
.replace(/<strong[^>]*>(.*?)<\/strong>/g, '**$1**')
.replace(/<section[^>]*>/g, '')
.replace(/<\/section>/g, '');
return (
<div className="flex flex-col min-h-screen bg-white relative">
{/* Product Hero */}
@@ -318,31 +365,29 @@ export default async function ProductPage({ params }: ProductPageProps) {
categories={product.frontmatter.categories}
sku={product.frontmatter.sku}
/>
<section className="relative pt-28 md:pt-40 pb-12 md:pb-24 overflow-hidden bg-primary-dark">
<section className="relative pt-40 pb-24 overflow-hidden bg-primary-dark">
{/* Background Decorative Elements */}
<div className="absolute top-0 right-0 w-1/2 h-full bg-gradient-to-l from-accent/5 to-transparent pointer-events-none" />
<div className="absolute -top-24 -right-24 w-96 h-96 bg-accent/10 rounded-full blur-3xl pointer-events-none" />
<Container className="relative z-10">
<div className="max-w-4xl animate-slide-up">
<nav className="flex flex-wrap items-center gap-y-1 mb-6 md:mb-12 text-white/40 text-[10px] font-black uppercase tracking-[0.2em]">
<nav className="flex items-center mb-12 text-white/40 text-[10px] font-black uppercase tracking-[0.2em]">
<Link
href={`/${locale}/${productsSlug}`}
className="hover:text-accent transition-colors shrink-0"
href={`/${locale}/${await mapFileSlugToTranslated('products', locale)}`}
className="hover:text-accent transition-colors"
>
{t.has('breadcrumb') ? t('breadcrumb') : 'Products'}
{t.has('breadcrumb') ? t('breadcrumb') : t('title').replace(/<[^>]*>/g, '')}
</Link>
<span className="mx-2 md:mx-4 opacity-20">/</span>
<span className="mx-4 opacity-20">/</span>
<Link
href={`/${locale}/${productsSlug}/${categorySlug}`}
className="hover:text-accent transition-colors shrink-0 max-w-[140px] truncate"
href={`/${locale}/${productSlug}`}
className="hover:text-accent transition-colors"
>
{categoryTitle}
</Link>
<span className="mx-2 md:mx-4 opacity-20">/</span>
<span className="text-white/90 truncate max-w-[140px] md:max-w-none">
{product.frontmatter.title}
</span>
<span className="mx-4 opacity-20">/</span>
<span className="text-white/90">{product.frontmatter.title}</span>
</nav>
<div className="flex flex-col lg:flex-row lg:items-center justify-between gap-12">
@@ -353,7 +398,7 @@ export default async function ProductPage({ params }: ProductPageProps) {
{t('englishVersion')}
</div>
)}
<div className="flex flex-wrap gap-2 mb-4 md:mb-8">
<div className="flex flex-wrap gap-3 mb-8">
{product.frontmatter.categories.map((cat, idx) => (
<Badge
key={idx}
@@ -364,10 +409,10 @@ export default async function ProductPage({ params }: ProductPageProps) {
</Badge>
))}
</div>
<Heading level={1} className="text-white mb-4 md:mb-8 uppercase">
<Heading level={1} className="text-white mb-8 uppercase">
{product.frontmatter.title}
</Heading>
<p className="text-base md:text-xl lg:text-2xl text-white/60 max-w-2xl leading-relaxed font-medium">
<p className="text-xl md:text-2xl text-white/60 max-w-2xl leading-relaxed font-medium">
{product.frontmatter.description}
</p>
</div>
@@ -381,16 +426,15 @@ export default async function ProductPage({ params }: ProductPageProps) {
{/* Large Product Image Section */}
{product.frontmatter.images && product.frontmatter.images.length > 0 && (
<div
className="relative md:-mt-32 mb-8 md:mb-32 animate-slide-up"
className="relative -mt-32 mb-32 animate-slide-up"
style={{ animationDelay: '200ms' }}
>
<div className="bg-white shadow-[0_32px_64px_-12px_rgba(0,0,0,0.1)] rounded-[24px] md:rounded-[48px] border border-neutral-dark/5 overflow-hidden p-6 md:p-20 lg:p-24">
<div className="relative w-full aspect-[4/3] md:aspect-[21/9]">
<div className="bg-white shadow-[0_32px_64px_-12px_rgba(0,0,0,0.1)] rounded-[48px] border border-neutral-dark/5 overflow-hidden p-12 md:p-20 lg:p-24">
<div className="relative w-full aspect-[21/9]">
<Image
src={product.frontmatter.images[0]}
alt={product.frontmatter.title}
fill
sizes="(max-width: 768px) 100vw, (max-width: 1200px) 50vw, 33vw"
className="object-contain transition-transform duration-1000 hover:scale-105"
priority
/>
@@ -409,7 +453,6 @@ export default async function ProductPage({ params }: ProductPageProps) {
src={img}
alt=""
fill
sizes="128px"
className="object-contain p-4 transition-transform duration-700 group-hover:scale-110"
/>
</div>
@@ -420,92 +463,68 @@ export default async function ProductPage({ params }: ProductPageProps) {
</div>
)}
<div className="grid grid-cols-1 lg:grid-cols-12 gap-8 lg:gap-20">
{/* Description Area Next to Sidebar */}
<div className="lg:col-span-8">
<div className="max-w-none prose prose-primary prose-base md:prose-lg xl:prose-xl mb-8 md:mb-16 pb-8 md:pb-16 border-b border-neutral-dark/5">
{descriptionContent ? (
<MDXContent data={descriptionContent} />
) : product.frontmatter.description ? (
<p className="text-lg md:text-xl text-text-secondary leading-relaxed">
{product.frontmatter.description}
</p>
) : null}
<div className="relative">
<div className="w-full">
{/* Main Content Area */}
<div className="max-w-none">
<MDXRemote source={processedContent} components={productComponents} />
</div>
{product.application && (
<div className="mt-12">
<MDXContent
data={typeof product.application === 'string' ? product.application : ''}
/>
{/* Datasheet Download Section - Only for Medium Voltage for now */}
{categoryFileSlug === 'medium-voltage-cables' && datasheetPath && (
<div className="mt-24 pt-24 border-t-2 border-neutral-dark/5">
<div className="mb-12">
<h2 className="text-3xl md:text-4xl font-black text-primary tracking-tighter uppercase mb-4">
{t('downloadDatasheet')}
</h2>
<div className="h-1.5 w-24 bg-accent rounded-full" />
</div>
)}
</div>
</div>
{/* Sidebar Column */}
<div className="lg:col-span-4 lg:sticky lg:top-32 h-fit">{sidebar}</div>
</div>
{/* Full-width Technical Data Below */}
<div className="mt-8 md:mt-16 pt-8 md:pt-16 border-t-0">
<div className="max-w-none prose prose-primary prose-lg md:prose-xl">
<MDXContent data={technicalContent} />
</div>
{/* Datasheet Download Section */}
{categoryFileSlug === 'medium-voltage-cables' && datasheetPath && (
<div className="mt-16 pt-16 border-t-2 border-neutral-dark/5">
<div className="mb-8">
<h2 className="text-3xl md:text-4xl font-black text-primary tracking-tighter uppercase mb-4">
{t('downloadDatasheet')}
</h2>
<div className="h-1.5 w-24 bg-accent rounded-full" />
<DatasheetDownload datasheetPath={datasheetPath} />
</div>
<DatasheetDownload datasheetPath={datasheetPath} />
</div>
)}
)}
{/* Structured Data (Hidden) */}
<JsonLd
id={`jsonld-${product.slug}`}
data={
{
'@context': 'https://schema.org',
'@type': 'Product',
name: product.frontmatter.title,
description: product.frontmatter.description,
sku: product.frontmatter.sku || product.slug.toUpperCase(),
image: product.frontmatter.images?.[0]
? `${SITE_URL}${product.frontmatter.images[0]}`
: undefined,
brand: {
'@type': 'Brand',
name: 'KLZ Cables',
},
offers: {
'@type': 'Offer',
availability: 'https://schema.org/InStock',
priceCurrency: 'EUR',
url: `${SITE_URL}/${locale}/${await mapFileSlugToTranslated('products', locale)}/${slug.join('/')}`,
itemCondition: 'https://schema.org/NewCondition',
},
additionalProperty: technicalItems.map((item: any) => ({
'@type': 'PropertyValue',
name: item.label,
value: item.value,
})),
category: product.frontmatter.categories.join(', '),
mainEntityOfPage: {
'@type': 'WebPage',
'@id': `${SITE_URL}/${locale}/${await mapFileSlugToTranslated('products', locale)}/${slug.join('/')}`,
},
} as any
}
/>
{/* Structured Data */}
<JsonLd
id={`jsonld-${product.slug}`}
data={
{
'@context': 'https://schema.org',
'@type': 'Product',
name: product.frontmatter.title,
description: product.frontmatter.description,
sku: product.frontmatter.sku || product.slug.toUpperCase(),
image: product.frontmatter.images?.[0]
? `${SITE_URL}${product.frontmatter.images[0]}`
: undefined,
brand: {
'@type': 'Brand',
name: 'KLZ Cables',
},
offers: {
'@type': 'Offer',
availability: 'https://schema.org/InStock',
priceCurrency: 'EUR',
url: `${SITE_URL}/${locale}/${await mapFileSlugToTranslated('products', locale)}/${slug.join('/')}`,
itemCondition: 'https://schema.org/NewCondition',
},
additionalProperty: technicalItems.map((item: any) => ({
'@type': 'PropertyValue',
name: item.label,
value: item.value,
})),
category: product.frontmatter.categories.join(', '),
mainEntityOfPage: {
'@type': 'WebPage',
'@id': `${SITE_URL}/${locale}/${await mapFileSlugToTranslated('products', locale)}/${slug.join('/')}`,
},
} as any
}
/>
</div>
</div>
{/* Related Products Section */}
<div className="mt-10 md:mt-16 pt-10 md:pt-16 border-t border-neutral-dark/5">
<div className="mt-16 pt-16 border-t border-neutral-dark/5">
<RelatedProducts
currentSlug={productSlug}
categories={product.frontmatter.categories}

View File

@@ -1,7 +1,7 @@
import { ImageResponse } from 'next/og';
import { getTranslations } from 'next-intl/server';
import { OGImageTemplate } from '@/components/OGImageTemplate';
import { getOgFonts, OG_IMAGE_SIZE, getOgAssets } from '@/lib/og-helper';
import { getOgFonts, OG_IMAGE_SIZE } from '@/lib/og-helper';
export const size = OG_IMAGE_SIZE;
export const contentType = 'image/png';
@@ -11,23 +11,12 @@ export default async function Image({ params }: { params: Promise<{ locale: stri
const { locale } = await params;
const t = await getTranslations({ locale, namespace: 'Products' });
const fonts = await getOgFonts();
const { logo, bgImage } = getOgAssets();
const title = t.has('meta.title')
? t('meta.title')
: t.has('breadcrumb')
? t('breadcrumb')
: 'Products';
const title = t('meta.title') || t('breadcrumb') || t('title').replace(/<[^>]*>/g, '');
const description = t('meta.description') || t('subtitle');
return new ImageResponse(
<OGImageTemplate
image={bgImage}
logo={logo}
title={title}
description={description}
label="Products"
/>,
<OGImageTemplate title={title} description={description} label="Products" />,
{
...OG_IMAGE_SIZE,
fonts,

View File

@@ -1,4 +1,5 @@
import Reveal from '@/components/Reveal';
import Scribble from '@/components/Scribble';
import { Badge, Button, Card, Container, Heading, Section } from '@/components/ui';
import { getTranslations, setRequestLocale } from 'next-intl/server';
import { Metadata } from 'next';
@@ -16,11 +17,7 @@ interface ProductsPageProps {
export async function generateMetadata({ params }: ProductsPageProps): Promise<Metadata> {
const { locale } = await params;
const t = await getTranslations({ locale, namespace: 'Products' });
const title = t.has('meta.title')
? t('meta.title')
: t.has('breadcrumb')
? t('breadcrumb')
: 'Products';
const title = t('meta.title') || t('breadcrumb') || t('title').replace(/<[^>]*>/g, '');
const description = t('meta.description') || t('subtitle');
return {
title,
@@ -58,7 +55,6 @@ export default async function ProductsPage({ params }: ProductsPageProps) {
const solarSlug = await mapFileSlugToTranslated('solar-cables', locale);
const productsSlug = await mapFileSlugToTranslated('products', locale);
const contactSlug = await mapFileSlugToTranslated('contact', locale);
const categories = [
{
@@ -94,7 +90,7 @@ export default async function ProductsPage({ params }: ProductsPageProps) {
return (
<div className="flex flex-col min-h-screen bg-neutral-light">
{/* Hero Section */}
<section className="relative flex items-center pt-32 pb-16 md:pt-40 md:pb-24 overflow-hidden bg-primary-dark">
<section className="relative min-h-[50vh] md:min-h-[70vh] flex items-center pt-32 pb-20 md:pt-40 md:pb-32 overflow-hidden bg-primary-dark">
<Container className="relative z-10">
<div className="max-w-4xl animate-slide-up">
<Badge
@@ -105,7 +101,15 @@ export default async function ProductsPage({ params }: ProductsPageProps) {
</Badge>
<Heading level={1} className="text-white mb-4 md:mb-8">
{t.rich('title', {
green: (chunks) => <span className="text-accent italic">{chunks}</span>,
green: (chunks) => (
<span className="relative inline-block">
<span className="relative z-10 text-accent italic">{chunks}</span>
<Scribble
variant="circle"
className="w-[140%] h-[140%] -top-[20%] -left-[20%] text-accent/30 hidden md:block"
/>
</span>
),
})}
</Heading>
<p className="text-lg md:text-xl text-white/70 leading-relaxed max-w-2xl mb-8 md:mb-12 line-clamp-2 md:line-clamp-none">
@@ -214,7 +218,7 @@ export default async function ProductsPage({ params }: ProductsPageProps) {
<div className="absolute top-0 right-0 w-1/2 h-full bg-accent/5 -skew-x-12 translate-x-1/4" />
<div className="relative z-10 flex flex-col lg:flex-row items-center justify-between gap-6 md:gap-12">
<div className="max-w-2xl text-center lg:text-left">
<h2 className="text-2xl md:text-4xl font-bold text-white mb-4 md:mb-8 tracking-tight">
<h2 className="text-2xl md:text-5xl lg:text-6xl font-bold text-white mb-4 md:mb-8 tracking-tight">
{t('cta.title')}
</h2>
<p className="text-base md:text-xl text-white/70 leading-relaxed">
@@ -222,13 +226,13 @@ export default async function ProductsPage({ params }: ProductsPageProps) {
</p>
</div>
<Button
href={`/${locale}/${contactSlug}`}
href={`/${locale}/contact`}
variant="accent"
size="lg"
className="group whitespace-nowrap w-full md:w-auto md:h-16 px-6 md:px-10 text-sm md:text-xl"
className="group whitespace-nowrap w-full md:w-auto md:h-16 md:px-10 md:text-xl"
>
{t('cta.button')}
<span className="ml-2 md:ml-4 transition-transform group-hover:translate-x-2">
<span className="ml-4 transition-transform group-hover:translate-x-2">
&rarr;
</span>
</Button>

View File

@@ -1,7 +1,7 @@
import { ImageResponse } from 'next/og';
import { getTranslations } from 'next-intl/server';
import { OGImageTemplate } from '@/components/OGImageTemplate';
import { getOgFonts, OG_IMAGE_SIZE, getOgAssets } from '@/lib/og-helper';
import { getOgFonts, OG_IMAGE_SIZE } from '@/lib/og-helper';
export const size = OG_IMAGE_SIZE;
export const contentType = 'image/png';
@@ -11,19 +11,12 @@ export default async function Image({ params }: { params: Promise<{ locale: stri
const { locale } = await params;
const t = await getTranslations({ locale, namespace: 'Team' });
const fonts = await getOgFonts();
const { logo, bgImage } = getOgAssets();
const title = t('meta.title') || t('hero.subtitle');
const description = t('meta.description') || t('hero.title');
return new ImageResponse(
<OGImageTemplate
image={bgImage}
logo={logo}
title={title}
description={description}
label="Our Team"
/>,
<OGImageTemplate title={title} description={description} label="Our Team" />,
{
...OG_IMAGE_SIZE,
fonts,

View File

@@ -122,12 +122,12 @@ export default async function TeamPage({ params }: TeamPageProps) {
<Badge variant="accent" className="mb-4 md:mb-8">
{t('michael.role')}
</Badge>
<Heading level={2} className="text-white mb-6 md:mb-10 text-2xl md:text-4xl">
<Heading level={2} className="text-white mb-6 md:mb-10 text-3xl md:text-5xl">
<span className="text-white">{t('michael.name')}</span>
</Heading>
<div className="relative mb-6 md:mb-12">
<div className="absolute -left-4 md:-left-8 top-0 bottom-0 w-1 md:w-1.5 bg-accent rounded-full" />
<p className="text-base md:text-xl font-bold italic leading-relaxed pl-5 md:pl-8 text-white/90">
<p className="text-lg md:text-2xl font-bold italic leading-relaxed pl-5 md:pl-8 text-white/90">
{t('michael.quote')}
</p>
</div>
@@ -156,7 +156,6 @@ export default async function TeamPage({ params }: TeamPageProps) {
alt={t('michael.name')}
fill
className="object-cover scale-105 hover:scale-100 transition-transform duration-1000"
quality={100}
sizes="(max-width: 1024px) 100vw, 50vw"
/>
<div className="absolute inset-0 bg-gradient-to-t from-primary-dark/60 lg:bg-gradient-to-r lg:from-primary-dark/20 to-transparent" />
@@ -226,7 +225,6 @@ export default async function TeamPage({ params }: TeamPageProps) {
alt={t('klaus.name')}
fill
className="object-cover scale-105 hover:scale-100 transition-transform duration-1000"
quality={100}
sizes="(max-width: 1024px) 100vw, 50vw"
/>
<div className="absolute inset-0 bg-gradient-to-t from-white/60 lg:bg-gradient-to-l lg:from-primary-dark/20 to-transparent" />
@@ -237,12 +235,12 @@ export default async function TeamPage({ params }: TeamPageProps) {
<Badge variant="saturated" className="mb-4 md:mb-8">
{t('klaus.role')}
</Badge>
<Heading level={2} className="text-saturated mb-6 md:mb-10 text-2xl md:text-4xl">
<Heading level={2} className="text-saturated mb-6 md:mb-10 text-3xl md:text-6xl">
{t('klaus.name')}
</Heading>
<div className="relative mb-6 md:mb-12">
<div className="absolute -left-4 md:-left-8 top-0 bottom-0 w-1 md:w-1.5 bg-saturated rounded-full" />
<p className="text-base md:text-xl font-bold italic leading-relaxed pl-5 md:pl-8 text-text-secondary">
<p className="text-lg md:text-3xl font-bold italic leading-relaxed pl-5 md:pl-8 text-text-secondary">
{t('klaus.quote')}
</p>
</div>

View File

@@ -1,84 +0,0 @@
'use server';
import * as React from 'react';
import { renderToBuffer } from '@react-pdf/renderer';
import { getAllProducts } from '@/lib/products';
import { getExcelTechnicalDataForProduct } from '@/lib/excel-products';
import { PDFBrochure } from '@/lib/pdf-brochure';
import { ProductData as PDFProductData } from '@/lib/pdf-datasheet';
/**
* Server action to generate a PDF brochure.
* Fetches products from Payload CMS and technical data from Excel.
* Returns the PDF as a base64 string.
*/
export async function generateBrochureAction(params: {
locale: 'en' | 'de';
productSlugs?: string[];
title?: string;
subtitle?: string;
}) {
try {
const { locale, productSlugs, title, subtitle } = params;
// 1. Fetch products from Payload
let products = await getAllProducts(locale);
// 2. Filter by slugs if provided
if (productSlugs && productSlugs.length > 0) {
products = products.filter((p) => productSlugs.includes(p.slug));
}
// 3. Map Payload products to PDF internal model and attach Excel data
const pdfProducts: PDFProductData[] = products.map((p, index) => {
// Get technical data from Excel
const excelData = getExcelTechnicalDataForProduct({
slug: p.slug,
sku: p.frontmatter.sku,
name: p.frontmatter.title,
});
return {
id: index + 1,
name: p.frontmatter.title,
sku: p.frontmatter.sku,
shortDescriptionHtml: p.frontmatter.description,
descriptionHtml: p.frontmatter.description,
// Application text is usually part of description in Payload v3
applicationHtml: '',
featuredImage: p.frontmatter.images[0] || null,
images: p.frontmatter.images,
categories: p.frontmatter.categories.map((name) => ({ name })),
attributes: excelData?.attributes || [],
};
});
if (pdfProducts.length === 0) {
throw new Error('No products found for brochure generation.');
}
// 4. Render to PDF buffer
// Note: React 19 / Next 15+ might need specific handling for renderToBuffer in actions
const buffer = await renderToBuffer(
React.createElement(PDFBrochure, {
products: pdfProducts,
locale,
title,
subtitle,
})
);
// 5. Convert to Base64 for returning to client
return {
success: true,
data: buffer.toString('base64'),
fileName: `KLZ_Cables_Brochure_${locale.toUpperCase()}.pdf`,
};
} catch (error) {
console.error('[Action] Brochure generation failed:', error);
return {
success: false,
error: error instanceof Error ? error.message : 'Unknown error occurred',
};
}
}

View File

@@ -1,7 +1,8 @@
'use server';
import client, { ensureAuthenticated } from '@/lib/directus';
import { createItem } from '@directus/sdk';
import { sendEmail } from '@/lib/mail/mailer';
import { env } from '@/lib/env';
import { render, ContactFormNotification, ConfirmationMessage } from '@mintel/mail';
import React from 'react';
import { getServerAppServices } from '@/lib/services/create-services.server';
@@ -40,31 +41,31 @@ export async function sendContactFormAction(formData: FormData) {
return { success: false, error: 'Missing required fields' };
}
logger.info('Payload CMS saving skipped because it has been removed', {
type: productName ? 'product_quote' : 'contact',
email,
});
// 1.5. Simple Fail-Safe Backup to Disk
// To ensure leads are never lost if email fails or Gotify is down, we append them to a local JSON Lines file.
// 1. Save to Directus
try {
const fs = await import('fs');
const path = await import('path');
const backupDir = path.join(process.cwd(), '.data');
if (!fs.existsSync(backupDir)) fs.mkdirSync(backupDir, { recursive: true });
const backupFile = path.join(backupDir, 'leads-backup.jsonl');
const leadData = {
timestamp: new Date().toISOString(),
name,
email,
productName,
message,
};
fs.appendFileSync(backupFile, JSON.stringify(leadData) + '\n');
logger.info('Successfully saved lead to local backup file', { backupFile });
} catch (backupError) {
logger.error('Failed to write to local leads backup', { error: String(backupError) });
await ensureAuthenticated();
if (productName) {
await client.request(
createItem('product_requests', {
product_name: productName,
email,
message,
}),
);
logger.info('Product request stored in Directus');
} else {
await client.request(
createItem('contact_submissions', {
name,
email,
message,
}),
);
logger.info('Contact submission stored in Directus');
}
} catch (error) {
logger.error('Failed to store submission in Directus', { error });
services.errors.captureException(error, { action: 'directus_store_submission' });
}
// 2. Send Emails
@@ -74,7 +75,6 @@ export async function sendContactFormAction(formData: FormData) {
? `Product Inquiry: ${productName}`
: 'New Contact Form Submission';
const confirmationSubject = 'Thank you for your inquiry';
const isTestSubmission = email === 'testing@mintel.me';
try {
// 2a. Send notification to Mintel/Client
@@ -87,35 +87,16 @@ export async function sendContactFormAction(formData: FormData) {
}),
);
if (!isTestSubmission) {
logger.info('Sending internal notification', { recipients: env.MAIL_RECIPIENTS });
const notificationResult = await sendEmail({
replyTo: email,
subject: notificationSubject,
html: notificationHtml,
});
const notificationResult = await sendEmail({
replyTo: email,
subject: notificationSubject,
html: notificationHtml,
});
if (notificationResult.success) {
logger.info('Notification email sent successfully', {
messageId: notificationResult.messageId,
});
} else {
logger.error('Notification email DELIVERY FAILED', {
error: notificationResult.error,
subject: notificationSubject,
recipients: env.MAIL_RECIPIENTS,
});
services.errors.captureException(
new Error(`Notification email failed: ${notificationResult.error}`),
{
action: 'sendContactFormAction_notification',
email,
recipients: env.MAIL_RECIPIENTS,
},
);
}
} else {
logger.info('Skipping notification email for test submission', { email });
if (notificationResult.success) {
logger.info('Notification email sent successfully', {
messageId: notificationResult.messageId,
});
}
// 2b. Send confirmation to Customer (branded as KLZ Cables)
@@ -127,36 +108,21 @@ export async function sendContactFormAction(formData: FormData) {
}),
);
if (!isTestSubmission) {
logger.info('Sending customer confirmation', { to: email });
const confirmationResult = await sendEmail({
to: email,
subject: confirmationSubject,
html: confirmationHtml,
});
const confirmationResult = await sendEmail({
to: email,
subject: confirmationSubject,
html: confirmationHtml,
});
if (confirmationResult.success) {
logger.info('Confirmation email sent successfully', {
messageId: confirmationResult.messageId,
});
} else {
logger.error('Confirmation email DELIVERY FAILED', {
error: confirmationResult.error,
subject: confirmationSubject,
to: email,
});
services.errors.captureException(
new Error(`Confirmation email failed: ${confirmationResult.error}`),
{ action: 'sendContactFormAction_confirmation', email },
);
}
} else {
logger.info('Skipping confirmation email for test submission', { email });
if (confirmationResult.success) {
logger.info('Confirmation email sent successfully', {
messageId: confirmationResult.messageId,
});
}
// Notify via Gotify (Internal)
await services.notifications.notify({
title: `📩 [KLZ] ${notificationSubject}`,
title: `📩 ${notificationSubject}`,
message: `New message from ${name} (${email}):\n\n${message}`,
priority: 5,
});

View File

@@ -0,0 +1,9 @@
import { checkHealth } from '@/lib/directus';
import { NextResponse } from 'next/server';
export const dynamic = 'force-dynamic';
export async function GET() {
const health = await checkHealth();
return NextResponse.json(health, { status: health.status === 'ok' ? 200 : 503 });
}

View File

@@ -17,11 +17,6 @@ export async function POST(request: NextRequest) {
const logger = services.logger.child({ component: 'sentry-relay' });
try {
// Prevent 403 Forbidden console noise in local dev
if (process.env.NODE_ENV === 'development') {
return NextResponse.json({ status: 'ignored_in_dev' }, { status: 200 });
}
const envelope = await request.text();
// Sentry envelopes can contain multiple parts separated by newlines
@@ -40,8 +35,7 @@ export async function POST(request: NextRequest) {
const dsnUrl = new URL(realDsn);
const projectId = dsnUrl.pathname.replace('/', '');
const sentryKey = dsnUrl.username;
const relayUrl = `${dsnUrl.protocol}//${dsnUrl.host}/api/${projectId}/envelope/?sentry_key=${sentryKey}`;
const relayUrl = `${dsnUrl.protocol}//${dsnUrl.host}/api/${projectId}/envelope/`;
logger.debug('Relaying Sentry envelope', {
projectId,

View File

@@ -1,55 +1,37 @@
import { config } from '@/lib/config';
import { MetadataRoute } from 'next';
import { getAllProductsMetadata } from '@/lib/products';
import { getAllProductsMetadata } from '@/lib/mdx';
import { getAllPostsMetadata } from '@/lib/blog';
import { getAllPagesMetadata } from '@/lib/pages';
import { mapFileSlugToTranslated } from '@/lib/slugs';
export const dynamic = 'force-dynamic';
export const revalidate = 3600; // Revalidate every hour
export default async function sitemap(): Promise<MetadataRoute.Sitemap> {
const baseUrl = config.baseUrl || 'https://klz-cables.com';
const locales = ['de', 'en'];
const routes = [
'',
'/blog',
'/contact',
'/team',
'/products',
'/products/low-voltage-cables',
'/products/medium-voltage-cables',
'/products/high-voltage-cables',
'/products/solar-cables',
];
const sitemapEntries: MetadataRoute.Sitemap = [];
for (const locale of locales) {
// Helper to generate localized URL Segment
const getLocalizedRoute = async (pageKey: string) => {
if (pageKey === '') return '';
const translated = await mapFileSlugToTranslated(pageKey, locale);
return `/${translated}`;
};
// Static routes
const staticPages = ['', 'blog', 'contact', 'team', 'products'];
for (const page of staticPages) {
const localizedRoute = await getLocalizedRoute(page);
for (const route of routes) {
sitemapEntries.push({
url: `${baseUrl}/${locale}${localizedRoute}`,
url: `${baseUrl}/${locale}${route}`,
lastModified: new Date(),
changeFrequency: page === '' ? 'daily' : 'weekly',
priority: page === '' ? 1 : 0.8,
});
}
// Categories routes
const productCategories = [
'low-voltage-cables',
'medium-voltage-cables',
'high-voltage-cables',
'solar-cables',
];
const translatedProducts = await mapFileSlugToTranslated('products', locale);
for (const category of productCategories) {
const translatedCategory = await mapFileSlugToTranslated(category, locale);
sitemapEntries.push({
url: `${baseUrl}/${locale}/${translatedProducts}/${translatedCategory}`,
lastModified: new Date(),
changeFrequency: 'weekly',
priority: 0.8,
changeFrequency: route === '' ? 'daily' : 'weekly',
priority: route === '' ? 1 : 0.8,
});
}
@@ -58,28 +40,10 @@ export default async function sitemap(): Promise<MetadataRoute.Sitemap> {
for (const product of productsMetadata) {
if (!product.frontmatter || !product.slug) continue;
const firstCat = product.frontmatter.categories[0] || '';
const normalizedCat = firstCat.toLowerCase().replace(/\s+/g, '-');
let categoryFileSlug = 'low-voltage-cables';
if (normalizedCat === 'hochspannungskabel' || normalizedCat === 'high-voltage-cables')
categoryFileSlug = 'high-voltage-cables';
else if (
normalizedCat === 'mittelspannungskabel' ||
normalizedCat === 'medium-voltage-cables'
)
categoryFileSlug = 'medium-voltage-cables';
else if (
normalizedCat === 'solarkabel' ||
normalizedCat === 'solar-cables' ||
normalizedCat === 'solar'
)
categoryFileSlug = 'solar-cables';
const translatedCategory = await mapFileSlugToTranslated(categoryFileSlug, locale);
const translatedSlug = await mapFileSlugToTranslated(product.slug, locale);
const category =
product.frontmatter.categories[0]?.toLowerCase().replace(/\s+/g, '-') || 'other';
sitemapEntries.push({
url: `${baseUrl}/${locale}/${translatedProducts}/${translatedCategory}/${translatedSlug}`,
url: `${baseUrl}/${locale}/products/${category}/${product.slug}`,
lastModified: new Date(),
changeFrequency: 'monthly',
priority: 0.7,
@@ -87,15 +51,12 @@ export default async function sitemap(): Promise<MetadataRoute.Sitemap> {
}
// Blog posts
const translatedBlog = await mapFileSlugToTranslated('blog', locale);
const postsMetadata = await getAllPostsMetadata(locale);
for (const post of postsMetadata) {
if (!post.frontmatter || !post.slug) continue;
const translatedSlug = await mapFileSlugToTranslated(post.slug, locale);
sitemapEntries.push({
url: `${baseUrl}/${locale}/${translatedBlog}/${translatedSlug}`,
url: `${baseUrl}/${locale}/blog/${post.slug}`,
lastModified: new Date(post.frontmatter.date),
changeFrequency: 'monthly',
priority: 0.6,
@@ -107,10 +68,8 @@ export default async function sitemap(): Promise<MetadataRoute.Sitemap> {
for (const page of pagesMetadata) {
if (!page.slug) continue;
const translatedSlug = await mapFileSlugToTranslated(page.slug, locale);
sitemapEntries.push({
url: `${baseUrl}/${locale}/${translatedSlug}`,
url: `${baseUrl}/${locale}/${page.slug}`,
lastModified: new Date(),
changeFrequency: 'monthly',
priority: 0.5,

View File

@@ -19,39 +19,14 @@ export async function POST(request: NextRequest) {
const logger = services.logger.child({ component: 'umami-smart-proxy' });
try {
// Prevent 400 Bad Request console noise in local dev
if (process.env.NODE_ENV === 'development') {
return NextResponse.json({ status: 'ignored_in_dev' }, { status: 200 });
}
let body;
try {
body = await request.json();
} catch (_parseError) {
logger.warn('Received malformed or empty JSON in analytics proxy');
return NextResponse.json({ status: 'ignored_bad_payload' }, { status: 200 });
}
if (!body || typeof body !== 'object') {
logger.warn('Received invalid body type in analytics proxy', { type: typeof body });
return NextResponse.json({ status: 'ignored_invalid_body' }, { status: 200 });
}
const body = await request.json();
const { type, payload } = body;
if (!type || !payload) {
logger.warn('Received incomplete analytics payload', {
hasType: !!type,
hasPayload: !!payload
});
return NextResponse.json({ status: 'ignored_incomplete_payload' }, { status: 200 });
}
// Inject the secret websiteId from server config
const websiteId = config.analytics.umami.websiteId;
if (!websiteId) {
logger.warn('Umami tracking received but no Website ID configured on server');
return NextResponse.json({ status: 'ignored_no_config' }, { status: 200 });
return NextResponse.json({ status: 'ignored' }, { status: 200 });
}
// Prepare the enhanced payload with the secret ID
@@ -81,12 +56,10 @@ export async function POST(request: NextRequest) {
if (!response.ok) {
const errorText = await response.text();
if (!process.env.CI) {
logger.error('Umami API responded with error', {
status: response.status,
error: errorText.slice(0, 100),
});
}
logger.error('Umami API responded with error', {
status: response.status,
error: errorText.slice(0, 100),
});
return new NextResponse(errorText, { status: response.status });
}
@@ -96,18 +69,16 @@ export async function POST(request: NextRequest) {
const errorStack = error instanceof Error ? error.stack : undefined;
// Console error to ensure it appears in logs even if logger fails
if (!process.env.CI) {
console.error('CRITICAL PROXY ERROR:', {
message: errorMessage,
stack: errorStack,
endpoint: config.analytics.umami.apiEndpoint,
});
console.error('CRITICAL PROXY ERROR:', {
message: errorMessage,
stack: errorStack,
endpoint: config.analytics.umami.apiEndpoint,
});
logger.error('Failed to proxy analytics request', {
error: errorMessage,
stack: errorStack,
});
}
logger.error('Failed to proxy analytics request', {
error: errorMessage,
stack: errorStack,
});
return NextResponse.json(
{

View File

@@ -1,86 +0,0 @@
import React from 'react';
import { Heading } from '@/components/ui';
import fs from 'fs/promises';
import path from 'path';
import matter from 'gray-matter';
export const AgbHistoryBlock: React.FC<{ title: string }> = async ({ title }) => {
const agbs: any[] = [];
try {
const dir = path.join(process.cwd(), 'content', 'agbs');
const files = await fs.readdir(dir);
for (const f of files) {
if (!f.endsWith('.mdx')) continue;
const fileContent = await fs.readFile(path.join(dir, f), 'utf-8');
const { data } = matter(fileContent);
agbs.push({ id: f, ...data });
}
agbs.sort(
(a, b) => new Date(b.versionDate || 0).getTime() - new Date(a.versionDate || 0).getTime(),
);
} catch (err) {
console.error('Failed reading AGBs from MDX:', err);
}
if (agbs.length === 0) {
return null;
}
return (
<div className="my-16 p-8 md:p-12 bg-neutral-light rounded-3xl shadow-sm border border-neutral-medium">
<Heading level={3} className="mb-8 text-saturated">
{title || 'Vorherige Versionen'}
</Heading>
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
{agbs.map((agb: any) => {
const date = new Date(agb.versionDate).toLocaleDateString('de-DE', {
year: 'numeric',
month: 'long',
day: 'numeric',
});
const fileUrl = agb.file?.url || agb.fileUrl || '';
const filename = agb.file?.filename || agb.filename || 'agb.pdf';
return (
<div
key={agb.id}
className="flex items-center justify-between p-6 bg-white rounded-2xl shadow-sm border border-neutral-medium hover:border-primary transition-all group"
>
<div>
<h4 className="font-bold text-saturated group-hover:text-primary transition-colors">
{agb.title}
</h4>
<p className="text-sm text-text-secondary mt-1">Gültig ab {date}</p>
</div>
{fileUrl && (
<a
href={fileUrl}
download={filename}
className="p-3 bg-neutral-light text-primary rounded-full hover:bg-primary hover:text-white transition-all shadow-sm"
title="PDF herunterladen"
>
<svg
xmlns="http://www.w3.org/2000/svg"
width="24"
height="24"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
>
<path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4" />
<polyline points="7 10 12 15 17 10" />
<line x1="12" x2="12" y1="15" y2="3" />
</svg>
</a>
)}
</div>
);
})}
</div>
</div>
);
};

View File

@@ -0,0 +1,84 @@
'use client';
import React, { useEffect, useState } from 'react';
import { AlertCircle, RefreshCw } from 'lucide-react';
import { config } from '../lib/config';
export default function CMSConnectivityNotice() {
const [, setStatus] = useState<'checking' | 'ok' | 'error'>('checking');
const [errorMsg, setErrorMsg] = useState('');
const [isVisible, setIsVisible] = useState(false);
useEffect(() => {
// Only show if we've detected an issue AND we are in a context where we want to see it
const checkCMS = async () => {
const isDebug = new URLSearchParams(window.location.search).has('cms_debug');
const isLocal = config.isDevelopment;
const isTesting = config.isTesting;
// Only proceed with check if it's developer context (Local or Testing)
// Staging and Production should NEVER see this unless forced with ?cms_debug
if (!isLocal && !isTesting && !isDebug) return;
try {
const response = await fetch('/api/health/cms');
const data = await response.json();
if (data.status !== 'ok') {
setStatus('error');
setErrorMsg(data.message);
setIsVisible(true);
} else {
setStatus('ok');
setIsVisible(false);
}
} catch {
// If it's a connection error, only show if we are really debugging
if (isDebug || isLocal) {
setStatus('error');
setErrorMsg('Could not connect to CMS health endpoint');
setIsVisible(true);
}
}
};
checkCMS();
}, []);
if (!isVisible) return null;
return (
<div className="fixed bottom-4 right-4 z-[9999] animate-slide-up">
<div className="bg-red-500/90 backdrop-blur-md border border-red-400 text-white p-4 rounded-2xl shadow-2xl max-w-sm">
<div className="flex items-start gap-3">
<div className="bg-white/20 p-2 rounded-lg">
<AlertCircle className="w-5 h-5" />
</div>
<div className="flex-1">
<h4 className="font-bold text-sm mb-1">CMS Issue Detected</h4>
<p className="text-xs opacity-90 leading-relaxed mb-3">
{errorMsg === 'relation "products" does not exist'
? 'The database schema is missing. Please sync your local data to this environment.'
: errorMsg || 'The application cannot connect to the Directus CMS.'}
</p>
<div className="flex gap-2">
<button
onClick={() => window.location.reload()}
className="bg-white text-red-600 text-[10px] font-bold uppercase tracking-wider px-3 py-1.5 rounded-lg flex items-center gap-2 hover:bg-neutral-100 transition-colors"
>
<RefreshCw className="w-3 h-3" />
Retry
</button>
<button
onClick={() => setIsVisible(false)}
className="bg-black/20 text-white text-[10px] font-bold uppercase tracking-wider px-3 py-1.5 rounded-lg hover:bg-black/30 transition-colors"
>
Dismiss
</button>
</div>
</div>
</div>
</div>
</div>
);
}

View File

@@ -148,6 +148,7 @@ export default function ContactForm() {
autoComplete="name"
enterKeyHint="next"
onFocus={() => handleFocus('contact-name')}
aria-label={t('form.name')}
required
/>
</div>
@@ -162,6 +163,7 @@ export default function ContactForm() {
enterKeyHint="next"
placeholder={t('form.emailPlaceholder')}
onFocus={() => handleFocus('contact-email')}
aria-label={t('form.email')}
required
/>
</div>
@@ -174,6 +176,7 @@ export default function ContactForm() {
enterKeyHint="send"
placeholder={t('form.messagePlaceholder')}
onFocus={() => handleFocus('contact-message')}
aria-label={t('form.message')}
required
/>
</div>

View File

@@ -1,18 +0,0 @@
'use client';
import dynamic from 'next/dynamic';
const FeedbackOverlay = dynamic(
() => import('@mintel/next-feedback/FeedbackOverlay').then((mod) => mod.FeedbackOverlay),
{ ssr: false },
);
interface FeedbackClientWrapperProps {
feedbackEnabled: boolean;
}
export default function FeedbackClientWrapper({ feedbackEnabled }: FeedbackClientWrapperProps) {
if (!feedbackEnabled) return null;
return <FeedbackOverlay />;
}

View File

@@ -15,14 +15,13 @@ export default function Footer() {
const currentYear = new Date().getFullYear();
return (
<footer className="bg-primary text-white py-14 md:py-24 relative overflow-hidden content-visibility-auto">
<footer className="bg-primary text-white py-24 relative overflow-hidden content-visibility-auto">
<div className="absolute top-0 left-0 w-full h-px bg-gradient-to-r from-transparent via-white/20 to-transparent" />
<Container>
<h2 className="sr-only">Footer Navigation</h2>
<div className="grid grid-cols-2 md:grid-cols-2 lg:grid-cols-12 gap-10 md:gap-16 mb-12 md:mb-20">
{/* Brand Column full width on mobile */}
<div className="col-span-2 md:col-span-2 lg:col-span-4 space-y-6 md:space-y-8">
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-12 gap-16 mb-20">
{/* Brand Column */}
<div className="lg:col-span-4 space-y-8">
<Link
href={`/${locale}`}
className="inline-block group"
@@ -38,8 +37,6 @@ export default function Footer() {
alt="KLZ Vertriebs GmbH"
width={150}
height={40}
sizes="(max-width: 768px) 150px, 150px"
style={{ width: 'auto' }}
className="h-10 w-auto transition-transform duration-500 group-hover:scale-110"
/>
</Link>
@@ -68,9 +65,9 @@ export default function Footer() {
</div>
</div>
{/* Legal Column */}
<div className="col-span-1 lg:col-span-2">
<h3 className="text-accent font-bold uppercase tracking-widest text-xs mb-5 md:mb-8">
{/* Links Columns */}
<div className="lg:col-span-2">
<h3 className="text-accent font-bold uppercase tracking-widest text-xs md:text-sm mb-8">
{t('legal')}
</h3>
<ul className="space-y-4 text-white/70 list-none m-0 p-0">
@@ -122,9 +119,8 @@ export default function Footer() {
</ul>
</div>
{/* Company Column */}
<div className="col-span-1 lg:col-span-2">
<h3 className="text-accent font-bold uppercase tracking-widest text-xs mb-5 md:mb-8">
<div className="lg:col-span-2">
<h3 className="text-accent font-bold uppercase tracking-widest text-xs md:text-sm mb-8">
{t('company')}
</h3>
<ul className="space-y-4 text-white/70 list-none m-0 p-0">
@@ -175,12 +171,12 @@ export default function Footer() {
</li>
<li>
<Link
href={`/${locale}/${locale === 'de' ? 'kontakt' : 'contact'}`}
href={`/${locale}/contact`}
className="text-white/70 hover:text-accent transition-all duration-300 hover:translate-x-1 inline-block"
onClick={() =>
trackEvent(AnalyticsEvents.LINK_CLICK, {
label: navT('contact'),
href: locale === 'de' ? '/kontakt' : '/contact',
href: '/contact',
location: 'footer_company',
})
}
@@ -191,9 +187,9 @@ export default function Footer() {
</ul>
</div>
{/* Recent Posts Column full width on mobile */}
<div className="col-span-2 md:col-span-2 lg:col-span-4">
<h3 className="text-accent font-bold uppercase tracking-widest text-xs mb-5 md:mb-8">
{/* Recent Posts Column */}
<div className="lg:col-span-4">
<h3 className="text-accent font-bold uppercase tracking-widest text-xs md:text-sm mb-8">
{t('recentPosts')}
</h3>
<ul className="space-y-6 list-none m-0 p-0">
@@ -244,11 +240,12 @@ export default function Footer() {
</div>
</div>
<div className="pt-8 md:pt-12 border-t border-white/10 flex flex-row justify-between items-center gap-4 text-white/70 text-xs md:text-sm font-medium">
<div className="pt-12 border-t border-white/10 flex flex-col md:flex-row justify-between items-center gap-8 text-white/70 text-xs md:text-sm font-medium">
<p>{t('copyright', { year: currentYear })}</p>
<div className="flex gap-8">
<Link
href="/en"
locale="en"
className="hover:text-white transition-colors"
onClick={() =>
trackEvent(AnalyticsEvents.TOGGLE_SWITCH, {
@@ -263,6 +260,7 @@ export default function Footer() {
</Link>
<Link
href="/de"
locale="de"
className="hover:text-white transition-colors"
onClick={() =>
trackEvent(AnalyticsEvents.TOGGLE_SWITCH, {

View File

@@ -2,6 +2,7 @@
import Link from 'next/link';
import Image from 'next/image';
import { motion } from 'framer-motion';
import { useTranslations } from 'next-intl';
import { usePathname } from 'next/navigation';
import { Button } from './ui';
@@ -17,6 +18,7 @@ export default function Header() {
const [isScrolled, setIsScrolled] = useState(false);
const [isMobileMenuOpen, setIsMobileMenuOpen] = useState(false);
const mobileMenuRef = useRef<HTMLDivElement>(null);
const closeButtonRef = useRef<HTMLButtonElement>(null);
// Extract locale from pathname
const currentLocale = pathname.split('/')[1] || 'en';
@@ -33,6 +35,7 @@ export default function Header() {
return () => window.removeEventListener('scroll', handleScroll);
}, []);
// Prevent scroll when mobile menu is open
// Prevent scroll when mobile menu is open and handle focus trap
useEffect(() => {
if (isMobileMenuOpen) {
@@ -84,49 +87,11 @@ export default function Header() {
}
}, [isMobileMenuOpen]);
// Function to get path for a different locale with segment translation
// Function to get path for a different locale
const getPathForLocale = (newLocale: string) => {
const segments = pathname.split('/');
const originLocale = segments[1] || 'en';
// Translation map for localized URL segments
const segmentMap: Record<string, Record<string, string>> = {
de: {
produkte: 'products',
kontakt: 'contact',
impressum: 'legal-notice',
datenschutz: 'privacy-policy',
agbs: 'terms',
niederspannungskabel: 'low-voltage-cables',
mittelspannungskabel: 'medium-voltage-cables',
hochspannungskabel: 'high-voltage-cables',
solarkabel: 'solar-cables',
},
en: {
products: 'produkte',
contact: 'kontakt',
'legal-notice': 'impressum',
'privacy-policy': 'datenschutz',
terms: 'agbs',
'low-voltage-cables': 'niederspannungskabel',
'medium-voltage-cables': 'mittelspannungskabel',
'high-voltage-cables': 'hochspannungskabel',
'solar-cables': 'solarkabel',
},
};
// Replace the locale segment
segments[1] = newLocale;
// Translate other segments if they exist in our map
const translatedSegments = segments.map((segment, index) => {
if (index <= 1) return segment; // Skip empty and locale segments
const mapping = segmentMap[originLocale as keyof typeof segmentMap];
return mapping && mapping[segment] ? mapping[segment] : segment;
});
return translatedSegments.join('/');
return segments.join('/');
};
const menuItems = [
@@ -137,12 +102,10 @@ export default function Header() {
];
const headerClass = cn(
'fixed top-0 left-0 right-0 z-50 transition-all duration-500 safe-area-p transform-gpu fill-mode-both',
'fixed top-0 left-0 right-0 z-50 transition-all duration-500 safe-area-p transform-gpu',
{
'bg-primary/95 backdrop-blur-md md:bg-transparent py-3 md:py-8 shadow-2xl md:shadow-none':
isHomePage && !isScrolled && !isMobileMenuOpen,
'bg-primary/90 backdrop-blur-md py-3 md:py-4 shadow-2xl':
!isHomePage || isScrolled || isMobileMenuOpen,
'bg-transparent py-4 md:py-8': isHomePage && !isScrolled && !isMobileMenuOpen,
'bg-primary py-3 md:py-4 shadow-2xl': !isHomePage || isScrolled || isMobileMenuOpen,
},
);
@@ -151,9 +114,19 @@ export default function Header() {
return (
<>
<header className={headerClass} style={{ animationDuration: '800ms' }}>
<motion.header
className={headerClass}
initial={{ y: -100, opacity: 0 }}
animate={{ y: 0, opacity: 1 }}
transition={{ duration: 0.8, ease: 'easeOut' }}
>
<div className="container mx-auto px-4 md:px-12 lg:px-16 max-w-7xl flex items-center justify-between">
<div className="flex-shrink-0 group touch-target fill-mode-both">
<motion.div
className="flex-shrink-0 group touch-target"
initial={{ scale: 0.8, opacity: 0 }}
animate={{ scale: 1, opacity: 1 }}
transition={{ duration: 0.6, ease: 'easeOut', delay: 0.1 }}
>
<Link
href={`/${currentLocale}`}
onClick={() =>
@@ -168,75 +141,65 @@ export default function Header() {
alt={t('home')}
width={120}
height={120}
sizes="(max-width: 768px) 120px, 120px"
style={{ width: 'auto' }}
className="h-10 md:h-14 w-auto transition-all duration-500 group-hover:scale-110"
priority
fetchPriority="high"
loading="eager"
decoding="sync"
/>
</Link>
</div>
</motion.div>
<div className="flex items-center gap-4 md:gap-12">
<nav className="hidden lg:flex items-center space-x-10">
{menuItems.map((item, idx) => (
<div
key={item.href}
className="animate-in fade-in slide-in-from-bottom-4 fill-mode-both"
style={{ animationDuration: '500ms', animationDelay: `${150 + idx * 80}ms` }}
>
{(() => {
const fullHref = `/${currentLocale}${item.href === '/' ? '' : item.href}`;
const isActive =
item.href === '/'
? pathname === `/${currentLocale}` || pathname === '/'
: pathname.startsWith(fullHref);
return (
<Link
href={fullHref}
aria-current={isActive ? 'page' : undefined}
onClick={() => {
setIsMobileMenuOpen(false);
trackEvent(AnalyticsEvents.LINK_CLICK, {
label: item.label,
href: item.href,
location: 'header_nav',
});
}}
className={cn(
textColorClass,
'hover:text-accent font-bold transition-all duration-500 text-base md:text-lg tracking-tight relative group inline-block hover:-translate-y-0.5',
isActive && 'text-accent',
)}
>
{item.label}
<span
className={cn(
'absolute -bottom-2 left-0 h-1 bg-accent transition-all duration-500 rounded-full shadow-[0_0_12px_rgba(130,237,32,0.6)]',
isActive ? 'w-full' : 'w-0 group-hover:w-full',
)}
/>
</Link>
);
})()}
</div>
<motion.div
className="flex items-center gap-4 md:gap-12"
initial="hidden"
animate="visible"
variants={{
visible: {
transition: {
staggerChildren: 0.08,
delayChildren: 0.3,
},
},
}}
>
<motion.nav className="hidden lg:flex items-center space-x-10" variants={navVariants}>
{menuItems.map((item, _idx) => (
<motion.div key={item.href} variants={navLinkVariants}>
<Link
href={`/${currentLocale}${item.href === '/' ? '' : item.href}`}
onClick={() => {
setIsMobileMenuOpen(false);
trackEvent(AnalyticsEvents.LINK_CLICK, {
label: item.label,
href: item.href,
location: 'header_nav',
});
}}
className={cn(
textColorClass,
'hover:text-accent font-bold transition-all duration-500 text-base md:text-lg tracking-tight relative group inline-block hover:-translate-y-0.5',
)}
>
{item.label}
<span className="absolute -bottom-2 left-0 w-0 h-1 bg-accent transition-all duration-500 group-hover:w-full rounded-full shadow-[0_0_12px_rgba(130,237,32,0.6)]" />
</Link>
</motion.div>
))}
</nav>
</motion.nav>
<div
className={cn(
'hidden lg:flex items-center space-x-8 animate-in fade-in slide-in-from-right-8 fill-mode-both',
textColorClass,
)}
style={{ animationDuration: '600ms', animationDelay: '300ms' }}
<motion.div
className={cn('hidden lg:flex items-center space-x-8', textColorClass)}
variants={headerRightVariants}
>
<div
className="flex items-center space-x-4 text-xs md:text-sm font-extrabold tracking-widest uppercase animate-in fade-in slide-in-from-left-4 fill-mode-both"
style={{ animationDuration: '500ms', animationDelay: '600ms' }}
<motion.div
className="flex items-center space-x-4 text-xs md:text-sm font-extrabold tracking-widest uppercase"
initial={{ opacity: 0, x: 20 }}
animate={{ opacity: 1, x: 0 }}
transition={{ duration: 0.5, delay: 0.6 }}
>
<div>
<motion.div
initial={{ opacity: 0, scale: 0.8 }}
animate={{ opacity: 1, scale: 1 }}
transition={{ duration: 0.4, delay: 0.65 }}
>
<Link
href={getPathForLocale('en')}
onClick={() =>
@@ -247,13 +210,22 @@ export default function Header() {
location: 'header',
})
}
className={`hover:text-accent transition-colors flex items-center gap-2 touch-target ${currentLocale === 'en' ? 'text-accent' : 'opacity-80'}`}
className={`hover:text-accent transition-colors flex items-center gap-2 touch-target ${currentLocale === 'en' ? 'text-accent' : 'opacity-60'}`}
>
EN
</Link>
</div>
<div className="w-px h-4 bg-current opacity-30" />
<div>
</motion.div>
<motion.div
className="w-px h-4 bg-current opacity-20"
initial={{ scaleY: 0 }}
animate={{ scaleY: 1 }}
transition={{ duration: 0.4, delay: 0.7 }}
/>
<motion.div
initial={{ opacity: 0, scale: 0.8 }}
animate={{ opacity: 1, scale: 1 }}
transition={{ duration: 0.4, delay: 0.75 }}
>
<Link
href={getPathForLocale('de')}
onClick={() =>
@@ -264,22 +236,23 @@ export default function Header() {
location: 'header',
})
}
className={`hover:text-accent transition-colors flex items-center gap-2 touch-target ${currentLocale === 'de' ? 'text-accent' : 'opacity-80'}`}
className={`hover:text-accent transition-colors flex items-center gap-2 touch-target ${currentLocale === 'de' ? 'text-accent' : 'opacity-60'}`}
>
DE
</Link>
</div>
</div>
</motion.div>
</motion.div>
<div
className="animate-in fade-in zoom-in-95 fill-mode-both"
style={{ animationDuration: '600ms', animationDelay: '700ms' }}
<motion.div
initial={{ scale: 0.9, opacity: 0 }}
animate={{ scale: 1, opacity: 1 }}
transition={{ duration: 0.6, type: 'spring', stiffness: 400, delay: 0.7 }}
>
<Button
href={`/${currentLocale}/${currentLocale === 'de' ? 'kontakt' : 'contact'}`}
href={`/${currentLocale}/contact`}
variant="white"
size="md"
className="px-8 shadow-xl hover:scale-105 transition-transform"
className="px-8 shadow-xl"
onClick={() =>
trackEvent(AnalyticsEvents.BUTTON_CLICK, {
label: t('contact'),
@@ -289,19 +262,27 @@ export default function Header() {
>
{t('contact')}
</Button>
</div>
</div>
</motion.div>
</motion.div>
{/* Mobile Menu Button */}
<button
<motion.button
className={cn(
'lg:hidden touch-target p-2 rounded-xl bg-white/10 border border-white/20 z-[70] relative transition-all duration-300',
'lg:hidden touch-target p-2 rounded-xl bg-white/10 border border-white/20 z-50',
textColorClass,
isMobileMenuOpen ? 'rotate-90 scale-110' : 'rotate-0 scale-100',
)}
aria-label={t('toggleMenu')}
aria-expanded={isMobileMenuOpen}
aria-controls="mobile-menu"
initial={{ scale: 0.8, opacity: 0, rotate: -180 }}
animate={{ scale: 1, opacity: 1, rotate: 0 }}
transition={{
duration: 0.6,
type: 'spring',
stiffness: 300,
damping: 20,
delay: 0.5,
}}
onClick={() => {
const newState = !isMobileMenuOpen;
setIsMobileMenuOpen(newState);
@@ -311,177 +292,213 @@ export default function Header() {
});
}}
>
<svg
className="w-7 h-7 transition-transform duration-300"
<motion.svg
className="w-7 h-7"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
initial={{ scale: 0 }}
animate={{ scale: 1 }}
transition={{ duration: 0.3, delay: 0.6 }}
>
{isMobileMenuOpen ? (
<path
<motion.path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M6 18L18 6M6 6l12 12"
initial={{ pathLength: 0 }}
animate={{ pathLength: 1 }}
transition={{ duration: 0.4, delay: 0.7 }}
/>
) : (
<path
<motion.path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M4 6h16M4 12h16M4 18h16"
initial={{ pathLength: 0 }}
animate={{ pathLength: 1 }}
transition={{ duration: 0.4, delay: 0.7 }}
/>
)}
</svg>
</button>
</div>
</motion.svg>
</motion.button>
</motion.div>
</div>
</header>
{/* Mobile Menu Overlay */}
<div
className={cn(
'fixed inset-0 bg-primary/95 backdrop-blur-3xl z-[60] lg:hidden transition-all duration-500 ease-in-out flex flex-col',
isMobileMenuOpen
? 'opacity-100 translate-y-0'
: 'opacity-0 -translate-y-full pointer-events-none',
)}
id="mobile-menu"
role="dialog"
aria-modal="true"
aria-label={t('menu')}
ref={mobileMenuRef}
inert={isMobileMenuOpen ? undefined : true}
>
{/* Close Button inside overlay */}
<div className="flex justify-end p-6 pt-8">
<button
className="touch-target p-2 rounded-xl bg-white/10 border border-white/20 text-white hover:bg-white/20 transition-all duration-300"
aria-label={t('toggleMenu')}
onClick={() => {
setIsMobileMenuOpen(false);
trackEvent(AnalyticsEvents.BUTTON_CLICK, {
type: 'mobile_menu',
action: 'close',
});
{/* Mobile Menu Overlay */}
<div
className={cn(
'fixed inset-0 bg-primary z-40 lg:hidden transition-all duration-500 ease-in-out flex flex-col',
isMobileMenuOpen
? 'opacity-100 translate-y-0'
: 'opacity-0 -translate-y-full pointer-events-none',
)}
id="mobile-menu"
role="dialog"
aria-modal="true"
aria-label={t('menu')}
ref={mobileMenuRef}
>
<motion.nav
className="flex-grow flex flex-col justify-center items-center p-8 space-y-8"
initial="closed"
animate={isMobileMenuOpen ? 'open' : 'closed'}
variants={{
open: {
transition: {
staggerChildren: 0.1,
delayChildren: 0.2,
},
},
}}
>
<svg className="w-7 h-7" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M6 18L18 6M6 6l12 12"
/>
</svg>
</button>
</div>
<nav className="flex-grow flex flex-col justify-center items-center p-8 space-y-8">
{menuItems.map((item, idx) => (
<div
key={item.href}
className={cn(
'transition-all duration-500 transform',
isMobileMenuOpen ? 'opacity-100 translate-y-0' : 'opacity-0 translate-y-8',
)}
style={{ transitionDelay: `${isMobileMenuOpen ? 200 + idx * 80 : 0}ms` }}
{menuItems.map((item, idx) => (
<motion.div
key={item.href}
variants={{
closed: { opacity: 0, y: 50, scale: 0.9 },
open: {
opacity: 1,
y: 0,
scale: 1,
transition: {
duration: 0.6,
ease: 'easeOut',
delay: idx * 0.08,
},
},
}}
>
<Link
href={`/${currentLocale}${item.href === '/' ? '' : item.href}`}
onClick={() => {
setIsMobileMenuOpen(false);
trackEvent(AnalyticsEvents.LINK_CLICK, {
label: item.label,
href: item.href,
location: 'mobile_menu',
});
}}
className="text-2xl md:text-3xl font-extrabold text-white hover:text-accent transition-all transform block py-4"
>
{item.label}
</Link>
</motion.div>
))}
<motion.div
className="pt-8 border-t border-white/10 w-full flex flex-col items-center space-y-8"
initial={{ opacity: 0, y: 30 }}
animate={isMobileMenuOpen ? { opacity: 1, y: 0 } : { opacity: 0, y: 30 }}
transition={{ duration: 0.5, delay: 0.8 }}
>
<Link
href={`/${currentLocale}${item.href === '/' ? '' : item.href}`}
aria-current={
(
item.href === '/'
? pathname === `/${currentLocale}` || pathname === '/'
: pathname.startsWith(`/${currentLocale}${item.href}`)
)
? 'page'
: undefined
}
onClick={() => {
setIsMobileMenuOpen(false);
trackEvent(AnalyticsEvents.LINK_CLICK, {
label: item.label,
href: item.href,
location: 'mobile_menu',
});
}}
className={cn(
'text-2xl md:text-3xl font-extrabold text-white hover:text-accent transition-all transform block py-4',
(item.href === '/'
? pathname === `/${currentLocale}` || pathname === '/'
: pathname.startsWith(`/${currentLocale}${item.href}`)) && 'text-accent',
)}
<motion.div
className="flex items-center space-x-8 text-lg md:text-xl font-extrabold tracking-widest uppercase text-white"
initial={{ opacity: 0, scale: 0.8 }}
animate={{ opacity: 1, scale: 1 }}
transition={{ duration: 0.4, delay: 0.9 }}
>
{item.label}
</Link>
</div>
))}
<div
className={cn(
'pt-8 border-t border-white/10 w-full flex flex-col items-center space-y-8 transition-all duration-500',
isMobileMenuOpen ? 'opacity-100 translate-y-0' : 'opacity-0 translate-y-8',
)}
style={{ transitionDelay: isMobileMenuOpen ? '600ms' : '0ms' }}
>
<div className="flex items-center space-x-8 text-lg md:text-xl font-extrabold tracking-widest uppercase text-white">
<div>
<Link
href={getPathForLocale('en')}
className={`hover:text-accent transition-colors ${currentLocale === 'en' ? 'text-accent' : 'opacity-80'}`}
<motion.div
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
transition={{ duration: 0.3, delay: 1.0 }}
>
EN
</Link>
</div>
<div className="w-px h-6 bg-white/30" />
<div>
<Link
href={getPathForLocale('de')}
className={`hover:text-accent transition-colors ${currentLocale === 'de' ? 'text-accent' : 'opacity-80'}`}
<Link
href={getPathForLocale('en')}
className={`hover:text-accent transition-colors ${currentLocale === 'en' ? 'text-accent' : 'opacity-60'}`}
>
EN
</Link>
</motion.div>
<motion.div
className="w-px h-6 bg-white/20"
initial={{ scaleX: 0 }}
animate={{ scaleX: 1 }}
transition={{ duration: 0.4, delay: 1.05 }}
/>
<motion.div
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
transition={{ duration: 0.3, delay: 1.1 }}
>
DE
</Link>
</div>
</div>
<Link
href={getPathForLocale('de')}
className={`hover:text-accent transition-colors ${currentLocale === 'de' ? 'text-accent' : 'opacity-60'}`}
>
DE
</Link>
</motion.div>
</motion.div>
<div className="w-full max-w-xs">
<Button
href={`/${currentLocale}/${currentLocale === 'de' ? 'kontakt' : 'contact'}`}
variant="accent"
size="lg"
className="w-full py-6 text-lg md:text-xl shadow-2xl hover:scale-105 transition-transform"
onClick={() => {
setIsMobileMenuOpen(false);
trackEvent(AnalyticsEvents.BUTTON_CLICK, {
label: t('contact'),
location: 'mobile_menu_cta',
});
}}
<motion.div
initial={{ scale: 0.9, opacity: 0, y: 20 }}
animate={{ scale: 1, opacity: 1, y: 0 }}
transition={{ type: 'spring', stiffness: 400, damping: 20, delay: 1.2 }}
>
{t('contact')}
</Button>
</div>
</div>
<Button
href={`/${currentLocale}/contact`}
variant="accent"
size="lg"
className="w-full max-w-xs py-6 text-lg md:text-xl shadow-2xl"
>
{t('contact')}
</Button>
</motion.div>
</motion.div>
{/* Bottom Branding */}
<div
className={cn(
'p-12 flex justify-center transition-all duration-700',
isMobileMenuOpen ? 'opacity-20 scale-100' : 'opacity-0 scale-75',
)}
style={{ transitionDelay: isMobileMenuOpen ? '800ms' : '0ms' }}
>
<Image
src="/logo-white.svg"
alt={t('home')}
width={80}
height={80}
sizes="80px"
unoptimized
/>
</div>
</nav>
</div>
{/* Bottom Branding */}
<motion.div
className="p-12 flex justify-center opacity-20"
initial={{ opacity: 0, scale: 0.8 }}
animate={isMobileMenuOpen ? { opacity: 0.2, scale: 1 } : { opacity: 0, scale: 0.8 }}
transition={{ duration: 0.5, delay: 1.4 }}
>
<motion.div
initial={{ scale: 0.5 }}
animate={{ scale: 1 }}
transition={{ type: 'spring', stiffness: 300, delay: 1.5 }}
>
<Image src="/logo-white.svg" alt={t('home')} width={80} height={80} unoptimized />
</motion.div>
</motion.div>
</motion.nav>
</div>
</motion.header>
</>
);
}
const navVariants = {
hidden: { opacity: 0 },
visible: {
opacity: 1,
transition: {
staggerChildren: 0.06,
delayChildren: 0.1,
},
},
} as const;
const navLinkVariants = {
hidden: { opacity: 0, y: 20, scale: 0.9 },
visible: {
opacity: 1,
y: 0,
scale: 1,
transition: {
duration: 0.5,
ease: 'easeOut',
},
},
} as const;
const headerRightVariants = {
hidden: { opacity: 0, x: 30 },
visible: {
opacity: 1,
x: 0,
transition: { duration: 0.6, ease: 'easeOut' },
},
} as const;

View File

@@ -7,14 +7,16 @@ interface JsonLdProps {
export default function JsonLd({ id, data }: JsonLdProps) {
// If data is provided, use it. Otherwise, use the default Organization + WebSite schema.
const rawData = data || [
const schemaData = data || [
{
'@context': 'https://schema.org',
'@type': 'Organization',
name: 'KLZ Cables',
url: 'https://klz-cables.com',
logo: 'https://klz-cables.com/logo-blue.svg',
sameAs: ['https://www.linkedin.com/company/klz-cables'],
sameAs: [
'https://www.linkedin.com/company/klz-cables',
],
description: 'Premium Cable Solutions for Renewable Energy and Infrastructure.',
address: {
'@type': 'PostalAddress',
@@ -34,32 +36,15 @@ export default function JsonLd({ id, data }: JsonLdProps) {
},
'query-input': 'required name=search_term_string',
},
},
}
];
// Harden schema items: Ensure each item is an object and has @context
const schemaData = (Array.isArray(rawData) ? rawData : [rawData])
.filter((item): item is WithContext<Thing> => {
// Basic sanity check: must be an object and not null
return typeof item === 'object' && item !== null;
})
.map((item) => {
// Ensure @context is present and correctly typed for consumer libraries
if (!item['@context']) {
return {
...(item as Record<string, any>),
'@context': 'https://schema.org',
} as WithContext<Thing>;
}
return item;
});
return (
<script
id={id}
type="application/ld+json"
dangerouslySetInnerHTML={{
__html: JSON.stringify(schemaData.length === 1 ? schemaData[0] : schemaData),
__html: JSON.stringify(schemaData),
}}
/>
);

View File

@@ -3,7 +3,7 @@
import React, { useEffect, useState, useCallback, useRef } from 'react';
import Image from 'next/image';
import { createPortal } from 'react-dom';
import { m, LazyMotion, AnimatePresence } from 'framer-motion';
import { motion, AnimatePresence } from 'framer-motion';
import { useRouter, useSearchParams, usePathname } from 'next/navigation';
interface LightboxProps {
@@ -139,120 +139,118 @@ export default function Lightbox({ isOpen, images, initialIndex, onClose }: Ligh
if (!mounted) return null;
return createPortal(
<LazyMotion strict features={() => import('@/lib/framer-features').then(res => res.default)}>
<AnimatePresence>
{isOpen && (
<div
className="fixed inset-0 z-[99999] flex items-center justify-center"
role="dialog"
aria-modal="true"
<AnimatePresence>
{isOpen && (
<div
className="fixed inset-0 z-[99999] flex items-center justify-center"
role="dialog"
aria-modal="true"
>
<motion.div
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
transition={{ duration: 0.5 }}
className="absolute inset-0 bg-primary/95 backdrop-blur-xl"
onClick={handleClose}
/>
<motion.button
initial={{ opacity: 0, scale: 0.5 }}
animate={{ opacity: 1, scale: 1 }}
exit={{ opacity: 0, scale: 0.5 }}
transition={{ delay: 0.1, duration: 0.4 }}
ref={closeButtonRef}
onClick={handleClose}
className="absolute top-6 right-6 text-white/60 hover:text-white transition-all duration-500 z-[10000] rounded-full w-14 h-14 flex items-center justify-center hover:bg-white/5 group border border-white/10"
aria-label="Close lightbox"
>
<m.div
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
transition={{ duration: 0.5 }}
className="absolute inset-0 bg-primary/95 backdrop-blur-xl"
onClick={handleClose}
/>
<div className="relative w-full h-full flex items-center justify-center group-hover:rotate-90 transition-transform duration-500">
<span className="text-3xl font-extralight leading-none mb-1">×</span>
</div>
</motion.button>
<m.button
initial={{ opacity: 0, scale: 0.5 }}
animate={{ opacity: 1, scale: 1 }}
exit={{ opacity: 0, scale: 0.5 }}
transition={{ delay: 0.1, duration: 0.4 }}
ref={closeButtonRef}
onClick={handleClose}
className="absolute top-6 right-6 text-white/60 hover:text-white transition-all duration-500 z-[10000] rounded-full w-14 h-14 flex items-center justify-center hover:bg-white/5 group border border-white/10"
aria-label="Close lightbox"
>
<div className="relative w-full h-full flex items-center justify-center group-hover:rotate-90 transition-transform duration-500">
<span className="text-3xl font-extralight leading-none mb-1">×</span>
<motion.button
initial={{ opacity: 0, x: -20 }}
animate={{ opacity: 1, x: 0 }}
exit={{ opacity: 0, x: -20 }}
transition={{ delay: 0.2, duration: 0.4 }}
onClick={prevImage}
className="absolute left-6 top-1/2 -translate-y-1/2 text-white/60 hover:text-white transition-all duration-500 w-14 h-14 flex items-center justify-center hover:bg-white/5 rounded-full z-[10000] group border border-white/10"
aria-label="Previous image"
>
<span className="text-4xl font-extralight group-hover:-translate-x-1 transition-transform duration-500">
</span>
</motion.button>
<motion.button
initial={{ opacity: 0, x: 20 }}
animate={{ opacity: 1, x: 0 }}
exit={{ opacity: 0, x: 20 }}
transition={{ delay: 0.2, duration: 0.4 }}
onClick={nextImage}
className="absolute right-6 top-1/2 -translate-y-1/2 text-white/60 hover:text-white transition-all duration-500 w-14 h-14 flex items-center justify-center hover:bg-white/5 rounded-full z-[10000] group border border-white/10"
aria-label="Next image"
>
<span className="text-4xl font-extralight group-hover:translate-x-1 transition-transform duration-500">
</span>
</motion.button>
<motion.div
initial={{ opacity: 0, y: 40, scale: 0.95 }}
animate={{ opacity: 1, y: 0, scale: 1 }}
exit={{ opacity: 0, y: 20, scale: 0.98 }}
transition={{ duration: 0.6, ease: [0.25, 0.46, 0.45, 0.94] }}
className="relative w-full h-full max-w-6xl max-h-[85vh] flex flex-col items-center justify-center p-4 md:p-12 z-20 pointer-events-none"
>
<div className="pointer-events-auto w-full h-full flex flex-col items-center justify-center">
<div className="relative w-full h-full shadow-[0_40px_100px_-20px_rgba(0,0,0,0.6)] ring-1 ring-white/20 overflow-hidden bg-primary-dark/50 rounded-2xl flex items-center justify-center">
<AnimatePresence mode="wait" initial={false}>
<motion.div
key={currentIndex}
initial={{ opacity: 0, scale: 1.1, filter: 'blur(10px)' }}
animate={{ opacity: 1, scale: 1, filter: 'blur(0px)' }}
exit={{ opacity: 0, scale: 0.9, filter: 'blur(10px)' }}
transition={{ duration: 0.7, ease: [0.25, 0.46, 0.45, 0.94] }}
className="relative w-full h-full"
>
<Image
src={images[currentIndex]}
alt={`Gallery image ${currentIndex + 1}`}
fill
className="object-cover transition-transform duration-1000 hover:scale-[1.03]"
unoptimized
/>
</motion.div>
</AnimatePresence>
{/* Technical Detail: Subtle grid overlay to reinforce industrial precision */}
<div className="absolute inset-0 pointer-events-none opacity-[0.03] bg-[url('/grid.svg')] bg-repeat z-10" />
{/* Premium Reflection: Subtle gradient to give material feel */}
<div className="absolute inset-0 pointer-events-none bg-gradient-to-tr from-white/10 via-transparent to-transparent opacity-40 z-10" />
</div>
</m.button>
<m.button
initial={{ opacity: 0, x: -20 }}
animate={{ opacity: 1, x: 0 }}
exit={{ opacity: 0, x: -20 }}
transition={{ delay: 0.2, duration: 0.4 }}
onClick={prevImage}
className="absolute left-6 top-1/2 -translate-y-1/2 text-white/60 hover:text-white transition-all duration-500 w-14 h-14 flex items-center justify-center hover:bg-white/5 rounded-full z-[10000] group border border-white/10"
aria-label="Previous image"
>
<span className="text-4xl font-extralight group-hover:-translate-x-1 transition-transform duration-500">
</span>
</m.button>
<m.button
initial={{ opacity: 0, x: 20 }}
animate={{ opacity: 1, x: 0 }}
exit={{ opacity: 0, x: 20 }}
transition={{ delay: 0.2, duration: 0.4 }}
onClick={nextImage}
className="absolute right-6 top-1/2 -translate-y-1/2 text-white/60 hover:text-white transition-all duration-500 w-14 h-14 flex items-center justify-center hover:bg-white/5 rounded-full z-[10000] group border border-white/10"
aria-label="Next image"
>
<span className="text-4xl font-extralight group-hover:translate-x-1 transition-transform duration-500">
</span>
</m.button>
<m.div
initial={{ opacity: 0, y: 40, scale: 0.95 }}
animate={{ opacity: 1, y: 0, scale: 1 }}
exit={{ opacity: 0, y: 20, scale: 0.98 }}
transition={{ duration: 0.6, ease: [0.25, 0.46, 0.45, 0.94] }}
className="relative w-full h-full max-w-6xl max-h-[85vh] flex flex-col items-center justify-center p-4 md:p-12 z-20 pointer-events-none"
>
<div className="pointer-events-auto w-full h-full flex flex-col items-center justify-center">
<div className="relative w-full h-full shadow-[0_40px_100px_-20px_rgba(0,0,0,0.6)] ring-1 ring-white/20 overflow-hidden bg-primary-dark/50 rounded-2xl flex items-center justify-center">
<AnimatePresence mode="wait" initial={false}>
<m.div
key={currentIndex}
initial={{ opacity: 0, scale: 1.1, filter: 'blur(10px)' }}
animate={{ opacity: 1, scale: 1, filter: 'blur(0px)' }}
exit={{ opacity: 0, scale: 0.9, filter: 'blur(10px)' }}
transition={{ duration: 0.7, ease: [0.25, 0.46, 0.45, 0.94] }}
className="relative w-full h-full"
>
<Image
src={images[currentIndex]}
alt={`Gallery image ${currentIndex + 1}`}
fill
className="object-cover transition-transform duration-1000 hover:scale-[1.03]"
unoptimized
/>
</m.div>
</AnimatePresence>
{/* Technical Detail: Subtle grid overlay to reinforce industrial precision */}
<div className="absolute inset-0 pointer-events-none opacity-[0.03] bg-[url('/grid.svg')] bg-repeat z-10" />
{/* Premium Reflection: Subtle gradient to give material feel */}
<div className="absolute inset-0 pointer-events-none bg-gradient-to-tr from-white/10 via-transparent to-transparent opacity-40 z-10" />
<motion.div
initial={{ opacity: 0, y: 10 }}
animate={{ opacity: 1, y: 0 }}
exit={{ opacity: 0, y: 10 }}
transition={{ delay: 0.3, duration: 0.4 }}
className="mt-8 flex items-center gap-4"
>
<div className="h-px w-12 bg-white/20" />
<div className="bg-white/5 backdrop-blur-2xl text-white px-6 py-2 rounded-full border border-white/10 text-[11px] font-bold tracking-[0.2em] uppercase">
{currentIndex + 1} <span className="text-accent mx-3">/</span> {images.length}
</div>
<m.div
initial={{ opacity: 0, y: 10 }}
animate={{ opacity: 1, y: 0 }}
exit={{ opacity: 0, y: 10 }}
transition={{ delay: 0.3, duration: 0.4 }}
className="mt-8 flex items-center gap-4"
>
<div className="h-px w-12 bg-white/20" />
<div className="bg-white/5 backdrop-blur-2xl text-white px-6 py-2 rounded-full border border-white/10 text-[11px] font-bold tracking-[0.2em] uppercase">
{currentIndex + 1} <span className="text-accent mx-3">/</span> {images.length}
</div>
<div className="h-px w-12 bg-white/20" />
</m.div>
</div>
</m.div>
</div>
)}
</AnimatePresence>
</LazyMotion>,
<div className="h-px w-12 bg-white/20" />
</motion.div>
</div>
</motion.div>
</div>
)}
</AnimatePresence>,
document.body,
);
}

View File

@@ -1,131 +0,0 @@
import React from 'react';
import { MDXRemote } from 'next-mdx-remote/rsc';
import { getLocale } from 'next-intl/server';
// Blog components
import TechnicalGrid from './blog/TechnicalGrid';
import Stats from './blog/Stats';
import VisualLinkPreview from './blog/VisualLinkPreview';
import StickyNarrative from './blog/StickyNarrative';
import ComparisonGrid from './blog/ComparisonGrid';
import HighlightBox from './blog/HighlightBox';
import ChatBubble from './blog/ChatBubble';
// Home components
import Hero from './home/Hero';
import ProductCategories from './home/ProductCategories';
import WhatWeDo from './home/WhatWeDo';
import RecentPosts from './home/RecentPosts';
import Experience from './home/Experience';
import WhyChooseUs from './home/WhyChooseUs';
import MeetTheTeam from './home/MeetTheTeam';
import GallerySection from './home/GallerySection';
import VideoSection from './home/VideoSection';
import CTA from './home/CTA';
import { AgbHistoryBlock } from './AgbHistoryBlock';
import { PDFDownloadBlock } from './PDFDownloadBlock';
function ContactSection(props: any) {
return (
<div className="p-8 border-2 border-dashed border-primary my-8 text-center text-primary font-bold">
Contact Section Placeholder
</div>
);
}
function HeroSection(props: any) {
return (
<div className="p-16 bg-primary-dark text-white text-center my-8">
<h1 className="text-4xl font-bold">{props.title}</h1>
<p>{props.subtitle}</p>
</div>
);
}
async function Block(props: any) {
const { type, children } = props;
let { data } = props;
const locale = await getLocale();
// If data was passed as a JSON string (via our lib/blog.ts fix), parse it back
if (typeof data === 'string') {
try {
data = JSON.parse(data);
} catch (e) {
console.error('Failed to parse MDX block data:', e);
}
}
// Normalize type (Payload sometimes uses blockType in data)
const blockType = type || data?.blockType;
switch (blockType) {
// Blog Components
case 'technicalGrid':
return <TechnicalGrid {...data} />;
case 'stats':
return <Stats {...data} />;
case 'visualLinkPreview':
return <VisualLinkPreview {...data} />;
case 'stickyNarrative':
return <StickyNarrative {...data} />;
case 'comparisonGrid':
return <ComparisonGrid {...data} />;
case 'highlightBox':
return <HighlightBox {...data}>{children}</HighlightBox>;
case 'chatBubble':
return <ChatBubble {...data}>{children}</ChatBubble>;
// Home Components
case 'homeHero':
return <Hero data={data} />;
case 'homeProductCategories':
return <ProductCategories data={data} />;
case 'homeWhatWeDo':
return <WhatWeDo data={data} />;
case 'homeRecentPosts':
return <RecentPosts data={data} locale={locale} />;
case 'homeExperience':
return <Experience data={data} />;
case 'homeWhyChooseUs':
return <WhyChooseUs data={data} />;
case 'homeMeetTheTeam':
return <MeetTheTeam data={data} />;
case 'homeGallery':
return <GallerySection data={data} />;
case 'homeVideo':
return <VideoSection data={data} />;
case 'homeCTA':
return <CTA data={data} />;
case 'agbHistory':
return <AgbHistoryBlock {...data} />;
case 'pdfDownload':
return <PDFDownloadBlock {...data} />;
default:
return (
<div className="p-8 border-2 border-dashed border-red-200 rounded-2xl my-8 bg-red-50 text-red-600">
<p className="font-bold mb-2">Unknown Block Type: {blockType}</p>
<pre className="text-xs overflow-auto p-4 bg-white/50 rounded">
{JSON.stringify({ type, data: !!data, hasChildren: !!children }, null, 2)}
</pre>
</div>
);
}
}
const components = {
ContactSection,
HeroSection,
Block,
// Add other components that could be in MDX here
};
export default function MDXContent({ data, className }: { data: string; className?: string }) {
if (!data) return null;
return (
<div className={className}>
<MDXRemote source={data} components={components} />
</div>
);
}

View File

@@ -5,7 +5,6 @@ interface OGImageTemplateProps {
description?: string;
label?: string;
image?: string;
logo?: string;
mode?: 'dark' | 'light' | 'image';
}
@@ -14,7 +13,6 @@ export function OGImageTemplate({
description,
label,
image,
logo,
mode = 'dark',
}: OGImageTemplateProps) {
const primaryBlue = '#001a4d';
@@ -154,26 +152,21 @@ export function OGImageTemplate({
height: '6px',
backgroundColor: accentGreen,
borderRadius: '3px',
marginRight: '32px',
marginRight: '24px',
}}
/>
{logo ? (
/* eslint-disable-next-line @next/next/no-img-element */
<img src={logo} alt="KLZ Cables" height="40" style={{ objectFit: 'contain' }} />
) : (
<div
style={{
fontSize: '24px',
fontWeight: 700,
color: 'white',
textTransform: 'uppercase',
letterSpacing: '0.15em',
display: 'flex',
}}
>
KLZ Cables
</div>
)}
<div
style={{
fontSize: '24px',
fontWeight: 700,
color: 'white',
textTransform: 'uppercase',
letterSpacing: '0.15em',
display: 'flex',
}}
>
KLZ Cables
</div>
</div>
{/* Saturated Blue Brand Strip */}

View File

@@ -1,38 +0,0 @@
'use client';
import React, { useState, useEffect } from 'react';
interface ObfuscatedEmailProps {
email: string;
className?: string;
children?: React.ReactNode;
}
/**
* A component that helps protect email addresses from simple spambots.
* It uses client-side mounting to render the actual email address,
* making it harder for static crawlers to harvest.
*/
export default function ObfuscatedEmail({ email, className = '', children }: ObfuscatedEmailProps) {
const [mounted, setMounted] = useState(false);
useEffect(() => {
setMounted(true);
}, []);
if (!mounted) {
// Show a placeholder or obscured version during SSR
return (
<span className={className} aria-hidden="true">
{children || email.replace('@', ' [at] ').replace(/\./g, ' [dot] ')}
</span>
);
}
// Once mounted on the client, render the real mailto link
return (
<a href={`mailto:${email}`} className={className}>
{children || email}
</a>
);
}

View File

@@ -1,41 +0,0 @@
'use client';
import React, { useState, useEffect } from 'react';
interface ObfuscatedPhoneProps {
phone: string;
className?: string;
children?: React.ReactNode;
}
/**
* A component that helps protect phone numbers from simple spambots.
* It stays obscured during SSR and hydrates into a functional tel: link on the client.
*/
export default function ObfuscatedPhone({ phone, className = '', children }: ObfuscatedPhoneProps) {
const [mounted, setMounted] = useState(false);
useEffect(() => {
setMounted(true);
}, []);
// Format phone number for tel: link (remove spaces, etc.)
const telLink = `tel:${phone.replace(/\s+/g, '')}`;
if (!mounted) {
// Show a placeholder or obscured version during SSR
// e.g. +49 881 925 [at] 37298
const obscured = phone.replace(/(\d{3})(\d{3})$/, ' $1...$2');
return (
<span className={className} aria-hidden="true">
{children || obscured}
</span>
);
}
return (
<a href={telLink} className={className}>
{children || phone}
</a>
);
}

View File

@@ -1,38 +0,0 @@
'use client';
import React from 'react';
import { usePathname } from 'next/navigation';
export const PDFDownloadBlock: React.FC<{ label: string; style: string; url?: string }> = ({
label,
style,
url,
}) => {
const pathname = usePathname();
// Extract slug from pathname
const segments = pathname.split('/').filter(Boolean);
// Pathname is usually /[locale]/[slug] or /[locale]/products/[slug]
// We want the page slug.
const slug = segments[segments.length - 1] || 'home';
const href = url || `/api/pages/${slug}/pdf`;
return (
<div className="my-8">
<a
href={href}
className={`inline-flex items-center px-8 py-3.5 font-bold rounded-full transition-all duration-300 shadow-lg hover:shadow-xl group ${
style === 'primary'
? 'bg-primary text-white hover:bg-primary-dark'
: style === 'secondary'
? 'bg-accent text-primary-dark hover:bg-neutral-light'
: 'border-2 border-primary text-primary hover:bg-primary hover:text-white'
}`}
>
<span className="mr-3 transition-transform group-hover:scale-12 bit-bounce">📄</span>
{label}
</a>
</div>
);
};

View File

@@ -35,7 +35,7 @@ export default function ProductSidebar({
<div className="relative w-full aspect-[16/10] mb-6 rounded-2xl overflow-hidden bg-white/5 backdrop-blur-md p-4 border border-white/10 z-10 group">
<div className="relative w-full h-full transition-transform duration-1000 ease-out group-hover:scale-105">
<Image
src={productImage.split('?')[0]}
src={productImage}
alt={productName}
fill
className="object-contain p-2 drop-shadow-[0_20px_30px_rgba(0,0,0,0.4)]"

View File

@@ -38,14 +38,14 @@ export default function ProductTechnicalData({ data }: ProductTechnicalDataProps
};
return (
<div className="space-y-8 md:space-y-16">
<div className="space-y-16">
{technicalItems.length > 0 && (
<div className="bg-white p-5 md:p-12 rounded-[20px] md:rounded-[32px] shadow-sm border border-neutral-dark/5">
<div className="bg-white p-8 md:p-12 rounded-[32px] shadow-sm border border-neutral-dark/5">
<h3 className="text-2xl font-bold text-primary mb-8 flex items-center gap-3">
<div className="w-2 h-8 bg-accent rounded-full" />
General Data
</h3>
<dl className="grid grid-cols-2 sm:grid-cols-2 lg:grid-cols-3 gap-x-6 gap-y-6 md:gap-x-12 md:gap-y-8">
<dl className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-x-12 gap-y-8">
{technicalItems.map((item, idx) => (
<div key={idx} className="flex flex-col group">
<dt className="text-sm font-bold uppercase tracking-widest text-primary/40 mb-2 group-hover:text-accent transition-colors">
@@ -72,7 +72,7 @@ export default function ProductTechnicalData({ data }: ProductTechnicalDataProps
return (
<div
key={idx}
className="bg-white p-5 md:p-12 rounded-[20px] md:rounded-[32px] shadow-sm border border-neutral-dark/5 overflow-hidden"
className="bg-white p-8 md:p-12 rounded-[32px] shadow-sm border border-neutral-dark/5 overflow-hidden"
>
<h3 className="text-2xl font-bold text-primary mb-8 flex items-center gap-3">
<div className="w-2 h-8 bg-accent rounded-full" />
@@ -83,7 +83,7 @@ export default function ProductTechnicalData({ data }: ProductTechnicalDataProps
</h3>
{table.metaItems.length > 0 && (
<dl className="grid grid-cols-2 sm:grid-cols-2 md:grid-cols-3 lg:grid-cols-4 gap-4 md:gap-8 mb-6 md:mb-12 bg-neutral-light/50 p-4 md:p-8 rounded-xl md:rounded-2xl border border-neutral-dark/5">
<dl className="grid grid-cols-1 sm:grid-cols-2 md:grid-cols-3 lg:grid-cols-4 gap-8 mb-12 bg-neutral-light/50 p-8 rounded-2xl border border-neutral-dark/5">
{table.metaItems.map((item, mIdx) => (
<div key={mIdx}>
<dt className="text-[10px] font-black uppercase tracking-[0.2em] text-primary/40 mb-1">
@@ -98,11 +98,9 @@ export default function ProductTechnicalData({ data }: ProductTechnicalDataProps
)}
<div className="relative">
{/* Scroll hint gradient on right edge for mobile */}
<div className="pointer-events-none absolute right-0 top-0 h-full w-8 bg-gradient-to-l from-white to-transparent z-20 md:hidden" />
<div
id={`voltage-table-${idx}`}
className={`overflow-x-auto -mx-5 md:-mx-12 px-5 md:px-12 transition-all duration-500 ease-in-out ${
className={`overflow-x-auto -mx-8 md:-mx-12 px-8 md:px-12 transition-all duration-500 ease-in-out ${
!isExpanded && hasManyRows ? 'max-h-[400px] overflow-y-hidden' : 'max-h-[none]'
}`}
>
@@ -132,14 +130,12 @@ export default function ProductTechnicalData({ data }: ProductTechnicalDataProps
<td className="px-3 py-2 text-xs font-bold text-primary sticky left-0 bg-white group-hover:bg-neutral-light/50 z-10 whitespace-nowrap">
{row.configuration}
</td>
{row.cells.map((cell: any, cellIdx: number) => (
{row.cells.map((cell, cellIdx) => (
<td
key={cellIdx}
className="px-3 py-2 text-xs text-text-secondary whitespace-nowrap"
>
{typeof cell === 'object' && cell !== null && 'value' in cell
? cell.value
: cell}
{cell}
</td>
))}
</tr>

View File

@@ -1,4 +1,4 @@
import { getAllProducts } from '@/lib/products';
import { getAllProducts } from '@/lib/mdx';
import { getTranslations } from 'next-intl/server';
import Image from 'next/image';
import { RelatedProductLink } from './RelatedProductLink';
@@ -29,32 +29,6 @@ export default async function RelatedProducts({
if (related.length === 0) return null;
// Pre-calculate translated slugs for related products
const relatedWithTranslatedSlugs = await Promise.all(
related.map(async (product) => {
// Find the category slug for the link
const categorySlugs = [
'low-voltage-cables',
'medium-voltage-cables',
'high-voltage-cables',
'solar-cables',
];
const catFileSlug =
categorySlugs.find((slug) => {
return product.frontmatter.categories.some(
(cat) => cat.toLowerCase().replace(/\s+/g, '-') === slug,
);
}) || 'low-voltage-cables';
const catSlug = await mapFileSlugToTranslated(catFileSlug, locale);
return {
...product,
catSlug,
};
}),
);
return (
<div className="">
<div className="flex items-end justify-between mb-12">
@@ -67,11 +41,29 @@ export default async function RelatedProducts({
</div>
<div className="grid grid-cols-1 md:grid-cols-3 gap-8">
{relatedWithTranslatedSlugs.map((product) => {
{related.map((product) => {
// Find the category slug for the link
const categorySlugs = [
'low-voltage-cables',
'medium-voltage-cables',
'high-voltage-cables',
'solar-cables',
];
const catSlug =
categorySlugs.find((slug) => {
const key = slug
.replace(/-cables$/, '')
.replace(/-([a-z])/g, (g) => g[1].toUpperCase());
const title = t(`categories.${key}.title`);
return product.frontmatter.categories.some(
(cat) => cat.toLowerCase().replace(/\s+/g, '-') === slug || cat === title,
);
}) || 'low-voltage-cables';
return (
<RelatedProductLink
key={product.slug}
href={`/${locale}/${productsSlug}/${product.catSlug}/${product.slug}`}
href={`/${locale}/${productsSlug}/${catSlug}/${product.slug}`}
productSlug={product.slug}
productTitle={product.frontmatter.title}
className="group block bg-white rounded-[32px] overflow-hidden shadow-sm hover:shadow-2xl transition-all duration-500 border border-neutral-dark/5"
@@ -80,7 +72,7 @@ export default async function RelatedProducts({
{product.frontmatter.images?.[0] ? (
<>
<Image
src={product.frontmatter.images[0].split('?')[0]}
src={product.frontmatter.images[0]}
alt={product.frontmatter.title}
fill
className="object-contain p-4 transition-transform duration-700 group-hover:scale-110 z-10"

View File

@@ -78,9 +78,6 @@ export default function RequestQuoteForm({ productName }: RequestQuoteFormProps)
}
};
const emailId = React.useId();
const requestId = React.useId();
if (status === 'success') {
return (
<div
@@ -167,33 +164,29 @@ export default function RequestQuoteForm({ productName }: RequestQuoteFormProps)
<form onSubmit={handleSubmit} className="space-y-3 !mt-0">
<div className="space-y-2 !mt-0">
<div className="space-y-1 !mt-0">
<label htmlFor={emailId} className="sr-only">
{t('email')}
</label>
<Input
type="email"
id={emailId}
id="quote-email"
required
value={email}
onChange={(e) => setEmail(e.target.value)}
onFocus={() => handleFocus('quote-email')}
placeholder={t('email')}
aria-label={t('email')}
className="h-9 text-xs !mt-0"
/>
</div>
<div className="space-y-1 !mt-0">
<label htmlFor={requestId} className="sr-only">
{t('message')}
</label>
<Textarea
id={requestId}
id="quote-request"
required
rows={3}
value={request}
onChange={(e) => setRequest(e.target.value)}
onFocus={() => handleFocus('quote-request')}
placeholder={t('message')}
aria-label={t('message')}
className="text-xs !mt-0"
/>
</div>

View File

@@ -1,6 +1,7 @@
'use client';
import React from 'react';
import { motion, Variants } from 'framer-motion';
import { cn } from '@/components/ui';
interface ScribbleProps {
@@ -10,6 +11,18 @@ interface ScribbleProps {
}
export default function Scribble({ variant, className, color = '#82ed20' }: ScribbleProps) {
const pathVariants: Variants = {
hidden: { pathLength: 0, opacity: 0 },
visible: {
pathLength: 1,
opacity: 1,
transition: {
duration: 1.8,
ease: 'easeInOut',
},
},
};
if (variant === 'circle') {
return (
<svg
@@ -18,10 +31,11 @@ export default function Scribble({ variant, className, color = '#82ed20' }: Scri
viewBox="0 0 800 350"
preserveAspectRatio="none"
>
<path
className="animate-draw-stroke"
pathLength="1"
style={{ strokeDasharray: 1, strokeDashoffset: 1, animation: 'draw-stroke 1.8s ease-in-out 0.5s forwards' }}
<motion.path
variants={pathVariants}
initial="hidden"
whileInView="visible"
viewport={{ once: true }}
transform="matrix(0.9791300296783447,0,0,0.9791300296783447,400,179)"
strokeLinejoin="miter"
fillOpacity="0"
@@ -43,10 +57,11 @@ export default function Scribble({ variant, className, color = '#82ed20' }: Scri
viewBox="-400 -55 730 60"
preserveAspectRatio="none"
>
<path
className="animate-draw-stroke"
pathLength="1"
style={{ strokeDasharray: 1, strokeDashoffset: 1, animation: 'draw-stroke 1.8s ease-in-out 0.5s forwards' }}
<motion.path
variants={pathVariants}
initial="hidden"
whileInView="visible"
viewport={{ once: true }}
d="m -383.25 -6 c 55.25 -22 130.75 -33.5 293.25 -38 c 54.5 -0.5 195 -2.5 401 15"
stroke={color}
strokeWidth="20"

View File

@@ -8,7 +8,7 @@ export default function SkipLink() {
return (
<a
href="#main-content"
className="fixed -top-full left-4 z-[100] px-6 py-3 bg-white text-primary-dark font-bold rounded-lg shadow-xl outline-none ring-2 ring-accent transition-all duration-300 focus:top-4"
className="sr-only focus:not-sr-only focus:fixed focus:top-4 focus:left-4 focus:z-[100] focus:px-6 focus:py-3 focus:bg-white focus:text-primary-dark focus:font-bold focus:rounded-lg focus:shadow-xl focus:outline-none focus:ring-2 focus:ring-accent transition-all"
>
{t('skipToContent')}
</a>

View File

@@ -1,7 +1,7 @@
'use client';
import dynamic from 'next/dynamic';
import { Suspense, useEffect, useState } from 'react';
import { Suspense } from 'react';
const DynamicAnalyticsProvider = dynamic(() => import('./AnalyticsProvider'), {
ssr: false,
@@ -9,35 +9,12 @@ const DynamicAnalyticsProvider = dynamic(() => import('./AnalyticsProvider'), {
const DynamicScrollDepthTracker = dynamic(() => import('./ScrollDepthTracker'), {
ssr: false,
});
const DynamicWebVitalsTracker = dynamic(() => import('./WebVitalsTracker'), {
ssr: false,
});
export default function AnalyticsShell() {
const [shouldLoad, setShouldLoad] = useState(false);
useEffect(() => {
// Disable analytics in CI to prevent console noise/score penalties
if (process.env.NEXT_PUBLIC_CI === 'true') {
return;
}
// Wait until browser is completely idle before loading heavy analytics/logger/sentry SDKs
if (typeof window !== 'undefined' && 'requestIdleCallback' in window) {
window.requestIdleCallback(() => setShouldLoad(true), { timeout: 3000 });
} else {
const timer = setTimeout(() => setShouldLoad(true), 2500);
return () => clearTimeout(timer);
}
}, []);
if (!shouldLoad) return null;
return (
<Suspense fallback={null}>
<DynamicAnalyticsProvider />
<DynamicScrollDepthTracker />
<DynamicWebVitalsTracker />
</Suspense>
);
}

View File

@@ -1,26 +0,0 @@
'use client';
import { useEffect } from 'react';
import { useAnalytics } from './useAnalytics';
import { AnalyticsEvents } from './analytics-events';
export default function ClientNotFoundTracker({ path }: { path: string }) {
const { trackEvent } = useAnalytics();
useEffect(() => {
trackEvent(AnalyticsEvents.ERROR, {
type: '404_not_found',
path,
});
import('@sentry/nextjs').then((Sentry) => {
Sentry.withScope((scope) => {
scope.setTag('status_code', '404');
scope.setTag('path', path);
Sentry.captureMessage(`Route Not Found: ${path}`, 'warning');
});
});
}, [trackEvent, path]);
return null;
}

View File

@@ -136,14 +136,18 @@ function AddToCartButton({ product, quantity = 1 }) {
product_category: product.category,
price: product.price,
quantity: quantity,
cart_total: 150.0, // Current cart total
cart_total: 150.00, // Current cart total
});
// Actual add to cart logic
// addToCart(product, quantity);
};
return <button onClick={handleAddToCart}>Add to Cart</button>;
return (
<button onClick={handleAddToCart}>
Add to Cart
</button>
);
}
```
@@ -167,7 +171,7 @@ function CheckoutComplete({ order }) {
transaction_tax: order.tax,
transaction_shipping: order.shipping,
product_count: order.items.length,
products: order.items.map((item) => ({
products: order.items.map(item => ({
product_id: item.product.id,
product_name: item.product.name,
quantity: item.quantity,
@@ -194,21 +198,27 @@ function WishlistButton({ product }) {
const toggleWishlist = () => {
const newState = !isInWishlist;
trackEvent(
newState ? AnalyticsEvents.PRODUCT_WISHLIST_ADD : AnalyticsEvents.PRODUCT_WISHLIST_REMOVE,
newState
? AnalyticsEvents.PRODUCT_WISHLIST_ADD
: AnalyticsEvents.PRODUCT_WISHLIST_REMOVE,
{
product_id: product.id,
product_name: product.name,
product_category: product.category,
},
}
);
setIsInWishlist(newState);
// Update wishlist in backend
};
return <button onClick={toggleWishlist}>{isInWishlist ? '❤️' : '🤍'}</button>;
return (
<button onClick={toggleWishlist}>
{isInWishlist ? '❤️' : '🤍'}
</button>
);
}
```
@@ -258,7 +268,7 @@ function ContactForm() {
};
const handleChange = (e: React.ChangeEvent<HTMLInputElement | HTMLTextAreaElement>) => {
setFormData((prev) => ({
setFormData(prev => ({
...prev,
[e.target.name]: e.target.value,
}));
@@ -300,7 +310,9 @@ function NewsletterSignup() {
return (
<div>
<input placeholder="Enter email" />
<button onClick={() => handleSubscribe('user@example.com')}>Subscribe</button>
<button onClick={() => handleSubscribe('user@example.com')}>
Subscribe
</button>
</div>
);
}
@@ -384,12 +396,10 @@ function LoginForm() {
};
return (
<form
onSubmit={(e) => {
e.preventDefault();
handleLogin('user@example.com', 'password');
}}
>
<form onSubmit={(e) => {
e.preventDefault();
handleLogin('user@example.com', 'password');
}}>
{/* Form fields */}
<button type="submit">Login</button>
</form>
@@ -408,7 +418,11 @@ import { AnalyticsEvents } from '@/components/analytics/analytics-events';
function SignupForm() {
const { trackEvent } = useAnalytics();
const handleSignup = (userData: { email: string; name: string; company?: string }) => {
const handleSignup = (userData: {
email: string;
name: string;
company?: string;
}) => {
trackEvent(AnalyticsEvents.USER_SIGNUP, {
user_email: userData.email,
user_name: userData.name,
@@ -422,16 +436,14 @@ function SignupForm() {
};
return (
<form
onSubmit={(e) => {
e.preventDefault();
handleSignup({
email: 'user@example.com',
name: 'John Doe',
company: 'ACME Corp',
});
}}
>
<form onSubmit={(e) => {
e.preventDefault();
handleSignup({
email: 'user@example.com',
name: 'John Doe',
company: 'ACME Corp',
});
}}>
{/* Form fields */}
<button type="submit">Sign Up</button>
</form>
@@ -471,7 +483,7 @@ function SearchBar() {
return (
<div>
<input
<input
value={query}
onChange={(e) => setQuery(e.target.value)}
placeholder="Search products..."
@@ -537,7 +549,7 @@ function ProductFilters() {
<option value="cables">Cables</option>
<option value="connectors">Connectors</option>
</select>
<button onClick={handleClearFilters}>Clear Filters</button>
</div>
);
@@ -619,7 +631,11 @@ function VideoPlayer({ videoId, videoTitle }) {
};
return (
<video onPlay={handlePlay} onPause={handlePause} onEnded={handleComplete}>
<video
onPlay={handlePlay}
onPause={handlePause}
onEnded={handleComplete}
>
<source src="/video.mp4" type="video/mp4" />
</video>
);
@@ -649,7 +665,11 @@ function DownloadButton({ fileName, fileType, fileSize }) {
// window.location.href = `/downloads/${fileName}`;
};
return <button onClick={handleDownload}>Download {fileName}</button>;
return (
<button onClick={handleDownload}>
Download {fileName}
</button>
);
}
```
@@ -680,7 +700,7 @@ class ErrorBoundary extends Component<ErrorBoundaryProps> {
componentDidCatch(error: Error, errorInfo: ErrorInfo) {
const { trackEvent } = useAnalytics();
trackEvent(AnalyticsEvents.ERROR, {
error_message: error.message,
error_stack: error.stack,
@@ -722,14 +742,14 @@ function ApiClient() {
const fetchData = async (endpoint: string) => {
try {
const response = await fetch(endpoint);
if (!response.ok) {
trackEvent(AnalyticsEvents.API_ERROR, {
endpoint: endpoint,
status_code: response.status,
error_message: response.statusText,
});
throw new Error(`HTTP ${response.status}`);
}
@@ -745,7 +765,7 @@ function ApiClient() {
error_message: error.message,
error_type: error.name,
});
throw error;
}
};
@@ -943,9 +963,15 @@ function CableProductPage({ cable }) {
return (
<div>
<h1>{cable.name}</h1>
<button onClick={handleTechnicalSpecDownload}>Download Technical Specs</button>
<button onClick={handleRequestQuote}>Request Quote</button>
<button onClick={handleBrochureDownload}>Download Brochure</button>
<button onClick={handleTechnicalSpecDownload}>
Download Technical Specs
</button>
<button onClick={handleRequestQuote}>
Request Quote
</button>
<button onClick={handleBrochureDownload}>
Download Brochure
</button>
</div>
);
}
@@ -984,8 +1010,12 @@ function WindFarmProjectPage({ project }) {
return (
<div>
<h1>{project.name}</h1>
<button onClick={handleProjectInquiry}>Request Project Consultation</button>
<button onClick={handleCableCalculation}>Calculate Cable Requirements</button>
<button onClick={handleProjectInquiry}>
Request Project Consultation
</button>
<button onClick={handleCableCalculation}>
Calculate Cable Requirements
</button>
</div>
);
}
@@ -1036,7 +1066,7 @@ test('tracks button click', () => {
// [Umami] Tracked pageview: /products/123
// To test without sending data to Umami:
// 1. Remove UMAMI_WEBSITE_ID from .env
// 1. Remove NEXT_PUBLIC_UMAMI_WEBSITE_ID from .env
// 2. Or set it to an empty string
// 3. Check console logs to verify events are being tracked
```
@@ -1139,9 +1169,7 @@ function WebVitalsTracker() {
}
});
observer.observe({
entryTypes: ['largest-contentful-paint', 'first-input-delay', 'layout-shift'],
});
observer.observe({ entryTypes: ['largest-contentful-paint', 'first-input-delay', 'layout-shift'] });
}
}, []);
@@ -1166,7 +1194,6 @@ This examples file demonstrates how to implement comprehensive analytics trackin
-**Business-specific events** (KLZ Cables, wind farms)
Remember to:
1. Use the `useAnalytics` hook for client-side tracking
2. Import events from `AnalyticsEvents` for consistency
3. Include relevant context in your events

View File

@@ -2,7 +2,7 @@
## Setup Checklist
- [ ] Add `UMAMI_WEBSITE_ID` to `.env` file
- [ ] Add `NEXT_PUBLIC_UMAMI_WEBSITE_ID` to `.env` file
- [ ] Verify `UmamiScript` is in your layout
- [ ] Verify `AnalyticsProvider` is in your layout
- [ ] Test in development mode
@@ -12,7 +12,7 @@
```bash
# Required
UMAMI_WEBSITE_ID=59a7db94-0100-4c7e-98ef-99f45b17f9c3
NEXT_PUBLIC_UMAMI_WEBSITE_ID=59a7db94-0100-4c7e-98ef-99f45b17f9c3
# Optional (defaults to https://analytics.infra.mintel.me/script.js)
NEXT_PUBLIC_UMAMI_SCRIPT_URL=https://analytics.infra.mintel.me/script.js
@@ -86,16 +86,16 @@ function ProductCard({ product }) {
## Common Events
| Event | When to Use | Example Properties |
| --------------------- | ------------------- | ------------------------------------------------- |
| `pageview` | Page loads | `{ url: '/products/123' }` |
| `button_click` | Button clicked | `{ button_id: 'cta', page: 'homepage' }` |
| `form_submit` | Form submitted | `{ form_id: 'contact', form_name: 'Contact Us' }` |
| `product_view` | Product page viewed | `{ product_id: '123', price: 99.99 }` |
| `product_add_to_cart` | Add to cart | `{ product_id: '123', quantity: 1 }` |
| `search` | Search performed | `{ search_query: 'cable', results: 42 }` |
| `user_login` | User logged in | `{ user_email: 'user@example.com' }` |
| `error` | Error occurred | `{ error_message: 'Something went wrong' }` |
| Event | When to Use | Example Properties |
|-------|-------------|-------------------|
| `pageview` | Page loads | `{ url: '/products/123' }` |
| `button_click` | Button clicked | `{ button_id: 'cta', page: 'homepage' }` |
| `form_submit` | Form submitted | `{ form_id: 'contact', form_name: 'Contact Us' }` |
| `product_view` | Product page viewed | `{ product_id: '123', price: 99.99 }` |
| `product_add_to_cart` | Add to cart | `{ product_id: '123', quantity: 1 }` |
| `search` | Search performed | `{ search_query: 'cable', results: 42 }` |
| `user_login` | User logged in | `{ user_email: 'user@example.com' }` |
| `error` | Error occurred | `{ error_message: 'Something went wrong' }` |
## Testing
@@ -112,7 +112,7 @@ In development, you'll see console logs:
```bash
# .env.local
# UMAMI_WEBSITE_ID=
# NEXT_PUBLIC_UMAMI_WEBSITE_ID=
```
## Troubleshooting
@@ -120,9 +120,8 @@ In development, you'll see console logs:
### Analytics Not Working?
1. **Check environment variables:**
```bash
echo $UMAMI_WEBSITE_ID
echo $NEXT_PUBLIC_UMAMI_WEBSITE_ID
```
2. **Verify script is loading:**
@@ -137,12 +136,12 @@ In development, you'll see console logs:
### Common Issues
| Issue | Solution |
| ------------------- | ----------------------------------- |
| No data in Umami | Check website ID and script URL |
| Events not tracking | Verify `useAnalytics` hook is used |
| Script not loading | Check network connection, CORS |
| Wrong data | Verify event properties are correct |
| Issue | Solution |
|-------|----------|
| No data in Umami | Check website ID and script URL |
| Events not tracking | Verify `useAnalytics` hook is used |
| Script not loading | Check network connection, CORS |
| Wrong data | Verify event properties are correct |
## Performance Tips

View File

@@ -20,7 +20,7 @@ Add these to your `.env` file:
```bash
# Required: Your Umami website ID
UMAMI_WEBSITE_ID=59a7db94-0100-4c7e-98ef-99f45b17f9c3
NEXT_PUBLIC_UMAMI_WEBSITE_ID=59a7db94-0100-4c7e-98ef-99f45b17f9c3
# Optional: Custom Umami script URL (defaults to https://analytics.infra.mintel.me/script.js)
NEXT_PUBLIC_UMAMI_SCRIPT_URL=https://analytics.infra.mintel.me/script.js
@@ -32,7 +32,7 @@ The `docker-compose.yml` already includes the environment variables:
```yaml
environment:
- UMAMI_WEBSITE_ID=${UMAMI_WEBSITE_ID}
- NEXT_PUBLIC_UMAMI_WEBSITE_ID=${NEXT_PUBLIC_UMAMI_WEBSITE_ID}
- NEXT_PUBLIC_UMAMI_SCRIPT_URL=${NEXT_PUBLIC_UMAMI_SCRIPT_URL:-https://analytics.infra.mintel.me/script.js}
```
@@ -75,7 +75,11 @@ function ProductCard({ product }) {
});
};
return <button onClick={handleAddToCart}>Add to Cart</button>;
return (
<button onClick={handleAddToCart}>
Add to Cart
</button>
);
}
```
@@ -92,7 +96,7 @@ function CustomNavigation() {
const navigateToCustomPage = () => {
// Track a custom pageview
trackPageview('/custom-path?param=value');
// Then perform navigation
window.location.href = '/custom-path?param=value';
};
@@ -273,7 +277,11 @@ function ErrorBoundary({ children }) {
});
};
return <ErrorBoundary onError={handleError}>{children}</ErrorBoundary>;
return (
<ErrorBoundary onError={handleError}>
{children}
</ErrorBoundary>
);
}
```
@@ -281,20 +289,20 @@ function ErrorBoundary({ children }) {
### Common Events
| Event Name | Description | Example Properties |
| --------------------- | --------------------- | ------------------------------------------------------------ |
| `pageview` | Page view | `{ url: '/products/123' }` |
| `button_click` | Button clicked | `{ button_id: 'cta-primary', page: 'homepage' }` |
| `link_click` | Link clicked | `{ link_url: '/products', link_text: 'View Products' }` |
| `form_submit` | Form submitted | `{ form_id: 'contact-form', form_name: 'Contact Us' }` |
| `product_view` | Product page viewed | `{ product_id: '123', product_name: 'Cable', price: 99.99 }` |
| `product_add_to_cart` | Product added to cart | `{ product_id: '123', quantity: 1 }` |
| `product_purchase` | Product purchased | `{ product_id: '123', transaction_id: 'TXN-123' }` |
| `search` | Search performed | `{ search_query: 'cable', search_results_count: 42 }` |
| `filter_apply` | Filter applied | `{ filter_type: 'category', filter_value: 'high-voltage' }` |
| `user_login` | User logged in | `{ user_email: 'user@example.com' }` |
| `user_signup` | User signed up | `{ user_email: 'user@example.com' }` |
| `error` | Error occurred | `{ error_message: 'Something went wrong' }` |
| Event Name | Description | Example Properties |
|------------|-------------|-------------------|
| `pageview` | Page view | `{ url: '/products/123' }` |
| `button_click` | Button clicked | `{ button_id: 'cta-primary', page: 'homepage' }` |
| `link_click` | Link clicked | `{ link_url: '/products', link_text: 'View Products' }` |
| `form_submit` | Form submitted | `{ form_id: 'contact-form', form_name: 'Contact Us' }` |
| `product_view` | Product page viewed | `{ product_id: '123', product_name: 'Cable', price: 99.99 }` |
| `product_add_to_cart` | Product added to cart | `{ product_id: '123', quantity: 1 }` |
| `product_purchase` | Product purchased | `{ product_id: '123', transaction_id: 'TXN-123' }` |
| `search` | Search performed | `{ search_query: 'cable', search_results_count: 42 }` |
| `filter_apply` | Filter applied | `{ filter_type: 'category', filter_value: 'high-voltage' }` |
| `user_login` | User logged in | `{ user_email: 'user@example.com' }` |
| `user_signup` | User signed up | `{ user_email: 'user@example.com' }` |
| `error` | Error occurred | `{ error_message: 'Something went wrong' }` |
### Custom Events
@@ -377,9 +385,8 @@ The analytics system includes development mode logging:
### Analytics Not Working
1. **Check environment variables:**
```bash
echo $UMAMI_WEBSITE_ID
echo $NEXT_PUBLIC_UMAMI_WEBSITE_ID
```
2. **Verify the script is loading:**
@@ -398,11 +405,11 @@ In development mode, you'll see console logs for all tracked events. This helps
### Disabling Analytics
To disable analytics (e.g., for local development), simply remove the `UMAMI_WEBSITE_ID` environment variable:
To disable analytics (e.g., for local development), simply remove the `NEXT_PUBLIC_UMAMI_WEBSITE_ID` environment variable:
```bash
# .env.local (not committed to git)
# UMAMI_WEBSITE_ID=
# NEXT_PUBLIC_UMAMI_WEBSITE_ID=
```
## Performance
@@ -431,7 +438,6 @@ The analytics implementation is optimized for performance:
## Support
For issues or questions about the analytics implementation, check:
1. This README for usage examples
2. The component source code for implementation details
3. The Umami documentation for platform-specific questions

View File

@@ -16,7 +16,6 @@ The project already had a solid foundation:
## What Was Enhanced
### 1. **Enhanced UmamiScript** (`components/analytics/UmamiScript.tsx`)
- ✅ Added TypeScript props interface for customization
- ✅ Added JSDoc documentation with usage examples
- ✅ Added error handling for script loading failures
@@ -24,13 +23,11 @@ The project already had a solid foundation:
- ✅ Improved type safety and comments
### 2. **Enhanced AnalyticsProvider** (`components/analytics/AnalyticsProvider.tsx`)
- ✅ Added comprehensive JSDoc documentation
- ✅ Added development mode logging
- ✅ Improved code comments
### 3. **New Custom Hook** (`components/analytics/useAnalytics.ts`)
- ✅ Type-safe `useAnalytics` hook for easy event tracking
-`trackEvent()` method for custom events
-`trackPageview()` method for manual pageview tracking
@@ -38,14 +35,12 @@ The project already had a solid foundation:
- ✅ Development mode logging
### 4. **Event Definitions** (`components/analytics/analytics-events.ts`)
- ✅ Centralized event constants for consistency
- ✅ Type-safe event names
- ✅ Helper functions for common event properties
- ✅ 30+ predefined events for various use cases
### 5. **Comprehensive Documentation**
-**README.md** - Full documentation with setup, usage, and best practices
-**EXAMPLES.md** - 20+ practical examples for different scenarios
-**QUICK_REFERENCE.md** - Quick start guide and troubleshooting
@@ -68,14 +63,12 @@ components/analytics/
## Key Features
### 🚀 Modern Implementation
- Uses Next.js `Script` component (not old-school `<script>` tags)
- TypeScript for type safety
- React hooks for clean API
- Environment variable configuration
### 📊 Comprehensive Tracking
- Automatic pageview tracking on route changes
- Custom event tracking with properties
- E-commerce events (products, cart, purchases)
@@ -84,7 +77,6 @@ components/analytics/
- Error and performance tracking
### 🎯 Developer Experience
- Type-safe event tracking
- Centralized event definitions
- Development mode logging
@@ -92,7 +84,6 @@ components/analytics/
- 20+ practical examples
### 🔒 Privacy & Performance
- No PII tracking by default
- Script loads after page is interactive
- Minimal performance impact
@@ -104,7 +95,7 @@ The project is already configured in `docker-compose.yml`:
```yaml
environment:
- UMAMI_WEBSITE_ID=${UMAMI_WEBSITE_ID}
- NEXT_PUBLIC_UMAMI_WEBSITE_ID=${NEXT_PUBLIC_UMAMI_WEBSITE_ID}
- NEXT_PUBLIC_UMAMI_SCRIPT_URL=${NEXT_PUBLIC_UMAMI_SCRIPT_URL:-https://analytics.infra.mintel.me/script.js}
```
@@ -113,7 +104,7 @@ environment:
Add to your `.env` file:
```bash
UMAMI_WEBSITE_ID=59a7db94-0100-4c7e-98ef-99f45b17f9c3
NEXT_PUBLIC_UMAMI_WEBSITE_ID=59a7db94-0100-4c7e-98ef-99f45b17f9c3
```
## Usage Examples
@@ -197,7 +188,7 @@ In development, you'll see console logs:
```bash
# .env.local
# UMAMI_WEBSITE_ID=
# NEXT_PUBLIC_UMAMI_WEBSITE_ID=
```
## Troubleshooting
@@ -205,9 +196,8 @@ In development, you'll see console logs:
### Analytics Not Working?
1. **Check environment variables:**
```bash
echo $UMAMI_WEBSITE_ID
echo $NEXT_PUBLIC_UMAMI_WEBSITE_ID
```
2. **Verify script is loading:**
@@ -222,12 +212,12 @@ In development, you'll see console logs:
### Common Issues
| Issue | Solution |
| ------------------- | ----------------------------------- |
| No data in Umami | Check website ID and script URL |
| Events not tracking | Verify `useAnalytics` hook is used |
| Script not loading | Check network connection, CORS |
| Wrong data | Verify event properties are correct |
| Issue | Solution |
|-------|----------|
| No data in Umami | Check website ID and script URL |
| Events not tracking | Verify `useAnalytics` hook is used |
| Script not loading | Check network connection, CORS |
| Wrong data | Verify event properties are correct |
## Performance Tips
@@ -249,13 +239,13 @@ In development, you'll see console logs:
1. ✅ **Setup complete** - All files are in place
2. ✅ **Documentation complete** - README, EXAMPLES, QUICK_REFERENCE
3. ✅ **Code enhanced** - Better TypeScript, error handling, docs
4. 📝 **Add to .env** - Set `UMAMI_WEBSITE_ID`
4. 📝 **Add to .env** - Set `NEXT_PUBLIC_UMAMI_WEBSITE_ID`
5. 🧪 **Test in development** - Verify events are tracked
6. 🚀 **Deploy** - Analytics will work in production
## Quick Start Checklist
- [ ] Add `UMAMI_WEBSITE_ID` to `.env` file
- [ ] Add `NEXT_PUBLIC_UMAMI_WEBSITE_ID` to `.env` file
- [ ] Verify `UmamiScript` is in `app/[locale]/layout.tsx`
- [ ] Verify `AnalyticsProvider` is in `app/[locale]/layout.tsx`
- [ ] Test in development mode (check console logs)

View File

@@ -29,14 +29,10 @@ export default function TrackedLink({
const { trackEvent } = useAnalytics();
const handleClick = (e: React.MouseEvent) => {
try {
trackEvent(eventName, {
href,
...eventProperties,
});
} catch (_e) {
// Analytics tracking should not block navigation, so we catch and ignore errors.
}
trackEvent(eventName, {
href,
...eventProperties,
});
if (onClick) onClick();
};

View File

@@ -1,54 +0,0 @@
'use client';
import { useReportWebVitals } from 'next/web-vitals';
import { useAnalytics } from './useAnalytics';
/**
* WebVitalsTracker component.
*
* Captures Next.js Web Vitals and reports them to Umami as custom events.
* This provides "meaningful" page speed tracking by measuring real user
* experiences (LCP, CLS, INP, etc.).
*/
export default function WebVitalsTracker() {
const { trackEvent } = useAnalytics();
useReportWebVitals((metric) => {
const { name, value, id, label } = metric;
// Determine rating (simplified version of web-vitals standards)
let rating: 'good' | 'needs-improvement' | 'poor' = 'good';
if (name === 'LCP') {
if (value > 4000) rating = 'poor';
else if (value > 2500) rating = 'needs-improvement';
} else if (name === 'CLS') {
if (value > 0.25) rating = 'poor';
else if (value > 0.1) rating = 'needs-improvement';
} else if (name === 'FID') {
if (value > 300) rating = 'poor';
else if (value > 100) rating = 'needs-improvement';
} else if (name === 'FCP') {
if (value > 3000) rating = 'poor';
else if (value > 1800) rating = 'needs-improvement';
} else if (name === 'TTFB') {
if (value > 1500) rating = 'poor';
else if (value > 800) rating = 'needs-improvement';
} else if (name === 'INP') {
if (value > 500) rating = 'poor';
else if (value > 200) rating = 'needs-improvement';
}
// Report to Umami
trackEvent('web-vital', {
metric: name,
value: Math.round(name === 'CLS' ? value * 1000 : value), // CLS is a score, multiply by 1000 to keep as integer if preferred
rating,
id,
label,
path: typeof window !== 'undefined' ? window.location.pathname : undefined,
});
});
return null;
}

View File

@@ -34,7 +34,7 @@ export default function AnimatedImage({
}
});
},
{ threshold: 0.1 },
{ threshold: 0.1 }
);
if (containerRef.current) {
@@ -45,17 +45,15 @@ export default function AnimatedImage({
}, []);
return (
<div
<div
ref={containerRef}
className={`relative overflow-hidden rounded-2xl shadow-2xl my-16 group ${className}`}
>
<div
className={`
<div className={`
absolute inset-0 bg-primary/10 z-10 pointer-events-none transition-opacity duration-1000
${isLoaded && isInView ? 'opacity-0' : 'opacity-100'}
`}
/>
`} />
<Image
src={src}
alt={alt}
@@ -69,9 +67,17 @@ export default function AnimatedImage({
onLoad={() => setIsLoaded(true)}
priority={priority}
/>
{/* Subtle reflection overlay */}
<div className="absolute inset-0 bg-gradient-to-tr from-white/5 to-transparent opacity-0 group-hover:opacity-100 transition-opacity duration-700 pointer-events-none" />
{alt && (
<div className="absolute bottom-0 left-0 right-0 p-6 bg-gradient-to-t from-black/60 to-transparent translate-y-full group-hover:translate-y-0 transition-transform duration-500">
<p className="text-sm text-white font-medium italic">
{alt}
</p>
</div>
)}
</div>
);
}

View File

@@ -1,42 +0,0 @@
'use client';
import { useEffect } from 'react';
import { useRouter } from 'next/navigation';
interface BlogPaginationProps {
currentPage: number;
totalPages: number;
locale: string;
}
export function BlogPaginationKeyboardObserver({
currentPage,
totalPages,
locale,
}: BlogPaginationProps) {
const router = useRouter();
useEffect(() => {
const handleKeyDown = (e: KeyboardEvent) => {
// Don't trigger if user is typing in an input
if (
document.activeElement?.tagName === 'INPUT' ||
document.activeElement?.tagName === 'TEXTAREA' ||
document.activeElement?.tagName === 'SELECT'
) {
return;
}
if (e.key === 'ArrowLeft' && currentPage > 1) {
router.push(`/${locale}/blog?page=${currentPage - 1}`);
} else if (e.key === 'ArrowRight' && currentPage < totalPages) {
router.push(`/${locale}/blog?page=${currentPage + 1}`);
}
};
window.addEventListener('keydown', handleKeyDown);
return () => window.removeEventListener('keydown', handleKeyDown);
}, [currentPage, totalPages, locale, router]);
return null;
}

View File

@@ -0,0 +1,171 @@
import Link from 'next/link';
import VisualLinkPreview from '@/components/blog/VisualLinkPreview';
import { Callout } from '@/components/ui';
import HighlightBox from '@/components/blog/HighlightBox';
import Stats from '@/components/blog/Stats';
import AnimatedImage from '@/components/blog/AnimatedImage';
import ChatBubble from '@/components/blog/ChatBubble';
import SplitHeading from '@/components/blog/SplitHeading';
import PowerCTA from '@/components/blog/PowerCTA';
import StickyNarrative from '@/components/blog/StickyNarrative';
import TechnicalGrid from '@/components/blog/TechnicalGrid';
import ComparisonGrid from '@/components/blog/ComparisonGrid';
export const mdxComponents = {
VisualLinkPreview,
Callout,
HighlightBox,
Stats,
AnimatedImage,
ChatBubble,
PowerCTA,
SplitHeading,
StickyNarrative,
TechnicalGrid,
ComparisonGrid,
h1: () => null,
a: ({ href, children, ...props }: any) => {
// Special handling for PDF downloads to make them prominent
if (href?.endsWith('.pdf')) {
return (
<a
href={href}
{...props}
target="_blank"
rel="noopener noreferrer"
className="inline-flex items-center gap-3 px-6 py-3 bg-primary text-white font-bold rounded-xl hover:bg-accent hover:text-primary-dark transition-all duration-300 no-underline my-8 group shadow-lg hover:shadow-xl hover:-translate-y-1"
>
<svg className="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 10v6m0 0l-3-3m3 3l3-3m2 8H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z" />
</svg>
<span>{children}</span>
<span className="text-xs opacity-50 font-normal group-hover:opacity-100 transition-opacity">(PDF)</span>
</a>
);
}
if (href?.startsWith('/')) {
return (
<Link href={href} {...props} className="text-primary font-medium hover:underline decoration-2 underline-offset-2 transition-all">
{children}
</Link>
);
}
return (
<a
href={href}
{...props}
target="_blank"
rel="noopener noreferrer"
className="text-primary font-medium hover:underline decoration-2 underline-offset-2 transition-all inline-flex items-center gap-1"
>
{children}
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M10 6H6a2 2 0 00-2 2v10a2 2 0 002 2h10a2 2 0 002-2v-4M14 4h6m0 0v6m0-6L10 14" />
</svg>
</a>
);
},
img: (props: any) => (
<AnimatedImage src={props.src} alt={props.alt} />
),
h2: ({ children, ...props }: any) => {
const id = typeof children === 'string'
? children.toLowerCase().replace(/[^\w\s-]/g, '').replace(/\s+/g, '-')
: props.id;
return (
<SplitHeading {...props} id={id} className="mt-16 mb-6 pb-3 border-b-2 border-primary/20">
{children}
</SplitHeading>
);
},
h3: ({ children, ...props }: any) => {
const id = typeof children === 'string'
? children.toLowerCase().replace(/[^\w\s-]/g, '').replace(/\s+/g, '-')
: props.id;
return (
<h3 {...props} id={id} className="text-2xl font-bold text-text-primary mt-12 mb-4">
{children}
</h3>
);
},
p: ({ children, ...props }: any) => (
<p {...props} className="text-lg text-text-secondary leading-relaxed mb-6">
{children}
</p>
),
ul: ({ children, ...props }: any) => (
<ul {...props} className="my-8 space-y-3">
{children}
</ul>
),
ol: ({ children, ...props }: any) => (
<ol {...props} className="my-8 space-y-3 list-decimal list-inside">
{children}
</ol>
),
li: ({ children, ...props }: any) => (
<li {...props} className="text-lg text-text-secondary flex items-start gap-3">
<span className="text-primary mt-1.5 flex-shrink-0">
<svg className="w-5 h-5" fill="currentColor" viewBox="0 0 20 20">
<path fillRule="evenodd" d="M10 18a8 8 0 100-16 8 8 0 000 16zm3.707-9.293a1 1 0 00-1.414-1.414L9 10.586 7.707 9.293a1 1 0 00-1.414 1.414l2 2a1 1 0 001.414 0l4-4z" clipRule="evenodd" />
</svg>
</span>
<span className="flex-1">{children}</span>
</li>
),
blockquote: ({ children, ...props }: any) => (
<blockquote {...props} className="my-8 pl-6 border-l-4 border-primary bg-neutral-light/30 py-4 pr-6 rounded-r-lg">
<div className="text-lg text-text-primary italic">
{children}
</div>
</blockquote>
),
strong: ({ children, ...props }: any) => (
<strong {...props} className="font-bold text-primary">
{children}
</strong>
),
code: ({ children, ...props }: any) => (
<code {...props} className="px-2 py-1 bg-neutral-light text-primary rounded font-mono text-sm">
{children}
</code>
),
pre: ({ children, ...props }: any) => (
<pre {...props} className="my-8 p-6 bg-neutral-dark/5 rounded-xl overflow-x-auto">
{children}
</pre>
),
table: ({ children, ...props }: any) => (
<div className="my-8 overflow-x-auto rounded-lg border border-neutral-200 shadow-sm">
<table {...props} className="w-full text-left text-sm text-text-secondary">
{children}
</table>
</div>
),
thead: ({ children, ...props }: any) => (
<thead {...props} className="bg-neutral-50 text-text-primary font-semibold border-b border-neutral-200">
{children}
</thead>
),
tbody: ({ children, ...props }: any) => (
<tbody {...props} className="divide-y divide-neutral-200 bg-white">
{children}
</tbody>
),
tr: ({ children, ...props }: any) => (
<tr {...props} className="hover:bg-neutral-50/50 transition-colors">
{children}
</tr>
),
th: ({ children, ...props }: any) => (
<th {...props} className="px-6 py-4 whitespace-nowrap">
{children}
</th>
),
td: ({ children, ...props }: any) => (
<td {...props} className="px-6 py-4">
{children}
</td>
),
};

View File

@@ -1,73 +1,51 @@
import React from 'react';
import Link from 'next/link';
import { PostData } from '@/lib/blog';
import { PostMdx } from '@/lib/blog';
interface PostNavigationProps {
prev: PostData | null;
next: PostData | null;
isPrevRandom?: boolean;
isNextRandom?: boolean;
prev: PostMdx | null;
next: PostMdx | null;
locale: string;
}
export default function PostNavigation({
prev,
next,
isPrevRandom,
isNextRandom,
locale,
}: PostNavigationProps) {
export default function PostNavigation({ prev, next, locale }: PostNavigationProps) {
if (!prev && !next) return null;
return (
<div className="grid grid-cols-1 md:grid-cols-2 w-full mt-16">
{/* Previous Post (Older) */}
{prev ? (
<Link
<Link
href={`/${locale}/blog/${prev.slug}`}
className="group relative h-64 md:h-80 overflow-hidden block"
>
{/* Background Image */}
{prev.frontmatter.featuredImage ? (
<div
className="absolute inset-0 z-0 bg-cover transition-transform duration-1000 group-hover:scale-105"
style={{
backgroundImage: `url(${prev.frontmatter.featuredImage.split('?')[0]})`,
backgroundPosition: `${prev.frontmatter.focalX ?? 50}% ${prev.frontmatter.focalY ?? 50}%`,
}}
<div
className="absolute inset-0 bg-cover bg-center transition-transform duration-700 group-hover:scale-110"
style={{ backgroundImage: `url(${prev.frontmatter.featuredImage})` }}
/>
) : (
<div className="absolute inset-0 bg-neutral-100" />
)}
{/* Overlay */}
<div className="absolute inset-0 bg-black/60 group-hover:bg-black/50 transition-colors duration-300" />
{/* Content */}
<div className="absolute inset-0 flex flex-col justify-center p-8 md:p-12 text-white z-10">
<span className="text-sm font-bold uppercase tracking-wider mb-2 text-white/90">
{isPrevRandom
? locale === 'de'
? 'Weiterer Artikel'
: 'More Article'
: locale === 'de'
? 'Vorheriger Beitrag'
: 'Previous Post'}
<span className="text-sm font-bold uppercase tracking-wider mb-2 opacity-70">
{locale === 'de' ? 'Vorheriger Beitrag' : 'Previous Post'}
</span>
<h3 className="text-xl md:text-2xl font-bold leading-tight group-hover:underline decoration-2 underline-offset-4">
{prev.frontmatter.title}
</h3>
</div>
{/* Arrow Icon */}
<div className="absolute left-4 top-1/2 -translate-y-1/2 text-white/70 group-hover:text-white group-hover:-translate-x-2 transition-all duration-300">
<div className="absolute left-4 top-1/2 -translate-y-1/2 text-white/30 group-hover:text-white group-hover:-translate-x-2 transition-all duration-300">
<svg className="w-8 h-8" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M15 19l-7-7 7-7"
/>
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M15 19l-7-7 7-7" />
</svg>
</div>
</Link>
@@ -77,42 +55,33 @@ export default function PostNavigation({
{/* Next Post (Newer) */}
{next ? (
<Link
<Link
href={`/${locale}/blog/${next.slug}`}
className="group relative h-64 md:h-80 overflow-hidden block"
>
{/* Background Image */}
{next.frontmatter.featuredImage ? (
<div
className="absolute inset-0 z-0 bg-cover transition-transform duration-1000 group-hover:scale-105"
style={{
backgroundImage: `url(${next.frontmatter.featuredImage.split('?')[0]})`,
backgroundPosition: `${next.frontmatter.focalX ?? 50}% ${next.frontmatter.focalY ?? 50}%`,
}}
<div
className="absolute inset-0 bg-cover bg-center transition-transform duration-700 group-hover:scale-110"
style={{ backgroundImage: `url(${next.frontmatter.featuredImage})` }}
/>
) : (
<div className="absolute inset-0 bg-neutral-100" />
)}
{/* Overlay */}
<div className="absolute inset-0 bg-black/60 group-hover:bg-black/50 transition-colors duration-300" />
{/* Content */}
<div className="absolute inset-0 flex flex-col justify-center items-end text-right p-8 md:p-12 text-white z-10">
<span className="text-sm font-bold uppercase tracking-wider mb-2 text-white/90">
{isNextRandom
? locale === 'de'
? 'Weiterer Artikel'
: 'More Article'
: locale === 'de'
? 'Nächster Beitrag'
: 'Next Post'}
<span className="text-sm font-bold uppercase tracking-wider mb-2 opacity-70">
{locale === 'de' ? 'Nächster Beitrag' : 'Next Post'}
</span>
<h3 className="text-xl md:text-2xl font-bold leading-tight group-hover:underline decoration-2 underline-offset-4">
{next.frontmatter.title}
</h3>
</div>
{/* Arrow Icon */}
<div className="absolute right-4 top-1/2 -translate-y-1/2 text-white/30 group-hover:text-white group-hover:translate-x-2 transition-all duration-300">
<svg className="w-8 h-8" fill="none" stroke="currentColor" viewBox="0 0 24 24">

View File

@@ -28,7 +28,7 @@ export default function PowerCTA({ locale }: PowerCTAProps) {
<span className="text-accent block">{isDe ? 'Energiewende?' : 'Energy Transition?'}</span>
</h3>
<p className="text-xl text-white/90 mb-10 leading-relaxed max-w-2xl">
<p className="text-xl text-white/70 mb-10 leading-relaxed max-w-2xl">
{isDe
? 'Von der Planung von Wind- und Solarparks bis zur Lieferung hochwertiger Energiekabel erwecken wir Ihre Projekte zum Leben.'
: 'From wind and solar park planning to delivering high-quality energy cables, we bring your projects to life.'}
@@ -45,7 +45,7 @@ export default function PowerCTA({ locale }: PowerCTAProps) {
? 'Zertifizierte Qualität nach EU-Standards'
: 'Certified quality according to EU standards',
].map((item, i) => (
<div key={i} className="flex items-center gap-4 text-white/90">
<div key={i} className="flex items-center gap-4 text-white/80">
<div className="w-6 h-6 rounded-full bg-accent/20 flex items-center justify-center flex-shrink-0">
<svg
className="w-3 h-3 text-accent"
@@ -88,7 +88,7 @@ export default function PowerCTA({ locale }: PowerCTAProps) {
/>
</svg>
</Link>
<p className="text-white/80 text-sm font-medium">
<p className="text-white/50 text-sm font-medium">
{isDe
? 'Kostenlose Erstberatung für Ihr Vorhaben.'
: 'Free initial consultation for your project.'}

View File

@@ -8,17 +8,15 @@ interface SplitHeadingProps {
id?: string;
}
export default function SplitHeading({
children,
className = '',
id,
level: Level = 'h2',
}: SplitHeadingProps & { level?: any }) {
export default function SplitHeading({ children, className = '', id }: SplitHeadingProps) {
return (
<div id={id} className={className}>
<Level className="text-xl md:text-2xl font-bold leading-tight text-text-primary">
<div
id={id}
className={className}
>
<h2 className="text-xl md:text-2xl font-bold leading-tight text-text-primary">
{children}
</Level>
</h2>
</div>
);
}

View File

@@ -53,7 +53,7 @@ export default function TableOfContents({ headings, locale }: TableOfContentsPro
return (
<nav className="hidden lg:block w-full ml-12">
<div className="relative pl-6 border-l border-neutral-200">
<h4 className="text-xs md:text-sm font-bold uppercase tracking-[0.2em] text-text-primary/70 mb-6">
<h4 className="text-xs md:text-sm font-bold uppercase tracking-[0.2em] text-text-primary/50 mb-6">
{locale === 'de' ? 'Inhalt' : 'Table of Contents'}
</h4>
<ul className="space-y-4">

View File

@@ -19,78 +19,53 @@ export default function VisualLinkPreview({ url, title, summary, image }: Visual
})();
return (
<Link
href={url}
target="_blank"
rel="noopener noreferrer"
className="block my-12 no-underline group"
>
<Link href={url} target="_blank" rel="noopener noreferrer" className="block my-12 no-underline group">
<div className="flex flex-col md:flex-row border border-neutral-200 rounded-2xl overflow-hidden bg-white transition-all duration-500 hover:shadow-2xl hover:border-primary/20 hover:-translate-y-1 group">
<div className="relative w-full md:w-64 h-48 md:h-auto flex-shrink-0 bg-neutral-50 overflow-hidden">
{image ? (
<Image
src={image.split('?')[0]}
alt={title}
<Image
src={image}
alt={title}
fill
unoptimized
className="object-cover transition-transform duration-700 group-hover:scale-110"
/>
) : (
<div className="w-full h-full flex items-center justify-center bg-primary/5">
<svg
className="w-12 h-12 text-primary/20"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={1.5}
d="M13.828 10.172a4 4 0 00-5.656 0l-4 4a4 4 0 105.656 5.656l1.102-1.101m-.758-4.899a4 4 0 005.656 0l4-4a4 4 0 00-5.656-5.656l-1.1 1.1"
/>
<svg className="w-12 h-12 text-primary/20" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.5} d="M13.828 10.172a4 4 0 00-5.656 0l-4 4a4 4 0 105.656 5.656l1.102-1.101m-.758-4.899a4 4 0 005.656 0l4-4a4 4 0 00-5.656-5.656l-1.1 1.1" />
</svg>
</div>
)}
{/* Industrial overlay */}
<div className="absolute inset-0 bg-primary/10 opacity-0 group-hover:opacity-100 transition-opacity duration-500 pointer-events-none" />
</div>
<div className="p-8 flex flex-col justify-center relative">
{/* Industrial accent corner */}
<div className="absolute top-0 right-0 w-12 h-12 bg-primary/5 -mr-6 -mt-6 rotate-45 transition-transform group-hover:scale-110" />
<div className="flex items-center gap-2 mb-3">
<span className="text-[10px] font-bold uppercase tracking-[0.2em] text-primary/80 bg-primary/10 px-2 py-0.5 rounded">
<span className="text-[10px] font-bold uppercase tracking-[0.2em] text-primary/60 bg-primary/5 px-2 py-0.5 rounded">
External Link
</span>
<span className="text-[10px] font-bold uppercase tracking-[0.2em] text-text-secondary/80">
<span className="text-[10px] font-bold uppercase tracking-[0.2em] text-text-secondary/40">
{hostname}
</span>
</div>
<h3 className="text-xl font-bold text-text-primary mb-3 group-hover:text-primary transition-colors line-clamp-2 leading-tight">
{title}
</h3>
<p className="text-text-secondary text-base line-clamp-2 leading-relaxed mb-4">
{summary}
</p>
<div className="flex items-center gap-2 text-primary font-bold text-xs uppercase tracking-widest">
<span>Read more</span>
<svg
className="w-4 h-4 transition-transform group-hover:translate-x-1"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M17 8l4 4m0 0l-4 4m4-4H3"
/>
<svg className="w-4 h-4 transition-transform group-hover:translate-x-1" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M17 8l4 4m0 0l-4 4m4-4H3" />
</svg>
</div>
</div>

View File

@@ -2,7 +2,7 @@ import React from 'react';
import { useTranslations, useLocale } from 'next-intl';
import { Section, Container, Button, Heading } from '../../components/ui';
export default function CTA({ data }: { data?: any }) {
export default function CTA() {
const t = useTranslations('Home.cta');
const locale = useLocale();
@@ -10,20 +10,20 @@ export default function CTA({ data }: { data?: any }) {
<Section className="bg-primary text-white py-32 relative overflow-hidden">
<div className="absolute top-0 right-0 w-1/3 h-full bg-accent/5 -skew-x-12 translate-x-1/2" />
<div className="absolute bottom-0 left-0 w-1/4 h-1/2 bg-primary/10 rounded-full blur-3xl -translate-x-1/2 translate-y-1/2" />
<Container className="relative z-10">
<div className="flex flex-col lg:flex-row items-center justify-between gap-16">
<div className="max-w-3xl text-center lg:text-left">
<Heading level={2} subtitle={data?.subtitle || t('subtitle')} className="text-white mb-6">
<span className="text-white">{data?.title || t('title')}</span>
<Heading level={2} subtitle={t('subtitle')} className="text-white mb-6">
<span className="text-white">{t('title')}</span>
</Heading>
<p className="text-lg md:text-xl text-white/70 leading-relaxed">
{data?.description || t('description')}
{t('description')}
</p>
</div>
<div className="flex-shrink-0">
<Button href={`/${locale}/contact`} variant="accent" size="xl" className="group px-12">
{data?.buttonLabel || t('button')}
{t('button')}
<span className="ml-3 transition-transform group-hover:translate-x-2">&rarr;</span>
</Button>
</div>

View File

@@ -3,7 +3,7 @@ import Image from 'next/image';
import { useTranslations } from 'next-intl';
import { Section, Container, Heading } from '../../components/ui';
export default function Experience({ data }: { data?: any }) {
export default function Experience() {
const t = useTranslations('Home.experience');
return (
@@ -11,11 +11,10 @@ export default function Experience({ data }: { data?: any }) {
<div className="absolute inset-0 z-0">
<Image
src="/uploads/2024/12/1694273920124-copy-2.webp"
alt={data?.subtitle || t('subtitle')}
alt={t('subtitle')}
fill
className="object-cover object-center scale-105 animate-slow-zoom"
sizes="100vw"
quality={100}
/>
<div className="absolute inset-0 bg-primary/80 mix-blend-multiply" />
<div className="absolute inset-0 bg-gradient-to-r from-primary via-primary/40 to-transparent" />
@@ -23,31 +22,31 @@ export default function Experience({ data }: { data?: any }) {
<Container className="relative z-10">
<div className="max-w-3xl">
<Heading level={2} subtitle={data?.subtitle || t('subtitle')} className="text-white">
<span className="text-white">{data?.title || t('title')}</span>
<Heading level={2} subtitle={t('subtitle')} className="text-white">
<span className="text-white">{t('title')}</span>
</Heading>
<div className="space-y-8 text-lg md:text-xl text-white/90 leading-relaxed font-medium">
<p className="border-l-4 border-accent pl-8 py-2 bg-white/5 backdrop-blur-sm rounded-r-xl">
{data?.paragraph1 || t('p1')}
{t('p1')}
</p>
<p className="pl-9">{data?.paragraph2 || t('p2')}</p>
<p className="pl-9">{t('p2')}</p>
</div>
<dl className="mt-16 grid grid-cols-1 md:grid-cols-2 gap-12">
<div className="animate-fade-in">
<dt className="text-2xl md:text-3xl font-extrabold text-accent mb-4">
{data?.badge1 || t('certifiedQuality')}
{t('certifiedQuality')}
</dt>
<dd className="text-base md:text-lg font-bold uppercase tracking-widest text-white/60">
{data?.badge1Text || t('vdeApproved')}
{t('vdeApproved')}
</dd>
</div>
<div className="animate-fade-in" style={{ animationDelay: '100ms' }}>
<dt className="text-2xl md:text-3xl font-extrabold text-accent mb-4">
{data?.badge2 || t('fullSpectrum')}
{t('fullSpectrum')}
</dt>
<dd className="text-base md:text-lg font-bold uppercase tracking-widest text-white/60">
{data?.badge2Text || t('solutionsRange')}
{t('solutionsRange')}
</dd>
</div>
</dl>

View File

@@ -3,11 +3,10 @@ import React from 'react';
import Image from 'next/image';
import { useTranslations } from 'next-intl';
import { Section, Container, Heading } from '../../components/ui';
import dynamic from 'next/dynamic';
const Lightbox = dynamic(() => import('../Lightbox'), { ssr: false });
import Lightbox from '../Lightbox';
import { useSearchParams } from 'next/navigation';
export default function GallerySection({ data }: { data?: any }) {
export default function GallerySection() {
const t = useTranslations('Home.gallery');
const searchParams = useSearchParams();
const images = [
@@ -26,8 +25,8 @@ export default function GallerySection({ data }: { data?: any }) {
return (
<Section className="bg-white text-white py-32">
<Container>
<Heading level={2} subtitle={data?.subtitle || t('subtitle')} align="center">
{data?.title || t('title')}
<Heading level={2} subtitle={t('subtitle')} align="center">
{t('title')}
</Heading>
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-8">

View File

@@ -1,13 +1,15 @@
'use client';
import Scribble from '@/components/Scribble';
import { Button, Container, Heading, Section } from '@/components/ui';
import { motion } from 'framer-motion';
import { useTranslations, useLocale } from 'next-intl';
import dynamic from 'next/dynamic';
import { useAnalytics } from '../analytics/useAnalytics';
import { AnalyticsEvents } from '../analytics/analytics-events';
const HeroIllustration = dynamic(() => import('./HeroIllustration'), { ssr: false });
export default function Hero({ data }: { data?: any }) {
export default function Hero() {
const t = useTranslations('Home.hero');
const locale = useLocale();
const { trackEvent } = useAnalytics();
@@ -15,84 +17,182 @@ export default function Hero({ data }: { data?: any }) {
return (
<Section className="relative min-h-[85vh] md:h-[90vh] flex flex-col items-center justify-center overflow-hidden bg-primary py-12 md:py-0 lg:py-0">
<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>
<motion.div
className="max-w-5xl mx-auto md:mx-0"
initial="hidden"
animate="visible"
variants={containerVariants}
>
<motion.div variants={headingVariants}>
<Heading
level={1}
className="text-center md:text-left mb-6 md:mb-8 md:max-w-none text-white text-3xl sm:text-4xl md:text-5xl font-extrabold"
className="text-center md:text-left mb-6 md:mb-8 md:max-w-none text-white text-4xl sm:text-5xl md:text-7xl font-extrabold [text-shadow:_-2px_-2px_0_#002b49,_2px_-2px_0_#002b49,_-2px_2px_0_#002b49,_2px_2px_0_#002b49,_-2px_0_0_#002b49,_2px_0_0_#002b49,_0_-2px_0_#002b49,_0_2px_0_#002b49]"
>
{data?.title ? (
<span
dangerouslySetInnerHTML={{
__html: data.title
.replace(/<green>/g, '<span class="text-accent italic">')
.replace(/<\/green>/g, '</span>'),
}}
/>
) : (
t.rich('title', {
green: (chunks) => <span className="text-accent italic">{chunks}</span>,
})
)}
{t.rich('title', {
green: (chunks) => (
<span className="relative inline-block">
<motion.span
className="relative z-10 text-accent italic"
variants={accentVariants}
>
{chunks}
</motion.span>
<motion.div
variants={scribbleVariants}
className="w-[140%] h-[140%] -top-[20%] -left-[20%] text-accent/30 hidden md:block absolute -z-10"
>
<Scribble variant="circle" />
</motion.div>
</span>
),
})}
</Heading>
</div>
<div>
<p className="text-lg md:text-xl text-white leading-relaxed max-w-2xl mb-10 md:mb-12">
{data?.subtitle || t('subtitle')}
</motion.div>
<motion.div variants={subtitleVariants}>
<p className="text-lg md:text-xl text-white/90 leading-relaxed max-w-2xl mb-10 md:mb-12">
{t('subtitle')}
</p>
</div>
<div className="flex flex-col sm:flex-row justify-center md:justify-start gap-4 md:gap-6">
<div>
</motion.div>
<motion.div
className="flex flex-col sm:flex-row justify-center md:justify-start gap-4 md:gap-6"
variants={buttonContainerVariants}
>
<motion.div variants={buttonVariants}>
<Button
href="/contact"
variant="accent"
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-transform"
className="group w-full sm:w-auto h-14 md:h-16 px-8 md:px-10 text-base md:text-lg"
onClick={() =>
trackEvent(AnalyticsEvents.BUTTON_CLICK, {
label: data?.ctaLabel || t('cta'),
label: t('cta'),
location: 'home_hero_primary',
})
}
>
{data?.ctaLabel || t('cta')}
<span className="transition-transform group-hover/btn:translate-x-1 ml-2">
&rarr;
</span>
{t('cta')}
<span className="transition-transform group-hover/btn:translate-x-1">&rarr;</span>
</Button>
</div>
<div>
</motion.div>
<motion.div variants={buttonVariants}>
<Button
href={`/${locale}/${locale === 'de' ? 'produkte' : 'products'}`}
variant="white"
size="lg"
className="group w-full sm:w-auto h-14 md:h-16 px-8 md:px-10 text-base md:text-lg md:bg-white md:text-primary md:hover:bg-neutral-light md:border-none hover:scale-105 transition-transform"
className="group w-full sm:w-auto h-14 md:h-16 px-8 md:px-10 text-base md:text-lg md:bg-white md:text-primary md:hover:bg-neutral-light md:border-none"
onClick={() =>
trackEvent(AnalyticsEvents.BUTTON_CLICK, {
label: data?.secondaryCtaLabel || t('exploreProducts'),
label: t('exploreProducts'),
location: 'home_hero_secondary',
})
}
>
{data?.secondaryCtaLabel || t('exploreProducts')}
{t('exploreProducts')}
</Button>
</div>
</div>
</div>
</motion.div>
</motion.div>
</motion.div>
</Container>
<div className="relative md:absolute inset-0 z-0 w-full h-[40vh] md:h-full order-1 md:order-none mb-[-80px] md:mb-0 mt-[80px] md:mt-0 overflow-visible pointer-events-none animate-in fade-in zoom-in-95 duration-1000 ease-out fill-mode-both">
<motion.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"
initial={{ opacity: 0, scale: 0.95, filter: 'blur(20px)' }}
animate={{ opacity: 1, scale: 1, filter: 'blur(0px)' }}
transition={{ duration: 2.2, ease: 'easeOut', delay: 0.05 }}
>
<HeroIllustration />
</div>
</motion.div>
<div
className="absolute bottom-6 md:bottom-10 left-1/2 -translate-x-1/2 hidden sm:block animate-in fade-in slide-in-from-bottom-4 duration-1000 ease-out fill-mode-both"
style={{ animationDelay: '2000ms' }}
<motion.div
className="absolute bottom-6 md:bottom-10 left-1/2 -translate-x-1/2 hidden sm:block"
initial={{ opacity: 0, y: 16 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 1, ease: 'easeOut', delay: 3 }}
>
<div className="w-6 h-10 border-2 border-white/30 rounded-full flex justify-center p-1">
<div className="w-1 h-2 bg-white rounded-full animate-bounce" />
<motion.div
className="w-1 h-2 bg-white rounded-full"
animate={{ y: [0, -10, 0] }}
transition={{
duration: 1.5,
repeat: Infinity,
ease: 'easeInOut',
}}
/>
</div>
</div>
</motion.div>
</Section>
);
}
const containerVariants = {
hidden: { opacity: 1 },
visible: {
opacity: 1,
transition: {
staggerChildren: 0.1,
delayChildren: 0.1,
},
},
} as const;
const headingVariants = {
hidden: { opacity: 1, y: 10, scale: 0.98 },
visible: {
opacity: 1,
y: 0,
scale: 1,
transition: { duration: 0.4, ease: [0.25, 0.46, 0.45, 0.94] },
},
} as const;
const accentVariants = {
hidden: { opacity: 0, scale: 0.9, rotate: -5 },
visible: {
opacity: 1,
scale: 1,
rotate: 0,
transition: { duration: 0.8, ease: [0.25, 0.46, 0.45, 0.94] },
},
} as const;
const scribbleVariants = {
hidden: { opacity: 0, scale: 0, rotate: 180 },
visible: {
opacity: 1,
scale: 1,
rotate: 0,
transition: { duration: 1, type: 'spring', stiffness: 300, damping: 20 },
},
} as const;
const subtitleVariants = {
hidden: { opacity: 1, y: 20, scale: 0.98 },
visible: {
opacity: 1,
y: 0,
scale: 1,
transition: { duration: 0.6, ease: [0.25, 0.46, 0.45, 0.94], delay: 0.1 },
},
} as const;
const buttonContainerVariants = {
hidden: { opacity: 1 },
visible: {
opacity: 1,
transition: {
staggerChildren: 0.15,
delayChildren: 0.4,
},
},
} as const;
const buttonVariants = {
hidden: { opacity: 1, y: 30, scale: 0.9 },
visible: {
opacity: 1,
y: 0,
scale: 1,
transition: { type: 'spring', stiffness: 400, damping: 20 },
},
} as const;

View File

@@ -125,9 +125,8 @@ export default function HeroIllustration() {
}, []);
const viewBox = isMobile ? '400 0 1000 1100' : '-400 -200 1800 1100';
// Increase scale slightly and opacity significantly on mobile to fix the "thin" appearance
const scale = isMobile ? 1.6 : 1;
const opacity = isMobile ? 0.9 : 0.85;
const scale = isMobile ? 1.44 : 1;
const opacity = isMobile ? 0.6 : 0.85;
return (
<div className="absolute inset-0 z-0 overflow-visible bg-primary w-full h-full">
@@ -155,15 +154,15 @@ export default function HeroIllustration() {
<stop offset="70%" stopColor="white" stopOpacity="0.4" />
<stop offset="100%" stopColor="white" stopOpacity="0" />
</linearGradient>
<filter id="glow" x="-50%" y="-50%" width="200%" height="200%">
<feGaussianBlur stdDeviation="3" result="blur" />
<filter id="glow" x="-20%" y="-20%" width="140%" height="140%">
<feGaussianBlur stdDeviation="1.5" result="blur" />
<feMerge>
<feMergeNode in="blur" />
<feMergeNode in="SourceGraphic" />
</feMerge>
</filter>
<filter id="soft-glow" x="-100%" y="-100%" width="300%" height="300%">
<feGaussianBlur stdDeviation="2" result="blur" />
<filter id="soft-glow" x="-50%" y="-50%" width="200%" height="200%">
<feGaussianBlur stdDeviation="1" result="blur" />
<feMerge>
<feMergeNode in="blur" />
<feMergeNode in="SourceGraphic" />
@@ -216,10 +215,10 @@ export default function HeroIllustration() {
</g>
{/* ANIMATED ENERGY FLOW */}
<g filter="url(#glow)">
<g>
{POWER_LINES.map((line, i) => {
// Only animate a subset of lines to reduce main-thread work
if (i % 2 !== 0) return null;
// Only animate a small subset of lines to reduce main-thread work significantly
if (i % 5 !== 0) return null;
const from = gridToScreen(line.from.col, line.from.row);
const to = gridToScreen(line.to.col, line.to.row);
const length = Math.sqrt(Math.pow(to.x - from.x, 2) + Math.pow(to.y - from.y, 2));

View File

@@ -3,7 +3,7 @@ import Image from 'next/image';
import { useTranslations, useLocale } from 'next-intl';
import { Section, Container, Button, Heading } from '../../components/ui';
export default function MeetTheTeam({ data }: { data?: any }) {
export default function MeetTheTeam() {
const t = useTranslations('Home.meetTheTeam');
const teamT = useTranslations('Team');
const locale = useLocale();
@@ -13,11 +13,10 @@ export default function MeetTheTeam({ data }: { data?: any }) {
<div className="absolute inset-0 z-0">
<Image
src="/uploads/2024/12/DSC08036-Large.webp"
alt={data?.subtitle || t('subtitle')}
alt={t('subtitle')}
fill
className="object-cover scale-105 animate-slow-zoom"
sizes="100vw"
quality={100}
/>
<div className="absolute inset-0 bg-primary/70 mix-blend-multiply" />
<div className="absolute inset-0 bg-gradient-to-t from-primary via-primary/20 to-transparent" />
@@ -25,20 +24,20 @@ export default function MeetTheTeam({ data }: { data?: any }) {
<Container className="relative z-10">
<div className="max-w-3xl text-white animate-slide-up">
<Heading level={2} subtitle={data?.subtitle || t('subtitle')} className="text-white mb-8">
<span className="text-white">{data?.title || t('title')}</span>
<Heading level={2} subtitle={t('subtitle')} className="text-white mb-8">
<span className="text-white">{t('title')}</span>
</Heading>
<div className="relative mb-12">
<div className="absolute -left-8 top-0 bottom-0 w-1 bg-accent rounded-full" />
<p className="text-xl md:text-2xl leading-relaxed font-medium italic text-white/90 pl-8">
"{data?.description || t('description')}"
"{t('description')}"
</p>
</div>
<div className="flex flex-wrap gap-8 items-center">
<Button href={`/${locale}/team`} variant="accent" size="xl" className="group">
{data?.ctaLabel || t('cta')}
{t('cta')}
<span className="ml-3 transition-transform group-hover:translate-x-2">&rarr;</span>
</Button>
@@ -62,7 +61,7 @@ export default function MeetTheTeam({ data }: { data?: any }) {
</div>
</div>
<span className="text-white/60 font-bold text-xs md:text-sm uppercase tracking-widest">
{data?.networkLabel || t('andNetwork')}
{t('andNetwork')}
</span>
</div>
</div>

View File

@@ -4,7 +4,7 @@ import Image from 'next/image';
import { useTranslations, useLocale } from 'next-intl';
import { Section } from '../../components/ui';
export default function ProductCategories({ data }: { data?: any }) {
export default function ProductCategories() {
const t = useTranslations('Products');
const locale = useLocale();
@@ -43,15 +43,7 @@ export default function ProductCategories({ data }: { data?: any }) {
return (
<Section className="bg-neutral-light py-0 md:py-0 lg:py-0 -mt-px">
{(data?.title || t.has('title')) && (
<h2 className="sr-only">
{data?.title ? (
<span dangerouslySetInnerHTML={{ __html: data.title }} />
) : (
t.rich('title', { green: (chunks: any) => <span>{chunks}</span> })
)}
</h2>
)}
<h2 className="sr-only">{t('title')}</h2>
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4">
{categories.map((category, idx) => (
<Link

View File

@@ -3,108 +3,99 @@ import Image from 'next/image';
import Link from 'next/link';
import { getAllPosts } from '@/lib/blog';
import { getTranslations } from 'next-intl/server';
import { Section, Container, Heading } from '../../components/ui';
import { Section, Container, Heading, Card, Badge } from '../../components/ui';
interface RecentPostsProps {
locale: string;
data?: any;
}
export default async function RecentPosts({ locale, data }: RecentPostsProps) {
export default async function RecentPosts({ locale }: RecentPostsProps) {
const t = await getTranslations('Blog');
const posts = await getAllPosts(locale);
const recentPosts = posts.slice(0, 4);
const recentPosts = posts.slice(0, 3);
if (recentPosts.length === 0) return null;
const title = data?.title || t('allArticles');
const subtitle = data?.subtitle || t('latestNews');
return (
<Section className="bg-neutral-light py-0 md:py-0 lg:py-0">
<Container className="py-12 md:py-16">
<div className="flex flex-col md:flex-row items-start md:items-end justify-between gap-6">
<Heading level={2} subtitle={subtitle} className="mb-0 text-primary">
{title}
<Section className="bg-neutral py-16 md:py-24">
<Container>
<div className="flex flex-col md:flex-row items-start md:items-end justify-between mb-12 md:mb-16 gap-6">
<Heading level={2} subtitle={t('latestNews')} className="mb-0 text-primary">
{t('allArticles')}
</Heading>
<Link
href={`/${locale}/blog`}
className="group flex items-center text-primary font-bold text-base md:text-lg touch-target"
>
{title}
{t('allArticles')}
<span className="ml-2 transition-transform group-hover:translate-x-2">&rarr;</span>
</Link>
</div>
</Container>
<ul className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 m-0 p-0 list-none">
{recentPosts.map((post, idx) => (
<li key={`${post.slug}-${idx}`} className="block">
<Link
href={`/${locale}/blog/${post.slug}`}
className="group block relative h-[400px] md:h-[500px] lg:h-[650px] overflow-hidden border-b md:border-b-0 md:border-r border-white/10 last:border-0 focus:outline-none"
>
{post.frontmatter.featuredImage && (
<>
<Image
src={post.frontmatter.featuredImage.split('?')[0]}
alt={post.frontmatter.title}
fill
className="object-cover transition-transform duration-1000 group-hover:scale-110"
style={{
objectPosition: `${post.frontmatter.focalX ?? 50}% ${post.frontmatter.focalY ?? 50}%`,
}}
sizes="(max-width: 768px) 100vw, (max-width: 1024px) 50vw, 25vw"
loading="lazy"
/>
<div className="absolute inset-0 bg-primary-dark/40 group-hover:bg-primary-dark/60 transition-all duration-500" />
</>
)}
<div className="absolute inset-0 p-8 md:p-10 flex flex-col justify-end text-white">
<div className="mb-4 md:mb-6 transform transition-all duration-500 group-hover:-translate-y-4">
<div className="flex flex-wrap items-center gap-2 mb-4">
{post.frontmatter.category && (
<span className="px-3 py-1 bg-accent text-primary-dark rounded-full text-[10px] md:text-xs font-bold uppercase tracking-wider shadow-sm">
{post.frontmatter.category}
</span>
)}
<time
dateTime={post.frontmatter.date}
suppressHydrationWarning
className="px-3 py-1 text-white/80 text-[10px] md:text-xs font-bold uppercase tracking-widest border border-white/20 rounded-full bg-white/10 backdrop-blur-md"
>
{new Date(post.frontmatter.date).toLocaleDateString(
locale === 'en' ? 'en-US' : 'de-DE',
{
year: 'numeric',
month: 'short',
day: 'numeric',
},
<ul className="grid grid-cols-1 md:grid-cols-3 gap-8 md:gap-10 list-none p-0 m-0">
{recentPosts.map((post) => (
<li key={post.slug}>
<Link href={`/${locale}/blog/${post.slug}`} className="group block h-full">
<Card
tag="article"
className="h-full flex flex-col border-none shadow-lg hover:shadow-2xl transition-all duration-500 rounded-3xl"
>
{post.frontmatter.featuredImage && (
<div className="relative h-64 overflow-hidden">
<Image
src={post.frontmatter.featuredImage}
alt={post.frontmatter.title}
fill
className="w-full h-full object-cover transition-transform duration-700 group-hover:scale-110"
sizes="(max-width: 768px) 100vw, 33vw"
loading="lazy"
/>
<div className="absolute inset-0 image-overlay-gradient opacity-0 group-hover:opacity-100 transition-opacity duration-500" />
{post.frontmatter.category && (
<Badge variant="accent" className="absolute top-4 left-4 shadow-md">
{post.frontmatter.category}
</Badge>
)}
</time>
{(new Date(post.frontmatter.date) > new Date() ||
post.frontmatter.public === false) && (
<span className="px-2 py-0.5 border border-white/40 text-white/90 rounded uppercase tracking-widest text-[10px] md:text-xs font-bold bg-neutral-dark/40 shadow-sm">
Draft Preview
</span>
)}
</div>
)}
<div className="p-6 md:p-8 flex flex-col flex-grow">
<div className="text-xs md:text-sm font-medium text-text-light mb-3 md:mb-4 flex items-center gap-2">
<span className="w-6 md:w-8 h-px bg-neutral-medium" />
<time dateTime={post.frontmatter.date}>
{new Date(post.frontmatter.date).toLocaleDateString(locale, {
year: 'numeric',
month: 'long',
day: 'numeric',
})}
</time>
</div>
<h3 className="text-lg md:text-xl font-bold text-primary group-hover:text-accent-dark transition-colors line-clamp-2 mb-4 md:mb-6 leading-tight">
{post.frontmatter.title}
</h3>
<div className="mt-auto flex items-center text-primary font-bold group-hover:underline decoration-2 underline-offset-4">
{t('readMore')}
<svg
className="ml-2 w-5 h-5 transition-transform group-hover:translate-x-2"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
aria-hidden="true"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M17 8l4 4m0 0l-4 4m4-4H3"
/>
</svg>
</div>
</div>
<h3 className="text-xl md:text-2xl font-bold mb-2 md:mb-4 leading-tight drop-shadow-md">
{post.frontmatter.title}
</h3>
</div>
<div className="flex items-center text-accent font-bold tracking-wider uppercase text-xs md:text-xs opacity-100 md:opacity-0 group-hover:opacity-100 transition-all duration-500 delay-100">
{t('readMore')}{' '}
<span className="ml-2 transition-transform group-hover:translate-x-2">
&rarr;
</span>
</div>
</div>
</Link>
</li>
))}
</ul>
</Card>
</Link>
</li>
))}
</ul>
</Container>
</Section>
);
}

View File

@@ -1,9 +1,10 @@
'use client';
import React, { useState, useEffect, useRef } from 'react';
import Scribble from '@/components/Scribble';
import { useTranslations } from 'next-intl';
export default function VideoSection({ data }: { data?: any }) {
export default function VideoSection() {
const t = useTranslations('Home.video');
const [isVisible, setIsVisible] = useState(false);
const sectionRef = useRef<HTMLElement>(null);
@@ -39,19 +40,17 @@ export default function VideoSection({ data }: { data?: any }) {
<div className="absolute inset-0 bg-gradient-to-b from-primary/60 via-transparent to-primary/60 flex items-center justify-center pointer-events-none">
<div className="max-w-5xl px-6 text-center animate-slide-up pointer-events-auto">
<h2 className="text-3xl md:text-4xl lg:text-5xl font-extrabold text-white leading-[1.1]">
{data?.title ? (
<span
dangerouslySetInnerHTML={{
__html: data.title
.replace(/<future>/g, '<span class="italic text-accent">')
.replace(/<\/future>/g, '</span>'),
}}
/>
) : (
t.rich('title', {
future: (chunks) => <span className="italic text-accent">{chunks}</span>,
})
)}
{t.rich('title', {
future: (chunks) => (
<span className="relative inline-block mx-2">
<span className="relative z-10 italic text-accent">{chunks}</span>
<Scribble
variant="underline"
className="w-full h-4 -bottom-2 left-0 text-accent/40"
/>
</span>
),
})}
</h2>
</div>
</div>

View File

@@ -2,35 +2,30 @@ import React from 'react';
import { useTranslations } from 'next-intl';
import { Section, Container, Heading } from '../../components/ui';
export default function WhatWeDo({ data }: { data?: any }) {
export default function WhatWeDo() {
const t = useTranslations('Home.whatWeDo');
const items = data?.items?.length ? data.items : [0, 1, 2, 3].map(idx => ({
title: t(`items.${idx}.title`),
description: t(`items.${idx}.description`)
}));
return (
<Section className="bg-white">
<Container>
<div className="sticky-narrative-container">
<div className="sticky-narrative-sidebar">
<div className="lg:sticky lg:top-32">
<Heading level={2} subtitle={data?.expertiseLabel || t('expertise')} className="text-primary-dark">
{data?.title || t('title')}
<Heading level={2} subtitle={t('expertise')} className="text-primary-dark">
{t('title')}
</Heading>
<p className="text-base md:text-lg text-text-secondary leading-relaxed">
{data?.subtitle || t('subtitle')}
{t('subtitle')}
</p>
<div className="mt-8 md:mt-12 p-6 md:p-8 bg-saturated/10 rounded-2xl border border-saturated/10">
<p className="text-saturated font-bold text-base md:text-base italic">
"{data?.quote || t('quote')}"
"{t('quote')}"
</p>
</div>
</div>
</div>
<div className="sticky-narrative-content grid grid-cols-1 md:grid-cols-2 gap-x-8 md:gap-x-12 gap-y-12 md:gap-y-20">
{items.map((item: any, idx: number) => (
{[0, 1, 2, 3].map((idx) => (
<div key={idx} className="group">
<div className="flex items-center gap-4 mb-4 md:mb-6">
<span className="flex items-center justify-center w-10 h-10 md:w-12 md:h-12 rounded-full bg-saturated text-white font-bold text-base md:text-lg shadow-lg shadow-saturated/20 group-hover:scale-110 transition-transform">
@@ -38,8 +33,8 @@ export default function WhatWeDo({ data }: { data?: any }) {
</span>
<div className="h-px flex-grow bg-neutral-medium" />
</div>
<h3 className="text-lg md:text-xl font-bold mb-3 md:mb-4 text-primary-dark group-hover:text-accent-dark transition-colors">{item.title}</h3>
<p className="text-text-secondary text-base md:text-base leading-relaxed">{item.description}</p>
<h3 className="text-lg md:text-xl font-bold mb-3 md:mb-4 text-primary-dark group-hover:text-accent-dark transition-colors">{t(`items.${idx}.title`)}</h3>
<p className="text-text-secondary text-base md:text-base leading-relaxed">{t(`items.${idx}.description`)}</p>
</div>
))}
</div>

View File

@@ -2,27 +2,24 @@ import React from 'react';
import { useTranslations } from 'next-intl';
import { Section, Container, Heading } from '../../components/ui';
export default function WhyChooseUs({ data }: { data?: any }) {
export default function WhyChooseUs() {
const t = useTranslations('Home.whyChooseUs');
const features = data?.features?.length ? data.features.map((f: any) => f.feature) : [0, 1, 2, 3].map(i => t(`features.${i}`));
const items = data?.items?.length ? data.items : [0, 1, 2, 3].map(idx => ({ title: t(`items.${idx}.title`), description: t(`items.${idx}.description`) }));
return (
<Section className="bg-neutral-light">
<Container>
<div className="grid grid-cols-1 lg:grid-cols-12 gap-16 lg:gap-24">
<div className="lg:col-span-4 order-1 lg:order-2">
<div className="sticky top-32">
<Heading level={2} subtitle={data?.tagline || t('whyKlz')} className="text-primary-dark">
{data?.title || t('title')}
<Heading level={2} subtitle={t('whyKlz')} className="text-primary-dark">
{t('title')}
</Heading>
<p className="text-base md:text-lg text-text-secondary leading-relaxed">
{data?.subtitle || t('subtitle')}
{t('subtitle')}
</p>
<ul className="mt-12 space-y-6 list-none p-0">
{features.map((featureText: string, i: number) => (
{[0, 1, 2, 3].map((i) => (
<li key={i} className="flex items-center gap-4">
<div className="flex-shrink-0 w-6 h-6 rounded-full bg-accent flex items-center justify-center">
<svg
@@ -41,7 +38,7 @@ export default function WhyChooseUs({ data }: { data?: any }) {
</svg>
</div>
<span className="font-bold text-primary-dark text-base md:text-base">
{featureText}
{t(`features.${i}`)}
</span>
</li>
))}
@@ -49,7 +46,7 @@ export default function WhyChooseUs({ data }: { data?: any }) {
</div>
</div>
<ul className="lg:col-span-8 grid grid-cols-1 md:grid-cols-2 gap-8 order-2 lg:order-1 list-none p-0 m-0">
{items.map((item: any, idx: number) => (
{[0, 1, 2, 3].map((idx) => (
<li
key={idx}
className="p-10 bg-white rounded-3xl border border-neutral-medium hover:border-accent transition-all duration-500 hover:shadow-xl group"
@@ -60,10 +57,10 @@ export default function WhyChooseUs({ data }: { data?: any }) {
</span>
</div>
<h3 className="text-xl font-bold mb-4 text-primary-dark">
{item.title}
{t(`items.${idx}.title`)}
</h3>
<p className="text-text-secondary text-base md:text-base leading-relaxed">
{item.description}
{t(`items.${idx}.description`)}
</p>
</li>
))}

View File

@@ -0,0 +1,118 @@
'use client';
import React, { useState, useEffect } from 'react';
import { finder } from '@medv/finder';
export function PickingHelper() {
const [pickingMode, setPickingMode] = useState<'click' | 'scroll' | null>(null);
const [hoveredElement, setHoveredElement] = useState<HTMLElement | null>(null);
useEffect(() => {
const handleMessage = (e: MessageEvent) => {
if (e.data.type === 'START_PICKING') {
setPickingMode(e.data.mode);
} else if (e.data.type === 'STOP_PICKING') {
setPickingMode(null);
setHoveredElement(null);
} else if (e.data.type === 'SET_HOVER_SELECTOR') {
const selector = e.data.selector;
if (selector) {
const el = document.querySelector(selector) as HTMLElement;
setHoveredElement(el || null);
} else {
setHoveredElement(null);
}
}
};
window.addEventListener('message', handleMessage);
return () => window.removeEventListener('message', handleMessage);
}, []);
useEffect(() => {
if (!pickingMode) return;
const handleMouseOver = (e: MouseEvent) => {
const target = e.target as HTMLElement;
if (target.closest('.record-mode-ignore') || target.closest('.feedback-ui-ignore')) return;
setHoveredElement(target);
};
const handleClick = (e: MouseEvent) => {
if (hoveredElement) {
e.preventDefault();
e.stopPropagation();
const selector = finder(hoveredElement, {
root: document.body,
seedMinLength: 3,
optimizedMinLength: 2,
className: (name) =>
!name.startsWith('record-mode-') &&
!name.startsWith('feedback-') &&
!name.includes('[') &&
!name.includes('/') &&
!name.match(/^[a-z]-[0-9]/) &&
!name.match(/[0-9]{4,}/), // Avoid dynamic IDs in classnames
idName: (name) => !name.startsWith('__next') && !name.includes(':') && !name.match(/[0-9]{5,}/),
});
const rect = hoveredElement.getBoundingClientRect();
window.parent.postMessage({
type: 'ELEMENT_SELECTED',
selector,
rect: {
x: rect.left,
y: rect.top,
width: rect.width,
height: rect.height
},
tagName: hoveredElement.tagName.toLowerCase()
}, '*');
setPickingMode(null);
setHoveredElement(null);
}
};
const handleKeyDown = (e: KeyboardEvent) => {
if (e.key === 'Escape') {
setPickingMode(null);
setHoveredElement(null);
window.parent.postMessage({ type: 'PICKING_CANCELLED' }, '*');
}
};
window.addEventListener('mouseover', handleMouseOver);
window.addEventListener('click', handleClick, true);
window.addEventListener('keydown', handleKeyDown);
return () => {
window.removeEventListener('mouseover', handleMouseOver);
window.removeEventListener('click', handleClick, true);
window.removeEventListener('keydown', handleKeyDown);
};
}, [pickingMode, hoveredElement]);
if (!hoveredElement) return null;
// Don't show highlight if we are in picking mode but NOT hovering anything (handled by logic above)
// but DO show if we have a hoveredElement (from message or mouseover)
const rect = hoveredElement.getBoundingClientRect();
return (
<div
className="fixed pointer-events-none border-2 border-[#82ed20] bg-[#82ed20]/15 transition-all z-[9999] shadow-[0_0_20px_rgba(130,237,32,0.3)] rounded-sm"
style={{
top: rect.top,
left: rect.left,
width: rect.width,
height: rect.height,
}}
>
<div className="absolute top-0 right-0 bg-[#82ed20] text-black text-[10px] font-black px-1.5 py-1 transform -translate-y-full uppercase tracking-tighter shadow-xl">
{hoveredElement.tagName.toLowerCase()}
</div>
</div>
);
}

View File

@@ -0,0 +1,90 @@
'use client';
import React, { useEffect, useState } from 'react';
import { motion, AnimatePresence } from 'framer-motion';
import { useRecordMode } from './RecordModeContext';
export function PlaybackCursor() {
const { isPlaying, cursorPosition, isClicking } = useRecordMode();
const [scrollOffset, setScrollOffset] = useState({ x: 0, y: 0 });
// Track scroll so cursor stays locked to the correct element
useEffect(() => {
if (!isPlaying) return;
const handleScroll = () => {
setScrollOffset({ x: window.scrollX, y: window.scrollY });
};
handleScroll(); // Init
window.addEventListener('scroll', handleScroll, { passive: true });
return () => window.removeEventListener('scroll', handleScroll);
}, [isPlaying]);
if (!isPlaying) return null;
return (
<motion.div
className="fixed z-[10000] pointer-events-none"
animate={{
x: cursorPosition.x,
y: cursorPosition.y,
scale: isClicking ? 0.8 : 1,
rotateX: isClicking ? 15 : 0,
rotateY: isClicking ? -15 : 0,
}}
transition={{
x: { type: 'spring', damping: 30, stiffness: 250, mass: 0.5 },
y: { type: 'spring', damping: 30, stiffness: 250, mass: 0.5 },
scale: { type: 'spring', damping: 15, stiffness: 400 },
rotateX: { type: 'spring', damping: 15, stiffness: 400 },
rotateY: { type: 'spring', damping: 15, stiffness: 400 },
}}
style={{ perspective: '1000px' }}
>
<AnimatePresence>
{isClicking && (
<motion.div
initial={{ scale: 0.5, opacity: 0 }}
animate={{ scale: 2.5, opacity: 0 }}
exit={{ opacity: 0 }}
transition={{ duration: 0.4, ease: 'easeOut' }}
className="absolute inset-0 rounded-full border-2 border-[#82ed20] blur-[1px]"
/>
)}
</AnimatePresence>
{/* Outer Pulse Ring */}
<div
className={`absolute -inset-3 rounded-full bg-[#82ed20]/10 ${isClicking ? 'scale-150 opacity-0' : 'animate-ping'} transition-all duration-300`}
/>
{/* Visual Cursor */}
<div className="relative">
{/* Soft Glow */}
<div
className={`absolute -inset-2 bg-[#82ed20]/20 rounded-full blur-md transition-all ${isClicking ? 'bg-[#82ed20]/50 blur-xl' : ''}`}
/>
{/* Pointer Arrow */}
<svg
width="24"
height="24"
viewBox="0 0 24 24"
fill="none"
xmlns="http://www.w3.org/2000/svg"
className={`drop-shadow-[0_2px_8px_rgba(130,237,32,0.5)] transition-transform ${isClicking ? 'translate-y-0.5' : ''}`}
>
<path
d="M3 3L10.07 19.97L12.58 12.58L19.97 10.07L3 3Z"
fill={isClicking ? '#82ed20' : 'white'}
stroke="black"
strokeWidth="1.5"
strokeLinejoin="round"
className="transition-colors duration-150"
/>
</svg>
</div>
</motion.div>
);
}

View File

@@ -0,0 +1,392 @@
'use client';
import React, { createContext, useContext, useState, useEffect, useRef } from 'react';
import { RecordEvent, RecordingSession } from '@/types/record-mode';
interface RecordModeContextType {
isActive: boolean;
setIsActive: (active: boolean) => void;
events: RecordEvent[];
addEvent: (event: Omit<RecordEvent, 'id' | 'timestamp'>) => void;
updateEvent: (id: string, event: Partial<RecordEvent>) => void;
removeEvent: (id: string) => void;
clearEvents: () => void;
setEvents: (events: RecordEvent[]) => void;
isPlaying: boolean;
playEvents: () => void;
stopPlayback: () => void;
cursorPosition: { x: number; y: number };
zoomLevel: number;
isBlurry: boolean;
currentSession: RecordingSession | null;
saveSession: (name: string) => void;
isFeedbackActive: boolean;
setIsFeedbackActive: (active: boolean) => void;
reorderEvents: (startIndex: number, endIndex: number) => void;
hoveredEventId: string | null;
setHoveredEventId: (id: string | null) => void;
isClicking: boolean;
isEnabled: boolean;
}
const RecordModeContext = createContext<RecordModeContextType | null>(null);
export function useRecordMode(): RecordModeContextType {
const context = useContext(RecordModeContext);
if (!context) {
return {
isActive: false,
setIsActive: () => {},
events: [],
addEvent: () => {},
updateEvent: () => {},
removeEvent: () => {},
clearEvents: () => {},
isPlaying: false,
playEvents: () => {},
stopPlayback: () => {},
cursorPosition: { x: 0, y: 0 },
zoomLevel: 1,
isBlurry: false,
currentSession: null,
isFeedbackActive: false,
setIsFeedbackActive: () => {},
saveSession: () => {},
reorderEvents: () => {},
hoveredEventId: null,
setHoveredEventId: () => {},
setEvents: () => {},
isClicking: false,
isEnabled: false,
};
}
return context;
}
export function RecordModeProvider({
children,
isEnabled = false,
}: {
children: React.ReactNode;
isEnabled?: boolean;
}) {
const [isActive, setIsActiveState] = useState(false);
const [events, setEvents] = useState<RecordEvent[]>([]);
const [isPlaying, setIsPlaying] = useState(false);
const [cursorPosition, setCursorPosition] = useState({ x: 0, y: 0 });
const [zoomLevel, setZoomLevel] = useState(1);
const [isBlurry, setIsBlurry] = useState(false);
const [isFeedbackActive, setIsFeedbackActiveState] = useState(false);
const [hoveredEventId, setHoveredEventId] = useState<string | null>(null);
const [isClicking, setIsClicking] = useState(false);
const [isEmbedded, setIsEmbedded] = useState(false);
useEffect(() => {
console.log('[RecordModeProvider] Mounted with isEnabled:', isEnabled);
}, [isEnabled]);
useEffect(() => {
if (!isEnabled) return;
const embedded =
typeof window !== 'undefined' &&
(window.location.search.includes('embedded=true') ||
window.name === 'record-mode-iframe' ||
window.self !== window.top);
setIsEmbedded(embedded);
}, [isEnabled]);
const setIsActive = (active: boolean) => {
if (!isEnabled) return;
setIsActiveState(active);
if (active) setIsFeedbackActiveState(false);
};
const setIsFeedbackActive = (active: boolean) => {
setIsFeedbackActiveState(active);
if (active && isEnabled) setIsActiveState(false);
};
const isPlayingRef = useRef(false);
const isLoadedRef = useRef(false);
useEffect(() => {
if (!isEnabled) return;
const savedEvents = localStorage.getItem('klz-record-events');
const savedActive = localStorage.getItem('klz-record-active');
if (savedEvents) setEvents(JSON.parse(savedEvents));
if (savedActive) setIsActive(JSON.parse(savedActive));
isLoadedRef.current = true;
}, [isEnabled]);
useEffect(() => {
if (!isEnabled || !isLoadedRef.current) return;
localStorage.setItem('klz-record-events', JSON.stringify(events));
}, [events, isEnabled]);
useEffect(() => {
if (!isEnabled) return;
localStorage.setItem('klz-record-active', JSON.stringify(isActive));
}, [isActive, isEnabled]);
useEffect(() => {
if (!isEnabled) return;
if (isEmbedded) {
const handlePlaybackMessage = (e: MessageEvent) => {
if (e.data.type === 'PLAY_EVENT') {
const { event } = e.data;
const el = event.selector
? (document.querySelector(event.selector) as HTMLElement)
: null;
if (el) {
if (event.type === 'scroll') {
el.scrollIntoView({ behavior: 'smooth', block: 'center' });
} else if (event.type === 'mouse') {
const currentRect = el.getBoundingClientRect();
let targetX = currentRect.left + currentRect.width / 2;
let targetY = currentRect.top + currentRect.height / 2;
if (event.clickOrigin === 'top-left') {
targetX = currentRect.left + 5;
targetY = currentRect.top + 5;
} else if (event.clickOrigin === 'top-right') {
targetX = currentRect.right - 5;
targetY = currentRect.top + 5;
} else if (event.clickOrigin === 'bottom-left') {
targetX = currentRect.left + 5;
targetY = currentRect.bottom - 5;
} else if (event.clickOrigin === 'bottom-right') {
targetX = currentRect.right - 5;
targetY = currentRect.bottom - 5;
}
const eventCoords = { clientX: targetX, clientY: targetY };
const dispatchMouse = (type: string) => {
el.dispatchEvent(
new MouseEvent(type, {
view: window,
bubbles: true,
cancelable: true,
...eventCoords,
}),
);
};
if (event.interactionType === 'click') {
setIsClicking(true);
dispatchMouse('mousedown');
setTimeout(() => {
dispatchMouse('mouseup');
if (event.realClick) {
dispatchMouse('click');
el.click();
}
setIsClicking(false);
}, 150);
} else {
dispatchMouse('mousemove');
dispatchMouse('mouseover');
dispatchMouse('mouseenter');
}
}
}
}
};
window.addEventListener('message', handlePlaybackMessage);
return () => window.removeEventListener('message', handlePlaybackMessage);
}
}, [isEmbedded, isEnabled]);
useEffect(() => {
if (!isEnabled || isEmbedded || !isActive) return;
const event = events.find((e) => e.id === hoveredEventId);
const iframe = document.querySelector('iframe[name="record-mode-iframe"]') as HTMLIFrameElement;
if (iframe?.contentWindow) {
iframe.contentWindow.postMessage(
{ type: 'SET_HOVER_SELECTOR', selector: event?.selector || null },
'*',
);
}
}, [hoveredEventId, events, isActive, isEmbedded, isEnabled]);
const addEvent = (event: Omit<RecordEvent, 'id' | 'timestamp'>) => {
if (!isEnabled) return;
const newEvent: RecordEvent = {
realClick: false,
...event,
id: Math.random().toString(36).substr(2, 9),
timestamp: Date.now(),
};
setEvents((prev) => [...prev, newEvent]);
};
const updateEvent = (id: string, updatedFields: Partial<RecordEvent>) => {
if (!isEnabled) return;
setEvents((prev) =>
prev.map((event) => (event.id === id ? { ...event, ...updatedFields } : event)),
);
};
const reorderEvents = (startIndex: number, endIndex: number) => {
if (!isEnabled) return;
const result = Array.from(events);
const [removed] = result.splice(startIndex, 1);
result.splice(endIndex, 0, removed);
setEvents(result);
};
const removeEvent = (id: string) => {
if (!isEnabled) return;
setEvents((prev) => prev.filter((event) => event.id !== id));
};
const clearEvents = () => {
if (!isEnabled) return;
if (confirm('Clear all recorded events?')) setEvents([]);
};
const currentSession: RecordingSession | null =
events.length > 0
? {
id: 'draft',
name: 'Draft Session',
events,
createdAt: new Date().toISOString(),
}
: null;
const saveSession = (name: string) => {
if (!isEnabled) return;
console.log('Saving session:', name, events);
};
const playEvents = async () => {
if (!isEnabled || events.length === 0 || isPlayingRef.current) return;
setIsPlaying(true);
isPlayingRef.current = true;
const sortedEvents = [...events].sort((a, b) => (a.timestamp || 0) - (b.timestamp || 0));
for (const event of sortedEvents) {
if (!isPlayingRef.current) break;
if (event.rect && !isEmbedded) {
const iframe = document.querySelector(
'iframe[name="record-mode-iframe"]',
) as HTMLIFrameElement;
const iframeRect = iframe?.getBoundingClientRect();
setCursorPosition({
x: (iframeRect?.left || 0) + event.rect.x + event.rect.width / 2,
y: (iframeRect?.top || 0) + event.rect.y + event.rect.height / 2,
});
}
if (event.selector) {
if (!isEmbedded) {
const iframe = document.querySelector(
'iframe[name="record-mode-iframe"]',
) as HTMLIFrameElement;
if (iframe?.contentWindow)
iframe.contentWindow.postMessage({ type: 'PLAY_EVENT', event }, '*');
} else {
const el = document.querySelector(event.selector) as HTMLElement;
if (el) {
if (event.type === 'scroll') el.scrollIntoView({ behavior: 'smooth', block: 'center' });
else if (event.type === 'mouse') {
const currentRect = el.getBoundingClientRect();
let targetX = currentRect.left + currentRect.width / 2;
let targetY = currentRect.top + currentRect.height / 2;
if (event.clickOrigin === 'top-left') {
targetX = currentRect.left + 5;
targetY = currentRect.top + 5;
} else if (event.clickOrigin === 'top-right') {
targetX = currentRect.right - 5;
targetY = currentRect.top + 5;
} else if (event.clickOrigin === 'bottom-left') {
targetX = currentRect.left + 5;
targetY = currentRect.bottom - 5;
} else if (event.clickOrigin === 'bottom-right') {
targetX = currentRect.right - 5;
targetY = currentRect.bottom - 5;
}
const eventCoords = { clientX: targetX, clientY: targetY };
const dispatchMouse = (type: string) => {
el.dispatchEvent(
new MouseEvent(type, {
view: window,
bubbles: true,
cancelable: true,
...eventCoords,
}),
);
};
if (event.interactionType === 'click') {
setIsClicking(true);
dispatchMouse('mousedown');
setTimeout(() => {
dispatchMouse('mouseup');
if (event.realClick) {
dispatchMouse('click');
el.click();
}
setIsClicking(false);
}, 150);
} else {
dispatchMouse('mousemove');
dispatchMouse('mouseover');
dispatchMouse('mouseenter');
}
}
}
}
}
if (event.zoom) setZoomLevel(event.zoom);
if (event.motionBlur) setIsBlurry(true);
await new Promise((resolve) => setTimeout(resolve, event.duration || 1000));
setIsBlurry(false);
}
setIsPlaying(false);
isPlayingRef.current = false;
setZoomLevel(1);
};
const stopPlayback = () => {
setIsPlaying(false);
isPlayingRef.current = false;
setZoomLevel(1);
setIsBlurry(false);
};
return (
<RecordModeContext.Provider
value={{
isActive,
setIsActive,
events,
addEvent,
updateEvent,
removeEvent,
clearEvents,
setEvents,
isPlaying,
playEvents,
stopPlayback,
cursorPosition,
zoomLevel,
isBlurry,
currentSession,
saveSession,
isFeedbackActive,
setIsFeedbackActive,
reorderEvents,
hoveredEventId,
setHoveredEventId,
isClicking,
isEnabled,
}}
>
{children}
</RecordModeContext.Provider>
);
}

View File

@@ -0,0 +1,583 @@
'use client';
import React, { useState, useEffect } from 'react';
import { useRecordMode } from './RecordModeContext';
import { Reorder, AnimatePresence } from 'framer-motion';
import {
Play,
Square,
MousePointer2,
Scroll,
Plus,
Save,
Trash2,
Eye,
Edit2,
X,
Check,
Download,
Settings2,
GripVertical,
Clock,
Maximize2,
Box,
ExternalLink,
} from 'lucide-react';
import { RecordEvent } from '@/types/record-mode';
import { PlaybackCursor } from './PlaybackCursor';
export function RecordModeOverlay() {
const {
isActive,
setIsActive,
events,
addEvent,
updateEvent,
removeEvent,
isPlaying,
playEvents,
saveSession,
clearEvents,
reorderEvents,
setHoveredEventId,
setEvents, // Added setEvents here
} = useRecordMode();
const [pickingMode, setPickingMode] = useState<'mouse' | 'scroll' | null>(null);
const [lastInteractionType, setLastInteractionType] = useState<'click' | 'hover'>('click');
const [hoveredElement, setHoveredElement] = useState<HTMLElement | null>(null);
const [editingEventId, setEditingEventId] = useState<string | null>(null);
// Edit form state
const [editForm, setEditForm] = useState<Partial<RecordEvent>>({});
const [mounted, setMounted] = useState(false);
useEffect(() => {
setMounted(true);
}, []);
useEffect(() => {
if (!mounted || !isActive) return;
const handleMessage = (e: MessageEvent) => {
if (e.data.type === 'ELEMENT_SELECTED') {
const { selector, rect, tagName } = e.data;
if (pickingMode === 'mouse') {
addEvent({
type: 'mouse',
interactionType: lastInteractionType,
selector,
duration: lastInteractionType === 'click' ? 1000 : 1500,
zoom: 1,
description: `Mouse ${lastInteractionType === 'click' ? '(Click)' : '(Hover)'} on ${tagName}`,
motionBlur: false,
realClick: false,
rect,
});
} else if (pickingMode === 'scroll') {
addEvent({
type: 'scroll',
selector,
duration: 1500,
zoom: 1,
description: `Scroll to ${tagName}`,
motionBlur: false,
rect,
});
}
setPickingMode(null);
} else if (e.data.type === 'PICKING_CANCELLED') {
setPickingMode(null);
}
};
window.addEventListener('message', handleMessage);
if (pickingMode) {
// Find the iframe and signal start picking
const iframe = document.querySelector('iframe');
if (iframe?.contentWindow) {
iframe.contentWindow.postMessage({ type: 'START_PICKING', mode: pickingMode }, '*');
}
} else {
// Signal stop picking
const iframe = document.querySelector('iframe');
if (iframe?.contentWindow) {
iframe.contentWindow.postMessage({ type: 'STOP_PICKING' }, '*');
}
}
return () => {
window.removeEventListener('message', handleMessage);
};
}, [isActive, pickingMode, addEvent, mounted]);
const saveEdit = () => {
if (editingEventId) {
updateEvent(editingEventId, editForm);
setEditingEventId(null);
}
};
const [showEvents, setShowEvents] = useState(true);
if (!mounted) return null;
if (!isActive) {
// Failsafe: Never render host toggle in embedded mode
if (
typeof window !== 'undefined' &&
(window.self !== window.top ||
window.name === 'record-mode-iframe' ||
window.location.search.includes('embedded=true'))
) {
return null;
}
return (
<button
onClick={() => setIsActive(true)}
className="fixed bottom-6 left-6 z-[9999] bg-[#82ed20]/20 hover:bg-[#82ed20]/30 text-[#82ed20] p-4 rounded-full shadow-2xl transition-all hover:scale-110 record-mode-ignore border border-[#82ed20]/30 backdrop-blur-md animate-pulse"
>
<div className="w-5 h-5 rounded-[4px] border-2 border-[#82ed20]" />
</button>
);
}
return (
<div className="fixed inset-0 z-[9998] pointer-events-none font-sans">
{/* 1. Global Toolbar - Slim Industrial Bar */}
<div className="fixed bottom-6 left-1/2 -translate-x-1/2 z-[10000] pointer-events-auto">
<div className="bg-black/80 backdrop-blur-2xl border border-white/10 p-2 rounded-[24px] shadow-[0_32px_80px_rgba(0,0,0,0.8)] flex items-center gap-2">
{/* Identity Tag */}
<div className="flex items-center gap-3 px-4 py-2 bg-white/5 rounded-[16px] border border-white/5 mx-1">
<div className="w-2 h-2 rounded-full bg-accent animate-pulse" />
<div className="flex flex-col">
<span className="text-[10px] font-bold text-white uppercase tracking-wider leading-none">
Event Builder
</span>
<span className="text-[8px] text-white/30 uppercase tracking-widest mt-1">
Manual Mode
</span>
</div>
</div>
<div className="w-px h-6 bg-white/10 mx-1" />
{/* Action Tools */}
<div className="flex items-center gap-1">
<button
onClick={() => {
setPickingMode('mouse');
setLastInteractionType('click');
}}
className={`flex items-center gap-2 px-4 py-2.5 rounded-[16px] transition-all text-xs font-bold uppercase tracking-wide ${pickingMode === 'mouse' ? 'bg-accent text-primary-dark shadow-lg shadow-accent/20' : 'text-white/40 hover:text-white hover:bg-white/5'}`}
>
<MousePointer2 size={16} />
<span>Mouse</span>
</button>
<button
onClick={() => setPickingMode('scroll')}
className={`flex items-center gap-2 px-4 py-2.5 rounded-[16px] transition-all text-xs font-bold uppercase tracking-wide ${pickingMode === 'scroll' ? 'bg-accent text-primary-dark shadow-lg shadow-accent/20' : 'text-white/40 hover:text-white hover:bg-white/5'}`}
>
<Scroll size={16} />
<span>Scroll</span>
</button>
<button
onClick={() =>
addEvent({
type: 'wait',
duration: 2000,
zoom: 1,
description: 'Wait for 2s',
motionBlur: false,
})
}
className="flex items-center gap-2 px-4 py-2.5 rounded-[16px] text-white/40 hover:text-white hover:bg-white/5 transition-all text-xs font-bold uppercase tracking-wide"
>
<Plus size={16} />
<span>Wait</span>
</button>
</div>
<div className="w-px h-6 bg-white/10 mx-1" />
{/* Sequence Controls */}
<div className="flex items-center gap-1 p-0.5">
<button
onClick={playEvents}
disabled={isPlaying || events.length === 0}
className="p-2.5 text-accent hover:bg-accent/10 rounded-[14px] disabled:opacity-20 transition-all"
title="Preview Sequence"
>
<Play size={18} fill="currentColor" />
</button>
<button
onClick={() => setShowEvents(!showEvents)}
className={`p-2.5 rounded-[14px] transition-all relative ${showEvents ? 'bg-white/10 text-white' : 'text-white/40 hover:text-white hover:bg-white/5'}`}
>
<Edit2 size={18} />
{events.length > 0 && (
<span className="absolute -top-1 -right-1 w-4 h-4 bg-accent text-primary-dark text-[10px] flex items-center justify-center rounded-full font-bold border-2 border-black">
{events.length}
</span>
)}
</button>
<button
onClick={async () => {
const session = { events, name: 'Recording', createdAt: new Date().toISOString() };
try {
const res = await fetch('/api/save-session', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(session),
});
if (res.ok) {
// Visual feedback could be improved, but alert is fine for dev tool
alert('Session saved to remotion/session.json');
} else {
const err = await res.json();
alert(`Failed to save: ${err.error}`);
}
} catch (e) {
console.error(e);
alert('Error saving session');
}
}}
disabled={events.length === 0}
className="p-3 bg-white/5 hover:bg-green-500/20 rounded-2xl disabled:opacity-30 transition-all text-green-400"
title="Save to Project (Dev)"
>
<Save size={20} />
</button>
<button
onClick={() => {
const data = JSON.stringify(
{ events, name: 'Recording', createdAt: new Date().toISOString() },
null,
2,
);
const blob = new Blob([data], { type: 'application/json' });
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = 'remotion-session.json';
a.click();
URL.revokeObjectURL(url);
}}
disabled={events.length === 0}
className="p-3 bg-white/5 hover:bg-blue-500/20 rounded-2xl disabled:opacity-30 transition-all text-blue-400"
title="Download JSON"
>
<Download size={20} />
</button>
</div>
<div className="w-px h-6 bg-white/10 mx-1" />
<button
onClick={() => setIsActive(false)}
className="p-2.5 text-red-500 hover:bg-red-500/10 rounded-[14px] transition-all mx-1"
title="Exit Studio"
>
<X size={20} />
</button>
</div>
</div>
{/* 2. Event Timeline Popover */}
{showEvents && (
<div className="fixed bottom-[100px] left-1/2 -translate-x-1/2 w-[400px] pointer-events-auto z-[9999]">
<div className="bg-black/90 backdrop-blur-3xl border border-white/10 rounded-[32px] p-6 shadow-[0_40px_100px_rgba(0,0,0,0.9)] max-h-[500px] overflow-hidden flex flex-col scale-in">
<div className="flex items-center justify-between mb-6">
<div>
<h4 className="text-white font-bold text-lg leading-none">Recording Track</h4>
<p className="text-[10px] text-white/30 uppercase tracking-widest mt-2">
{events.length} Actions Recorded
</p>
</div>
<button
onClick={clearEvents}
disabled={events.length === 0}
className="text-red-400/40 hover:text-red-400 transition-colors p-2 hover:bg-red-500/10 rounded-xl disabled:opacity-10"
>
<Trash2 size={18} />
</button>
</div>
<Reorder.Group
axis="y"
values={events}
onReorder={setEvents}
className="flex-1 overflow-y-auto space-y-2 pr-2 scrollbar-hide"
>
{events.length === 0 ? (
<div className="py-12 flex flex-col items-center justify-center text-white/10">
<Plus size={40} strokeWidth={1} />
<p className="text-xs mt-4">Timeline is empty</p>
</div>
) : (
events.map((event, index) => (
<Reorder.Item
key={event.id}
value={event}
className="group flex items-center gap-3 bg-white/[0.03] border border-white/5 p-3 rounded-[20px] transition-all hover:bg-white/[0.06] hover:border-white/10"
onMouseEnter={() => setHoveredEventId(event.id)}
onMouseLeave={() => setHoveredEventId(null)}
>
<div className="cursor-grab active:cursor-grabbing text-white/10 hover:text-white/30 transition-colors">
<GripVertical size={16} />
</div>
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2">
<span className="text-white text-[10px] font-black uppercase tracking-widest">
{event.type === 'mouse' ? `Mouse (${event.interactionType})` : event.type}
</span>
{event.clickOrigin &&
event.clickOrigin !== 'center' &&
event.interactionType === 'click' && (
<span className="text-[8px] bg-accent/20 text-accent px-1.5 py-0.5 rounded uppercase font-bold">
{event.clickOrigin}
</span>
)}
<span className="text-[8px] bg-white/10 px-1.5 py-0.5 rounded text-white/40 font-mono italic">
{event.duration}ms
</span>
</div>
<p className="text-[9px] text-white/30 truncate font-mono mt-1 opacity-60">
{event.selector || 'system:wait'}
</p>
</div>
<div className="flex items-center gap-1">
<button
onClick={() => {
setEditingEventId(event.id);
setEditForm(event);
}}
className="p-2 text-white/0 group-hover:text-white/40 hover:text-white hover:bg-white/10 rounded-xl transition-all"
>
<Settings2 size={14} />
</button>
<button
onClick={() => removeEvent(event.id)}
className="p-2 text-white/0 group-hover:text-red-400/60 hover:text-red-400 hover:bg-red-500/10 rounded-xl transition-all"
>
<Trash2 size={14} />
</button>
</div>
</Reorder.Item>
))
)}
</Reorder.Group>
</div>
</div>
)}
{/* Industrial Selector Highlighter - handled inside iframe via PickingHelper */}
{/* Picking Tooltip */}
{pickingMode && (
<div className="fixed top-8 left-1/2 -translate-x-1/2 z-[10000] pointer-events-auto">
<div className="bg-accent text-primary-dark px-6 py-3 rounded-full flex items-center gap-4 shadow-[0_20px_40px_rgba(130,237,32,0.4)] animate-reveal border border-primary-dark/10">
<div className="flex items-center gap-2">
<div className="w-2 h-2 rounded-full bg-primary-dark animate-pulse" />
<span className="font-black uppercase tracking-widest text-xs">
Assigning {pickingMode}
</span>
</div>
<div className="w-px h-6 bg-primary-dark/20" />
<button
onClick={() => {
setPickingMode(null);
setHoveredElement(null);
}}
className="text-[10px] font-bold uppercase tracking-widest opacity-60 hover:opacity-100 transition-opacity bg-primary-dark/10 px-3 py-1.5 rounded-full"
>
ESC to Cancel
</button>
</div>
</div>
)}
<PlaybackCursor />
{/* 3. Event Options Panel (Sidebar-like) */}
<AnimatePresence>
{editingEventId && (
<div className="fixed inset-y-0 right-0 w-[350px] bg-black/95 backdrop-blur-3xl border-l border-white/10 z-[11000] pointer-events-auto p-8 shadow-[-40px_0_100px_rgba(0,0,0,0.9)] flex flex-col">
<div className="flex items-center justify-between mb-8">
<h3 className="text-white font-black uppercase tracking-tighter text-xl">
Event Options
</h3>
<button
onClick={() => setEditingEventId(null)}
className="p-2 text-white/40 hover:text-white transition-colors"
>
<X size={20} />
</button>
</div>
<div className="flex-1 space-y-8 overflow-y-auto pr-2 scrollbar-hide">
{/* Type Display */}
<div className="space-y-3">
<label className="text-[10px] uppercase tracking-[0.2em] font-bold text-white/30 leading-none">
Interaction Type
</label>
<div className="flex gap-2 p-1 bg-white/5 rounded-2xl border border-white/5">
<button
onClick={() =>
setEditForm((prev) => ({ ...prev, type: 'mouse', interactionType: 'click' }))
}
className={`flex-1 flex items-center justify-center gap-2 py-3 rounded-xl transition-all border ${editForm.type === 'mouse' && editForm.interactionType === 'click' ? 'bg-accent text-primary-dark border-accent' : 'text-white/40 border-transparent hover:border-white/10'}`}
>
<MousePointer2 size={14} />
<span className="text-[10px] font-black uppercase">Click</span>
</button>
<button
onClick={() =>
setEditForm((prev) => ({ ...prev, type: 'mouse', interactionType: 'hover' }))
}
className={`flex-1 flex items-center justify-center gap-2 py-3 rounded-xl transition-all border ${editForm.type === 'mouse' && editForm.interactionType === 'hover' ? 'bg-accent text-primary-dark border-accent' : 'text-white/40 border-transparent hover:border-white/10'}`}
>
<Eye size={14} />
<span className="text-[10px] font-black uppercase">Hover</span>
</button>
<button
onClick={() => setEditForm((prev) => ({ ...prev, type: 'scroll' }))}
className={`flex-1 flex items-center justify-center gap-2 py-3 rounded-xl transition-all border ${editForm.type === 'scroll' ? 'bg-accent text-primary-dark border-accent' : 'text-white/40 border-transparent hover:border-white/10'}`}
>
<Scroll size={14} />
<span className="text-[10px] font-black uppercase">Scroll</span>
</button>
<button
onClick={() => setEditForm((prev) => ({ ...prev, type: 'wait' }))}
className={`flex-1 flex items-center justify-center gap-2 py-3 rounded-xl transition-all border ${editForm.type === 'wait' ? 'bg-accent text-primary-dark border-accent' : 'text-white/40 border-transparent hover:border-white/10'}`}
>
<Clock size={14} />
<span className="text-[10px] font-black uppercase">Wait</span>
</button>
</div>
</div>
{/* Precise Click Origin */}
{editForm.type === 'mouse' && editForm.interactionType === 'click' && (
<div className="space-y-4">
<label className="text-[10px] uppercase tracking-[0.2em] font-bold text-white/30 leading-none">
Click Origin
</label>
<div className="grid grid-cols-3 gap-2 p-2 bg-white/5 rounded-2xl border border-white/5">
{[
{ id: 'top-left', label: 'TL' },
{ id: 'top-right', label: 'TR' },
{ id: 'center', label: 'CTR' },
{ id: 'bottom-left', label: 'BL' },
{ id: 'bottom-right', label: 'BR' },
].map((origin) => (
<button
key={origin.id}
onClick={() =>
setEditForm((prev) => ({ ...prev, clickOrigin: origin.id as any }))
}
className={`py-3 rounded-xl text-[10px] font-black uppercase tracking-tighter transition-all border ${editForm.clickOrigin === origin.id ? 'bg-accent text-primary-dark border-accent' : 'bg-transparent text-white/40 border-white/5 hover:border-white/20'}`}
>
{origin.label}
</button>
))}
</div>
</div>
)}
{/* Timing */}
<div className="space-y-4">
<label className="text-[10px] uppercase tracking-[0.2em] font-bold text-white/30 leading-none flex items-center justify-between">
<span>Timeline Allocation</span>
<span className="text-accent">{editForm.duration}ms</span>
</label>
<input
type="range"
min="0"
max="5000"
step="100"
value={editForm.duration || 1000}
onChange={(e) =>
setEditForm((prev) => ({ ...prev, duration: parseInt(e.target.value) }))
}
className="w-full h-1 bg-white/10 rounded-lg appearance-none cursor-pointer accent-accent"
/>
</div>
{/* Zoom & Effects */}
<div className="space-y-6">
<div className="flex items-center justify-between p-4 bg-white/5 rounded-2xl border border-white/5 group hover:border-white/20 transition-all">
<div className="flex items-center gap-3">
<Maximize2 size={18} className="text-white/40" />
<span className="text-xs font-bold text-white uppercase tracking-wider">
Zoom Shift
</span>
</div>
<input
type="number"
step="0.1"
min="1"
max="3"
value={editForm.zoom || 1}
onChange={(e) =>
setEditForm((prev) => ({ ...prev, zoom: parseFloat(e.target.value) }))
}
className="w-16 bg-white/10 border border-white/10 rounded-lg px-2 py-1 text-xs text-white text-center font-mono"
/>
</div>
<button
onClick={() => setEditForm((prev) => ({ ...prev, motionBlur: !prev.motionBlur }))}
className={`flex items-center justify-between w-full p-4 rounded-2xl border transition-all ${editForm.motionBlur ? 'bg-accent/10 border-accent/30 text-accent' : 'bg-white/5 border-white/5 text-white/40'}`}
>
<div className="flex items-center gap-3">
<Box size={18} />
<span className="text-xs font-bold uppercase tracking-wider">Motion Blur</span>
</div>
{editForm.motionBlur ? <Check size={18} /> : <div className="w-[18px]" />}
</button>
{editForm.type === 'mouse' && editForm.interactionType === 'click' && (
<button
onClick={() => setEditForm((prev) => ({ ...prev, realClick: !prev.realClick }))}
className={`flex items-center justify-between w-full p-4 rounded-2xl border transition-all ${editForm.realClick ? 'bg-orange-500/10 border-orange-500/30 text-orange-400' : 'bg-white/5 border-white/5 text-white/40'}`}
>
<div className="flex items-center gap-3">
<ExternalLink size={18} />
<div className="flex flex-col items-start">
<span className="text-xs font-bold uppercase tracking-wider">
Trigger Navigation
</span>
<span className="text-[8px] opacity-60">
Allows URL transitions in Studio
</span>
</div>
</div>
{editForm.realClick ? <Check size={18} /> : <div className="w-[18px]" />}
</button>
)}
</div>
</div>
<button
onClick={saveEdit}
className="mt-8 py-5 bg-accent text-primary-dark text-xs font-black uppercase tracking-[0.2em] rounded-2xl shadow-2xl shadow-accent/20 hover:scale-[1.02] transition-all"
>
Commit Changes
</button>
</div>
)}
</AnimatePresence>
</div>
);
}

View File

@@ -0,0 +1,261 @@
'use client';
import React from 'react';
import { useRecordMode } from './RecordModeContext';
export function RecordModeVisuals({ children }: { children: React.ReactNode }) {
const { isActive, isPlaying, zoomLevel, cursorPosition, isBlurry } = useRecordMode();
const [mounted, setMounted] = React.useState(false);
const [isEmbedded, setIsEmbedded] = React.useState(false);
const [iframeUrl, setIframeUrl] = React.useState<string | null>(null);
React.useEffect(() => {
setMounted(true);
// Explicit non-magical detection
const embedded =
window.location.search.includes('embedded=true') || window.name === 'record-mode-iframe';
setIsEmbedded(embedded);
if (!embedded) {
const url = new URL(window.location.href);
url.searchParams.set('embedded', 'true');
setIframeUrl(url.toString());
}
}, [isEmbedded]);
// Hydration Guard: Match server on first render
if (!mounted) return <>{children}</>;
// Recursion Guard: If we are already in an embedded iframe,
// strictly return just the children to prevent Inception.
if (isEmbedded) {
return (
<>
<style
dangerouslySetInnerHTML={{
__html: `
/* Harder Isolation: Hide ALL potentially duplicate overlays and DEV TOOLS */
#nextjs-portal,
#nextjs-portal-root,
[data-nextjs-toast-wrapper],
.nextjs-static-indicator,
[data-nextjs-indicator],
[class*="nextjs-"],
[id*="nextjs-"],
nextjs-portal,
#feedback-overlay,
.feedback-ui-root,
.feedback-ui-ignore,
[class*="z-[9999]"],
[class*="z-[10000]"],
[style*="z-index: 9999"],
[style*="z-index: 10000"],
.fixed.bottom-6.left-6,
.fixed.bottom-6.left-1/2,
.feedback-ui-overlay,
[id^="feedback-"],
[class^="feedback-"] {
display: none !important;
opacity: 0 !important;
visibility: hidden !important;
pointer-events: none !important;
z-index: -10000 !important;
}
/* Nuclear Option 2.0: Kill ALL scrollbars on ALL elements */
* {
scrollbar-width: none !important;
-ms-overflow-style: none !important;
}
*::-webkit-scrollbar {
display: none !important;
width: 0 !important;
height: 0 !important;
}
html, body {
border-radius: 3rem;
background: #050505 !important;
color: white !important;
overflow-x: hidden !important;
overflow-y: auto !important;
}
`,
}}
/>
{children}
</>
);
}
return (
<>
{/* Global Style for Body Lock */}
{isActive && (
<style
dangerouslySetInnerHTML={{
__html: `
html, body {
overflow: hidden !important;
height: 100vh !important;
position: fixed !important;
width: 100vw !important;
}
/* Kill Next.js Dev tools on host while Studio is active */
#nextjs-portal,
[data-nextjs-toast-wrapper],
.nextjs-static-indicator {
display: none !important;
}
`,
}}
/>
)}
<div
className={`transition-all duration-1000 ${isActive ? 'fixed inset-0 z-[9997] bg-[#020202] flex items-center justify-center p-6 md:p-12 lg:p-20' : 'relative w-full'}`}
>
{/* Studio Background - Only visible when active */}
{isActive && (
<div className="absolute inset-0 z-0 pointer-events-none overflow-hidden">
<div className="absolute inset-0 bg-gradient-to-br from-[#03110a] via-[#020202] to-[#030a11] animate-pulse duration-[10s]" />
<div
className="absolute -top-[60%] -left-[50%] w-[140%] h-[140%] rounded-full opacity-[0.7]"
style={{
background: 'radial-gradient(circle, #10b981 0%, transparent 70%)',
filter: 'blur(160px)',
animation: 'mesh-float-1 18s ease-in-out infinite',
}}
/>
<div
className="absolute -bottom-[60%] -right-[50%] w-[130%] h-[130%] rounded-full opacity-[0.55]"
style={{
background: 'radial-gradient(circle, #06b6d4 0%, transparent 70%)',
filter: 'blur(150px)',
animation: 'mesh-float-2 22s ease-in-out infinite',
}}
/>
<div
className="absolute -top-[30%] -right-[40%] w-[100%] h-[100%] rounded-full opacity-[0.5]"
style={{
background: 'radial-gradient(circle, #82ed20 0%, transparent 70%)',
filter: 'blur(130px)',
animation: 'mesh-float-3 14s ease-in-out infinite',
}}
/>
<div
className="absolute -bottom-[50%] -left-[40%] w-[110%] h-[110%] rounded-full opacity-[0.45]"
style={{
background: 'radial-gradient(circle, #2563eb 0%, transparent 70%)',
filter: 'blur(140px)',
animation: 'mesh-float-4 20s ease-in-out infinite',
}}
/>
<div
className="absolute inset-0 opacity-[0.12] mix-blend-overlay"
style={{
backgroundImage: `url("data:image/svg+xml,%3Csvg viewBox='0 0 256 256' xmlns='http://www.w3.org/2000/svg'%3E%3Cfilter id='noiseFilter'%3E%3CfeTurbulence type='fractalNoise' baseFrequency='0.7' numOctaves='4' stitchTiles='stitch'/%3E%3C/filter%3E%3Crect width='100%25' height='100%25' filter='url(%23noiseFilter)'/%3E%3C/svg%3E")`,
backgroundSize: '128px 128px',
}}
/>
<div
className="absolute inset-0 opacity-[0.06]"
style={{
backgroundImage:
'repeating-linear-gradient(0deg, transparent, transparent 2px, rgba(255,255,255,0.03) 2px, rgba(255,255,255,0.03) 4px)',
}}
/>
</div>
)}
<div
className={`transition-all duration-700 ease-in-out relative z-10 w-full ${isActive ? 'h-full max-h-[1000px] max-w-[1600px] drop-shadow-[0_60px_150px_rgba(0,0,0,1)] scale-in' : 'h-full'}`}
style={{
transform: isPlaying ? `scale(${zoomLevel})` : undefined,
transformOrigin: isPlaying ? `${cursorPosition.x}px ${cursorPosition.y}px` : 'center',
filter: isBlurry ? 'blur(4px)' : 'none',
willChange: 'transform, filter',
WebkitBackfaceVisibility: 'hidden',
backfaceVisibility: 'hidden',
}}
>
<div
className={
isActive
? 'relative h-full w-full rounded-[3rem] overflow-hidden bg-[#050505] isolate'
: 'w-full h-full'
}
style={{ transform: isActive ? 'translateZ(0)' : 'none' }}
>
{isActive && (
<>
<div className="absolute inset-0 rounded-[3rem] border border-white/[0.08] pointer-events-none z-50" />
<div
className="absolute inset-[-2px] rounded-[3rem] pointer-events-none z-20"
style={{
background:
'linear-gradient(135deg, rgba(16,185,129,0.15), rgba(130,237,32,0.15))',
animation: 'pulse-ring 4s ease-in-out infinite',
}}
/>
<div className="absolute inset-0 bg-gradient-to-b from-transparent via-[#82ed20]/[0.05] to-transparent h-[15%] w-full top-[-15%] animate-scan-slow z-50 pointer-events-none opacity-20" />
</>
)}
<div
className={
isActive
? 'w-full h-full rounded-[3rem] overflow-hidden relative'
: 'w-full h-full relative'
}
style={{
WebkitMaskImage: isActive ? '-webkit-radial-gradient(white, black)' : 'none',
transform: isActive ? 'translateZ(0)' : 'none',
}}
>
{isActive && iframeUrl ? (
<iframe
src={iframeUrl}
name="record-mode-iframe"
className="w-full h-full border-0 block"
style={{
backgroundColor: '#050505',
scrollbarWidth: 'none',
msOverflowStyle: 'none',
height: '100%',
width: '100%',
}}
/>
) : (
<div
className={
isActive
? 'blur-2xl opacity-20 pointer-events-none scale-95 transition-all duration-700'
: 'transition-all duration-700'
}
>
{children}
</div>
)}
</div>
</div>
</div>
<style
dangerouslySetInnerHTML={{
__html: `
@keyframes mesh-float-1 { 0%, 100% { transform: translate(0, 0) scale(1) rotate(0deg); } 33% { transform: translate(15%, 10%) scale(1.1) rotate(5deg); } 66% { transform: translate(-10%, 20%) scale(0.9) rotate(-3deg); } }
@keyframes mesh-float-2 { 0%, 100% { transform: translate(0, 0) scale(1) rotate(0deg); } 33% { transform: translate(-20%, -15%) scale(1.2) rotate(-8deg); } 66% { transform: translate(15%, -10%) scale(0.8) rotate(4deg); } }
@keyframes mesh-float-3 { 0%, 100% { transform: translate(0, 0) scale(1.2); } 50% { transform: translate(20%, -25%) scale(0.7); } }
@keyframes mesh-float-4 { 0%, 100% { transform: translate(0, 0) scale(1); } 50% { transform: translate(-15%, 25%) scale(1.1); } }
@keyframes pulse-ring { 0%, 100% { opacity: 0.15; transform: scale(1); } 50% { opacity: 0.4; transform: scale(1.005); } }
@keyframes scan-slow { 0% { transform: translateY(-100%); opacity: 0; } 5% { opacity: 0.2; } 95% { opacity: 0.2; } 100% { transform: translateY(800%); opacity: 0; } }
@keyframes scale-in { 0% { transform: scale(0.95); opacity: 0; } 100% { transform: scale(1); opacity: 1; } }
.scale-in { animation: scale-in 0.7s cubic-bezier(0.16, 1, 0.3, 1) forwards; }
`,
}}
/>
</div>
</>
);
}

View File

@@ -0,0 +1,75 @@
'use client';
import React, { useState, useEffect } from 'react';
import { useRecordMode } from './RecordModeContext';
import dynamic from 'next/dynamic';
const FeedbackOverlay = dynamic(
() => import('@mintel/next-feedback/FeedbackOverlay').then((mod) => mod.FeedbackOverlay),
{ ssr: false },
);
const RecordModeOverlay = dynamic(
() => import('./RecordModeOverlay').then((mod) => mod.RecordModeOverlay),
{ ssr: false },
);
import { PickingHelper } from './PickingHelper';
interface ToolCoordinatorProps {
isEmbedded?: boolean;
feedbackEnabled?: boolean;
}
export function ToolCoordinator({
isEmbedded: isEmbeddedProp,
feedbackEnabled = false,
}: ToolCoordinatorProps) {
const { isActive, setIsActive, isFeedbackActive, setIsFeedbackActive, isEnabled } =
useRecordMode();
const [isEmbedded, setIsEmbedded] = useState(false);
const [mounted, setMounted] = useState(false);
useEffect(() => {
setMounted(true);
const embedded =
isEmbeddedProp ||
window.location.search.includes('embedded=true') ||
window.name === 'record-mode-iframe' ||
window.self !== window.top;
setIsEmbedded(embedded);
}, [isEmbeddedProp]);
if (!mounted) return null;
// Nothing enabled → render nothing
if (!feedbackEnabled && !isEnabled) return null;
// Iframe → only PickingHelper
if (isEmbedded) return <PickingHelper />;
// Record Mode active and enabled
if (isActive && isEnabled) return <RecordModeOverlay />;
// Feedback active and enabled
if (isFeedbackActive && feedbackEnabled) {
return (
<FeedbackOverlay
isActive={isFeedbackActive}
onActiveChange={(active) => setIsFeedbackActive(active)}
/>
);
}
// Baseline: toggle buttons
return (
<div className="feedback-ui-ignore">
{feedbackEnabled && (
<FeedbackOverlay
isActive={false}
onActiveChange={(active) => setIsFeedbackActive(active)}
/>
)}
{isEnabled && <RecordModeOverlay />}
</div>
);
}

View File

@@ -3,8 +3,7 @@
import { useTranslations } from 'next-intl';
import { useState } from 'react';
import Image from 'next/image';
import dynamic from 'next/dynamic';
const Lightbox = dynamic(() => import('@/components/Lightbox'), { ssr: false });
import Lightbox from '@/components/Lightbox';
import { Section, Container, Heading } from '@/components/ui';
export default function Gallery() {

View File

@@ -26,7 +26,7 @@ export function Button({
...props
}: ButtonProps) {
const baseStyles =
'inline-flex items-center justify-center whitespace-nowrap rounded-full font-semibold transition-all duration-500 ease-out focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary focus-visible:ring-offset-2 disabled:opacity-50 disabled:pointer-events-none active:scale-95 hover:-translate-y-1 hover:scale-[1.02] relative overflow-hidden group/btn isolate';
'inline-flex items-center justify-center rounded-full font-semibold transition-all duration-500 ease-out focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary focus-visible:ring-offset-2 disabled:opacity-50 disabled:pointer-events-none active:scale-95 hover:-translate-y-1 hover:scale-[1.02] relative overflow-hidden group/btn isolate';
const variants = {
primary: 'bg-primary text-white shadow-md hover:shadow-primary/30 hover:shadow-2xl',
@@ -45,8 +45,8 @@ export function Button({
const sizes = {
sm: 'h-9 px-4 text-sm md:text-base',
md: 'h-11 px-6 text-base md:text-lg',
lg: 'h-14 px-5 md:px-8 text-base md:text-lg',
xl: 'h-16 px-6 md:px-10 text-lg md:text-xl',
lg: 'h-14 px-8 text-base md:text-lg',
xl: 'h-16 px-10 text-lg md:text-xl',
};
const styles = cn(baseStyles, variants[variant], sizes[size], className);
@@ -82,13 +82,8 @@ export function Button({
);
if (href) {
const { type, disabled, ...linkProps } = props as any;
return (
<Link
href={disabled ? '#' : href}
className={cn(styles, disabled && 'pointer-events-none opacity-50')}
{...linkProps}
>
<Link href={href} className={styles}>
{content}
</Link>
);

View File

@@ -13,7 +13,7 @@
"categories:performance": [
"error",
{
"minScore": 0.7
"minScore": 0.8
}
],
"categories:accessibility": [

View File

@@ -1,253 +0,0 @@
---
title: Allgemeine Verkaufsbedingungen (AVB)
slug: agbs
layout: default
excerpt: ''
featuredImage: null
redirectUrl: null
redirectPermanent: true
_status: published
---
## Allgemeine Verkaufsbedingungen
### 1 Geltungsbereich
1.1 Ausschließlich die nachfolgenden Allgemeinen Verkaufsbedingungen (nachfolgend „AVB“) gelten für den gesamten Geschäftsverkehr (nachfolgend „Lieferung von Waren“) der KLZ Vertriebs GmbH, Raiffeisenstraße 22, 73630 Remshalden (nachfolgend „KLZ“) und Unternehmern in Ausübung ihrer gewerblichen oder selbständigen beruflichen Tätigkeit und juristischen Personen des öffentlichen Rechts (nachfolgend „Kunde“). Der Geltung von abweichenden, zusätzlichen oder entgegenstehenden allgemeinen Geschäftsbedingungen des Kunden wird hiermit widersprochen.
1.2 Diese AVB gelten auch dann, wenn KLZ die Lieferung von Waren an den Kunden vorbehaltlos in Kenntnis entgegenstehender oder von diesen AVB abweichenden allgemeinen Geschäftsbedingungen des Kunden ausführt.
1.3 Im Rahmen laufender Geschäftsbeziehungen gelten diese AVB auch dann für künftige Lieferungen von Waren, wenn sie nicht ausdrücklich erneut in den Vertrag einbezogen wurden.
1.4 Änderungen dieser AVB werden dem Kunden schriftlich, per Telefax oder per E-Mail mitgeteilt. Widerspricht der Kunde diesen Änderungen nicht innerhalb von vier (4) Wochen nach Zugang der Mitteilung, gelten die Änderungen als durch den Kunden anerkannt. Auf das Widerspruchsrecht und die Rechtsfolgen des Schweigens wird der Kunde im Rahmen der Mitteilung über die Änderung der Geschäftsbedingungen gesondert hingewiesen.
1.5 Hinweise auf die Geltung gesetzlicher Vorschriften im Rahmen dieser AVB haben nur klarstellende Bedeutung. Auch ohne eine derartige Klarstellung gelten daher die gesetzlichen Vorschriften, soweit sie in diesen AVB nicht unmittelbar abgeändert oder ausdrücklich ausgeschlossen werden.
### 2 Vertragsschluss
2.1 Der Vertrag über die Lieferung von Waren kommt grundsätzlich durch ein Angebot des Kunden, z.B. über ein Auftragsformular, per E-Mail oder per Brief, und eine Annahme dieses Angebots durch KLZ in Textform z.B. durch eine Auftragsbestätigung per E-Mail oder Fax zustande. Der Annahme des Angebots durch KLZ steht es gleich, wenn KLZ den Auftrag oder die Bestellung binnen vierzehn (14) Kalendertagen ab Zugang des Angebots bei KLZ vorbehaltlos ausführt.
2.2 Alle Angebote von KLZ sind insbesondere hinsichtlich Verfügbarkeit, angegebener Mengen, Lieferfristen und Nebenleistungen freibleibend und unverbindlich, sofern sie nicht ausdrücklich in Textform als verbindlich gekennzeichnet wurden. Die Angebote stehen unter dem Vorbehalt der zeitgerechten, qualitativen und quantitativen Selbstbelieferung von KLZ.
2.3 Der Umfang der von KLZ zu liefernden Waren ergibt sich aus dem jeweiligen Angebot oder der jeweiligen Auftragsbestätigung von KLZ.
2.4 Technische Angaben, Muster, Preise, Abbildungen, Zeichnungen, Gewichts-, Maß-, Leistungs- und Gebrauchsangaben, sowie sonstige Beschreibungen der zu liefernden Ware in dem Angebot oder der Auftragsbestätigung sind nur annähernd maßgebend, soweit sie nicht ausdrücklich von KLZ in Textform als verbindlich gekennzeichnet wurden. Sie stellen keine Beschaffenheitsvereinbarung oder Garantie einer entsprechenden Beschaffenheit oder Haltbarkeit der zu liefernden Ware dar, es sei denn, sie wurden ausdrücklich in Textform als solche vereinbart. Entsprechendes gilt für Erwartungen des Kunden hinsichtlich der zu liefernden Ware oder deren Verwendung.
2.5 Handelsübliche und branchenübliche Abweichungen vom Leistungsumfang der zu liefernden Ware durch KLZ sind zulässig, soweit sie die Verwendbarkeit der zu liefernden Ware zum vertraglich vorgesehenen Zweck nicht beeinträchtigen. Dies umfasst auch Über- oder Unterlieferungen. Abweichungen der zu liefernden Ware, die durch zwingende rechtliche oder technische Normen, die nach Auftragsbestätigung in Kraft treten, bedingt sind, sind zulässig, soweit sie nicht erheblich und dem Kunden zumutbar sind. Änderungen, die zu einer technischen Verbesserung der zu liefernden Ware führen, sind stets zulässig.
2.6 Änderungen des Lieferumfangs durch den Kunden bedürfen zu ihrer Wirksamkeit stets der in Textform erklärten Einwilligung von KLZ. Mehraufwände und sonstige Kosten, die durch die Änderung des Lieferumfangs auf Wunsch des Kunden entstehen, sind von dem Kunden gesondert zu vergüten.
2.7 Soweit das Angebot oder die Auftragsbestätigung von KLZ offensichtliche Irrtümer, Schreib- oder Rechenfehler enthält, ist das Dokument für KLZ hinsichtlich der offensichtlichen Fehler nicht verbindlich. Der Kunde wird KLZ auf entsprechende Fehler hinweisen.
2.8 Verschlechtern sich die Vermögensverhältnisse des Kunden wesentlich oder wird der Antrag zur Eröffnung eines Insolvenz- oder vergleichbaren Verfahrens über das Vermögen des Kunden mangels Masse abgelehnt, ist KLZ berechtigt, ganz oder teilweise vom Vertrag zurückzutreten.
### 3 Abrufaufträge
3.1 Erteilt der Kunde KLZ einen Abrufauftrag und werden über die Abruftermine keine gesonderten Vereinbarungen in Textform getroffen, ist der Kunde verpflichtet, KLZ die einzelnen Abruftermine so mitzuteilen, dass zwischen Eingang der Abrufmitteilung bei KLZ und Auslieferung der Ware mindestens vierzehn (14) Werktage und zwischen Eingang der Abrufmitteilung und der letzten Auslieferung der Ware maximal neunzig (90) Werktage liegen. Werktage sind dabei alle Kalendertage, bei denen es sich nicht um einen Sonntag oder einen gesetzlichen Feiertag am Sitz von KLZ handelt.
### 4 Preise und Zahlungsbedingungen
4.1 Die in dem Angebot oder der Auftragsbestätigung von KLZ angegebenen Preise gelten für den in dem Angebot oder der Auftragsbestätigung von KLZ aufgeführten Lieferumfang und verstehen sich grundsätzlich in EURO und ohne Abzüge zuzüglich Versandkosten ab dem jeweiligen Werk des Herstellers der Ware, exklusive Verpackung und zuzüglich der jeweils geltenden gesetzlichen Umsatzsteuer, sofern nicht ausdrücklich etwas anderes in Textform vereinbart wurde. Die in dem Angebot oder der Auftragsbestätigung von KLZ ausgewiesenen Hohlpreise verstehen sich darüber hinaus zuzüglich Metallzuschlag und auftragsspezifischer Schnittkosten.
4.2 Sollte sich nach Vertragsschluss herausstellen, dass zur Durchführung des Vertrages bzw. zur Lieferung der Waren Mehrleistungen erforderlich sind, so werden diese Mehrleistungen gesondert abgerechnet und vergütet.
4.3 Hat sich der Preis für eine zu liefernde Ware, bzw. der vereinbarte Hohlpreis z.B. aufgrund gestiegener Preise for Energie, Transport, Arbeitskraft oder Material zwischen Vertragsschluss und Lieferdatum um mehr als fünf (5) % erhöht, werden die Parteien partnerschaftlich und konstruktiv über eine Anpassung des Preises bzw. des Hohlpreises diskutieren. KLZ ist zum Rücktritt von dem betroffenen Vertrag berechtigt, wenn innerhalb von vierzehn (14) Kalendertagen ab Anzeige der Preiserhöhung durch KLZ keine einvernehmliche Lösung gefunden wird.
4.4 Der Kaufpreis wird sofort mit Erhalt der Rechnung oder Lieferung der Ware, je nachdem welcher Zeitpunkt früher eintritt, zur Zahlung fällig, sofern keine anderen Zahlungskonditionen in Textform vereinbart sind. Der Kunde hat den ausstehenden Kaufpreis innerhalb von zehn (10) Kalendertagen nach Erhalt der Rechnung oder Erhalt der Ware, je nachdem welcher Zeitpunkt früher eintritt, frei Konto von KLZ und ohne Abzug zu bezahlen. Die Zahlungsfrist gilt mit vollständiger Gutschrift des ausstehenden Kaufpreises auf dem Konto von KLZ als gewahrt. Nach Ablauf der Zahlungsfrist kommt der Kunde automatisch mit der Zahlung des ausstehenden Kaufpreises in Verzug, ohne dass es hierfür einer gesonderten Mahnung von KLZ bedarf.
4.5 Befindet sich der Kunde mit einer Zahlung in Verzug, ist KLZ berechtigt, die zu liefernde Ware nur Zug-um-Zug gegen Zahlung der Summe, mit der sich der Kunde in Verzug befindet, zu liefern. Dies gilt auch für alle künftigen Lieferungen von Waren von KLZ an den Kunden. Darüber hinaus stehen KLZ sämtliche Ansprüche gegen den Kunden aufgrund dessen Zahlungsverzug uneingeschränkt zu.
4.6 Befindet sich der Kunde seit mehr als vierzehn (14) Kalendertagen in Zahlungsverzug, ist KLZ dazu berechtigt, vom Vertrag zurückzutreten und die gelieferte Ware unverzüglich zurück zu verlangen, ohne dass dem Kunden hieraus Ansprüche entstehen.
4.7 Das Recht des Kunden, Zahlungen zurückzuhalten oder mit Gegenansprüchen aufzurechnen, steht dem Kunden nur insoweit zu, als seine Gegenansprüche unbestritten oder rechtskräftig festgestellt sind, oder wenn die jeweiligen Ansprüche in einem Gegenseitigkeitsverhältnis zueinanderstehen.
### 5 Metallnotierung
5.1 Basis zur Berechnung des Kupferpreises ist der Durchschnitt des Liefervormonats der Notierung „LME Copper official price cash offer“, zuzüglich der jeweils aktuellen von KLZ benannten Kupfer-Prämie.
5.2 Basis zur Berechnung des Aluminiumpreises ist der Durchschnitt des Liefervormonats der Notierung „LME Aluminium official price cash offer" zuzüglich der jeweils von KLZ benannten Aluminium-Prämie.
5.3 USD werden auf Basis des EUR/USD LME-FX-Rate (MTLE) in EUR umgerechnet. Die entsprechenden Notierungen können der Webseite www.westmetall.com entnommen werden. Die Prämienzuschläge können stark variieren und KLZ behält sich das Recht vor, diese fristgerecht anzupassen, ungeachtet der Angebotslegung.
### 6 Metallzahl
6.1 Die von KLZ in dem Angebot oder der Auftragsbestätigung ausgewiesene Metallzahl ist eine rein kaufmännische Berechnungsgröße für den Metallinhalt, der in die Berechnung des Gesamtpreises eines Kabels eingeht.
6.2 Die Angabe der Metallzahl dient ausschließlich dazu, dem Kunden eine Vergleichbarkeit auf Grundlage der Holpreisbasis zu ermöglichen. Die Metallzahl definiert nicht das Gewicht des tatsächlich im Kabel enthaltenen Leitermetalls. Die Metallzahl ist ein rein kalkulatorischer Berechnungsfaktor, der keine unmittelbaren Rückschlüsse auf die im Kabel verwendeten Kupfer- bzw. Aluminiummengen zulässt. KLZ weist den Kunden hiermit ausdrücklich darauf hin, final nur den Vollpreis für Vergleichszwecke heranzuziehen.
6.3 Auf Anfrage des Kunden in Textform ist KLZ grundsätzlich dazu bereit, andere Metallzahlen zu berechnen und auszuweisen. Bei jeglicher Änderung bleibt der Vollpreis der gleiche Betrag. Ein Anspruch des Kunden auf Berechnung und Angabe anderer Metallzahlen besteht nicht.
### 7 Lieferung
7.1 Lieferungen erfolgen grundsätzlich DAP (INCOTERMS® 2020) sofern die Parteien keine anderweitige Vereinbarung in Textform getroffen haben.
7.2 Die Lieferung der Ware wird nach Möglichkeit geschlossen vorgenommen. KLZ ist jedoch berechtigt, Teillieferungen vorzunehmen, wenn dies für den Kunden zumutbar ist, insbesondere wenn wegen Fehlens von Waren eine erhebliche Verzögerung der gesamten Lieferung eintreten würde.
7.3 Lieferfristen aus Angeboten und Auftragsbestätigungen sind grundsätzlich für KLZ unverbindlich. Eine verbindliche Lieferfrist muss ausdrücklich in Textform als solche gekennzeichnet werden.
7.4 Die Lieferfrist beginnt mit dem Zugang der Auftragsbestätigung bei dem Kunden. Die Einhaltung der Lieferfrist setzt die rechtzeitige Klärung aller kaufmännischen und technischen Fragen, sowie die Einhaltung der vereinbarten Zahlungsbedingungen und sonstigen Verpflichtungen des Kunden voraus. Werden diese Voraussetzungen nicht rechtzeitig erfüllt, so verlängern sich die Fristen angemessen; dies gilt nicht, wenn ausschließlich KLZ die Verzögerung zu vertreten hat.
7.5 Die Einhaltung auch einer verbindlichen Lieferfrist steht unter dem Vorbehalt ordnungsgemäßer, insbesondere rechtzeitiger quantitativer und qualitativer Selbstbelieferung von KLZ durch dessen Vorlieferanten. KLZ ist im Fall der nicht ordnungsgemäßen Selbstbelieferung zum Rücktritt vom Vertrag berechtigt. KLZ informiert den Kunden unverzüglich, wenn und soweit KLZ von seinem Recht zum Rücktritt vom Vertrag Gebrauch macht und gewährt etwa erbrachte Vorleistungen des Kunden zurück.
7.6 Die Lieferfrist gilt als gewahrt, wenn KLZ die zu liefernde Ware dem Kunden bis zum Ablauf der Lieferfrist an dem benannten Lieferort innerhalb der üblichen Geschäftszeiten zur Annahme erstmalig angeboten hat.
7.7 Auch wenn eine verbindliche Lieferfrist vereinbart wurde, kommt KLZ mit der Lieferung der Ware nur dann in Verzug, wenn der Kunde KLZ nach Ablauf der Lieferfrist eine angemessene Nachfrist zur Lieferung der Ware in Textform gesetzt hat und diese Nachfrist erfolglos verstrichen ist.
7.8 Wird die Lieferung der Ware, oder das Abladen der Ware, auf Wunsch des Kunden verzögert, oder verzögert sich die Lieferung der Ware, oder das Abladen der Ware, aus sonstigen Gründen, die der Kunde zu vertreten hat, ist KLZ berechtigt, dem Kunden nach Ablauf der vertraglich vereinbarten Lieferfrist ein Lagergeld in Höhe von zwei (2) % des jeweiligen Nettoauftragswertes für jeden angefangenen Monat, maximal jedoch fünf (5) % des jeweiligen Nettoauftragswertes in Rechnung zu stellen. Das Lagergeld ist der Höhe nach auf maximal fünf (5) % des Nettoauftragswertes pro Vertrag beschränkt.
7.9 KLZ behält sich die Geltendmachung aller darüberhinausgehenden Ansprüche gegenüber dem Kunden aufgrund dessen verzögerter Übernahme der Ware uneingeschränkt vor. Das Lagergeld ist auf weitergehende Zahlungsansprüche von KLZ gegenüber dem Kunden aufgrund dessen verzögerter Übernahme der Ware anzurechnen. Dem Kunden bleibt der Nachweis gestattet, dass KLZ überhaupt kein oder nur ein geringerer Schaden entstanden ist.
7.10 Kabel werden von KLZ auf stabilen Vollholztrommeln geliefert. Auf Wunsch vermittelt KLZ dem Kunden Partner, die diese Trommeln gegen eine Gebühr abholen. Bei Lagerzeiten über drei (3) Monaten ist zu beachten, dass die Trommel regelmäßig bewegt wird, um eine einseitige Belastung zu vermeiden. Folgeschäden bei Nichtbeachtung wie Instabilität schließt KLZ aus.
7.11 Rücksendungen von Waren an KLZ, die nicht vorher von KLZ in Textform bestätigt wurden, erfolgen auf alleinige Gefahr des Kunden.
### 8 Gefahrübergang
8.1 Die Gefahr des zufälligen Untergangs und der zufälligen Verschlechterung der zu liefernden Ware geht grundsätzlich DAP (INCOTERMS® 2020) auf den Kunden über, sofern die Parteien keine anderweitige Vereinbarung in Textform getroffen haben.
8.2 Verzögert sich die Versendung bzw. Übergabe der Ware aus Gründen, die der Kunde zu vertreten hat, geht die Gefahr des zufälligen Untergangs und der zufälligen Verschlechterung der Ware mit der Durchführung des ersten Zustellversuchs innerhalb der gewöhnlichen Öffnungszeiten des Kunden durch KLZ auf den Kunden über.
### 9 Eigentumsvorbehalt
9.1 Die jeweils gelieferten Waren bleiben bis zur vollständigen Bezahlung sämtlicher fälliger Forderungen aus der Geschäftsbeziehung zwischen dem Kunden und KLZ in dem Eigentum von KLZ (nachfolgend „Vorbehaltsware“). Dabei ist unerheblich, aus welchem Rechtsgrund die Forderungen von KLZ gegen den Kunden bestehen. Erfasst sind insbesondere auch Saldoforderungen aus laufender Rechnung.
9.2 Der Kunde ist berechtigt, die gelieferte Ware im Rahmen des ordnungsgemäßen Geschäftsbetriebes zu veräußern, umzubilden oder weiterzuverarbeiten.
9.3 Die Verarbeitung oder Umbildung der Vorbehaltsware durch den Kunden erfolgt jedoch stets für KLZ und KLZ erwirbt Eigentum an der neuen Sache. Wird die Vorbehaltsware mit anderen Sachen zu einer einheitlichen Sache verbunden, verarbeitet oder untrennbar vermischt oder vermengt oder in sonstiger Weise verbunden, erwirbt KLZ Miteigentum an der neuen Sache im Verhältnis des Wertes der Vorbehaltsware zu den anderen Gegenständen zur Zeit der Verarbeitung bzw. Vermischung, Vermengung oder sonstigen Verbindung. Erfolgt die Vermischung, Vermengung oder sonstige Verbindung dergestalt, dass die Sache des Kunden als Hauptsache anzusehen ist, so willigt der Kunde bereits jetzt dazu ein, KLZ anteiliges Miteigentum an der neuen Sache, in dem Mengenverhältnis der Vorbehaltsware zu den fremden Sachen, zu übertragen. Für die so entstandenen Sachen gelten die Bestimmungen für Vorbehaltsware.
9.4 Der Kunde hat die Vorbehaltsware mit kaufmännischer Sorgfalt für KLZ zu verwahren, pfleglich zu behandeln und auf seine Kosten gegen Feuer, Wasser und Diebstahl zum Neuwert zu versichern. Der Kunde tritt seine entsprechenden Ansprüche aus den Versicherungsverträgen an KLZ ab; KLZ nimmt die Abtretung an.
9.5 Der Kunde darf die Vorbehaltsware nur im ordnungsgemäßen Geschäftsgang bis zum Eintritt des Verwertungsfalles veräußern. Verpfändungen und Sicherungsübereignungen hinsichtlich der Vorbehaltsware sind unzulässig. Eingriffe Dritter, wie etwa die Pfändung oder Zwangsvollstreckung, durch welche Rechte von KLZ beeinträchtigt werden, hat der Kunde KLZ unverzüglich in Textform anzeigen. Der Kunde hat den Dritten darüber hinaus unverzüglich in Textform darauf hinzuweisen, dass KLZ Eigentümer der Vorbehaltsware ist. Soweit der Dritte nicht in der Lage ist, KLZ die gerichtlichen und außergerichtlichen Kosten einer Klage nach § 771 ZPO zu erstatten, haftet der Kunde für den dadurch entstandenen Ausfall. Weitergehende Ansprüche gegen den Kunden bleiben unberührt.
9.6 Für den Fall der Weiterveräußerung der Vorbehaltsware tritt der Kunde die ihm daraus seinerseits erwachsenden Forderungen gegen seinen Abnehmer bereits jetzt an KLZ ab; KLZ nimmt die Abtretung an. Gleiches gilt für sonstige Forderungen, die an die Stelle der Vorbehaltsware treten oder sonst hinsichtlich der Vorbehaltsware entstehen, wie z. B. Versicherungsansprüche oder Ansprüche aus unerlaubter Handlung bei Verlust oder Zerstörung.
9.7 Wird die Vorbehaltsware nach Verarbeitung oder zusammen mit anderen Waren, die KLZ nicht gehören, weiterveräußert oder wird sie mit einem Grundstück oder mit beweglichen Sachen verbunden, so erklärt der Kunde bereits jetzt die Abtretung der Forderung des Kunden gegen seine Abnehmer in Höhe des zwischen dem Kunden und KLZ vereinbarten Kaufpreises für die Vorbehaltsware; KLZ nimmt die Abtretung an.
9.8 Der Kunde wird ermächtigt, die abgetretenen Forderungen für KLZ einzuziehen. KLZ bleibt auch weiterhin zur Einziehung der abgetretenen Forderungen berechtigt, wird die abgetretenen Forderungen jedoch nicht selbst einziehen, solange kein Grund nach Ziffer 9.9 vorliegt, der KLZ zum Widerruf der Einziehungsermächtigung berechtigt. Zur Abtretung der Forderung an Dritte, einschließlich Forderungsverkauf an Factoring Banken, ist der Kunde nur mit vorheriger schriftlicher Zustimmung von KLZ berechtigt. KLZ wird die Zustimmung nicht verweigern, sofern eine gleichwertige Sicherheit gestellt wird.
9.9 KLZ kann die Ermächtigung zur Weiterveräußerung und Einziehung widerrufen, sobald sich der Kunde gegenüber KLZ im Zahlungsverzug befindet, oder das Insolvenzverfahren oder ein vergleichbares Verfahren über das Vermögen des Kunden eröffnet oder mangels Masse abgewiesen wurde.
9.10 Der Kunde hat die eingezogenen Forderungen binnen zehn (10) Werktagen auf ein von KLZ benanntes Konto zu überweisen.
9.11 KLZ ist zur Offenlegung der Abtretung berechtigt und kann von dem Kunden verlangen, dass dieser die Abtretung seinen Abnehmern anzeigt. Auf Verlangen ist der Kunde verpflichtet, KLZ die Namen seiner Abnehmer bekanntzugeben und alle erforderlichen Unterlagen herauszugeben, die für die Einziehung der Forderung durch KLZ selbst erforderlich sind. Auch ist der Kunde ab Verzugseintritt verpflichtet, die Abtretung der Forderung an KLZ seinem Abnehmer gegenüber schriftlich mitzuteilen.
9.12 KLZ wird die Vorbehaltsware sowie die an ihre Stelle tretenden Sachen oder Forderungen freigeben, soweit ihr Wert die Höhe der gesicherten Forderungen um mehr als zehn (10) % übersteigt. Die Auswahl der danach freizugebenden Gegenstände liegt bei KLZ.
9.13 Tritt KLZ bei vertragswidrigem Verhalten des Kunden, insbesondere Zahlungsverzug, vom Vertrag zurück (Verwertungsfall), ist KLZ berechtigt, die Vorbehaltsware herauszuverlangen. Der Kunde hat in diesem Fall KLZ alle Informationen und Unterlagen zu übergeben, die erforderlich sind, um die Vorbehaltsware herauszuverlangen und die an KLZ abgetretenen Ansprüche des Kunden gegenüber seinen Abnehmern geltend zu machen, mindestens jedoch eine Aufstellung über die bei dem Kunden oder bei den Abnehmern befindliche Vorbehaltsware, auch soweit sie bereits verarbeitet ist, und eine Aufstellung aller abgetretenen Forderungen nebst Rechnungsgutschriften.
### 10 Höhere Gewalt
10.1 „Höhere Gewalt“ bedeutet das Eintreten eines Ereignisses oder Umstands, welcher eine Partei daran hindert, eine oder mehrere ihrer vertraglichen Verpflichtungen zu erfüllen, wenn und soweit die von dem Hindernis betroffene Partei nachweist, dass (a) dieses Hindernis außerhalb der ihr zumutbaren Kontrolle liegt und (b) es zu dem Zeitpunkt des Vertragsschlusses nicht in zumutbarer Weise vorhersehbar war und (c) die Auswirkungen des Hindernisses von der betroffenen Partei nicht in zumutbarer Weise hätte vermieden oder überwunden werden können.
10.2 Bei dem Beweis des Gegenteils wird insbesondere, aber nicht abschließend, bei den folgenden Ereignissen ein Fall Höherer Gewalt im Sinne des vorstehenden Absatzes vermutet: Krieg, Terrorakte, Währungs- und Handelsbeschränkungen, Embargo, Sanktionen, Amtshandlungen, Befolgung von Gesetzen oder Regierungsanordnungen, staatliche Ausreise- und Exportverbote oder staatliche Einreise- und Importverbote, Enteignung, Epidemie, Naturkatastrophe, extremes Naturereignis, Explosionen, Feuer, Zerstörung von Ausrüstungen, Demonstrationen oder Versammlungen, Ereignisse, die das Passieren von wichtigen Transportrouten verhindern, allgemeine Arbeitsunruhen, insbesondere Boykott, Streik und Aussperrungen, Energieknappheit oder Beeinträchtigung von Transportmitteln.
10.3 Eine Partei, die sich mit Erfolg auf diese Klausel beruft, ist ab dem Zeitpunkt, zu dem das Hindernis ihr die Leistungserbringung unmöglich macht, von ihrer Pflicht zur Erfüllung ihrer vertraglichen Verpflichtungen und von jeder Schadensersatzpflicht oder jedem anderen vertraglichen Rechtsbehelf wegen Vertragsverletzung suspendiert, sofern der Eintritt eines Ereignisses Höherer Gewalt der anderen Partei unverzüglich mitgeteilt wird. Ist die Auswirkung des geltend gemachten Hindernisses oder Ereignisses vorübergehend, so gelten die eben dargelegten Folgen nur so lange, wie das geltend gemachte Hindernis die Vertragserfüllung durch die betroffene Partei verhindert sowie einer angemessenen Anlaufzeit. Dauert das Hindernis oder Ereignis höherer Gewalt bei der betroffenen Partei länger als vier (4) Monate an, ist die andere Partei zur ganzen oder teilweisen außerordentlichen Kündigung bzw. zum Rücktritt vom Vertrag berechtigt. Wechselseitige Schadensersatzansprüche wegen dieser Kündigung bzw. dieses Rücktritts sind ausgeschlossen.
10.4 Der Kunde ist verpflichtet, auf Verlangen von KLZ innerhalb einer angemessenen Frist zu erklären, ob er wegen einem bei KLZ eingetretenen Ereignis Höherer Gewalt den Vertrag kündigt bzw. von diesem Zurücktritt oder weiterhin auf der Durchführung der Lieferung der Ware besteht.
10.5 Sofern sich aufgrund eines Ereignisses Höherer Gewalt die wirtschaftliche Bedeutung oder der Inhalt der Lieferung der Ware erheblich verändert oder auf den Betrieb von KLZ erheblich einwirkt, werden die Parteien den zugrundeliegenden Vertrag unter Beachtung von Treu und Glauben angemessen anpassen. Soweit dies wirtschaftlich nicht vertretbar ist, steht KLZ das Recht zu, von dem Vertrag zurückzutreten. Will KLZ von diesem Rücktrittsrecht Gebrauch machen, so hat er dies nach Erkenntnis der Tragweite des Ereignisses Höherer Gewalt unverzüglich dem Kunden mitzuteilen, und zwar auch dann, wenn zunächst mit dem Kunden eine Verlängerung der Lieferzeit vereinbart war.
### 11 Gewährleistung und Mängelrüge
11.1 Mängelansprüche bestehen nicht bei nur unerheblicher Abweichung der gelieferten Ware von den vereinbarten Spezifikationen, bei unerheblicher Beeinträchtigung der von KLZ vorgegebenen Brauchbarkeit, bei natürlicher Abnutzung, übermäßiger Beanspruchung, Verwendung ungeeigneter Betriebsmittel, oder bei Mängeln, die aufgrund besonderer äußerer Einflüsse entstehen, sowie bei nicht reproduzierbaren Softwarefehlern. Werden vom Kunden oder von Dritten unsachgemäß Änderungen oder Instandsetzungsarbeiten vorgenommen, so bestehen für diese und die daraus entstehenden Folgen ebenfalls keine Mängelansprüche.
11.2 Eine Gewährleistungsverpflichtung von KLZ besteht nur, wenn der Kunde den ihm obliegenden Untersuchungs- und Rügepflichten fristgerecht nachgekommen ist. Eingehende Waren hat der Kunde unverzüglich auf äußerlich erkennbare Transportschäden und Identität der gelieferten Ware mit der Bestellung zu überprüfen. Äußerlich erkennbare Mängel sind unverzüglich, spätestens jedoch innerhalb von drei (3) Werktagen ab Übergabe, sonstige Mängel innerhalb von fünf (5) Werktagen nach ihrer Entdeckung gegenüber KLZ im Rahmen einer Mängelrüge mindestens in Textform geltend zu machen.
11.3 Mängelrügen müssen eine genaue Beschreibung der aufgetretenen Mängel, der betroffenen Lieferungen und wenn möglich, auch eine bildhafte Dokumentation des Mangels enthalten. KLZ ist berechtigt, mangelhafte Teile auf eigene Kosten zur Übersendung und Untersuchung anzufordern.
11.4 Beim Verlegen von Kabeln in Gräben oder Rohren, bzw. in Bauwerken ist eine ständige Sichtkontrolle durch den Kabelverleger vorzunehmen. Der Kabelverleger hat laufend zu überprüfen, ob Auffälligkeiten zu vermerken sind.
11.5 KLZ ist berechtigt, innerhalb einer angemessenen Frist, anerkannte Mängel nach eigener Wahl durch Beseitigung (Nachbesserung) oder durch Lieferung mangelfreier Ware (Ersatzlieferung) zu beheben. Der Kunde wird KLZ bei der Beseitigung von Mängeln in zumutbarem Umfang unterstützen.
11.6 Die Kosten der Nacherfüllung und Mangeluntersuchung werden bei berechtigten Beanstandungen von KLZ getragen. Dies gilt nicht für Aufwendungen, die dadurch entstehen, dass die gelieferten Waren durch den Kunden an einen anderen als den Ort der gewerblichen Niederlassung des Kunden verbracht worden sind, es sei denn, das Verbringen entspricht dem vertraglich vorgesehenen Verwendungszweck der gelieferten Ware. Satz 1 gilt ebenfalls nicht für Mehraufwendungen, die dadurch entstehen, dass der Kunde die gelieferte Ware ohne Zustimmung von KLZ verändert hat.
11.7 Hat der Kunde die Ware verbaut, obwohl er positive Kenntnis oder grob fahrlässige Unkenntnis des Mangels hatte, ist KLZ nicht dazu verpflichtet, die Ein- und Ausbaukosten der Ware zu erstatten.
11.8 Der Kunde kann erst nach zweimaligem Fehlschlagen der Mängelbeseitigung vom Vertrag zurücktreten oder den vereinbarten Kaufpreis angemessen mindern. Bei nur unerheblicher Minderung des Werts oder der Tauglichkeit der gelieferten Waren ist der Rücktritt ausgeschlossen, ebenso wenn der Kunde sich im Annahmeverzug befindet oder wenn der Kunde den Mangel zu vertreten hat. Solange der Kunde KLZ gegenüber nicht den Rücktritt vom Vertrag erklärt oder Schadensersatz wegen Nichterfüllung verlangt hat, ist KLZ auch nach Ablauf der vom Kunden gesetzten Frist zur Nacherfüllung berechtigt, es sei denn, der Kunde hat zuvor schriftlich die Ablehnung der Nacherfüllung angezeigt.
11.9 Beruht ein Mangel auf dem Verschulden von KLZ, kann der Kunde unter den in Ziffer 12 bestimmten Voraussetzungen Schadensersatz verlangen. Ziffer 12 gilt entsprechend für den Ersatz vergeblicher Aufwendungen, den der Kunde anstelle eines Schadensersatzes statt der Leistung verlangen kann.
11.10 Besteht Uneinigkeit zwischen den Parteien in Bezug auf Mängel an den Waren, ist ein Gutachten bei dem VDE-Institut über die streitige Frage einzuholen. Das Ergebnis des Gutachtens des VDE-Instituts ist für die Feststellung der Mangelfreiheit oder eines Mangels maßgebend. KLZ ist nur dann verpflichtet, die Kosten des Gutachtens des VDE-Instituts zu tragen, wenn das Gutachten einen Mangel feststellt.
11.11 Die gesetzlichen Mängelgewährleistungsansprüche des Kunden verjähren innerhalb von zwölf (12) Monaten ab Gefahrübergang. §§ 438 Abs. 1 Nr. 2, 479 Abs. 1 und 634a Abs. 1 Nr. 2 BGB bleiben hiervon unberührt. Diese Verkürzung der Verjährungsfrist gilt nicht bei Vorsatz, grober Fahrlässigkeit, der Verletzung von Leben, Körper, Gesundheit, oder jedweder wesentlicher Vertragspflichtverletzung, zwingender gesetzlicher Haftungsnormen wie denen des ProdHaftG, sowie bei Nichteinhaltung einer Beschaffenheitsgarantie oder dem arglistigen Verschweigen eines Mangels. Die gesetzlichen Regelungen über Ablaufhemmung, Hemmung und Neubeginn der Fristen bleiben unberührt.
### 12 Haftung
12.1 KLZ haftet uneingeschränkt für Vorsatz und grobe Fahrlässigkeit. KLZ haftet ferner uneingeschränkt für Schäden aus der Verletzung des Lebens, des Körpers oder der Gesundheit, sowie für arglistiges Verschweigen eines Mangels und Verletzung von garantierten Beschaffenheitsmerkmalen, sowie im Rahmen zwingender gesetzlicher Vorschriften, wie etwa denen des Produkthaftungsgesetzes.
12.2 Bei der Verletzung von wesentlichen Vertragspflichten haftet KLZ im Übrigen der Höhe nach begrenzt auf den bei Vertragsschluss für KLZ typischerweise vorhersehbaren Schaden bzw. die typischerweise vorhersehbaren Aufwendungen. Vertragswesentlich sind die Verpflichtungen, deren Erfüllung die ordnungsgemäße Vertragsdurchführung erst ermöglichen und auf deren Einhaltung der Kunde vertraut und auch vertrauen darf. Im Übrigen haftet KLZ bei leichter Fahrlässigkeit nicht auf Schadensersatz.
12.3 Eine Haftung for mittelbare Schäden und Folgeschäden, die Folge von Mängeln der gelieferten Waren sind, besteht nur für solche Schäden, die bei bestimmungsgemäßer Verwendung der gelieferten Ware typischerweise zu erwarten sind.
12.4 Die Haftung entfällt for Schäden, die auf Reparaturen, Änderungen oder anderen Eingriffen oder auf die Verwendung von Nicht-Originalteilen von KLZ im Rahmen der Reparatur oder Änderung bzw. des Eingriffs zurückzuführen sind, die der Kunde ohne Zustimmung von KLZ vorgenommen hat.
12.5 Soweit die Haftung von KLZ ausgeschlossen oder beschränkt ist, gilt dies auch für die Haftung der Angestellten, Arbeitnehmer, Mitarbeiter, Vertreter und Erfüllungsgehilfen von KLZ.
### 13 Produkthaftung
13.1 Der Kunde wird die gelieferten Waren ausschließlich vertragsgemäß und entsprechend den verfügbaren Anleitungen von KLZ verwenden, insbesondere wird er vorhandene Warnhinweise über Gefahren bei Gebrauch der gelieferten Waren nicht verändern oder entfernen. Bei Verletzung dieser Pflicht stellt der Kunde KLZ im Innenverhältnis von Produkthaftungsansprüchen Dritter frei, es sei denn der Kunde ist for den die Haftung auslösenden Fehler nicht verantwortlich.
13.2 Wird KLZ aufgrund eines Produktfehlers zu einem Produktrückruf oder einer -warnung veranlasst, so wird der Kunde nach besten Kräften bei den Maßnahmen mitwirken, die KLZ für erforderlich und zweckmäßig hält. KLZ ist verpflichtet, die Kosten des Produktrückrufs oder der -warnung zu tragen, es sei denn KLZ ist für den Produktfehler und den eingetretenen Schaden nach produkthaftungsrechtlichen Grundsätzen nicht verantwortlich.
13.3 Der Kunde wird KLZ unverzüglich über ihm bekanntwerdende Risiken bei der Verwendung der gelieferten Ware und mögliche Produktfehler in Textform informieren.
### 14 Exportkontrolle
14.1 Lieferungen von Waren von KLZ stehen unter dem Vorbehalt, dass der Vertragserfüllung keine Hindernisse aufgrund von nationalen oder internationalen exportkontrollrechtlichen Vorschriften entgegenstehen. Sofern sich die Durchführung des Vertrages aufgrund von Exportkontrollprüfungen oder Genehmigungsverfahren verzögert, werden Fristen und Lieferzeiten außer Kraft gesetzt. KLZ ist berechtigt, von der Einzelbestellung zurückzutreten und den Liefervertrag fristlos zu kündigen, wenn ein solcher Rücktritt oder eine Kündigung zur Einhaltung nationaler oder internationalen Exportkontrollbestimmung erforderlich ist. Die Geltendmachung von Schadensersatzansprüchen oder anderen Rechten durch den Kunden wegen vorgenannter Kündigung oder Verzögerung ist ausgeschlossen.
14.2 Der Kunde verpflichtet sich zur Einhaltung des nationalen und internationalen Exportkontrollrechts, insbesondere bei Weitergabe der gelieferten Ware an Dritte.
### 15 Urheberrechte und Datenschutz
15.1 An Kostenvoranschlägen, Zeichnungen, Modellen, Teilen, Schablonen, Berechnungen, Beschreibungen, Mustern und anderen Unterlagen (im Folgenden: „Unterlagen“ genannt) behält sich KLZ seine eigentums- und urheberrechtlichen Verwertungsrechte uneingeschränkt vor. Die Unterlagen dürfen nur nach vorheriger schriftlicher Zustimmung von KLZ Dritten zugänglich gemacht werden und sind KLZ auf Verlangen unverzüglich zurückzugeben.
15.2 Ohne ausdrückliche Zustimmung von KLZ in Textform ist der Kunde nicht berechtigt, die gelieferte Ware zu Werbezwecken zu verwenden.
15.3 KLZ erhebt und verwendet personenbezogenen Daten ausschließlich gemäß den Bestimmungen der Datenschutzgrundverordnung und des Bundesdatenschutzgesetzes.
15.4 Personenbezogenen Daten, die für die Begründung, inhaltliche Ausgestaltung, Abwicklung oder Änderung des Vertragsverhältnisses erforderlich sind (Bestandsdaten), werden ausschließlich zur Durchführung des zwischen KLZ und dem Kunden abgeschlossenen Vertrages verwendet. Zur Abwicklung des Vertragsverhältnisses müssen diese Daten im erforderlichen Umfang an Dritte (z.B. Transporteure oder Spediteure) weitergegeben werden. Der Kunde erteilt für die vorbeschriebene Verwendung seiner personenbezogenen Daten hiermit eine entsprechende Einwilligung.
15.5 Diese Einwilligungserklärung ist jederzeit -ohne Angabe von Gründen- widerruflich. Der Widerruf ist zu richten an info@klz-cables.com .
15.6 Eine darüberhinausgehende Nutzung der Bestandsdaten des Kunden für Zwecke der Werbung, der Marktforschung oder zur bedarfsgerechten Gestaltung der Angebote von KLZ bedarf der gesonderten ausdrücklichen Einwilligung des Kunden.
15.7 Auf Anordnung einer zuständigen Stelle darf KLZ im Einzelfall Auskunft über personenbezogene Daten des Kunden im Rahmen der Anordnung an die zuständige Stelle erteilen, soweit dies für Zwecke der Strafverfolgung, zur Gefahrenabwehr, zur Erfüllung der gesetzlichen Aufgaben der Verfassungsschutzbehörden oder zur Durchsetzung der Rechte am geistigen Eigentum erforderlich ist.
### 16 Geheimhaltung
16.1 „Vertrauliche Informationen“ sind
(i) alle Informationen, Daten, Zeichnungen, Unterlagen, Materialien, Know-how oder sonstige Geschäftsgeheimnisse im Sinne von § 2 Nr. 1 des Gesetzes zum Schutz von Geschäftsgeheimnissen (GeschGehG), insbesondere alle Entwürfe, Skizzen, Zeichnungen, technischen Protokolle, Modelle oder elektronische Daten, gleich in welcher Verkörperung,
(ii) sowie alle nicht offenkundigen kaufmännischen, betrieblichen oder technischen Informationen oder Gegenstände, gleich in welcher Verkörperung, die dem Kunden im Rahmen der Geschäftsbeziehung mit KLZ offengelegt oder anderweitig bekannt werden,
(iii) die entweder direkt als vertraulich gekennzeichnet wurden oder nachträglich schriftlich oder in Textform als vertraulich gekennzeichnet werden. Des Weiteren gelten auch solche Informationen als vertraulich, welche der Natur nach bzw. aus den Umständen heraus oder aus anderen Gründen erkennbar als vertraulich anzusehen sind, soweit die Parteien nichts anderes in Schriftform vereinbart haben.
16.2 Vertrauliche Informationen im Sinne von Ziffer 16.1 sind jedoch nicht solche Informationen,
(i) die der Kunde von öffentlich zugänglichen Quellen bezogen hat und die allgemein bekannt oder auf legalem Wege zugänglich sind und diese Quellen die Informationen ebenfalls auf legalem Wege erlangt haben,
(ii) die der Kunde von einem berechtigten Dritten rechtmäßig erhalten hat,
(iii) die der Kunde eigenständig entwickelt hat,
(iv) die kraft Gesetzes oder aufgrund gerichtlicher oder behördlicher Anordnung offengelegt wurden oder offenzulegen sind. Der Kunde ist in diesem Fall verpflichtet, KLZ vor einer solchen Offenlegung der Informationen unverzüglich zu informieren und zu versuchen, die Offenlegung zu verhindern, soweit dies mit angemessenen und gesetzlich zulässigen Mitteln möglich ist.
16.3 Die Darlegungs- und Beweislast für das Vorliegen einer in Ziffer 16.2 benannten Ausnahmetatbestände trägt diejenige Partei, die sich auf das Vorliegen einer oder mehrerer dieser Ausnahmetatbestände beruft.
16.4 der Kunde verpflichtet sich, die Vertraulichen Informationen strikt vertraulich zu behandeln und ausschließlich zur Erfüllung des Vertragszwecks zu verwenden. Die Vertraulichen Informationen dürfen durch den Kunden nur an solche Mitarbeiter oder andere Personen weitergegeben werden, die sie zur Erreichung des Vertragszwecks zwingend erhalten müssen. Sämtliche Mitarbeiter des Kunden, welche Kenntnis von Vertraulichen Informationen erhalten, müssen durch den Kunden ebenfalls zur Geheimhaltung hinsichtlich der Vertraulichen Informationen verpflichtet sein oder werden. Diese Geheimhaltungspflicht soll auch für die Zeit nach einem etwaigen Ausscheiden des Mitarbeiters aus dem Unternehmen des Kunden for weitere fünf (5) Jahre gelten, soweit dies rechtlich möglich ist.
16.5 Der Kunde verpflichtet sich, auf erstes Anfordern von KLZ und/oder nach Beendigung der Geschäftsbeziehung oder Beendigung des zugrundeliegenden Vertrage, gleich aus welchem Rechtsgrund, alle unter diese Geheimhaltungsvereinbarung fallenden Vertraulichen Informationen und davon angefertigte Kopien an KLZ herauszugeben oder sachgemäß zu zerstören bzw. sicher und dauerhaft zu löschen und die vollständige Herausgabe, vollständige sachgemäße Zerstörung oder vollständige sichere und dauerhafte Löschung KLZ gegenüber schriftlich zu bestätigen.
16.6 Ziffer 16.5 gilt nicht für Vertrauliche Informationen, die zwingenden gesetzlichen Aufbewahrungspflichten unterliegen oder in automatisierten Back-Up Dateien im Rahmen des ordnungsgemäßen Sicherungsprozesses sicher gespeichert wurden. Für diese Vertraulichen Informationen gelten die Bestimmungen dieser Geheimhaltungsvereinbarung bis zu ihrer Herausgabe, sicheren und dauerhaften Löschung oder vollständigen sachgemäßen Zerstörung fort.
16.7 Die Geheimhaltungsverpflichtungen gemäß dieser Ziffer 16 gelten für einen Zeitraum von fünf (5) Jahren nach Beendigung des zugrundeliegenden Vertrages, gleich aus welchem Rechtsgrund, fort.
16.8 Für jeden Einzelfall eines Verstoßes des Kunden gegen die Pflichten aus dieser Geheimhaltungsvereinbarung ist der Kunde zur Zahlung einer Vertragsstrafe an KLZ verpflichtet, deren Höhe in das billige Ermessen von KLZ gestellt wird und im Streitfall durch das zuständige Gericht überprüft werden kann. Durch die Geltendmachung der Vertragsstrafe werden weitergehende Schadensersatzansprüche von KLZ nicht ausgeschlossen.
### 17 Schlussbestimmungen
17.1 Änderungen oder Ergänzungen dieser AVB bedürfen der Textform. Dies gilt auch für die Abänderung des Textformerfordernisses selbst.
17.2 Auf Verträge zwischen KLZ und dem Kunden, der Auslegung dieser AVB, sowie die gesamten Rechtsbeziehungen zwischen KLZ und dem Kunden gilt ausschließlich das Recht der Bundesrepublik Deutschland unter Ausschluss des UN-Kaufrechts und des Kollisionsrechts. Erfüllungsort sowie ausschließlicher Gerichtsstand für alle Streitigkeiten aus oder im Zusammenhang mit der Rechtsbeziehung zwischen KLZ und dem Kunden ist Stuttgart.
17.3 Sollten einzelne Bestimmungen dieser AVB unwirksam oder undurchführbar sein oder werden, so wird hierdurch die Wirksamkeit aller sonstigen Bestimmungen dieser AVB im Übrigen nicht berührt. Die unwirksame oder undurchführbare Bestimmung wird von den Vertragsparteien einvernehmlich durch eine rechtswirksame Regelung ersetzt, welche dem wirtschaftlichen Erfolg und Zweck der unwirksame oder undurchführbare Bestimmung am nächsten kommt. Dies gilt gleichermaßen für den Fall einer Regelungslücke.
Stand: April 2026
<Block type="pdfDownload" data={{"id":"69a9b09ea220fecfb9d54bb8","label":"Als PDF herunterladen","style":"primary","url":"/AVB-KLZ-4-2026.pdf","blockName":"","blockType":"pdfDownload"}} />
<Block type="agbHistory" data={{"id":"69a9b09ea220fecfb9d54bb9","blockName":"AGB History","blockType":"agbHistory","title":"Vorherige Versionen"}} />

View File

@@ -1,23 +0,0 @@
---
title: Blog
slug: blog
layout: default
excerpt: >-
[vc_column column_padding=&#8221;no-extra-padding&#8221;
column_padding_tablet=&#8221;inherit&#8221;
column_padding_phone=&#8221;inherit&#8221;
column_padding_position=&#8221;all&#8221;
column_element_direction_desktop=&#8221;default&#8221;
column_element_spacing=&#8221;default&#8221;
desktop_text_alignment=&#8221;default&#8221;
tablet_text_alignment=&#8221;default&#8221;
phone_text_alignment=&#8221;default&#8221;&#8230;
featuredImage: null
redirectUrl: null
redirectPermanent: true
_status: published
---
## Blog &#8211; Deutsch
[recent*posts style=&#8221;single*large*featured&#8221; large*featured*padding=&#8221;8%&#8221; button*color=&#8221;Accent-Color&#8221; button*arrow*style=&#8221;default&#8221; load*in*animation=&#8221;none&#8221; single*large*featured*content*side*spacing=&#8221;default&#8221; single*large*featured*content*alignment=&#8221;middle&#8221; large*featured*content*max*width=&#8221;default&#8221; bg*overlay=&#8221;solid*color&#8221; large*featured*heading*tag=&#8221;h2&#8243; category=&#8221;all&#8221; order=&#8221;DESC&#8221; orderby=&#8221;rand&#8221; image*loading=&#8221;default&#8221; blog*remove*post*author=&#8221;true&#8221;]

View File

@@ -1,16 +0,0 @@
---
title: Kontakt
slug: kontakt
layout: fullBleed
excerpt: >-
Wir sind für Sie da. Kontaktieren Sie uns für Beratung, Angebote oder
technische Fragen.
featuredImage: null
redirectUrl: null
redirectPermanent: true
_status: published
---
<HeroSection badge="Kontakt" title="Sprechen Sie mit uns" subtitle="Wir sind für Sie da. Kontaktieren Sie uns für Beratung, Angebote oder technische Fragen." alignment="left" />
<ContactSection showMap={true} showForm={true} showHours={true} />

View File

@@ -1,14 +0,0 @@
---
title: Produkte
slug: produkte
layout: fullBleed
excerpt: ''
featuredImage: null
redirectUrl: null
redirectPermanent: true
_status: published
---
## Produkte
null

View File

@@ -1,32 +0,0 @@
---
title: Startseite
slug: start
layout: fullBleed
excerpt: >-
Ihr Experte für hochwertige Stromkabel, Mittelspannungslösungen und
Solarkabel. Zuverlässige Infrastruktur für eine grüne Energiezukunft.
featuredImage: null
redirectUrl: null
redirectPermanent: true
_status: published
---
<Block type="homeHero" data={{"note":"Hero section with primary video and CTA.","blockType":"homeHero","id":"69f85c4f31a177188da0eae2"}} />
<Block type="homeProductCategories" data={{"note":"Product categories overview based on CMS data.","blockType":"homeProductCategories","id":"69f85c4f31a177188da0eae3"}} />
<Block type="homeWhatWeDo" data={{"note":"What we do / capabilities overview.","blockType":"homeWhatWeDo","items":[],"id":"69f85c4f31a177188da0eae4"}} />
<Block type="homeRecentPosts" data={{"note":"Latest 3 blog articles snippet.","blockType":"homeRecentPosts","id":"69f85c4f31a177188da0eae5"}} />
<Block type="homeExperience" data={{"note":"Experience and history timeline snippet.","blockType":"homeExperience","id":"69f85c4f31a177188da0eae6"}} />
<Block type="homeWhyChooseUs" data={{"note":"Why choose KLZ Cables metrics and selling points.","blockType":"homeWhyChooseUs","features":[],"items":[],"id":"69f85c4f31a177188da0eae7"}} />
<Block type="homeMeetTheTeam" data={{"note":"High-level Meet the Team teaser.","blockType":"homeMeetTheTeam","id":"69f85c4f31a177188da0eae8"}} />
<Block type="homeGallery" data={{"note":"Image gallery from our facilities.","blockType":"homeGallery","id":"69f85c4f31a177188da0eae9"}} />
<Block type="homeVideo" data={{"note":"Secondary informative background video.","blockType":"homeVideo","id":"69f85c4f31a177188da0eaea"}} />
<Block type="homeCTA" data={{"note":"Bottom call to action linking to contact.","blockType":"homeCTA","id":"69f85c4f31a177188da0eaeb"}} />

View File

@@ -1,22 +0,0 @@
---
title: Team
slug: team
layout: fullBleed
excerpt: >-
Zwei Generationen, eine Vision: Deutschlands zuverlässigster Partner für Kabel
& Leitungen.
featuredImage: null
redirectUrl: null
redirectPermanent: true
_status: published
---
<HeroSection badge="Das Team" title="Tradition trifft Moderne" subtitle="Zwei Generationen, eine Vision: Deutschlands zuverlässigster Partner für Kabel & Leitungen." alignment="center" />
<Block type="teamProfile" data={{"name":"Michael Bodemer","role":"Geschäftsführer","quote":"Innovation entsteht dort, wo Erfahrung auf frische Ideen trifft.","layout":"imageRight","blockType":"teamProfile","colorScheme":"dark","description":"Als Geschäftsführer verbindet Michael jahrzehntelange Branchenexpertise mit einem klaren Blick für die Zukunft. Sein Fokus liegt auf nachhaltigen Lösungen und modernster Technologie.","linkedinUrl":"https://www.linkedin.com/in/michael-bodemer-33b493122/","id":"69f85c4f31a177188da0eaef"}} />
<Block type="stats" data={{"stats":[{"id":"699f0fd414336173ca384447","label":"Jahre Expertise","value":"30+"},{"id":"699f0fd414336173ca384448","label":"Netzwerk","value":"Global"}],"blockType":"stats","id":"69f85c4f31a177188da0eaf0"}} />
<Block type="teamProfile" data={{"name":"Klaus Mintel","role":"Gründer & Berater","quote":"Qualität ist kein Zufall sie ist das Ergebnis von Engagement und Erfahrung.","layout":"imageLeft","blockType":"teamProfile","colorScheme":"light","description":"Klaus gründete KLZ Cables und hat das Unternehmen zu einem der zuverlässigsten Partner der Kabelindustrie aufgebaut. Er bringt Jahrzehnte an Expertise ein.","linkedinUrl":"https://www.linkedin.com/in/klaus-mintel-b80a8b193/","id":"69f85c4f31a177188da0eaf1"}} />
<Block type="manifestoGrid" data={{"items":[{"id":"699f0fd414336173ca38444b","title":"Qualität","description":"Wir liefern nur Produkte, die höchsten Standards entsprechen."},{"id":"699f0fd414336173ca38444c","title":"Zuverlässigkeit","description":"Termingerechte Lieferung ist für uns selbstverständlich."},{"id":"699f0fd414336173ca38444d","title":"Partnerschaft","description":"Langfristige Beziehungen sind die Grundlage unseres Erfolgs."},{"id":"699f0fd414336173ca38444e","title":"Innovation","description":"Wir investieren in neue Technologien und nachhaltige Lösungen."},{"id":"699f0fd414336173ca38444f","title":"Transparenz","description":"Offene Kommunikation und faire Preise zeichnen uns aus."},{"id":"699f0fd414336173ca384450","title":"Nachhaltigkeit","description":"Verantwortung für Umwelt und Gesellschaft ist Teil unserer DNA."}],"title":"Unsere Werte","tagline":"Seit der Gründung leiten uns klare Prinzipien, die wir jeden Tag leben.","subtitle":"Was uns antreibt","blockType":"manifestoGrid","id":"69f85c4f31a177188da0eaf2"}} />

View File

@@ -1,92 +0,0 @@
---
title: 100 % erneuerbare Energie? Nur mit der richtigen Kabelinfrastruktur!
slug: 100-erneuerbare-energie-nur-mit-der-richtigen-kabelinfrastruktur
excerpt: >-
Die Energiewende braucht leistungsfähige Netze. Erfahren Sie, warum
Investitionen in die Kabelinfrastruktur der Schlüssel zu 100 % erneuerbarer
Energie sind.
date: '2025-03-31T10:00:34.000Z'
featuredImage:
id: 74
alt: image fx 6
caption: null
updatedAt: '2026-02-24T17:47:40.395Z'
createdAt: '2026-02-24T17:47:40.395Z'
url: /media/image_fx_-13.webp
thumbnailURL: /media/image_fx_-13-600x327.webp
filename: image_fx_-13.webp
mimeType: image/webp
filesize: 78878
width: 1408
height: 768
focalX: 50
focalY: 50
sizes:
thumbnail:
url: /media/image_fx_-13-600x327.webp
width: 600
height: 327
mimeType: image/webp
filesize: 18740
filename: image_fx_-13-600x327.webp
card:
url: /media/image_fx_-13-768x419.webp
width: 768
height: 419
mimeType: image/webp
filesize: 27876
filename: image_fx_-13-768x419.webp
tablet:
url: /media/image_fx_-13-1024x559.webp
width: 1024
height: 559
mimeType: image/webp
filesize: 44152
filename: image_fx_-13-1024x559.webp
category: Kabel Technologie
_status: published
---
## 100 % erneuerbare Energie? Nur mit der richtigen Kabelinfrastruktur!
<Block type="stickyNarrative" data={{"items":[{"id":"699de43c6365be78f9ef2266","title":"Netzengpässe","content":"**Ursache:** Alte Leitungen für zentrale Kraftwerke, nicht für dezentrale Energie. **Lösung:** Neue Hoch- & Mittelspannungskabel."},{"id":"699de43c6365be78f9ef2267","title":"Abregelung von Solar- & Windstrom","content":"**Ursache:** Netz kann nicht genug Strom aufnehmen. **Lösung:** Smart Grids & Speicherlösungen."},{"id":"699de43c6365be78f9ef2268","title":"Lange Transportwege","content":"**Ursache:** Erzeugung oft weit entfernt vom Verbrauch. **Lösung:** Hochleistungskabel & lokale Netze."}],"title":"Herausforderungen im Stromnetz","blockType":"stickyNarrative","id":"69f85c4f31a177188da0eb20"}} />
⚠️ Ein Netz aus der Vergangenheit kann keine Zukunftsenergie transportieren!
Wer heute nur in erneuerbare Energieanlagen investiert, aber die Kabelinfrastruktur ignoriert, wird morgen teuren ungenutzten Strom haben.
# Welche Kabel brauchen wir für die Energiewende?
Nicht jedes Kabel ist gleich und nicht jedes Kabel ist für die Herausforderungen der Energiewende geeignet. Hier kommt es auf Spannungsebene, Kapazität und Effizienz an.
## Die drei Säulen der Energiewende-Verkabelung:
- [**Hochspannungskabel**](https://de.wikipedia.org/wiki/Hochspannungskabel) → Ferntransport von Strom über lange Distanzen
- [**Mittelspannungskabel**](https://de.wikipedia.org/wiki/Mittelspannungsnetz) → Netzanschlüsse für Solar- &amp; Windparks
- [**Niederspannungskabel**](https://de.wikipedia.org/wiki/Niederspannung) → Verbindung zu Haushalten &amp; Speichern
🔍 **Was macht ein gutes Erneuerbaren-Kabel aus?**<br />✔ Hohe Belastbarkeit für schwankende Einspeisungen<br />✔ Wetter- und temperaturbeständige Isolierung<br />✔ Nachhaltige Materialien für ein CO₂-armes Stromnetz
💡 Je besser das Kabel, desto weniger Strom geht unterwegs verloren und desto grüner wird die Energie!
# Solar- und Windparks allein reichen nicht
Ohne passende Kabel bleibt der Strom dort, wo er erzeugt wird. Doch welcher Netzausbau macht wirklich Sinn?
<Block type="comparisonGrid" data={{"items":[{"id":"699de43c6365be78f9ef226a","label":"Netzstabilität","leftValue":"Sehr hoch","rightValue":"Mittel"},{"id":"699de43c6365be78f9ef226b","label":"Umweltverträglichkeit","leftValue":"Unauffällig, keine Eingriffe ins Landschaftsbild","rightValue":"Sichtbar, problematisch für Vögel"},{"id":"699de43c6365be78f9ef226c","label":"Wartung & Lebensdauer","leftValue":"Kaum Wartung, langlebig","rightValue":"Witterungsanfällig, kürzere Lebensdauer"},{"id":"699de43c6365be78f9ef226d","label":"Kosten","leftValue":"Höher in der Installation, aber effizienter im Betrieb","rightValue":"Günstiger zu errichten, aber höhere Folgekosten"}],"title":"Erdkabel vs. Freileitungen Was ist die bessere Wahl?","blockType":"comparisonGrid","leftLabel":"Erdkabel","rightLabel":"Freileitung","id":"69f85c4f31a177188da0eb21"}} />
<Block type="visualLinkPreview" data={{"url":"https://www.hochspannungsblog.at/wissenswertes/netzaufbau/vergleich-freileitung-erdkabel","image":"/media/visual-links/69f85c4f31a177188da0eb22.jpg","title":"Freileitung und Erdkabel sind „Stand der Technik“","summary":"Freileitung oder Erdkabel? Wir erklären Ihnen die Unterschiede und Möglichkeiten, aber auch warum was möglich ist und warum was nicht.","blockType":"visualLinkPreview","id":"69f85c4f31a177188da0eb22"}} />
Die Zukunft gehört Netzen, die nicht nur Strom transportieren, sondern ihn aktiv steuern. Dazu braucht es nicht nur mehr Kabel, sondern auch die richtigen Kabel mit intelligenter Technik.
# Fazit: Die Energiewende beginnt unter der Erde
Die Diskussion über erneuerbare Energien dreht sich oft um den Ausbau von Wind- und Solarparks. Doch selten wird über die entscheidende Infrastruktur gesprochen, die nötig ist, um diese Energie auch zuverlässig nutzbar zu machen.
Die Realität zeigt: Ein modernes Stromnetz ist der Schlüssel zur Energiewende. Wenn Strom nicht effizient transportiert oder gespeichert werden kann, führt das zu Netzengpässen und Abregelungen genau das Gegenteil dessen, was mit der Energiewende erreicht werden soll.
Drei zentrale Erkenntnisse lassen sich festhalten:
- **Erneuerbare Energien **brauchen** leistungsfähige Netze.** Ohne eine solide Kabelinfrastruktur bleibt der erzeugte Strom oft ungenutzt, weil das Netz ihn nicht aufnehmen kann.
- **Investitionen in Kabel **sind ebenso** wichtig **wie** Investitionen in Erzeugungsanlagen.** Während neue Windräder und Solaranlagen sichtbar sind, bleibt der notwendige Netzausbau oft unsichtbar und wird deshalb unterschätzt.
- **Ohne intelligente Netztechnik **lassen sich** Schwankungen nicht ausgleichen.** Moderne Kabel mit integrierter Steuerungstechnik sind essenziell, um Energie genau dorthin zu bringen, wo sie gebraucht wird.
Wenn es um die Zukunft der Energieversorgung geht, führt kein Weg an leistungsfähigen Kabelsystemen vorbei. Die Energiewende ist nicht nur eine Frage der Erzeugung, sondern vor allem eine Frage des Transports und der Verteilung.
# KLZ Ihr Partner für die grüne Energiezukunft
Die Energiewende erfordert eine neue Generation von Netzinfrastruktur. KLZ ist Ihr Partner für die zuverlässige Verkabelung von Solar- und Windkraftprojekten von der Mittel- bis zur Hochspannungsebene.
Mit jahrzehntelanger Erfahrung in der Kabelbranche wissen wir, worauf es bei der Netzanbindung von erneuerbaren Energien ankommt. Unsere Kabel sind speziell für hohe Lasten und wechselnde Einspeisungen ausgelegt. Wir liefern nicht nur das Material, sondern beraten auch zu den besten Lösungen für eine effiziente und nachhaltige Stromverteilung.
## Unsere Stärken:
- **Schnelle &amp; zuverlässige Lieferung** Wir sorgen dafür, dass Ihre Projekte ohne Verzögerungen starten können.
- **Technische Beratung &amp; Planung** Welche Kabel sind optimal für Ihr Vorhaben? Wir unterstützen Sie mit fundierter Expertise.
- **Nachhaltige Kabeltechnologie** Umweltfreundliche Materialien und langlebige Kabel sorgen für eine zukunftssichere Energieversorgung.
- **Spezialisiert auf erneuerbare Energien** Unsere Lösungen sind genau auf die Anforderungen von Wind- und Solarparks zugeschnitten.
Ob Netzanschluss, Hochspannungsleitungen oder Kabelinfrastruktur für große Solarparks wir stehen Ihnen mit unserem Know-how zur Seite.
Lassen Sie uns gemeinsam die Energiezukunft gestalten. [Kontaktieren Sie uns](/de/contact/) für eine Beratung oder ein individuelles Angebot.

View File

@@ -1,122 +0,0 @@
---
title: >-
Die Kunst der Kabellogistik: Der Transport des Fundamentes moderner
Energienetze
slug: >-
die-kunst-der-kabellogistik-der-transport-des-fundamentes-moderner-energienetze
excerpt: ''
date: '2025-01-14T12:43:59.000Z'
featuredImage:
id: 76
alt: transportation and logistics trucking 2023 11 27 04 54 40 utc scaled
caption: null
updatedAt: '2026-02-24T17:47:41.099Z'
createdAt: '2026-02-24T17:47:41.099Z'
url: >-
/media/transportation-and-logistics-trucking-2023-11-27-04-54-40-utc-scaled-5.webp
thumbnailURL: >-
/media/transportation-and-logistics-trucking-2023-11-27-04-54-40-utc-scaled-5-600x449.webp
filename: transportation-and-logistics-trucking-2023-11-27-04-54-40-utc-scaled-5.webp
mimeType: image/webp
filesize: 207640
width: 2560
height: 1914
focalX: 50
focalY: 50
sizes:
thumbnail:
url: >-
/media/transportation-and-logistics-trucking-2023-11-27-04-54-40-utc-scaled-5-600x449.webp
width: 600
height: 449
mimeType: image/webp
filesize: 28468
filename: >-
transportation-and-logistics-trucking-2023-11-27-04-54-40-utc-scaled-5-600x449.webp
card:
url: >-
/media/transportation-and-logistics-trucking-2023-11-27-04-54-40-utc-scaled-5-768x574.webp
width: 768
height: 574
mimeType: image/webp
filesize: 41998
filename: >-
transportation-and-logistics-trucking-2023-11-27-04-54-40-utc-scaled-5-768x574.webp
tablet:
url: >-
/media/transportation-and-logistics-trucking-2023-11-27-04-54-40-utc-scaled-5-1024x766.webp
width: 1024
height: 766
mimeType: image/webp
filesize: 64146
filename: >-
transportation-and-logistics-trucking-2023-11-27-04-54-40-utc-scaled-5-1024x766.webp
category: Kabel Technologie
_status: published
---
## Die Kunst der Kabellogistik: Der Transport des Fundamentes moderner Energienetze
Der Transport von Kabeln ist eine besondere logistische Aufgabe, die weit über das einfache Bewegen von Waren hinausgeht. Kabel sind schwer, empfindlich und variieren stark in ihrer Größe, weshalb sie eine sorgfältige Handhabung benötigen, um Schäden und Verzögerungen zu vermeiden. Ob bei Bauprojekten, Elektroinstallationen oder industriellen Einrichtungen das Wissen über die spezifischen Anforderungen des Kabeltransports ist unerlässlich, um Risiken wie Beschädigungen, Verzögerungen und Sicherheitsprobleme zu minimieren.
Warum ist der Kabeltransport so besonders?
Hohe Gewichte: Kabeltrommeln können mehrere Tonnen wiegen. Ohne eine sichere Befestigung können sie während des Transports kippen oder rollen und damit ernsthafte Gefahren verursachen.<br />
Empfindliche Materialien: Trotz des Gewichts sind Kabel anfällig für Beschädigungen. Fehlerhafte Handhabung kann zu Knicken, Abnutzungen oder sogar Funktionsstörungen führen.<br />
Unterschiedliche Größen: Kabeltrommeln gibt es in vielen Variationen, weshalb für das Be- und Entladen sowie die Sicherung immer maßgeschneiderte Lösungen erforderlich sind.<br />
Strikte Zeitvorgaben: Eine verspätete Lieferung von Kabeln kann ganze Projekte zum Stillstand bringen und zu teuren Verzögerungen führen. Zuverlässige Logistik ist daher unverzichtbar.
Kabel mögen robust wirken, aber sie benötigen während des gesamten Transports besondere Aufmerksamkeit.
🚚 **Logistik-Tipp:** Eine durchdachte Kabellogistik sorgt dafür, dass der Transport reibungslos verläuft und Ihre wertvolle Fracht stets geschützt bleibt!
### Wichtige Elemente professioneller Kabellogistik
Was unterscheidet einen erfolgreichen Kabeltransport von einem gescheiterten? Wir bringen die wesentlichen Unterschiede auf den Punkt:
Professionelle Sicherung: Schwere Trommeln müssen mit den richtigen Trägern und Methoden fest am Boden verankert werden. Riemen müssen so angebracht werden, dass sie ein Verrutschen verhindern, ohne die Kabel zu beschädigen.<br />
Spezialisierte Handhabung: Das Be- und Entladen erfordert geschulte Fachkräfte, die die besonderen Anforderungen von Kabeltrommeln kennen. Ein falscher Handgriff kann zu irreparablen Schäden führen.<br />
Effiziente Zeitplanung: Timing ist alles. Verspätete Lieferungen können Projekte verzögern, während überstürzte Operationen das Risiko von Fehlern erhöhen. Das richtige Gleichgewicht zu finden, ist entscheidend.<br />
Zuverlässige Partner: Nicht jedes Logistikunternehmen ist auf den Kabeltransport spezialisiert. Die Auswahl erfahrener Partner ist entscheidend, um Pannen zu vermeiden.
Unser Ansatz stellt sicher, dass jeder dieser Faktoren präzise umgesetzt wird, sodass eine reibungslose und beschädigungsfreie Lieferung garantiert ist.
📦 **Logistik-Tipp:** Mit den richtigen Techniken und verlässlichen Partnern wird Ihr Kabeltransport sicher, pünktlich und problemlos!
### Wie wir es möglich machen: Unsere Kabellogistik-Strategie
Wir liefern nicht nur Kabel, sondern auch Vertrauen. So sorgen wir für einen reibungslosen und zuverlässigen Ablauf:
Die richtigen Partner wählen: Wir arbeiten mit Speditionen zusammen, die viel Erfahrung im Umgang mit Kabeltrommeln haben. Diese Fachleute wissen genau, wie man Kabel sicher sichert, lädt und transportiert.<br />
Moderne Technik: Unsere Logistikpartner setzen auf fortschrittliche Ausrüstung, um die Trommeln sicher zu verankern und sicherzustellen, dass sie während des Transports an ihrem Platz bleiben.<br />
Vorausschauende Planung: Vom Routenmanagement bis zur Zeitplanung wir planen jede Lieferung genau, um enge Fristen einzuhalten und gleichzeitig höchste Sicherheit zu gewährleisten.<br />
Strategische Standorte: Durch unser Logistikzentrum können wir Deutschland und die Niederlande schnell und effizient erreichen, wodurch wir Transitzeiten verkürzen und Emissionen reduzieren.
🌱 **Mehrwert:** Mit unserem kostenlosen Trommelrückgabeservice gehen wir noch einen Schritt weiter, um unseren Kunden zu helfen, Kosten zu sparen und Abfall zu verringern!
### Nachhaltigkeit in der Kabellogistik
Die Kabelindustrie spielt eine zentrale Rolle in der grünen Energie-Revolution, und Logistik trägt maßgeblich dazu bei, die Nachhaltigkeit zu gewährleisten.
Effizienter Transport: Durch die Optimierung von Routen und die Bündelung von Lieferungen reduzieren wir den Kraftstoffverbrauch und die CO2-Emissionen.<br />
Recycling-Programme: Unser Trommelrückgabeservice sorgt dafür, dass Materialien, wo immer möglich, wiederverwendet werden ein Beitrag zur Kreislaufwirtschaft.<br />
Umweltbewusste Partnerschaften: Wir arbeiten bevorzugt mit Logistikpartnern zusammen, die genauso wie wir einen starken Fokus auf Umweltverantwortung legen.
Diese Maßnahmen stehen im Einklang mit unserer Mission, erneuerbare Energieprojekte zu unterstützen und gleichzeitig unseren ökologischen Fußabdruck zu minimieren.
🌍 **Grünes Engagement:** Durch diese Bemühungen tragen wir zu erneuerbaren Energieprojekten bei und arbeiten kontinuierlich an einer saubereren, nachhaltigeren Zukunft.
💚 **Partnern Sie mit uns** und verstärken Sie Ihre Nachhaltigkeitsinitiativen, um die grüne Energie-Revolution voranzutreiben!
### Häufige Fehler in der Kabellogistik und wie man sie vermeidet
Die Kabellogistik ist eine hochriskante Aufgabe, bei der Fehler ernsthafte Konsequenzen haben können. Hier sind einige häufige Fallstricke und wie wir sie vermeiden:
Unzureichende Sicherung der Trommeln: Eine schlecht gesicherte Trommel kann während des Transports verrutschen, was zu Schäden an den Kabeln und Sicherheitsrisiken führt. Wir wenden branchenübliche Best Practices an, um jede Lieferung sicher zu verankern.<br />
Falsche Handhabung: Fehlerhafte Handhabung beim Be- oder Entladen kann kostspielige Schäden verursachen. Unsere Partner sind geschult, jede Trommel mit größter Sorgfalt zu behandeln.<br />
Verpasste Fristen: Verzögerungen bei der Lieferung können ganze Projekte ins Stocken bringen. Wir legen großen Wert auf Pünktlichkeit und planen vorausschauend, um immer im Zeitrahmen zu bleiben.<br />
Unzureichende Kommunikation: Ein Mangel an Transparenz kann dazu führen, dass Kunden nicht wissen, wo sich ihre Sendungen befinden. Wir halten unsere Kunden stets auf dem Laufenden und sorgen dafür, dass sie jederzeit informiert sind.
⚡ **Logistik-Tipp:** Indem wir diese häufigen Fehler vermeiden, stellen wir sicher, dass der Kabeltransport jedes Mal reibungslos und zuverlässig
### Innovation im Bereich Kabellogistik
Die Logistikbranche entwickelt sich ständig weiter, und neue Technologien verändern die Art und Weise, wie Kabel transportiert werden. Hier sind einige Trends, die Sie im Auge behalten sollten:
Kranlose Ladesysteme: Innovationen wie [ReelFrame ](https://www.worldcargonews.com/shipping-logistics/2022/12/enter-reelframe-for-craneless-loading-and-transport-of-cables)ersparen den Einsatz von Kranen und machen den Kabeltransport effizienter und weniger abhängig von schwerem Gerät.<br />
Intelligente Lagerhaltung: Moderne Bestandsverwaltungssysteme helfen Unternehmen, Kabelbestände präziser zu verfolgen und zu verwalten. Weitere Informationen finden Sie [hier](https://www.innovites.com/article/making-it-work-in-the-wire-cable-industry-warehousing-and-logistics).<br />
Nachhaltige Lieferketten: Immer mehr Unternehmen setzen auf die Reduzierung von Emissionen und die Einführung umweltfreundlicher Praktiken. Einblicke von [Belden](https://www.belden.com/blogs/smart-building/4-major-factors-impacting-the-cable-supply-chain-and-how-were-responding) zeigen, wie die Branche auf diese Herausforderungen reagiert.
Die Logistikbranche entwickelt sich weiter, mit Innovationen wie kranlosen Ladesystemen, intelligenter Lagerhaltung und nachhaltigen Lieferketten, die darauf abzielen, die Effizienz zu steigern und den ökologischen Fußabdruck zu reduzieren.
💡 **Pro-Tipp:** Achten Sie auf diese Trends, um sicherzustellen, dass Ihre Kabellogistik in Zukunft wettbewerbsfähig und nachhaltig bleibt!
### Möchten Sie tiefer in die Welt der Kabellogistik und des Supply Chain Managements eintauchen?
Schauen Sie sich diese Ressourcen an:
- [Overcoming global supply chain issues in the wire and cable industry](https://nassaunationalcable.com/blogs/blog/overcoming-global-supply-chain-issues-in-wire-and-cable-industry-2023)
- [Making it work in the wire &amp; cable industry: Warehousing and logistics](https://www.innovites.com/article/making-it-work-in-the-wire-cable-industry-warehousing-and-logistics)
- [4 major factors impacting the cable supply chain](https://www.belden.com/blogs/smart-building/4-major-factors-impacting-the-cable-supply-chain-and-how-were-responding)
Diese Artikel bieten wertvolle Einblicke in die Herausforderungen und Innovationen, die die Branche heute prägen.
### Der Schlüssel zum Erfolg: Wie Kabellogistik Ihre Projekte voranbringt
Kabellogistik ist das Fundament moderner Infrastrukturprojekte. Egal, ob es um die Versorgung von erneuerbaren Energieanlagen oder die Unterstützung des industriellen Wachstums geht Kabel sind entscheidend für die Zukunft. Eine zuverlässige Logistik sorgt dafür, dass diese wichtigen Komponenten pünktlich und in einwandfreiem Zustand ankommen, sodass Sie Verzögerungen vermeiden und Ihre Projekte problemlos weiterlaufen können.
Wenn Sie einen Logistikpartner wählen, der sich mit Kabeltransport auskennt, investieren Sie nicht nur in einen Lieferdienst Sie sorgen dafür, dass Ihr Projekt erfolgreich wird.
Bereit, den nächsten Schritt zu gehen? Kontaktieren Sie uns noch heute, um über Ihre Kabellogistik-Bedürfnisse zu sprechen und herauszufinden, wie wir Ihre Abläufe verbessern können. Lassen Sie uns die Zukunft gemeinsam vorantreiben Trommel für Trommel.

Some files were not shown because too many files have changed in this diff Show More