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
1126 changed files with 231518 additions and 44461 deletions

View File

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

View File

@@ -10,6 +10,7 @@
# ──────────────────────────────────────────────────────────────────────────── # ────────────────────────────────────────────────────────────────────────────
NODE_ENV=development NODE_ENV=development
NEXT_PUBLIC_BASE_URL=http://localhost:3000 NEXT_PUBLIC_BASE_URL=http://localhost:3000
DIRECTUS_PORT=8055
# TARGET is used to differentiate between environments (testing, staging, production) # TARGET is used to differentiate between environments (testing, staging, production)
# NEXT_PUBLIC_TARGET makes this information available to the frontend # NEXT_PUBLIC_TARGET makes this information available to the frontend
TARGET=development TARGET=development
@@ -46,18 +47,9 @@ MAIL_RECIPIENTS=info@klz-cables.com
LOG_LEVEL=info LOG_LEVEL=info
GATEKEEPER_PASSWORD=klz2026 GATEKEEPER_PASSWORD=klz2026
SENTRY_DSN= SENTRY_DSN=
# For Directus Error Tracking
# SENTRY_ENVIRONMENT is set automatically by CI # 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) # Deployment Configuration (CI/CD only)
# ──────────────────────────────────────────────────────────────────────────── # ────────────────────────────────────────────────────────────────────────────

View File

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

View File

@@ -13,12 +13,8 @@ on:
required: false required: false
default: 'false' default: 'false'
env:
PUPPETEER_SKIP_DOWNLOAD: "true"
COREPACK_NPM_REGISTRY: "https://registry.npmmirror.com"
concurrency: 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 cancel-in-progress: true
jobs: jobs:
@@ -35,6 +31,7 @@ jobs:
traefik_host: ${{ steps.determine.outputs.traefik_host }} traefik_host: ${{ steps.determine.outputs.traefik_host }}
traefik_rule: ${{ steps.determine.outputs.traefik_rule }} traefik_rule: ${{ steps.determine.outputs.traefik_rule }}
next_public_url: ${{ steps.determine.outputs.next_public_url }} next_public_url: ${{ steps.determine.outputs.next_public_url }}
directus_url: ${{ steps.determine.outputs.directus_url }}
project_name: ${{ steps.determine.outputs.project_name }} project_name: ${{ steps.determine.outputs.project_name }}
short_sha: ${{ steps.determine.outputs.short_sha }} short_sha: ${{ steps.determine.outputs.short_sha }}
container: container:
@@ -86,29 +83,25 @@ jobs:
TRAEFIK_HOST="${SLUG}.branch.mintel.me" TRAEFIK_HOST="${SLUG}.branch.mintel.me"
fi fi
# Standardize Traefik Rule (escaped backticks for Traefik v3) # Standardize Traefik Rule
if [[ "$TRAEFIK_HOST" == *","* ]]; then 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') PRIMARY_HOST=$(echo "$TRAEFIK_HOST" | cut -d',' -f1 | sed 's/ //g')
else else
TRAEFIK_RULE='Host(`'"$TRAEFIK_HOST"'`)' TRAEFIK_RULE="Host(\"$TRAEFIK_HOST\")"
PRIMARY_HOST="$TRAEFIK_HOST" PRIMARY_HOST="$TRAEFIK_HOST"
fi fi
GATEKEEPER_HOST="gatekeeper.$PRIMARY_HOST"
{ {
echo "target=$TARGET" echo "target=$TARGET"
echo "image_tag=$IMAGE_TAG" echo "image_tag=$IMAGE_TAG"
echo "env_file=$ENV_FILE" echo "env_file=$ENV_FILE"
echo "traefik_host=$PRIMARY_HOST" echo "traefik_host=$PRIMARY_HOST"
echo "traefik_rule=$TRAEFIK_RULE" echo "traefik_rule=$TRAEFIK_RULE"
echo "gatekeeper_host=$GATEKEEPER_HOST"
echo "next_public_url=https://$PRIMARY_HOST" echo "next_public_url=https://$PRIMARY_HOST"
echo "directus_url=https://cms.$PRIMARY_HOST"
if [[ "$TARGET" == "production" ]]; then if [[ "$TARGET" == "production" ]]; then
echo "project_name=klz-cablescom" echo "project_name=klz-cablescom"
elif [[ "$TARGET" == "branch" ]]; then
echo "project_name=$PRJ-branch-$SLUG"
else else
echo "project_name=$PRJ-$TARGET" echo "project_name=$PRJ-$TARGET"
fi fi
@@ -124,13 +117,13 @@ jobs:
if [[ -n "$UPSTREAM_VERSION" && "$UPSTREAM_VERSION" != "workspace:"* ]]; then if [[ -n "$UPSTREAM_VERSION" && "$UPSTREAM_VERSION" != "workspace:"* ]]; then
# 1. Discovery (Works without token for public repositories) # 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 if [[ -z "$UPSTREAM_SHA" ]]; then
echo "⚠️ Warning: Tag $TAG_TO_WAIT not found in mmintel/at-mintel." echo "❌ Error: Tag $TAG_TO_WAIT not found in mmintel/at-mintel."
else exit 1
echo "✅ Tag verified: Found upstream SHA $UPSTREAM_SHA for $TAG_TO_WAIT"
fi fi
echo "✅ Tag verified: Found upstream SHA $UPSTREAM_SHA for $TAG_TO_WAIT"
# 2. Status Check (Requires GITEA_PAT for cross-repo API access) # 2. Status Check (Requires GITEA_PAT for cross-repo API access)
POLL_TOKEN="${{ secrets.GITEA_PAT || secrets.MINTEL_PRIVATE_TOKEN }}" POLL_TOKEN="${{ secrets.GITEA_PAT || secrets.MINTEL_PRIVATE_TOKEN }}"
@@ -161,38 +154,34 @@ jobs:
steps: steps:
- name: Checkout repository - name: Checkout repository
uses: actions/checkout@v4 uses: actions/checkout@v4
- name: Setup pnpm
uses: pnpm/action-setup@v3
with:
version: 10
- name: Setup Node.js - name: Setup Node.js
uses: actions/setup-node@v4 uses: actions/setup-node@v4
with: with:
node-version: 20 node-version: 20
cache: 'pnpm'
- name: Setup pnpm
uses: pnpm/action-setup@v3
with:
version: 10
- name: 🔐 Registry Auth - name: 🔐 Registry Auth
run: | run: |
echo "@mintel:registry=https://git.infra.mintel.me/api/packages/mmintel/npm" > .npmrc echo "@mintel:registry=https://${{ vars.REGISTRY_HOST || 'npm.infra.mintel.me' }}" > .npmrc
echo "//git.infra.mintel.me/api/packages/mmintel/npm/:_authToken=${{ secrets.NPM_TOKEN }}" >> .npmrc echo "//${{ vars.REGISTRY_HOST || 'npm.infra.mintel.me' }}/:_authToken=${{ secrets.REGISTRY_PASS }}" >> .npmrc
- name: Install dependencies - name: Install dependencies
run: | run: pnpm install --frozen-lockfile
pnpm store prune
pnpm install --no-frozen-lockfile
- name: 🔒 Security Audit
run: pnpm audit --audit-level high || echo "⚠️ Audit found vulnerabilities (non-blocking)"
- name: 🧪 QA Checks - name: 🧪 QA Checks
if: github.event.inputs.skip_checks != 'true' if: github.event.inputs.skip_checks != 'true'
env: run: |
TURBO_TELEMETRY_DISABLED: "1" pnpm lint
run: npx turbo run lint typecheck test --cache-dir=".turbo" pnpm typecheck
pnpm test
# ────────────────────────────────────────────────────────────────────────────── # ──────────────────────────────────────────────────────────────────────────────
# JOB 3: Build & Push # JOB 3: Build & Push
# ────────────────────────────────────────────────────────────────────────────── # ──────────────────────────────────────────────────────────────────────────────
build: build:
name: 🏗️ Build name: 🏗️ Build
needs: [prepare, qa] needs: [prepare]
if: needs.prepare.outputs.target != 'skip' if: needs.prepare.outputs.target != 'skip'
runs-on: docker runs-on: docker
container: container:
@@ -209,17 +198,19 @@ jobs:
with: with:
context: . context: .
push: true push: true
provenance: false platforms: linux/arm64
platforms: linux/amd64
build-args: | build-args: |
NEXT_PUBLIC_BASE_URL=${{ needs.prepare.outputs.next_public_url }} NEXT_PUBLIC_BASE_URL=${{ needs.prepare.outputs.next_public_url }}
NEXT_PUBLIC_TARGET=${{ needs.prepare.outputs.target }} 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_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' }} UMAMI_API_ENDPOINT=${{ secrets.UMAMI_API_ENDPOINT || vars.UMAMI_API_ENDPOINT || 'https://analytics.infra.mintel.me' }}
NPM_TOKEN=${{ secrets.NPM_TOKEN }} NPM_TOKEN=${{ secrets.REGISTRY_PASS }}
tags: registry.infra.mintel.me/mintel/klz-2026:${{ needs.prepare.outputs.image_tag }} 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: | secrets: |
"NPM_TOKEN=${{ secrets.NPM_TOKEN }}" "NPM_TOKEN=${{ secrets.REGISTRY_PASS }}"
# ────────────────────────────────────────────────────────────────────────────── # ──────────────────────────────────────────────────────────────────────────────
# JOB 4: Deploy # JOB 4: Deploy
@@ -235,17 +226,27 @@ jobs:
IMAGE_TAG: ${{ needs.prepare.outputs.image_tag }} IMAGE_TAG: ${{ needs.prepare.outputs.image_tag }}
PROJECT_NAME: ${{ needs.prepare.outputs.project_name }} PROJECT_NAME: ${{ needs.prepare.outputs.project_name }}
NEXT_PUBLIC_BASE_URL: ${{ needs.prepare.outputs.next_public_url }} 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 }} 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) # Secrets mapping (Mail)
MAIL_HOST: ${{ secrets.SMTP_HOST || vars.SMTP_HOST }} MAIL_HOST: ${{ secrets.SMTP_HOST || vars.SMTP_HOST }}
MAIL_PORT: ${{ secrets.SMTP_PORT || vars.SMTP_PORT || '587' }} MAIL_PORT: ${{ secrets.SMTP_PORT || vars.SMTP_PORT || '587' }}
MAIL_USERNAME: ${{ secrets.SMTP_USER || vars.SMTP_USER }} MAIL_USERNAME: ${{ secrets.SMTP_USER || vars.SMTP_USER }}
MAIL_PASSWORD: ${{ secrets.SMTP_PASS || vars.SMTP_PASS }} MAIL_PASSWORD: ${{ secrets.SMTP_PASS || vars.SMTP_PASS }}
MAIL_FROM: ${{ secrets.SMTP_FROM || vars.SMTP_FROM || 'noreply@klz-cables.com' }} MAIL_FROM: ${{ secrets.SMTP_FROM || vars.SMTP_FROM }}
MAIL_RECIPIENTS: ${{ secrets.CONTACT_RECIPIENT || vars.CONTACT_RECIPIENT || 'info@klz-cables.com' }} MAIL_RECIPIENTS: ${{ secrets.CONTACT_RECIPIENT || vars.CONTACT_RECIPIENT }}
# Monitoring # Monitoring
SENTRY_DSN: ${{ secrets.SENTRY_DSN || vars.SENTRY_DSN }} SENTRY_DSN: ${{ secrets.SENTRY_DSN || vars.SENTRY_DSN }}
@@ -256,10 +257,6 @@ jobs:
# Analytics # Analytics
UMAMI_WEBSITE_ID: ${{ secrets.UMAMI_WEBSITE_ID || vars.UMAMI_WEBSITE_ID }} 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' }} 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: steps:
- name: Checkout repository - name: Checkout repository
uses: actions/checkout@v4 uses: actions/checkout@v4
@@ -287,7 +284,7 @@ jobs:
AUTH_MIDDLEWARE_UNPROTECTED="$STD_MW" AUTH_MIDDLEWARE_UNPROTECTED="$STD_MW"
# Gatekeeper Origin # Gatekeeper Origin
GATEKEEPER_ORIGIN="${NEXT_PUBLIC_BASE_URL}/gatekeeper" GATEKEEPER_ORIGIN="$NEXT_PUBLIC_BASE_URL/gatekeeper"
{ {
echo "# Generated by CI - $TARGET" echo "# Generated by CI - $TARGET"
@@ -303,6 +300,21 @@ jobs:
echo "MAIL_FROM=$MAIL_FROM" echo "MAIL_FROM=$MAIL_FROM"
echo "MAIL_RECIPIENTS=$MAIL_RECIPIENTS" echo "MAIL_RECIPIENTS=$MAIL_RECIPIENTS"
echo "" 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 ""
echo "# Gatekeeper" echo "# Gatekeeper"
echo "GATEKEEPER_PASSWORD=$GATEKEEPER_PASSWORD" echo "GATEKEEPER_PASSWORD=$GATEKEEPER_PASSWORD"
@@ -313,16 +325,11 @@ jobs:
echo "UMAMI_WEBSITE_ID=$UMAMI_WEBSITE_ID" echo "UMAMI_WEBSITE_ID=$UMAMI_WEBSITE_ID"
echo "UMAMI_API_ENDPOINT=$UMAMI_API_ENDPOINT" echo "UMAMI_API_ENDPOINT=$UMAMI_API_ENDPOINT"
echo "" echo ""
echo "# Notifications"
echo "GOTIFY_URL=$GOTIFY_URL"
echo "GOTIFY_TOKEN=$GOTIFY_TOKEN"
echo ""
echo "TARGET=$TARGET" echo "TARGET=$TARGET"
echo "SENTRY_ENVIRONMENT=$TARGET" echo "SENTRY_ENVIRONMENT=$TARGET"
echo "PROJECT_NAME=$PROJECT_NAME" echo "PROJECT_NAME=$PROJECT_NAME"
printf 'TRAEFIK_HOST_RULE=%s\n' "$TRAEFIK_RULE" printf 'TRAEFIK_HOST_RULE=%s\n' "$TRAEFIK_RULE"
echo "TRAEFIK_HOST=$TRAEFIK_HOST" echo "TRAEFIK_HOST=$TRAEFIK_HOST"
echo "GATEKEEPER_HOST=$GATEKEEPER_HOST"
echo "TRAEFIK_ENTRYPOINT=websecure" echo "TRAEFIK_ENTRYPOINT=websecure"
echo "TRAEFIK_TLS=true" echo "TRAEFIK_TLS=true"
echo "TRAEFIK_CERT_RESOLVER=le" echo "TRAEFIK_CERT_RESOLVER=le"
@@ -347,25 +354,20 @@ jobs:
ssh-keyscan -H alpha.mintel.me >> ~/.ssh/known_hosts 2>/dev/null ssh-keyscan -H alpha.mintel.me >> ~/.ssh/known_hosts 2>/dev/null
# Transfer and Restart # Transfer and Restart
if [[ "$TARGET" == "production" ]]; then
SITE_DIR="/home/deploy/sites/klz-cables.com" SITE_DIR="/home/deploy/sites/klz-cables.com"
elif [[ "$TARGET" == "testing" ]]; then ssh root@alpha.mintel.me "mkdir -p $SITE_DIR/directus/schema $SITE_DIR/directus/uploads $SITE_DIR/directus/extensions"
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"
scp .env.deploy root@alpha.mintel.me:$SITE_DIR/$ENV_FILE scp .env.deploy root@alpha.mintel.me:$SITE_DIR/$ENV_FILE
scp docker-compose.yml root@alpha.mintel.me:$SITE_DIR/docker-compose.yml scp docker-compose.yml root@alpha.mintel.me:$SITE_DIR/docker-compose.yml
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 && echo '${{ secrets.REGISTRY_PASS }}' | docker login registry.infra.mintel.me -u '${{ secrets.REGISTRY_USER }}' --password-stdin"
ssh root@alpha.mintel.me "cd $SITE_DIR && docker compose -p '${{ needs.prepare.outputs.project_name }}' --env-file '$ENV_FILE' pull" ssh root@alpha.mintel.me "cd $SITE_DIR && docker compose -p '${{ needs.prepare.outputs.project_name }}' --env-file '$ENV_FILE' pull"
ssh root@alpha.mintel.me "cd $SITE_DIR && docker compose -p '${{ needs.prepare.outputs.project_name }}' --env-file '$ENV_FILE' up -d --remove-orphans" ssh root@alpha.mintel.me "cd $SITE_DIR && docker compose -p '${{ needs.prepare.outputs.project_name }}' --env-file '$ENV_FILE' up -d --remove-orphans"
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'" ssh root@alpha.mintel.me "docker system prune -f --filter 'until=24h'"
- name: 🧹 Post-Deploy Cleanup (Runner) - name: 🧹 Post-Deploy Cleanup (Runner)
@@ -373,10 +375,10 @@ jobs:
run: docker builder prune -f --filter "until=1h" 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: smoke_test:
name: 🧪 Post-Deploy Verification name: 🧪 Smoke Test
needs: [prepare, deploy] needs: [prepare, deploy]
if: needs.deploy.result == 'success' && needs.prepare.outputs.target != 'branch' if: needs.deploy.result == 'success' && needs.prepare.outputs.target != 'branch'
runs-on: docker runs-on: docker
@@ -385,93 +387,113 @@ jobs:
steps: steps:
- name: Checkout repository - name: Checkout repository
uses: actions/checkout@v4 uses: actions/checkout@v4
- name: Setup pnpm
uses: pnpm/action-setup@v3
with:
version: 10
- name: Setup Node.js - name: Setup Node.js
uses: actions/setup-node@v4 uses: actions/setup-node@v4
with: with:
node-version: 20 node-version: 20
cache: 'pnpm'
- name: Setup pnpm
uses: pnpm/action-setup@v3
with:
version: 10
- name: 🔐 Registry Auth - name: 🔐 Registry Auth
run: | run: |
echo "@mintel:registry=https://git.infra.mintel.me/api/packages/mmintel/npm" > .npmrc echo "@mintel:registry=https://${{ vars.REGISTRY_HOST || 'npm.infra.mintel.me' }}" > .npmrc
echo "//git.infra.mintel.me/api/packages/mmintel/npm/:_authToken=${{ secrets.NPM_TOKEN }}" >> .npmrc echo "//${{ vars.REGISTRY_HOST || 'npm.infra.mintel.me' }}/:_authToken=${{ secrets.REGISTRY_PASS }}" >> .npmrc
- name: Install dependencies - name: Install dependencies
id: deps run: pnpm install --frozen-lockfile
run: | - name: 🚀 Run OG Image Check
pnpm store prune env:
pnpm install --no-frozen-lockfile TEST_URL: ${{ needs.prepare.outputs.next_public_url }}
- name: Setup Chrome run: pnpm run check:og
id: setup-chrome
uses: browser-actions/setup-chrome@v1
with:
install-dependencies: true
# ── Minimalist Smoke Test ────────────────────────────────────────── # ──────────────────────────────────────────────────────────────────────────────
- name: 🌐 Smoke Test (Essential Flow) # JOB 6: Lighthouse (Performance & Accessibility)
if: always() && steps.deps.outcome == 'success' # ──────────────────────────────────────────────────────────────────────────────
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: env:
NEXT_PUBLIC_BASE_URL: ${{ needs.prepare.outputs.next_public_url }} NEXT_PUBLIC_BASE_URL: ${{ needs.prepare.outputs.next_public_url }}
GATEKEEPER_PASSWORD: ${{ secrets.GATEKEEPER_PASSWORD || 'klz2026' }} GATEKEEPER_PASSWORD: ${{ secrets.GATEKEEPER_PASSWORD || 'klz2026' }}
PUPPETEER_EXECUTABLE_PATH: ${{ steps.setup-chrome.outputs.chrome-path }} CHROME_PATH: /usr/bin/chromium
run: npx tsx scripts/smoke.ts PAGESPEED_LIMIT: 8
run: pnpm run pagespeed:test
# ────────────────────────────────────────────────────────────────────────────── # ──────────────────────────────────────────────────────────────────────────────
# JOB 7: Notifications # JOB 7: Notifications
# ────────────────────────────────────────────────────────────────────────────── # ──────────────────────────────────────────────────────────────────────────────
notifications: notifications:
name: 🔔 Notify name: 🔔 Notify
needs: [prepare, deploy, post_deploy_checks] needs: [prepare, deploy, smoke_test, lighthouse]
if: always() if: always()
runs-on: docker runs-on: docker
container: container:
image: catthehacker/ubuntu:act-latest image: catthehacker/ubuntu:act-latest
steps: steps:
- name: 🔔 Gotify - name: 🔔 Gotify
shell: bash
run: | run: |
DEPLOY="${{ needs.deploy.result }}" STATUS="${{ needs.deploy.result }}"
SMOKE="${{ needs.post_deploy_checks.result }}" TITLE="klz-cables.com: $STATUS"
PERF="${{ needs.post_deploy_checks.result }}" [[ "$STATUS" == "success" ]] && PRIORITY=5 || PRIORITY=8
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
curl -s -k -X POST "${{ secrets.GOTIFY_URL }}/message?token=${{ secrets.GOTIFY_TOKEN }}" \ curl -s -k -X POST "${{ secrets.GOTIFY_URL }}/message?token=${{ secrets.GOTIFY_TOKEN }}" \
-F "title=$TITLE" \ -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 -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

29
.gitignore vendored
View File

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

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 # Stage 1: Builder
FROM node:20-alpine AS base FROM registry.infra.mintel.me/mintel/nextjs:v1.7.10 AS base
RUN apk add --no-cache libc6-compat curl
# Enable pnpm
RUN corepack enable && corepack prepare pnpm@10.3.0 --activate
WORKDIR /app WORKDIR /app
# Arguments for build-time configuration # Arguments for build-time configuration
@@ -12,6 +8,7 @@ ARG NEXT_PUBLIC_TARGET
ARG DIRECTUS_URL ARG DIRECTUS_URL
ARG UMAMI_WEBSITE_ID ARG UMAMI_WEBSITE_ID
ARG UMAMI_API_ENDPOINT ARG UMAMI_API_ENDPOINT
ARG NPM_TOKEN
# Environment variables for Next.js build # Environment variables for Next.js build
ENV NEXT_PUBLIC_BASE_URL=$NEXT_PUBLIC_BASE_URL 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 # Configure private registry and install dependencies
RUN --mount=type=cache,id=pnpm,target=/pnpm/store \ RUN --mount=type=cache,id=pnpm,target=/pnpm/store \
--mount=type=secret,id=NPM_TOKEN \ --mount=type=secret,id=NPM_TOKEN \
export NPM_TOKEN=$(cat /run/secrets/NPM_TOKEN) && \ export NPM_TOKEN=$(cat /run/secrets/NPM_TOKEN 2>/dev/null || echo $NPM_TOKEN) && \
echo "@mintel:registry=https://git.infra.mintel.me/api/packages/mmintel/npm" > .npmrc && \ echo "@mintel:registry=https://npm.infra.mintel.me" > .npmrc && \
echo "//git.infra.mintel.me/api/packages/mmintel/npm/:_authToken=\${NPM_TOKEN}" >> .npmrc && \ echo "//npm.infra.mintel.me/:_authToken=\${NPM_TOKEN}" >> .npmrc && \
pnpm store prune && \ pnpm install --frozen-lockfile && \
pnpm install --no-frozen-lockfile && \
rm .npmrc rm .npmrc
# Copy source code # Copy source code
@@ -46,37 +42,25 @@ CMD ["pnpm", "dev:local"]
# Build application # Build application
# Stage 3: Builder (Production) # Stage 3: Builder (Production)
FROM base AS builder 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 RUN pnpm build
# Stage 2: Runner # Stage 3: Runner
FROM node:20-alpine AS runner FROM registry.infra.mintel.me/mintel/runtime:v1.7.10 AS runner
WORKDIR /app WORKDIR /app
# Install dependencies for health checks and system compatibility # Create nextjs user and group (standardized in runtime image but ensuring local ownership)
RUN apk add --no-cache libc6-compat curl USER root
RUN chown -R nextjs:nodejs /app
# Create nextjs user and group
RUN addgroup --system --gid 1001 nodejs && \
adduser --system --uid 1001 nextjs && \
chown -R nextjs:nodejs /app
USER nextjs USER nextjs
ENV HOSTNAME="0.0.0.0" ENV HOSTNAME="0.0.0.0"
ENV PORT=3000 ENV PORT=3000
ENV NODE_ENV=production ENV NODE_ENV=production
# Copy standalone output # Copy standalone output and static files
COPY --from=builder --chown=nextjs:nodejs /app/.next/standalone ./
# Copy public and static files
COPY --from=builder --chown=nextjs:nodejs /app/public ./public COPY --from=builder --chown=nextjs:nodejs /app/public ./public
COPY --from=builder --chown=nextjs:nodejs /app/.next/standalone ./
COPY --from=builder --chown=nextjs:nodejs /app/.next/static ./.next/static COPY --from=builder --chown=nextjs:nodejs /app/.next/static ./.next/static
COPY --from=builder --chown=nextjs:nodejs /app/.next/cache ./.next/cache
CMD ["node", "server.js"] 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** **Status**: ✅ **READY FOR DEPLOYMENT**
**Version**: 1.0.0 **Version**: 1.0.0
**Last Updated**: December 27, 2025 **Last Updated**: December 27, 2025
Trigger rebuilding for x86 architecture.

View File

@@ -1,7 +1,7 @@
import { ImageResponse } from 'next/og'; import { ImageResponse } from 'next/og';
import { getPageBySlug } from '@/lib/pages'; import { getPageBySlug } from '@/lib/pages';
import { OGImageTemplate } from '@/components/OGImageTemplate'; 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 size = OG_IMAGE_SIZE;
export const contentType = 'image/png'; export const contentType = 'image/png';
@@ -20,12 +20,9 @@ export default async function Image({
} }
const fonts = await getOgFonts(); const fonts = await getOgFonts();
const { logo, bgImage } = getOgAssets();
return new ImageResponse( return new ImageResponse(
<OGImageTemplate <OGImageTemplate
image={bgImage}
logo={logo}
title={pageData.frontmatter.title} title={pageData.frontmatter.title}
description={pageData.frontmatter.excerpt} description={pageData.frontmatter.excerpt}
label="Information" 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 { Container, Badge, Heading } from '@/components/ui';
import { getTranslations, setRequestLocale } from 'next-intl/server'; import { getTranslations, setRequestLocale } from 'next-intl/server';
import { Metadata } from 'next'; import { Metadata } from 'next';
import { getPageBySlug, getAllPages } from '@/lib/pages'; import { getPageBySlug, getAllPages } from '@/lib/pages';
import { mapSlugToFileSlug, mapFileSlugToTranslated } from '@/lib/slugs'; import { mdxComponents } from '@/components/blog/MDXComponents';
import MDXContent from '@/components/MDXContent';
import { SITE_URL } from '@/lib/schema'; import { SITE_URL } from '@/lib/schema';
import TrackedLink from '@/components/analytics/TrackedLink'; 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> { export async function generateMetadata({ params }: PageProps): Promise<Metadata> {
const { locale, slug } = await params; const { locale, slug } = await params;
const pageData = await getPageBySlug(slug, locale); const pageData = await getPageBySlug(slug, locale);
if (!pageData) return {}; 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 { return {
title: pageData.frontmatter.title, title: pageData.frontmatter.title,
description: pageData.frontmatter.excerpt || '', description: pageData.frontmatter.excerpt || '',
alternates: { alternates: {
canonical: `${SITE_URL}/${locale}/${currentLocaleSlug}`, canonical: `${SITE_URL}/${locale}/${slug}`,
languages: { languages: {
de: `${SITE_URL}/de/${deSlug}`, de: `${SITE_URL}/de/${slug}`,
en: `${SITE_URL}/en/${enSlug}`, en: `${SITE_URL}/en/${slug}`,
'x-default': `${SITE_URL}/en/${enSlug}`, 'x-default': `${SITE_URL}/en/${slug}`,
}, },
}, },
openGraph: { openGraph: {
title: `${pageData.frontmatter.title} | KLZ Cables`, title: `${pageData.frontmatter.title} | KLZ Cables`,
description: pageData.frontmatter.excerpt || '', description: pageData.frontmatter.excerpt || '',
url: `${SITE_URL}/${locale}/${currentLocaleSlug}`, url: `${SITE_URL}/${locale}/${slug}`,
}, },
twitter: { twitter: {
card: 'summary_large_image', card: 'summary_large_image',
@@ -62,32 +69,6 @@ export default async function StandardPage({ params }: PageProps) {
notFound(); 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 ( return (
<div className="flex flex-col min-h-screen bg-white"> <div className="flex flex-col min-h-screen bg-white">
{/* Hero Section */} {/* 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 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> </div>
<Container className="relative z-10"> <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"> <Badge variant="accent" className="mb-4 md:mb-6">
{t('badge')} {t('badge')}
</Badge> </Badge>
@@ -112,7 +93,7 @@ export default async function StandardPage({ params }: PageProps) {
<div className="max-w-4xl mx-auto"> <div className="max-w-4xl mx-auto">
{/* Excerpt/Lead paragraph if available */} {/* Excerpt/Lead paragraph if available */}
{pageData.frontmatter.excerpt && ( {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"> <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} {pageData.frontmatter.excerpt}
</p> </p>
@@ -120,8 +101,8 @@ export default async function StandardPage({ params }: PageProps) {
)} )}
{/* Main content with shared blog components */} {/* 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"> <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">
<MDXContent data={pageData.content} /> <MDXRemote source={pageData.content} components={mdxComponents} />
</div> </div>
{/* Support Section */} {/* 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> <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> <p className="text-lg text-white/70 mb-8">{t('supportTeamAvailable')}</p>
<TrackedLink <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" 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={{ eventProperties={{
location: 'generic_page_support_cta', location: 'generic_page_support_cta',

View File

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

View File

@@ -1,27 +1,13 @@
import { ImageResponse } from 'next/og'; import { ImageResponse } from 'next/og';
import { getPostBySlug } from '@/lib/blog'; import { getPostBySlug } from '@/lib/blog';
import { OGImageTemplate } from '@/components/OGImageTemplate'; 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'; import { SITE_URL } from '@/lib/schema';
export const size = OG_IMAGE_SIZE; export const size = OG_IMAGE_SIZE;
export const contentType = 'image/png'; export const contentType = 'image/png';
export const runtime = 'nodejs'; 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({ export default async function Image({
params, params,
}: { }: {
@@ -35,7 +21,6 @@ export default async function Image({
} }
const fonts = await getOgFonts(); 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 // 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 // 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}` : `${SITE_URL}${post.frontmatter.featuredImage}`
: undefined; : 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( return new ImageResponse(
<OGImageTemplate <OGImageTemplate
logo={logo}
title={post.frontmatter.title} title={post.frontmatter.title}
description={post.frontmatter.excerpt} description={post.frontmatter.excerpt}
label={post.frontmatter.category || 'Blog'} label={post.frontmatter.category || 'Blog'}
image={base64Image || featuredImage || bgImage} image={featuredImage}
/>, />,
{ {
...OG_IMAGE_SIZE, ...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 JsonLd from '@/components/JsonLd';
import { SITE_URL } from '@/lib/schema'; import { SITE_URL } from '@/lib/schema';
import { import { MDXRemote } from 'next-mdx-remote/rsc';
getPostBySlug, import { getPostBySlug, getAdjacentPosts, getReadingTime, getHeadings } from '@/lib/blog';
getAdjacentPosts,
getReadingTime,
extractLexicalHeadings,
getPostSlugs,
} from '@/lib/blog';
import { Metadata } from 'next'; import { Metadata } from 'next';
import Link from 'next/link'; import Link from 'next/link';
import Image from 'next/image';
import PostNavigation from '@/components/blog/PostNavigation'; import PostNavigation from '@/components/blog/PostNavigation';
import PowerCTA from '@/components/blog/PowerCTA'; import PowerCTA from '@/components/blog/PowerCTA';
import TableOfContents from '@/components/blog/TableOfContents'; import TableOfContents from '@/components/blog/TableOfContents';
import { mdxComponents } from '@/components/blog/MDXComponents';
import { Heading } from '@/components/ui'; import { Heading } from '@/components/ui';
import { setRequestLocale } from 'next-intl/server'; import { setRequestLocale } from 'next-intl/server';
import BlogEngagementTracker from '@/components/analytics/BlogEngagementTracker'; import BlogEngagementTracker from '@/components/analytics/BlogEngagementTracker';
// Payload CMS Imports
import MDXContent from '@/components/MDXContent';
interface BlogPostProps { interface BlogPostProps {
params: Promise<{ params: Promise<{
locale: string; locale: string;
@@ -34,21 +26,12 @@ export async function generateMetadata({ params }: BlogPostProps): Promise<Metad
if (!post) return {}; 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 || ''; const description = post.frontmatter.excerpt || '';
return { return {
title: post.frontmatter.title, title: post.frontmatter.title,
description: description, description: description,
alternates: { alternates: {
canonical: `${SITE_URL}/${locale}/blog/${post.slug}`, canonical: `${SITE_URL}/${locale}/blog/${slug}`,
languages: {
de: `${SITE_URL}/de/blog/${deSlug}`,
en: `${SITE_URL}/en/blog/${enSlug}`,
'x-default': `${SITE_URL}/en/blog/${enSlug}`,
},
}, },
openGraph: { openGraph: {
title: `${post.frontmatter.title} | KLZ Cables`, title: `${post.frontmatter.title} | KLZ Cables`,
@@ -56,7 +39,7 @@ export async function generateMetadata({ params }: BlogPostProps): Promise<Metad
type: 'article', type: 'article',
publishedTime: post.frontmatter.date, publishedTime: post.frontmatter.date,
authors: ['KLZ Cables'], authors: ['KLZ Cables'],
url: `${SITE_URL}/${locale}/blog/${post.slug}`, url: `${SITE_URL}/${locale}/blog/${slug}`,
}, },
twitter: { twitter: {
card: 'summary_large_image', card: 'summary_large_image',
@@ -70,25 +53,13 @@ export default async function BlogPost({ params }: BlogPostProps) {
const { locale, slug } = await params; const { locale, slug } = await params;
setRequestLocale(locale); setRequestLocale(locale);
const post = await getPostBySlug(slug, locale); const post = await getPostBySlug(slug, locale);
const { prev, next } = await getAdjacentPosts(slug, locale);
if (!post) { if (!post) {
notFound(); notFound();
} }
// If the user accessed this post using a slug from a different locale const headings = getHeadings(post.content);
// (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);
return ( return (
<article className="bg-white min-h-screen font-sans selection:bg-primary/10 selection:text-primary"> <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} title={post.frontmatter.title}
slug={slug} slug={slug}
category={post.frontmatter.category} category={post.frontmatter.category}
readingTime={getReadingTime(rawTextContent)} readingTime={getReadingTime(post.content)}
/> />
{/* Featured Image Header */} {/* Featured Image Header */}
{post.frontmatter.featuredImage ? ( {post.frontmatter.featuredImage ? (
<div className="relative w-full h-[70vh] min-h-[500px] overflow-hidden group"> <div className="relative w-full h-[70vh] min-h-[500px] overflow-hidden group">
<div className="absolute inset-0 transition-transform duration-[3s] ease-out scale-110 group-hover:scale-100"> <div
<Image className="absolute inset-0 bg-cover bg-center transition-transform duration-[3s] ease-out scale-110 group-hover:scale-100"
src={post.frontmatter.featuredImage.split('?')[0]} style={{ backgroundImage: `url(${post.frontmatter.featuredImage})` }}
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 via-neutral-dark/40 to-transparent" />
<div className="absolute inset-0 bg-gradient-to-t from-neutral-dark/90 via-neutral-dark/40 to-transparent" />
{/* Title overlay on image */} {/* Title overlay on image */}
<div className="absolute inset-0 flex flex-col justify-end pb-16 md:pb-24"> <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"> <div className="max-w-4xl">
{post.frontmatter.category && ( {post.frontmatter.category && (
<div className="overflow-hidden mb-6"> <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} {post.frontmatter.category}
</span> </span>
</div> </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} {post.frontmatter.title}
</Heading> </Heading>
<div className="flex flex-wrap items-center gap-6 text-white/80 text-sm md:text-base font-medium"> <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} suppressHydrationWarning> <time dateTime={post.frontmatter.date}>
{new Date(post.frontmatter.date).toLocaleDateString( {new Date(post.frontmatter.date).toLocaleDateString(locale, {
locale === 'en' ? 'en-US' : 'de-DE',
{
year: 'numeric', year: 'numeric',
month: 'long', month: 'long',
day: 'numeric', day: 'numeric',
}, })}
)}
</time> </time>
<span className="w-1 h-1 bg-white/30 rounded-full" /> <span className="w-1 h-1 bg-white/30 rounded-full" />
<span>{getReadingTime(rawTextContent)} min read</span> <span>{getReadingTime(post.content)} 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>
</>
)}
</div> </div>
</div> </div>
</div> </div>
@@ -172,28 +123,16 @@ export default async function BlogPost({ params }: BlogPostProps) {
<Heading level={1} className="mb-8"> <Heading level={1} className="mb-8">
{post.frontmatter.title} {post.frontmatter.title}
</Heading> </Heading>
<div className="flex items-center gap-6 text-text-primary/80 font-medium"> <div className="flex items-center gap-6 text-text-secondary font-medium">
<time dateTime={post.frontmatter.date} suppressHydrationWarning> <time dateTime={post.frontmatter.date}>
{new Date(post.frontmatter.date).toLocaleDateString( {new Date(post.frontmatter.date).toLocaleDateString(locale, {
locale === 'en' ? 'en-US' : 'de-DE',
{
year: 'numeric', year: 'numeric',
month: 'long', month: 'long',
day: 'numeric', day: 'numeric',
}, })}
)}
</time> </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="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"> <span>{getReadingTime(post.content)} min read</span>
Draft Preview
</span>
</>
)}
</div> </div>
</div> </div>
</header> </header>
@@ -206,16 +145,16 @@ export default async function BlogPost({ params }: BlogPostProps) {
<div className="sticky-narrative-content"> <div className="sticky-narrative-content">
{/* Excerpt/Lead paragraph if available */} {/* Excerpt/Lead paragraph if available */}
{post.frontmatter.excerpt && ( {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"> <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} {post.frontmatter.excerpt}
</p> </p>
</div> </div>
)} )}
{/* Main content with enhanced styling rendering Payload Lexical */} {/* 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"> <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]">
<MDXContent data={post.content} /> <MDXRemote source={post.content} components={mdxComponents} />
</div> </div>
{/* Power CTA */} {/* Power CTA */}
@@ -225,13 +164,7 @@ export default async function BlogPost({ params }: BlogPostProps) {
{/* Post Navigation */} {/* Post Navigation */}
<div className="mt-16"> <div className="mt-16">
<PostNavigation <PostNavigation prev={prev} next={next} locale={locale} />
prev={prev}
next={next}
isPrevRandom={isPrevRandom}
isNextRandom={isNextRandom}
locale={locale}
/>
</div> </div>
{/* Back to blog link */} {/* Back to blog link */}
@@ -258,9 +191,9 @@ export default async function BlogPost({ params }: BlogPostProps) {
</div> </div>
</div> </div>
{/* Right Column: Sticky Sidebar - TOC */} {/* Right Column: Sticky Sidebar */}
<aside className="sticky-narrative-sidebar hidden lg:block"> <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} /> <TableOfContents headings={headings} locale={locale} />
</div> </div>
</aside> </aside>
@@ -300,8 +233,8 @@ export default async function BlogPost({ params }: BlogPostProps) {
'@id': `${SITE_URL}/${locale}/blog/${slug}`, '@id': `${SITE_URL}/${locale}/blog/${slug}`,
}, },
articleSection: post.frontmatter.category, articleSection: post.frontmatter.category,
wordCount: rawTextContent.split(/\s+/).length, wordCount: post.content.split(/\s+/).length,
timeRequired: `PT${getReadingTime(rawTextContent)}M`, timeRequired: `PT${getReadingTime(post.content)}M`,
} as any } as any
} }
/> />

View File

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

View File

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

View File

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

View File

@@ -8,7 +8,6 @@ import { SITE_URL } from '@/lib/schema';
import { getOGImageMetadata } from '@/lib/metadata'; import { getOGImageMetadata } from '@/lib/metadata';
import { Suspense } from 'react'; import { Suspense } from 'react';
import ContactMap from '@/components/ContactMap'; import ContactMap from '@/components/ContactMap';
import ObfuscatedEmail from '@/components/ObfuscatedEmail';
interface ContactPageProps { interface ContactPageProps {
params: Promise<{ params: Promise<{
@@ -25,9 +24,9 @@ export async function generateMetadata({ params }: ContactPageProps): Promise<Me
title, title,
description, description,
alternates: { alternates: {
canonical: `${SITE_URL}/${locale}/${locale === 'de' ? 'kontakt' : 'contact'}`, canonical: `${SITE_URL}/${locale}/contact`,
languages: { languages: {
de: `${SITE_URL}/de/kontakt`, de: `${SITE_URL}/de/contact`,
en: `${SITE_URL}/en/contact`, en: `${SITE_URL}/en/contact`,
'x-default': `${SITE_URL}/en/contact`, 'x-default': `${SITE_URL}/en/contact`,
}, },
@@ -35,7 +34,7 @@ export async function generateMetadata({ params }: ContactPageProps): Promise<Me
openGraph: { openGraph: {
title: `${title} | KLZ Cables`, title: `${title} | KLZ Cables`,
description, description,
url: `${SITE_URL}/${locale}/${locale === 'de' ? 'kontakt' : 'contact'}`, url: `${SITE_URL}/${locale}/contact`,
siteName: 'KLZ Cables', siteName: 'KLZ Cables',
locale: `${locale.toUpperCase()}_DE`, locale: `${locale.toUpperCase()}_DE`,
type: 'website', type: 'website',
@@ -60,21 +59,6 @@ export default async function ContactPage({ params }: ContactPageProps) {
const { locale } = await params; const { locale } = await params;
setRequestLocale(locale); setRequestLocale(locale);
const t = await getTranslations({ locale, namespace: 'Contact' }); 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 ( return (
<div className="flex flex-col min-h-screen bg-neutral-light"> <div className="flex flex-col min-h-screen bg-neutral-light">
<JsonLd <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"> <h4 className="text-base md:text-xl font-bold text-primary mb-1 md:mb-2">
{t('info.email')} {t('info.email')}
</h4> </h4>
<ObfuscatedEmail <a
email="info@klz-cables.com" href="mailto:info@klz-cables.com"
className="text-sm md:text-lg text-text-secondary hover:text-primary transition-colors font-medium touch-target" 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>
</div> </div>
</address> </address>

View File

@@ -16,18 +16,12 @@ export default function Error({
const t = useTranslations('Error'); const t = useTranslations('Error');
useEffect(() => { 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(); const services = getAppServices();
services.errors.captureException(error); services.errors.captureException(error);
services.logger.error('Application error caught by boundary', { services.logger.error('Application error caught by boundary', {
message: error?.message || 'Unknown error', message: error.message,
stack: error?.stack, stack: error.stack,
digest: error?.digest, digest: error.digest
}); });
}, [error]); }, [error]);
@@ -42,14 +36,19 @@ export default function Error({
<Heading level={1} className="text-6xl md:text-8xl font-bold mb-2 text-saturated"> <Heading level={1} className="text-6xl md:text-8xl font-bold mb-2 text-saturated">
500 500
</Heading> </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> </div>
<Heading level={2} className="text-2xl md:text-3xl font-bold mb-4"> <Heading level={2} className="text-2xl md:text-3xl font-bold mb-4">
{t('title')} {t('title')}
</Heading> </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"> <div className="flex flex-col sm:flex-row gap-4">
<Button onClick={() => reset()} variant="saturated" size="lg"> <Button onClick={() => reset()} variant="saturated" size="lg">

View File

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

View File

@@ -1,42 +1,23 @@
import { getTranslations } from 'next-intl/server'; 'use client';
import { useTranslations } from 'next-intl';
import { Container, Button, Heading } from '@/components/ui'; import { Container, Button, Heading } from '@/components/ui';
import Scribble from '@/components/Scribble'; import Scribble from '@/components/Scribble';
import { headers } from 'next/headers'; import { useEffect } from 'react';
import ClientNotFoundTracker from '@/components/analytics/ClientNotFoundTracker'; import { useAnalytics } from '@/components/analytics/useAnalytics';
import { AnalyticsEvents } from '@/components/analytics/analytics-events';
export default async function NotFound() { export default function NotFound() {
const t = await getTranslations('Error.notFound'); const t = useTranslations('Error.notFound');
const { trackEvent } = useAnalytics();
// Try to determine the requested path useEffect(() => {
const headersList = await headers(); trackEvent(AnalyticsEvents.ERROR, {
const urlPath = headersList.get('x-invoke-path') || ''; type: '404_not_found',
path: typeof window !== 'undefined' ? window.location.pathname : 'unknown',
let suggestedUrl = null; });
let suggestedLang = null; }, [trackEvent]);
// 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('/');
}
}
return ( return (
<>
<ClientNotFoundTracker path={urlPath} />
<Container className="relative py-24 flex flex-col items-center justify-center text-center min-h-[70vh] overflow-hidden"> <Container className="relative py-24 flex flex-col items-center justify-center text-center min-h-[70vh] overflow-hidden">
{/* Industrial Background Element */} {/* Industrial Background Element */}
<div className="absolute inset-0 -z-10 opacity-[0.03] pointer-events-none flex items-center justify-center"> <div className="absolute inset-0 -z-10 opacity-[0.03] pointer-events-none flex items-center justify-center">
@@ -57,30 +38,13 @@ export default async function NotFound() {
{t('title')} {t('title')}
</Heading> </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>
{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>
)}
<div className="flex flex-col sm:flex-row gap-4"> <div className="flex flex-col sm:flex-row gap-4">
<Button href="/" variant={suggestedUrl ? 'outline' : 'accent'} size="lg"> <Button href="/" variant="accent" size="lg">
{t('cta')} {t('cta')}
</Button> </Button>
<Button href="/contact" variant={suggestedUrl ? 'ghost' : 'outline'} size="lg"> <Button href="/contact" variant="outline" size="lg">
Contact Support Contact Support
</Button> </Button>
</div> </div>
@@ -88,6 +52,5 @@ export default async function NotFound() {
{/* Decorative Industrial Line */} {/* 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" /> <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> </Container>
</>
); );
} }

View File

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

View File

@@ -1,12 +1,11 @@
import Hero from '@/components/home/Hero'; import Hero from '@/components/home/Hero';
import JsonLd from '@/components/JsonLd'; import JsonLd from '@/components/JsonLd';
import { getBreadcrumbSchema, SITE_URL } from '@/lib/schema'; 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 dynamic from 'next/dynamic';
import Reveal from '@/components/Reveal'; 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 RecentPosts = dynamic(() => import('@/components/home/RecentPosts'));
const Experience = dynamic(() => import('@/components/home/Experience')); const Experience = dynamic(() => import('@/components/home/Experience'));
const WhyChooseUs = dynamic(() => import('@/components/home/WhyChooseUs')); const WhyChooseUs = dynamic(() => import('@/components/home/WhyChooseUs'));
@@ -27,13 +26,6 @@ export default async function HomePage({ params }: { params: Promise<{ locale: s
id="breadcrumb-home" id="breadcrumb-home"
data={getBreadcrumbSchema([{ name: 'Home', item: `/${locale}` }])} 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 /> <Hero />
<Reveal> <Reveal>
<ProductCategories /> <ProductCategories />

View File

@@ -7,16 +7,16 @@ import RelatedProducts from '@/components/RelatedProducts';
import DatasheetDownload from '@/components/DatasheetDownload'; import DatasheetDownload from '@/components/DatasheetDownload';
import { Badge, Card, Container, Heading, Section } from '@/components/ui'; import { Badge, Card, Container, Heading, Section } from '@/components/ui';
import { getDatasheetPath } from '@/lib/datasheets'; import { getDatasheetPath } from '@/lib/datasheets';
import { getAllProducts, getProductBySlug } from '@/lib/products'; import { getAllProducts, getProductBySlug } from '@/lib/mdx';
import { mapFileSlugToTranslated, mapSlugToFileSlug } from '@/lib/slugs'; import { mapFileSlugToTranslated, mapSlugToFileSlug } from '@/lib/slugs';
import { Metadata } from 'next'; import { Metadata } from 'next';
import { getTranslations, setRequestLocale } from 'next-intl/server'; import { getTranslations, setRequestLocale } from 'next-intl/server';
import { getProductOGImageMetadata } from '@/lib/metadata'; import { getProductOGImageMetadata } from '@/lib/metadata';
import { MDXRemote } from 'next-mdx-remote/rsc';
import Image from 'next/image'; import Image from 'next/image';
import Link from 'next/link'; import Link from 'next/link';
import { notFound, redirect } from 'next/navigation'; import { notFound } from 'next/navigation';
import ProductEngagementTracker from '@/components/analytics/ProductEngagementTracker'; import ProductEngagementTracker from '@/components/analytics/ProductEngagementTracker';
import MDXContent from '@/components/MDXContent';
interface ProductPageProps { interface ProductPageProps {
params: Promise<{ params: Promise<{
@@ -53,82 +53,135 @@ export async function generateMetadata({ params }: ProductPageProps): Promise<Me
title: categoryTitle, title: categoryTitle,
description: categoryDesc, description: categoryDesc,
alternates: { alternates: {
canonical: `${SITE_URL}/${locale}/${await mapFileSlugToTranslated('products', locale)}/${await mapFileSlugToTranslated(fileSlug, locale)}`, canonical: `${SITE_URL}/${locale}/${await mapFileSlugToTranslated('products', locale)}/${productSlug}`,
languages: { languages: {
de: `${SITE_URL}/de/${await mapFileSlugToTranslated('products', 'de')}/${await mapFileSlugToTranslated(fileSlug, 'de')}`, de: `${SITE_URL}/de/${await mapFileSlugToTranslated('products', 'de')}/${await mapFileSlugToTranslated(productSlug, 'de')}`,
en: `${SITE_URL}/en/${await mapFileSlugToTranslated('products', 'en')}/${await mapFileSlugToTranslated(fileSlug, 'en')}`, en: `${SITE_URL}/en/${await mapFileSlugToTranslated('products', 'en')}/${await mapFileSlugToTranslated(productSlug, 'en')}`,
'x-default': `${SITE_URL}/en/${await mapFileSlugToTranslated('products', 'en')}/${await mapFileSlugToTranslated(fileSlug, '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); const product = await getProductBySlug(productSlug, locale);
if (!product) return {}; if (!product) return {};
const currentLocalePath = await getLocalizedPath(locale);
return { return {
title: product.frontmatter.title, title: product.frontmatter.title,
description: product.frontmatter.description, description: product.frontmatter.description,
alternates: { alternates: {
canonical: `${SITE_URL}/${locale}/${currentLocalePath}`, canonical: `${SITE_URL}/${locale}/${await mapFileSlugToTranslated('products', locale)}/${slug.join('/')}`,
languages: { languages: {
de: `${SITE_URL}/de/${await getLocalizedPath('de')}`, de: `${SITE_URL}/de/${await mapFileSlugToTranslated('products', 'de')}/${await mapFileSlugToTranslated(slug[0], 'de')}/${await mapFileSlugToTranslated(productSlug, 'de')}`,
en: `${SITE_URL}/en/${await getLocalizedPath('en')}`, en: `${SITE_URL}/en/${await mapFileSlugToTranslated('products', 'en')}/${await mapFileSlugToTranslated(slug[0], 'en')}/${await mapFileSlugToTranslated(productSlug, 'en')}`,
'x-default': `${SITE_URL}/en/${await getLocalizedPath('en')}`, 'x-default': `${SITE_URL}/en/${await mapFileSlugToTranslated('products', 'en')}/${await mapFileSlugToTranslated(slug[0], 'en')}/${await mapFileSlugToTranslated(productSlug, 'en')}`,
}, },
}, },
openGraph: { openGraph: {
title: product.frontmatter.title, title: `${product.frontmatter.title} | KLZ Cables`,
description: product.frontmatter.description, description: product.frontmatter.description,
type: 'website', type: 'website',
url: `${SITE_URL}/${locale}/${currentLocalePath}`, url: `${SITE_URL}/${locale}/${await mapFileSlugToTranslated('products', locale)}/${slug.join('/')}`,
images: getProductOGImageMetadata(productSlug, product.frontmatter.title, locale), images: getProductOGImageMetadata(productSlug, product.frontmatter.title, locale),
}, },
twitter: { twitter: {
card: 'summary_large_image', card: 'summary_large_image',
title: product.frontmatter.title, title: `${product.frontmatter.title} | KLZ Cables`,
description: product.frontmatter.description, 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) { export default async function ProductPage({ params }: ProductPageProps) {
const { locale, slug } = await params; const { locale, slug } = await params;
setRequestLocale(locale); setRequestLocale(locale);
const productSlug = slug[slug.length - 1]; const productSlug = slug[slug.length - 1];
const t = await getTranslations('Products'); const t = await getTranslations('Products');
const productsSlug = await mapFileSlugToTranslated('products', locale);
// Check if it's a category page
const categories = [ const categories = [
'low-voltage-cables', 'low-voltage-cables',
'medium-voltage-cables', 'medium-voltage-cables',
'high-voltage-cables', 'high-voltage-cables',
'solar-cables', 'solar-cables',
]; ];
const fileSlug = await mapSlugToFileSlug(productSlug, locale);
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];
if (categories.includes(fileSlug)) { if (categories.includes(fileSlug)) {
const allProducts = await getAllProducts(locale); const allProducts = await getAllProducts(locale);
@@ -139,27 +192,14 @@ export default async function ProductPage({ params }: ProductPageProps) {
? t(`categories.${categoryKey}.title`) ? t(`categories.${categoryKey}.title`)
: fileSlug; : fileSlug;
const filteredProducts = allProducts.filter((p) => { // Filter products for this category
const firstCat = p.frontmatter.categories[0] || ''; const filteredProducts = allProducts.filter((p) =>
const normalizedCat = firstCat.toLowerCase().replace(/\s+/g, '-'); p.frontmatter.categories.some(
let pFileSlug = 'low-voltage-cables'; (cat) => cat.toLowerCase().replace(/\s+/g, '-') === fileSlug || cat === categoryTitle,
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;
});
// Get translated product slugs
const productsWithTranslatedSlugs = await Promise.all( const productsWithTranslatedSlugs = await Promise.all(
filteredProducts.map(async (p) => ({ filteredProducts.map(async (p) => ({
...p, ...p,
@@ -174,10 +214,10 @@ export default async function ProductPage({ params }: ProductPageProps) {
<div className="max-w-4xl animate-slide-up"> <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"> <nav className="flex items-center mb-8 text-white/40 text-sm font-bold uppercase tracking-widest">
<Link <Link
href={`/${locale}/${productsSlug}`} href={`/${locale}/${await mapFileSlugToTranslated('products', locale)}`}
className="hover:text-accent transition-colors" className="hover:text-accent transition-colors"
> >
{t.has('breadcrumb') ? t('breadcrumb') : 'Products'} {t.has('breadcrumb') ? t('breadcrumb') : t('title').replace(/<[^>]*>/g, '')}
</Link> </Link>
<span className="mx-3 opacity-30">/</span> <span className="mx-3 opacity-30">/</span>
<span className="text-white/90">{categoryTitle}</span> <span className="text-white/90">{categoryTitle}</span>
@@ -196,7 +236,7 @@ export default async function ProductPage({ params }: ProductPageProps) {
{productsWithTranslatedSlugs.map((product) => ( {productsWithTranslatedSlugs.map((product) => (
<Link <Link
key={product.slug} 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" 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"> <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" 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" 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" /> <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,7 +307,19 @@ export default async function ProductPage({ params }: ProductPageProps) {
notFound(); notFound();
} }
let technicalItems: any[] = []; // Extract technical data for schema
const technicalDataMatch = product.content.match(
/technicalData=\{<ProductTechnicalData data=\{(.*?)\}\s*\/>\}/s,
);
let technicalItems = [];
if (technicalDataMatch) {
try {
const data = JSON.parse(technicalDataMatch[1]);
technicalItems = data.technicalItems || [];
} catch (e) {
console.error('Failed to parse technical data for schema', e);
}
}
const datasheetPath = getDatasheetPath(productSlug, locale); const datasheetPath = getDatasheetPath(productSlug, locale);
const isFallback = (product.frontmatter as any).isFallback; const isFallback = (product.frontmatter as any).isFallback;
@@ -279,29 +332,6 @@ export default async function ProductPage({ params }: ProductPageProps) {
? t(`categories.${categoryKey}.title`) ? t(`categories.${categoryKey}.title`)
: categoryFileSlug; : 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) {
technicalContent = tabBlockMatch[0];
descriptionContent = product.content.replace(tabBlockMatch[0], '');
const dataMatch =
technicalContent.match(/data="({.*?})"/s) || technicalContent.match(/data={({.*?})}/s);
if (dataMatch && dataMatch[1]) {
try {
const rawJsonStr = dataMatch[1].replace(/&quot;/g, '"');
const data = JSON.parse(rawJsonStr);
technicalItems = data.technicalItems || [];
} catch (e) {
console.error('Failed to parse productTabs data for page:', e);
}
}
}
}
const sidebar = ( const sidebar = (
<ProductSidebar <ProductSidebar
productName={product.frontmatter.title} productName={product.frontmatter.title}
@@ -310,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 ( return (
<div className="flex flex-col min-h-screen bg-white relative"> <div className="flex flex-col min-h-screen bg-white relative">
{/* Product Hero */} {/* Product Hero */}
@@ -319,31 +365,29 @@ export default async function ProductPage({ params }: ProductPageProps) {
categories={product.frontmatter.categories} categories={product.frontmatter.categories}
sku={product.frontmatter.sku} 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 */} {/* 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-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" /> <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"> <Container className="relative z-10">
<div className="max-w-4xl animate-slide-up"> <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 <Link
href={`/${locale}/${productsSlug}`} href={`/${locale}/${await mapFileSlugToTranslated('products', locale)}`}
className="hover:text-accent transition-colors shrink-0" className="hover:text-accent transition-colors"
> >
{t.has('breadcrumb') ? t('breadcrumb') : 'Products'} {t.has('breadcrumb') ? t('breadcrumb') : t('title').replace(/<[^>]*>/g, '')}
</Link> </Link>
<span className="mx-2 md:mx-4 opacity-20">/</span> <span className="mx-4 opacity-20">/</span>
<Link <Link
href={`/${locale}/${productsSlug}/${categorySlug}`} href={`/${locale}/${productSlug}`}
className="hover:text-accent transition-colors shrink-0 max-w-[140px] truncate" className="hover:text-accent transition-colors"
> >
{categoryTitle} {categoryTitle}
</Link> </Link>
<span className="mx-2 md:mx-4 opacity-20">/</span> <span className="mx-4 opacity-20">/</span>
<span className="text-white/90 truncate max-w-[140px] md:max-w-none"> <span className="text-white/90">{product.frontmatter.title}</span>
{product.frontmatter.title}
</span>
</nav> </nav>
<div className="flex flex-col lg:flex-row lg:items-center justify-between gap-12"> <div className="flex flex-col lg:flex-row lg:items-center justify-between gap-12">
@@ -354,7 +398,7 @@ export default async function ProductPage({ params }: ProductPageProps) {
{t('englishVersion')} {t('englishVersion')}
</div> </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) => ( {product.frontmatter.categories.map((cat, idx) => (
<Badge <Badge
key={idx} key={idx}
@@ -365,10 +409,10 @@ export default async function ProductPage({ params }: ProductPageProps) {
</Badge> </Badge>
))} ))}
</div> </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} {product.frontmatter.title}
</Heading> </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} {product.frontmatter.description}
</p> </p>
</div> </div>
@@ -382,16 +426,15 @@ export default async function ProductPage({ params }: ProductPageProps) {
{/* Large Product Image Section */} {/* Large Product Image Section */}
{product.frontmatter.images && product.frontmatter.images.length > 0 && ( {product.frontmatter.images && product.frontmatter.images.length > 0 && (
<div <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' }} 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="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-[4/3] md:aspect-[21/9]"> <div className="relative w-full aspect-[21/9]">
<Image <Image
src={product.frontmatter.images[0]} src={product.frontmatter.images[0]}
alt={product.frontmatter.title} alt={product.frontmatter.title}
fill fill
sizes="(max-width: 768px) 100vw, (max-width: 1200px) 50vw, 33vw"
className="object-contain transition-transform duration-1000 hover:scale-105" className="object-contain transition-transform duration-1000 hover:scale-105"
priority priority
/> />
@@ -410,7 +453,6 @@ export default async function ProductPage({ params }: ProductPageProps) {
src={img} src={img}
alt="" alt=""
fill fill
sizes="128px"
className="object-contain p-4 transition-transform duration-700 group-hover:scale-110" className="object-contain p-4 transition-transform duration-700 group-hover:scale-110"
/> />
</div> </div>
@@ -421,42 +463,17 @@ export default async function ProductPage({ params }: ProductPageProps) {
</div> </div>
)} )}
<div className="grid grid-cols-1 lg:grid-cols-12 gap-8 lg:gap-20"> <div className="relative">
{/* Description Area Next to Sidebar */} <div className="w-full">
<div className="lg:col-span-8"> {/* Main Content Area */}
<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"> <div className="max-w-none">
{descriptionContent ? ( <MDXRemote source={processedContent} components={productComponents} />
<MDXContent data={descriptionContent} />
) : product.frontmatter.description ? (
<p className="text-lg md:text-xl text-text-secondary leading-relaxed">
{product.frontmatter.description}
</p>
) : null}
{product.application && (
<div className="mt-12">
<MDXContent
data={typeof product.application === 'string' ? product.application : ''}
/>
</div>
)}
</div>
</div> </div>
{/* Sidebar Column */} {/* Datasheet Download Section - Only for Medium Voltage for now */}
<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 && ( {categoryFileSlug === 'medium-voltage-cables' && datasheetPath && (
<div className="mt-16 pt-16 border-t-2 border-neutral-dark/5"> <div className="mt-24 pt-24 border-t-2 border-neutral-dark/5">
<div className="mb-8"> <div className="mb-12">
<h2 className="text-3xl md:text-4xl font-black text-primary tracking-tighter uppercase mb-4"> <h2 className="text-3xl md:text-4xl font-black text-primary tracking-tighter uppercase mb-4">
{t('downloadDatasheet')} {t('downloadDatasheet')}
</h2> </h2>
@@ -466,7 +483,7 @@ export default async function ProductPage({ params }: ProductPageProps) {
</div> </div>
)} )}
{/* Structured Data (Hidden) */} {/* Structured Data */}
<JsonLd <JsonLd
id={`jsonld-${product.slug}`} id={`jsonld-${product.slug}`}
data={ data={
@@ -504,9 +521,10 @@ export default async function ProductPage({ params }: ProductPageProps) {
} }
/> />
</div> </div>
</div>
{/* Related Products Section */} {/* 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 <RelatedProducts
currentSlug={productSlug} currentSlug={productSlug}
categories={product.frontmatter.categories} categories={product.frontmatter.categories}

View File

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

View File

@@ -1,4 +1,5 @@
import Reveal from '@/components/Reveal'; import Reveal from '@/components/Reveal';
import Scribble from '@/components/Scribble';
import { Badge, Button, Card, Container, Heading, Section } from '@/components/ui'; import { Badge, Button, Card, Container, Heading, Section } from '@/components/ui';
import { getTranslations, setRequestLocale } from 'next-intl/server'; import { getTranslations, setRequestLocale } from 'next-intl/server';
import { Metadata } from 'next'; import { Metadata } from 'next';
@@ -16,11 +17,7 @@ interface ProductsPageProps {
export async function generateMetadata({ params }: ProductsPageProps): Promise<Metadata> { export async function generateMetadata({ params }: ProductsPageProps): Promise<Metadata> {
const { locale } = await params; const { locale } = await params;
const t = await getTranslations({ locale, namespace: 'Products' }); const t = await getTranslations({ locale, namespace: 'Products' });
const title = t.has('meta.title') const title = t('meta.title') || t('breadcrumb') || t('title').replace(/<[^>]*>/g, '');
? t('meta.title')
: t.has('breadcrumb')
? t('breadcrumb')
: 'Products';
const description = t('meta.description') || t('subtitle'); const description = t('meta.description') || t('subtitle');
return { return {
title, title,
@@ -58,7 +55,6 @@ export default async function ProductsPage({ params }: ProductsPageProps) {
const solarSlug = await mapFileSlugToTranslated('solar-cables', locale); const solarSlug = await mapFileSlugToTranslated('solar-cables', locale);
const productsSlug = await mapFileSlugToTranslated('products', locale); const productsSlug = await mapFileSlugToTranslated('products', locale);
const contactSlug = await mapFileSlugToTranslated('contact', locale);
const categories = [ const categories = [
{ {
@@ -94,7 +90,7 @@ export default async function ProductsPage({ params }: ProductsPageProps) {
return ( return (
<div className="flex flex-col min-h-screen bg-neutral-light"> <div className="flex flex-col min-h-screen bg-neutral-light">
{/* Hero Section */} {/* 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"> <Container className="relative z-10">
<div className="max-w-4xl animate-slide-up"> <div className="max-w-4xl animate-slide-up">
<Badge <Badge
@@ -105,7 +101,15 @@ export default async function ProductsPage({ params }: ProductsPageProps) {
</Badge> </Badge>
<Heading level={1} className="text-white mb-4 md:mb-8"> <Heading level={1} className="text-white mb-4 md:mb-8">
{t.rich('title', { {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> </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"> <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="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="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"> <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')} {t('cta.title')}
</h2> </h2>
<p className="text-base md:text-xl text-white/70 leading-relaxed"> <p className="text-base md:text-xl text-white/70 leading-relaxed">
@@ -222,13 +226,13 @@ export default async function ProductsPage({ params }: ProductsPageProps) {
</p> </p>
</div> </div>
<Button <Button
href={`/${locale}/${contactSlug}`} href={`/${locale}/contact`}
variant="accent" variant="accent"
size="lg" 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')} {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; &rarr;
</span> </span>
</Button> </Button>

View File

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

View File

@@ -122,12 +122,12 @@ export default async function TeamPage({ params }: TeamPageProps) {
<Badge variant="accent" className="mb-4 md:mb-8"> <Badge variant="accent" className="mb-4 md:mb-8">
{t('michael.role')} {t('michael.role')}
</Badge> </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> <span className="text-white">{t('michael.name')}</span>
</Heading> </Heading>
<div className="relative mb-6 md:mb-12"> <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" /> <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')} {t('michael.quote')}
</p> </p>
</div> </div>
@@ -156,7 +156,6 @@ export default async function TeamPage({ params }: TeamPageProps) {
alt={t('michael.name')} alt={t('michael.name')}
fill fill
className="object-cover scale-105 hover:scale-100 transition-transform duration-1000" className="object-cover scale-105 hover:scale-100 transition-transform duration-1000"
quality={100}
sizes="(max-width: 1024px) 100vw, 50vw" 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" /> <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')} alt={t('klaus.name')}
fill fill
className="object-cover scale-105 hover:scale-100 transition-transform duration-1000" className="object-cover scale-105 hover:scale-100 transition-transform duration-1000"
quality={100}
sizes="(max-width: 1024px) 100vw, 50vw" 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" /> <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"> <Badge variant="saturated" className="mb-4 md:mb-8">
{t('klaus.role')} {t('klaus.role')}
</Badge> </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')} {t('klaus.name')}
</Heading> </Heading>
<div className="relative mb-6 md:mb-12"> <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" /> <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')} {t('klaus.quote')}
</p> </p>
</div> </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'; 'use server';
import client, { ensureAuthenticated } from '@/lib/directus';
import { createItem } from '@directus/sdk';
import { sendEmail } from '@/lib/mail/mailer'; import { sendEmail } from '@/lib/mail/mailer';
import { env } from '@/lib/env';
import { render, ContactFormNotification, ConfirmationMessage } from '@mintel/mail'; import { render, ContactFormNotification, ConfirmationMessage } from '@mintel/mail';
import React from 'react'; import React from 'react';
import { getServerAppServices } from '@/lib/services/create-services.server'; 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' }; return { success: false, error: 'Missing required fields' };
} }
logger.info('Payload CMS saving skipped because it has been removed', { // 1. Save to Directus
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.
try { try {
const fs = await import('fs'); await ensureAuthenticated();
const path = await import('path'); if (productName) {
const backupDir = path.join(process.cwd(), '.data'); await client.request(
if (!fs.existsSync(backupDir)) fs.mkdirSync(backupDir, { recursive: true }); createItem('product_requests', {
product_name: productName,
const backupFile = path.join(backupDir, 'leads-backup.jsonl'); email,
const leadData = { message,
timestamp: new Date().toISOString(), }),
);
logger.info('Product request stored in Directus');
} else {
await client.request(
createItem('contact_submissions', {
name, name,
email, email,
productName,
message, message,
}; }),
fs.appendFileSync(backupFile, JSON.stringify(leadData) + '\n'); );
logger.info('Successfully saved lead to local backup file', { backupFile }); logger.info('Contact submission stored in Directus');
} catch (backupError) { }
logger.error('Failed to write to local leads backup', { error: String(backupError) }); } catch (error) {
logger.error('Failed to store submission in Directus', { error });
services.errors.captureException(error, { action: 'directus_store_submission' });
} }
// 2. Send Emails // 2. Send Emails
@@ -74,7 +75,6 @@ export async function sendContactFormAction(formData: FormData) {
? `Product Inquiry: ${productName}` ? `Product Inquiry: ${productName}`
: 'New Contact Form Submission'; : 'New Contact Form Submission';
const confirmationSubject = 'Thank you for your inquiry'; const confirmationSubject = 'Thank you for your inquiry';
const isTestSubmission = email === 'testing@mintel.me';
try { try {
// 2a. Send notification to Mintel/Client // 2a. Send notification to Mintel/Client
@@ -87,8 +87,6 @@ export async function sendContactFormAction(formData: FormData) {
}), }),
); );
if (!isTestSubmission) {
logger.info('Sending internal notification', { recipients: env.MAIL_RECIPIENTS });
const notificationResult = await sendEmail({ const notificationResult = await sendEmail({
replyTo: email, replyTo: email,
subject: notificationSubject, subject: notificationSubject,
@@ -99,23 +97,6 @@ export async function sendContactFormAction(formData: FormData) {
logger.info('Notification email sent successfully', { logger.info('Notification email sent successfully', {
messageId: notificationResult.messageId, 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 });
} }
// 2b. Send confirmation to Customer (branded as KLZ Cables) // 2b. Send confirmation to Customer (branded as KLZ Cables)
@@ -127,8 +108,6 @@ export async function sendContactFormAction(formData: FormData) {
}), }),
); );
if (!isTestSubmission) {
logger.info('Sending customer confirmation', { to: email });
const confirmationResult = await sendEmail({ const confirmationResult = await sendEmail({
to: email, to: email,
subject: confirmationSubject, subject: confirmationSubject,
@@ -139,24 +118,11 @@ export async function sendContactFormAction(formData: FormData) {
logger.info('Confirmation email sent successfully', { logger.info('Confirmation email sent successfully', {
messageId: confirmationResult.messageId, 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 });
} }
// Notify via Gotify (Internal) // Notify via Gotify (Internal)
await services.notifications.notify({ await services.notifications.notify({
title: `📩 [KLZ] ${notificationSubject}`, title: `📩 ${notificationSubject}`,
message: `New message from ${name} (${email}):\n\n${message}`, message: `New message from ${name} (${email}):\n\n${message}`,
priority: 5, 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' }); const logger = services.logger.child({ component: 'sentry-relay' });
try { 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(); const envelope = await request.text();
// Sentry envelopes can contain multiple parts separated by newlines // 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 dsnUrl = new URL(realDsn);
const projectId = dsnUrl.pathname.replace('/', ''); const projectId = dsnUrl.pathname.replace('/', '');
const sentryKey = dsnUrl.username; const relayUrl = `${dsnUrl.protocol}//${dsnUrl.host}/api/${projectId}/envelope/`;
const relayUrl = `${dsnUrl.protocol}//${dsnUrl.host}/api/${projectId}/envelope/?sentry_key=${sentryKey}`;
logger.debug('Relaying Sentry envelope', { logger.debug('Relaying Sentry envelope', {
projectId, projectId,

View File

@@ -1,55 +1,37 @@
import { config } from '@/lib/config'; import { config } from '@/lib/config';
import { MetadataRoute } from 'next'; import { MetadataRoute } from 'next';
import { getAllProductsMetadata } from '@/lib/products'; import { getAllProductsMetadata } from '@/lib/mdx';
import { getAllPostsMetadata } from '@/lib/blog'; import { getAllPostsMetadata } from '@/lib/blog';
import { getAllPagesMetadata } from '@/lib/pages'; 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> { export default async function sitemap(): Promise<MetadataRoute.Sitemap> {
const baseUrl = config.baseUrl || 'https://klz-cables.com'; const baseUrl = config.baseUrl || 'https://klz-cables.com';
const locales = ['de', 'en']; 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 = []; const sitemapEntries: MetadataRoute.Sitemap = [];
for (const locale of locales) { 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 // Static routes
const staticPages = ['', 'blog', 'contact', 'team', 'products']; for (const route of routes) {
for (const page of staticPages) {
const localizedRoute = await getLocalizedRoute(page);
sitemapEntries.push({ sitemapEntries.push({
url: `${baseUrl}/${locale}${localizedRoute}`, url: `${baseUrl}/${locale}${route}`,
lastModified: new Date(), lastModified: new Date(),
changeFrequency: page === '' ? 'daily' : 'weekly', changeFrequency: route === '' ? 'daily' : 'weekly',
priority: page === '' ? 1 : 0.8, priority: route === '' ? 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,
}); });
} }
@@ -58,28 +40,10 @@ export default async function sitemap(): Promise<MetadataRoute.Sitemap> {
for (const product of productsMetadata) { for (const product of productsMetadata) {
if (!product.frontmatter || !product.slug) continue; if (!product.frontmatter || !product.slug) continue;
const firstCat = product.frontmatter.categories[0] || ''; const category =
const normalizedCat = firstCat.toLowerCase().replace(/\s+/g, '-'); product.frontmatter.categories[0]?.toLowerCase().replace(/\s+/g, '-') || 'other';
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);
sitemapEntries.push({ sitemapEntries.push({
url: `${baseUrl}/${locale}/${translatedProducts}/${translatedCategory}/${translatedSlug}`, url: `${baseUrl}/${locale}/products/${category}/${product.slug}`,
lastModified: new Date(), lastModified: new Date(),
changeFrequency: 'monthly', changeFrequency: 'monthly',
priority: 0.7, priority: 0.7,
@@ -87,15 +51,12 @@ export default async function sitemap(): Promise<MetadataRoute.Sitemap> {
} }
// Blog posts // Blog posts
const translatedBlog = await mapFileSlugToTranslated('blog', locale);
const postsMetadata = await getAllPostsMetadata(locale); const postsMetadata = await getAllPostsMetadata(locale);
for (const post of postsMetadata) { for (const post of postsMetadata) {
if (!post.frontmatter || !post.slug) continue; if (!post.frontmatter || !post.slug) continue;
const translatedSlug = await mapFileSlugToTranslated(post.slug, locale);
sitemapEntries.push({ sitemapEntries.push({
url: `${baseUrl}/${locale}/${translatedBlog}/${translatedSlug}`, url: `${baseUrl}/${locale}/blog/${post.slug}`,
lastModified: new Date(post.frontmatter.date), lastModified: new Date(post.frontmatter.date),
changeFrequency: 'monthly', changeFrequency: 'monthly',
priority: 0.6, priority: 0.6,
@@ -107,10 +68,8 @@ export default async function sitemap(): Promise<MetadataRoute.Sitemap> {
for (const page of pagesMetadata) { for (const page of pagesMetadata) {
if (!page.slug) continue; if (!page.slug) continue;
const translatedSlug = await mapFileSlugToTranslated(page.slug, locale);
sitemapEntries.push({ sitemapEntries.push({
url: `${baseUrl}/${locale}/${translatedSlug}`, url: `${baseUrl}/${locale}/${page.slug}`,
lastModified: new Date(), lastModified: new Date(),
changeFrequency: 'monthly', changeFrequency: 'monthly',
priority: 0.5, priority: 0.5,

View File

@@ -19,39 +19,14 @@ export async function POST(request: NextRequest) {
const logger = services.logger.child({ component: 'umami-smart-proxy' }); const logger = services.logger.child({ component: 'umami-smart-proxy' });
try { try {
// Prevent 400 Bad Request console noise in local dev const body = await request.json();
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 { type, payload } = body; 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 // Inject the secret websiteId from server config
const websiteId = config.analytics.umami.websiteId; const websiteId = config.analytics.umami.websiteId;
if (!websiteId) { if (!websiteId) {
logger.warn('Umami tracking received but no Website ID configured on server'); 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 // Prepare the enhanced payload with the secret ID
@@ -81,12 +56,10 @@ export async function POST(request: NextRequest) {
if (!response.ok) { if (!response.ok) {
const errorText = await response.text(); const errorText = await response.text();
if (!process.env.CI) {
logger.error('Umami API responded with error', { logger.error('Umami API responded with error', {
status: response.status, status: response.status,
error: errorText.slice(0, 100), error: errorText.slice(0, 100),
}); });
}
return new NextResponse(errorText, { status: response.status }); return new NextResponse(errorText, { status: response.status });
} }
@@ -96,7 +69,6 @@ export async function POST(request: NextRequest) {
const errorStack = error instanceof Error ? error.stack : undefined; const errorStack = error instanceof Error ? error.stack : undefined;
// Console error to ensure it appears in logs even if logger fails // Console error to ensure it appears in logs even if logger fails
if (!process.env.CI) {
console.error('CRITICAL PROXY ERROR:', { console.error('CRITICAL PROXY ERROR:', {
message: errorMessage, message: errorMessage,
stack: errorStack, stack: errorStack,
@@ -107,7 +79,6 @@ export async function POST(request: NextRequest) {
error: errorMessage, error: errorMessage,
stack: errorStack, stack: errorStack,
}); });
}
return NextResponse.json( 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" autoComplete="name"
enterKeyHint="next" enterKeyHint="next"
onFocus={() => handleFocus('contact-name')} onFocus={() => handleFocus('contact-name')}
aria-label={t('form.name')}
required required
/> />
</div> </div>
@@ -162,6 +163,7 @@ export default function ContactForm() {
enterKeyHint="next" enterKeyHint="next"
placeholder={t('form.emailPlaceholder')} placeholder={t('form.emailPlaceholder')}
onFocus={() => handleFocus('contact-email')} onFocus={() => handleFocus('contact-email')}
aria-label={t('form.email')}
required required
/> />
</div> </div>
@@ -174,6 +176,7 @@ export default function ContactForm() {
enterKeyHint="send" enterKeyHint="send"
placeholder={t('form.messagePlaceholder')} placeholder={t('form.messagePlaceholder')}
onFocus={() => handleFocus('contact-message')} onFocus={() => handleFocus('contact-message')}
aria-label={t('form.message')}
required required
/> />
</div> </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(); const currentYear = new Date().getFullYear();
return ( 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" /> <div className="absolute top-0 left-0 w-full h-px bg-gradient-to-r from-transparent via-white/20 to-transparent" />
<Container> <Container>
<h2 className="sr-only">Footer Navigation</h2> <div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-12 gap-16 mb-20">
<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 */}
{/* Brand Column full width on mobile */} <div className="lg:col-span-4 space-y-8">
<div className="col-span-2 md:col-span-2 lg:col-span-4 space-y-6 md:space-y-8">
<Link <Link
href={`/${locale}`} href={`/${locale}`}
className="inline-block group" className="inline-block group"
@@ -38,8 +37,6 @@ export default function Footer() {
alt="KLZ Vertriebs GmbH" alt="KLZ Vertriebs GmbH"
width={150} width={150}
height={40} height={40}
sizes="(max-width: 768px) 150px, 150px"
style={{ width: 'auto' }}
className="h-10 w-auto transition-transform duration-500 group-hover:scale-110" className="h-10 w-auto transition-transform duration-500 group-hover:scale-110"
/> />
</Link> </Link>
@@ -68,9 +65,9 @@ export default function Footer() {
</div> </div>
</div> </div>
{/* Legal Column */} {/* Links Columns */}
<div className="col-span-1 lg:col-span-2"> <div className="lg:col-span-2">
<h3 className="text-accent font-bold uppercase tracking-widest text-xs mb-5 md:mb-8"> <h3 className="text-accent font-bold uppercase tracking-widest text-xs md:text-sm mb-8">
{t('legal')} {t('legal')}
</h3> </h3>
<ul className="space-y-4 text-white/70 list-none m-0 p-0"> <ul className="space-y-4 text-white/70 list-none m-0 p-0">
@@ -122,9 +119,8 @@ export default function Footer() {
</ul> </ul>
</div> </div>
{/* Company Column */} <div className="lg:col-span-2">
<div className="col-span-1 lg:col-span-2"> <h3 className="text-accent font-bold uppercase tracking-widest text-xs md:text-sm mb-8">
<h3 className="text-accent font-bold uppercase tracking-widest text-xs mb-5 md:mb-8">
{t('company')} {t('company')}
</h3> </h3>
<ul className="space-y-4 text-white/70 list-none m-0 p-0"> <ul className="space-y-4 text-white/70 list-none m-0 p-0">
@@ -175,12 +171,12 @@ export default function Footer() {
</li> </li>
<li> <li>
<Link <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" className="text-white/70 hover:text-accent transition-all duration-300 hover:translate-x-1 inline-block"
onClick={() => onClick={() =>
trackEvent(AnalyticsEvents.LINK_CLICK, { trackEvent(AnalyticsEvents.LINK_CLICK, {
label: navT('contact'), label: navT('contact'),
href: locale === 'de' ? '/kontakt' : '/contact', href: '/contact',
location: 'footer_company', location: 'footer_company',
}) })
} }
@@ -191,9 +187,9 @@ export default function Footer() {
</ul> </ul>
</div> </div>
{/* Recent Posts Column full width on mobile */} {/* Recent Posts Column */}
<div className="col-span-2 md:col-span-2 lg:col-span-4"> <div className="lg:col-span-4">
<h3 className="text-accent font-bold uppercase tracking-widest text-xs mb-5 md:mb-8"> <h3 className="text-accent font-bold uppercase tracking-widest text-xs md:text-sm mb-8">
{t('recentPosts')} {t('recentPosts')}
</h3> </h3>
<ul className="space-y-6 list-none m-0 p-0"> <ul className="space-y-6 list-none m-0 p-0">
@@ -244,11 +240,12 @@ export default function Footer() {
</div> </div>
</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> <p>{t('copyright', { year: currentYear })}</p>
<div className="flex gap-8"> <div className="flex gap-8">
<Link <Link
href="/en" href="/en"
locale="en"
className="hover:text-white transition-colors" className="hover:text-white transition-colors"
onClick={() => onClick={() =>
trackEvent(AnalyticsEvents.TOGGLE_SWITCH, { trackEvent(AnalyticsEvents.TOGGLE_SWITCH, {
@@ -263,6 +260,7 @@ export default function Footer() {
</Link> </Link>
<Link <Link
href="/de" href="/de"
locale="de"
className="hover:text-white transition-colors" className="hover:text-white transition-colors"
onClick={() => onClick={() =>
trackEvent(AnalyticsEvents.TOGGLE_SWITCH, { trackEvent(AnalyticsEvents.TOGGLE_SWITCH, {

View File

@@ -2,6 +2,7 @@
import Link from 'next/link'; import Link from 'next/link';
import Image from 'next/image'; import Image from 'next/image';
import { motion } from 'framer-motion';
import { useTranslations } from 'next-intl'; import { useTranslations } from 'next-intl';
import { usePathname } from 'next/navigation'; import { usePathname } from 'next/navigation';
import { Button } from './ui'; import { Button } from './ui';
@@ -17,6 +18,7 @@ export default function Header() {
const [isScrolled, setIsScrolled] = useState(false); const [isScrolled, setIsScrolled] = useState(false);
const [isMobileMenuOpen, setIsMobileMenuOpen] = useState(false); const [isMobileMenuOpen, setIsMobileMenuOpen] = useState(false);
const mobileMenuRef = useRef<HTMLDivElement>(null); const mobileMenuRef = useRef<HTMLDivElement>(null);
const closeButtonRef = useRef<HTMLButtonElement>(null);
// Extract locale from pathname // Extract locale from pathname
const currentLocale = pathname.split('/')[1] || 'en'; const currentLocale = pathname.split('/')[1] || 'en';
@@ -33,6 +35,7 @@ export default function Header() {
return () => window.removeEventListener('scroll', handleScroll); return () => window.removeEventListener('scroll', handleScroll);
}, []); }, []);
// Prevent scroll when mobile menu is open
// Prevent scroll when mobile menu is open and handle focus trap // Prevent scroll when mobile menu is open and handle focus trap
useEffect(() => { useEffect(() => {
if (isMobileMenuOpen) { if (isMobileMenuOpen) {
@@ -84,49 +87,11 @@ export default function Header() {
} }
}, [isMobileMenuOpen]); }, [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 getPathForLocale = (newLocale: string) => {
const segments = pathname.split('/'); 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; segments[1] = newLocale;
return segments.join('/');
// 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('/');
}; };
const menuItems = [ const menuItems = [
@@ -137,12 +102,10 @@ export default function Header() {
]; ];
const headerClass = cn( 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': 'bg-transparent py-4 md:py-8': isHomePage && !isScrolled && !isMobileMenuOpen,
isHomePage && !isScrolled && !isMobileMenuOpen, 'bg-primary py-3 md:py-4 shadow-2xl': !isHomePage || isScrolled || isMobileMenuOpen,
'bg-primary/90 backdrop-blur-md py-3 md:py-4 shadow-2xl':
!isHomePage || isScrolled || isMobileMenuOpen,
}, },
); );
@@ -151,9 +114,19 @@ export default function Header() {
return ( 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="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 <Link
href={`/${currentLocale}`} href={`/${currentLocale}`}
onClick={() => onClick={() =>
@@ -168,35 +141,30 @@ export default function Header() {
alt={t('home')} alt={t('home')}
width={120} width={120}
height={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" className="h-10 md:h-14 w-auto transition-all duration-500 group-hover:scale-110"
priority priority
fetchPriority="high"
loading="eager"
decoding="sync"
/> />
</Link> </Link>
</div> </motion.div>
<div className="flex items-center gap-4 md:gap-12"> <motion.div
<nav className="hidden lg:flex items-center space-x-10"> className="flex items-center gap-4 md:gap-12"
{menuItems.map((item, idx) => ( initial="hidden"
<div animate="visible"
key={item.href} variants={{
className="animate-in fade-in slide-in-from-bottom-4 fill-mode-both" visible: {
style={{ animationDuration: '500ms', animationDelay: `${150 + idx * 80}ms` }} transition: {
staggerChildren: 0.08,
delayChildren: 0.3,
},
},
}}
> >
{(() => { <motion.nav className="hidden lg:flex items-center space-x-10" variants={navVariants}>
const fullHref = `/${currentLocale}${item.href === '/' ? '' : item.href}`; {menuItems.map((item, _idx) => (
const isActive = <motion.div key={item.href} variants={navLinkVariants}>
item.href === '/'
? pathname === `/${currentLocale}` || pathname === '/'
: pathname.startsWith(fullHref);
return (
<Link <Link
href={fullHref} href={`/${currentLocale}${item.href === '/' ? '' : item.href}`}
aria-current={isActive ? 'page' : undefined}
onClick={() => { onClick={() => {
setIsMobileMenuOpen(false); setIsMobileMenuOpen(false);
trackEvent(AnalyticsEvents.LINK_CLICK, { trackEvent(AnalyticsEvents.LINK_CLICK, {
@@ -208,35 +176,30 @@ export default function Header() {
className={cn( className={cn(
textColorClass, 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', '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} {item.label}
<span <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)]" />
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> </Link>
); </motion.div>
})()}
</div>
))} ))}
</nav> </motion.nav>
<div <motion.div
className={cn( className={cn('hidden lg:flex items-center space-x-8', textColorClass)}
'hidden lg:flex items-center space-x-8 animate-in fade-in slide-in-from-right-8 fill-mode-both', variants={headerRightVariants}
textColorClass,
)}
style={{ animationDuration: '600ms', animationDelay: '300ms' }}
> >
<div <motion.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" className="flex items-center space-x-4 text-xs md:text-sm font-extrabold tracking-widest uppercase"
style={{ animationDuration: '500ms', animationDelay: '600ms' }} initial={{ opacity: 0, x: 20 }}
animate={{ opacity: 1, x: 0 }}
transition={{ duration: 0.5, delay: 0.6 }}
>
<motion.div
initial={{ opacity: 0, scale: 0.8 }}
animate={{ opacity: 1, scale: 1 }}
transition={{ duration: 0.4, delay: 0.65 }}
> >
<div>
<Link <Link
href={getPathForLocale('en')} href={getPathForLocale('en')}
onClick={() => onClick={() =>
@@ -247,13 +210,22 @@ export default function Header() {
location: '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 EN
</Link> </Link>
</div> </motion.div>
<div className="w-px h-4 bg-current opacity-30" /> <motion.div
<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 <Link
href={getPathForLocale('de')} href={getPathForLocale('de')}
onClick={() => onClick={() =>
@@ -264,22 +236,23 @@ export default function Header() {
location: '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 DE
</Link> </Link>
</div> </motion.div>
</div> </motion.div>
<div <motion.div
className="animate-in fade-in zoom-in-95 fill-mode-both" initial={{ scale: 0.9, opacity: 0 }}
style={{ animationDuration: '600ms', animationDelay: '700ms' }} animate={{ scale: 1, opacity: 1 }}
transition={{ duration: 0.6, type: 'spring', stiffness: 400, delay: 0.7 }}
> >
<Button <Button
href={`/${currentLocale}/${currentLocale === 'de' ? 'kontakt' : 'contact'}`} href={`/${currentLocale}/contact`}
variant="white" variant="white"
size="md" size="md"
className="px-8 shadow-xl hover:scale-105 transition-transform" className="px-8 shadow-xl"
onClick={() => onClick={() =>
trackEvent(AnalyticsEvents.BUTTON_CLICK, { trackEvent(AnalyticsEvents.BUTTON_CLICK, {
label: t('contact'), label: t('contact'),
@@ -289,19 +262,27 @@ export default function Header() {
> >
{t('contact')} {t('contact')}
</Button> </Button>
</div> </motion.div>
</div> </motion.div>
{/* Mobile Menu Button */} {/* Mobile Menu Button */}
<button <motion.button
className={cn( 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, textColorClass,
isMobileMenuOpen ? 'rotate-90 scale-110' : 'rotate-0 scale-100',
)} )}
aria-label={t('toggleMenu')} aria-label={t('toggleMenu')}
aria-expanded={isMobileMenuOpen} aria-expanded={isMobileMenuOpen}
aria-controls="mobile-menu" 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={() => { onClick={() => {
const newState = !isMobileMenuOpen; const newState = !isMobileMenuOpen;
setIsMobileMenuOpen(newState); setIsMobileMenuOpen(newState);
@@ -311,37 +292,45 @@ export default function Header() {
}); });
}} }}
> >
<svg <motion.svg
className="w-7 h-7 transition-transform duration-300" className="w-7 h-7"
fill="none" fill="none"
stroke="currentColor" stroke="currentColor"
viewBox="0 0 24 24" viewBox="0 0 24 24"
initial={{ scale: 0 }}
animate={{ scale: 1 }}
transition={{ duration: 0.3, delay: 0.6 }}
> >
{isMobileMenuOpen ? ( {isMobileMenuOpen ? (
<path <motion.path
strokeLinecap="round" strokeLinecap="round"
strokeLinejoin="round" strokeLinejoin="round"
strokeWidth={2} strokeWidth={2}
d="M6 18L18 6M6 6l12 12" d="M6 18L18 6M6 6l12 12"
initial={{ pathLength: 0 }}
animate={{ pathLength: 1 }}
transition={{ duration: 0.4, delay: 0.7 }}
/> />
) : ( ) : (
<path <motion.path
strokeLinecap="round" strokeLinecap="round"
strokeLinejoin="round" strokeLinejoin="round"
strokeWidth={2} strokeWidth={2}
d="M4 6h16M4 12h16M4 18h16" d="M4 6h16M4 12h16M4 18h16"
initial={{ pathLength: 0 }}
animate={{ pathLength: 1 }}
transition={{ duration: 0.4, delay: 0.7 }}
/> />
)} )}
</svg> </motion.svg>
</button> </motion.button>
</motion.div>
</div> </div>
</div>
</header>
{/* Mobile Menu Overlay */} {/* Mobile Menu Overlay */}
<div <div
className={cn( 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', 'fixed inset-0 bg-primary z-40 lg:hidden transition-all duration-500 ease-in-out flex flex-col',
isMobileMenuOpen isMobileMenuOpen
? 'opacity-100 translate-y-0' ? 'opacity-100 translate-y-0'
: 'opacity-0 -translate-y-full pointer-events-none', : 'opacity-0 -translate-y-full pointer-events-none',
@@ -351,52 +340,39 @@ export default function Header() {
aria-modal="true" aria-modal="true"
aria-label={t('menu')} aria-label={t('menu')}
ref={mobileMenuRef} ref={mobileMenuRef}
inert={isMobileMenuOpen ? undefined : true}
> >
{/* Close Button inside overlay */} <motion.nav
<div className="flex justify-end p-6 pt-8"> className="flex-grow flex flex-col justify-center items-center p-8 space-y-8"
<button initial="closed"
className="touch-target p-2 rounded-xl bg-white/10 border border-white/20 text-white hover:bg-white/20 transition-all duration-300" animate={isMobileMenuOpen ? 'open' : 'closed'}
aria-label={t('toggleMenu')} variants={{
onClick={() => { open: {
setIsMobileMenuOpen(false); transition: {
trackEvent(AnalyticsEvents.BUTTON_CLICK, { staggerChildren: 0.1,
type: 'mobile_menu', delayChildren: 0.2,
action: 'close', },
}); },
}} }}
> >
<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) => ( {menuItems.map((item, idx) => (
<div <motion.div
key={item.href} key={item.href}
className={cn( variants={{
'transition-all duration-500 transform', closed: { opacity: 0, y: 50, scale: 0.9 },
isMobileMenuOpen ? 'opacity-100 translate-y-0' : 'opacity-0 translate-y-8', open: {
)} opacity: 1,
style={{ transitionDelay: `${isMobileMenuOpen ? 200 + idx * 80 : 0}ms` }} y: 0,
scale: 1,
transition: {
duration: 0.6,
ease: 'easeOut',
delay: idx * 0.08,
},
},
}}
> >
<Link <Link
href={`/${currentLocale}${item.href === '/' ? '' : item.href}`} href={`/${currentLocale}${item.href === '/' ? '' : item.href}`}
aria-current={
(
item.href === '/'
? pathname === `/${currentLocale}` || pathname === '/'
: pathname.startsWith(`/${currentLocale}${item.href}`)
)
? 'page'
: undefined
}
onClick={() => { onClick={() => {
setIsMobileMenuOpen(false); setIsMobileMenuOpen(false);
trackEvent(AnalyticsEvents.LINK_CLICK, { trackEvent(AnalyticsEvents.LINK_CLICK, {
@@ -405,83 +381,124 @@ export default function Header() {
location: 'mobile_menu', location: 'mobile_menu',
}); });
}} }}
className={cn( className="text-2xl md:text-3xl font-extrabold text-white hover:text-accent transition-all transform block py-4"
'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',
)}
> >
{item.label} {item.label}
</Link> </Link>
</div> </motion.div>
))} ))}
<div <motion.div
className={cn( className="pt-8 border-t border-white/10 w-full flex flex-col items-center space-y-8"
'pt-8 border-t border-white/10 w-full flex flex-col items-center space-y-8 transition-all duration-500', initial={{ opacity: 0, y: 30 }}
isMobileMenuOpen ? 'opacity-100 translate-y-0' : 'opacity-0 translate-y-8', animate={isMobileMenuOpen ? { opacity: 1, y: 0 } : { opacity: 0, y: 30 }}
)} transition={{ duration: 0.5, delay: 0.8 }}
style={{ transitionDelay: isMobileMenuOpen ? '600ms' : '0ms' }} >
<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 }}
>
<motion.div
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
transition={{ duration: 0.3, delay: 1.0 }}
> >
<div className="flex items-center space-x-8 text-lg md:text-xl font-extrabold tracking-widest uppercase text-white">
<div>
<Link <Link
href={getPathForLocale('en')} href={getPathForLocale('en')}
className={`hover:text-accent transition-colors ${currentLocale === 'en' ? 'text-accent' : 'opacity-80'}`} className={`hover:text-accent transition-colors ${currentLocale === 'en' ? 'text-accent' : 'opacity-60'}`}
> >
EN EN
</Link> </Link>
</div> </motion.div>
<div className="w-px h-6 bg-white/30" /> <motion.div
<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 }}
>
<Link <Link
href={getPathForLocale('de')} href={getPathForLocale('de')}
className={`hover:text-accent transition-colors ${currentLocale === 'de' ? 'text-accent' : 'opacity-80'}`} className={`hover:text-accent transition-colors ${currentLocale === 'de' ? 'text-accent' : 'opacity-60'}`}
> >
DE DE
</Link> </Link>
</div> </motion.div>
</div> </motion.div>
<div className="w-full max-w-xs"> <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 }}
>
<Button <Button
href={`/${currentLocale}/${currentLocale === 'de' ? 'kontakt' : 'contact'}`} href={`/${currentLocale}/contact`}
variant="accent" variant="accent"
size="lg" size="lg"
className="w-full py-6 text-lg md:text-xl shadow-2xl hover:scale-105 transition-transform" className="w-full max-w-xs py-6 text-lg md:text-xl shadow-2xl"
onClick={() => {
setIsMobileMenuOpen(false);
trackEvent(AnalyticsEvents.BUTTON_CLICK, {
label: t('contact'),
location: 'mobile_menu_cta',
});
}}
> >
{t('contact')} {t('contact')}
</Button> </Button>
</div> </motion.div>
</div> </motion.div>
{/* Bottom Branding */} {/* Bottom Branding */}
<div <motion.div
className={cn( className="p-12 flex justify-center opacity-20"
'p-12 flex justify-center transition-all duration-700', initial={{ opacity: 0, scale: 0.8 }}
isMobileMenuOpen ? 'opacity-20 scale-100' : 'opacity-0 scale-75', animate={isMobileMenuOpen ? { opacity: 0.2, scale: 1 } : { opacity: 0, scale: 0.8 }}
)} transition={{ duration: 0.5, delay: 1.4 }}
style={{ transitionDelay: isMobileMenuOpen ? '800ms' : '0ms' }}
> >
<Image <motion.div
src="/logo-white.svg" initial={{ scale: 0.5 }}
alt={t('home')} animate={{ scale: 1 }}
width={80} transition={{ type: 'spring', stiffness: 300, delay: 1.5 }}
height={80} >
sizes="80px" <Image src="/logo-white.svg" alt={t('home')} width={80} height={80} unoptimized />
unoptimized </motion.div>
/> </motion.div>
</div> </motion.nav>
</nav>
</div> </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) { export default function JsonLd({ id, data }: JsonLdProps) {
// If data is provided, use it. Otherwise, use the default Organization + WebSite schema. // If data is provided, use it. Otherwise, use the default Organization + WebSite schema.
const rawData = data || [ const schemaData = data || [
{ {
'@context': 'https://schema.org', '@context': 'https://schema.org',
'@type': 'Organization', '@type': 'Organization',
name: 'KLZ Cables', name: 'KLZ Cables',
url: 'https://klz-cables.com', url: 'https://klz-cables.com',
logo: 'https://klz-cables.com/logo-blue.svg', 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.', description: 'Premium Cable Solutions for Renewable Energy and Infrastructure.',
address: { address: {
'@type': 'PostalAddress', '@type': 'PostalAddress',
@@ -34,32 +36,15 @@ export default function JsonLd({ id, data }: JsonLdProps) {
}, },
'query-input': 'required name=search_term_string', '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 ( return (
<script <script
id={id} id={id}
type="application/ld+json" type="application/ld+json"
dangerouslySetInnerHTML={{ 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 React, { useEffect, useState, useCallback, useRef } from 'react';
import Image from 'next/image'; import Image from 'next/image';
import { createPortal } from 'react-dom'; 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'; import { useRouter, useSearchParams, usePathname } from 'next/navigation';
interface LightboxProps { interface LightboxProps {
@@ -139,7 +139,6 @@ export default function Lightbox({ isOpen, images, initialIndex, onClose }: Ligh
if (!mounted) return null; if (!mounted) return null;
return createPortal( return createPortal(
<LazyMotion strict features={() => import('@/lib/framer-features').then(res => res.default)}>
<AnimatePresence> <AnimatePresence>
{isOpen && ( {isOpen && (
<div <div
@@ -147,7 +146,7 @@ export default function Lightbox({ isOpen, images, initialIndex, onClose }: Ligh
role="dialog" role="dialog"
aria-modal="true" aria-modal="true"
> >
<m.div <motion.div
initial={{ opacity: 0 }} initial={{ opacity: 0 }}
animate={{ opacity: 1 }} animate={{ opacity: 1 }}
exit={{ opacity: 0 }} exit={{ opacity: 0 }}
@@ -156,7 +155,7 @@ export default function Lightbox({ isOpen, images, initialIndex, onClose }: Ligh
onClick={handleClose} onClick={handleClose}
/> />
<m.button <motion.button
initial={{ opacity: 0, scale: 0.5 }} initial={{ opacity: 0, scale: 0.5 }}
animate={{ opacity: 1, scale: 1 }} animate={{ opacity: 1, scale: 1 }}
exit={{ opacity: 0, scale: 0.5 }} exit={{ opacity: 0, scale: 0.5 }}
@@ -169,9 +168,9 @@ export default function Lightbox({ isOpen, images, initialIndex, onClose }: Ligh
<div className="relative w-full h-full flex items-center justify-center group-hover:rotate-90 transition-transform duration-500"> <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> <span className="text-3xl font-extralight leading-none mb-1">×</span>
</div> </div>
</m.button> </motion.button>
<m.button <motion.button
initial={{ opacity: 0, x: -20 }} initial={{ opacity: 0, x: -20 }}
animate={{ opacity: 1, x: 0 }} animate={{ opacity: 1, x: 0 }}
exit={{ opacity: 0, x: -20 }} exit={{ opacity: 0, x: -20 }}
@@ -183,9 +182,9 @@ export default function Lightbox({ isOpen, images, initialIndex, onClose }: Ligh
<span className="text-4xl font-extralight group-hover:-translate-x-1 transition-transform duration-500"> <span className="text-4xl font-extralight group-hover:-translate-x-1 transition-transform duration-500">
</span> </span>
</m.button> </motion.button>
<m.button <motion.button
initial={{ opacity: 0, x: 20 }} initial={{ opacity: 0, x: 20 }}
animate={{ opacity: 1, x: 0 }} animate={{ opacity: 1, x: 0 }}
exit={{ opacity: 0, x: 20 }} exit={{ opacity: 0, x: 20 }}
@@ -197,9 +196,9 @@ export default function Lightbox({ isOpen, images, initialIndex, onClose }: Ligh
<span className="text-4xl font-extralight group-hover:translate-x-1 transition-transform duration-500"> <span className="text-4xl font-extralight group-hover:translate-x-1 transition-transform duration-500">
</span> </span>
</m.button> </motion.button>
<m.div <motion.div
initial={{ opacity: 0, y: 40, scale: 0.95 }} initial={{ opacity: 0, y: 40, scale: 0.95 }}
animate={{ opacity: 1, y: 0, scale: 1 }} animate={{ opacity: 1, y: 0, scale: 1 }}
exit={{ opacity: 0, y: 20, scale: 0.98 }} exit={{ opacity: 0, y: 20, scale: 0.98 }}
@@ -209,7 +208,7 @@ export default function Lightbox({ isOpen, images, initialIndex, onClose }: Ligh
<div className="pointer-events-auto w-full h-full flex flex-col items-center justify-center"> <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"> <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}> <AnimatePresence mode="wait" initial={false}>
<m.div <motion.div
key={currentIndex} key={currentIndex}
initial={{ opacity: 0, scale: 1.1, filter: 'blur(10px)' }} initial={{ opacity: 0, scale: 1.1, filter: 'blur(10px)' }}
animate={{ opacity: 1, scale: 1, filter: 'blur(0px)' }} animate={{ opacity: 1, scale: 1, filter: 'blur(0px)' }}
@@ -224,7 +223,7 @@ export default function Lightbox({ isOpen, images, initialIndex, onClose }: Ligh
className="object-cover transition-transform duration-1000 hover:scale-[1.03]" className="object-cover transition-transform duration-1000 hover:scale-[1.03]"
unoptimized unoptimized
/> />
</m.div> </motion.div>
</AnimatePresence> </AnimatePresence>
{/* Technical Detail: Subtle grid overlay to reinforce industrial precision */} {/* Technical Detail: Subtle grid overlay to reinforce industrial precision */}
@@ -234,7 +233,7 @@ export default function Lightbox({ isOpen, images, initialIndex, onClose }: Ligh
<div className="absolute inset-0 pointer-events-none bg-gradient-to-tr from-white/10 via-transparent to-transparent opacity-40 z-10" /> <div className="absolute inset-0 pointer-events-none bg-gradient-to-tr from-white/10 via-transparent to-transparent opacity-40 z-10" />
</div> </div>
<m.div <motion.div
initial={{ opacity: 0, y: 10 }} initial={{ opacity: 0, y: 10 }}
animate={{ opacity: 1, y: 0 }} animate={{ opacity: 1, y: 0 }}
exit={{ opacity: 0, y: 10 }} exit={{ opacity: 0, y: 10 }}
@@ -246,13 +245,12 @@ export default function Lightbox({ isOpen, images, initialIndex, onClose }: Ligh
{currentIndex + 1} <span className="text-accent mx-3">/</span> {images.length} {currentIndex + 1} <span className="text-accent mx-3">/</span> {images.length}
</div> </div>
<div className="h-px w-12 bg-white/20" /> <div className="h-px w-12 bg-white/20" />
</m.div> </motion.div>
</div> </div>
</m.div> </motion.div>
</div> </div>
)} )}
</AnimatePresence> </AnimatePresence>,
</LazyMotion>,
document.body, document.body,
); );
} }

View File

@@ -1,134 +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';
import ProductTechnicalData from './ProductTechnicalData';
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} />;
case 'productTabs':
return <ProductTechnicalData data={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; description?: string;
label?: string; label?: string;
image?: string; image?: string;
logo?: string;
mode?: 'dark' | 'light' | 'image'; mode?: 'dark' | 'light' | 'image';
} }
@@ -14,7 +13,6 @@ export function OGImageTemplate({
description, description,
label, label,
image, image,
logo,
mode = 'dark', mode = 'dark',
}: OGImageTemplateProps) { }: OGImageTemplateProps) {
const primaryBlue = '#001a4d'; const primaryBlue = '#001a4d';
@@ -154,13 +152,9 @@ export function OGImageTemplate({
height: '6px', height: '6px',
backgroundColor: accentGreen, backgroundColor: accentGreen,
borderRadius: '3px', 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 <div
style={{ style={{
fontSize: '24px', fontSize: '24px',
@@ -173,7 +167,6 @@ export function OGImageTemplate({
> >
KLZ Cables KLZ Cables
</div> </div>
)}
</div> </div>
{/* Saturated Blue Brand Strip */} {/* 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 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"> <div className="relative w-full h-full transition-transform duration-1000 ease-out group-hover:scale-105">
<Image <Image
src={productImage.split('?')[0]} src={productImage}
alt={productName} alt={productName}
fill fill
className="object-contain p-2 drop-shadow-[0_20px_30px_rgba(0,0,0,0.4)]" 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 ( return (
<div className="space-y-8 md:space-y-16"> <div className="space-y-16">
{technicalItems.length > 0 && ( {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"> <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" /> <div className="w-2 h-8 bg-accent rounded-full" />
General Data General Data
</h3> </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) => ( {technicalItems.map((item, idx) => (
<div key={idx} className="flex flex-col group"> <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"> <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 ( return (
<div <div
key={idx} 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"> <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" /> <div className="w-2 h-8 bg-accent rounded-full" />
@@ -83,7 +83,7 @@ export default function ProductTechnicalData({ data }: ProductTechnicalDataProps
</h3> </h3>
{table.metaItems.length > 0 && ( {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) => ( {table.metaItems.map((item, mIdx) => (
<div key={mIdx}> <div key={mIdx}>
<dt className="text-[10px] font-black uppercase tracking-[0.2em] text-primary/40 mb-1"> <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"> <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 <div
id={`voltage-table-${idx}`} 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]' !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"> <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} {row.configuration}
</td> </td>
{row.cells.map((cell: any, cellIdx: number) => ( {row.cells.map((cell, cellIdx) => (
<td <td
key={cellIdx} key={cellIdx}
className="px-3 py-2 text-xs text-text-secondary whitespace-nowrap" className="px-3 py-2 text-xs text-text-secondary whitespace-nowrap"
> >
{typeof cell === 'object' && cell !== null && 'value' in cell {cell}
? cell.value
: cell}
</td> </td>
))} ))}
</tr> </tr>

View File

@@ -1,4 +1,4 @@
import { getAllProducts } from '@/lib/products'; import { getAllProducts } from '@/lib/mdx';
import { getTranslations } from 'next-intl/server'; import { getTranslations } from 'next-intl/server';
import Image from 'next/image'; import Image from 'next/image';
import { RelatedProductLink } from './RelatedProductLink'; import { RelatedProductLink } from './RelatedProductLink';
@@ -29,32 +29,6 @@ export default async function RelatedProducts({
if (related.length === 0) return null; 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 ( return (
<div className=""> <div className="">
<div className="flex items-end justify-between mb-12"> <div className="flex items-end justify-between mb-12">
@@ -67,11 +41,29 @@ export default async function RelatedProducts({
</div> </div>
<div className="grid grid-cols-1 md:grid-cols-3 gap-8"> <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 ( return (
<RelatedProductLink <RelatedProductLink
key={product.slug} key={product.slug}
href={`/${locale}/${productsSlug}/${product.catSlug}/${product.slug}`} href={`/${locale}/${productsSlug}/${catSlug}/${product.slug}`}
productSlug={product.slug} productSlug={product.slug}
productTitle={product.frontmatter.title} 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" 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] ? ( {product.frontmatter.images?.[0] ? (
<> <>
<Image <Image
src={product.frontmatter.images[0].split('?')[0]} src={product.frontmatter.images[0]}
alt={product.frontmatter.title} alt={product.frontmatter.title}
fill fill
className="object-contain p-4 transition-transform duration-700 group-hover:scale-110 z-10" 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') { if (status === 'success') {
return ( return (
<div <div
@@ -167,33 +164,29 @@ export default function RequestQuoteForm({ productName }: RequestQuoteFormProps)
<form onSubmit={handleSubmit} className="space-y-3 !mt-0"> <form onSubmit={handleSubmit} className="space-y-3 !mt-0">
<div className="space-y-2 !mt-0"> <div className="space-y-2 !mt-0">
<div className="space-y-1 !mt-0"> <div className="space-y-1 !mt-0">
<label htmlFor={emailId} className="sr-only">
{t('email')}
</label>
<Input <Input
type="email" type="email"
id={emailId} id="quote-email"
required required
value={email} value={email}
onChange={(e) => setEmail(e.target.value)} onChange={(e) => setEmail(e.target.value)}
onFocus={() => handleFocus('quote-email')} onFocus={() => handleFocus('quote-email')}
placeholder={t('email')} placeholder={t('email')}
aria-label={t('email')}
className="h-9 text-xs !mt-0" className="h-9 text-xs !mt-0"
/> />
</div> </div>
<div className="space-y-1 !mt-0"> <div className="space-y-1 !mt-0">
<label htmlFor={requestId} className="sr-only">
{t('message')}
</label>
<Textarea <Textarea
id={requestId} id="quote-request"
required required
rows={3} rows={3}
value={request} value={request}
onChange={(e) => setRequest(e.target.value)} onChange={(e) => setRequest(e.target.value)}
onFocus={() => handleFocus('quote-request')} onFocus={() => handleFocus('quote-request')}
placeholder={t('message')} placeholder={t('message')}
aria-label={t('message')}
className="text-xs !mt-0" className="text-xs !mt-0"
/> />
</div> </div>

View File

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

View File

@@ -8,7 +8,7 @@ export default function SkipLink() {
return ( return (
<a <a
href="#main-content" 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')} {t('skipToContent')}
</a> </a>

View File

@@ -1,7 +1,7 @@
'use client'; 'use client';
import dynamic from 'next/dynamic'; import dynamic from 'next/dynamic';
import { Suspense, useEffect, useState } from 'react'; import { Suspense } from 'react';
const DynamicAnalyticsProvider = dynamic(() => import('./AnalyticsProvider'), { const DynamicAnalyticsProvider = dynamic(() => import('./AnalyticsProvider'), {
ssr: false, ssr: false,
@@ -9,35 +9,12 @@ const DynamicAnalyticsProvider = dynamic(() => import('./AnalyticsProvider'), {
const DynamicScrollDepthTracker = dynamic(() => import('./ScrollDepthTracker'), { const DynamicScrollDepthTracker = dynamic(() => import('./ScrollDepthTracker'), {
ssr: false, ssr: false,
}); });
const DynamicWebVitalsTracker = dynamic(() => import('./WebVitalsTracker'), {
ssr: false,
});
export default function AnalyticsShell() { 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 ( return (
<Suspense fallback={null}> <Suspense fallback={null}>
<DynamicAnalyticsProvider /> <DynamicAnalyticsProvider />
<DynamicScrollDepthTracker /> <DynamicScrollDepthTracker />
<DynamicWebVitalsTracker />
</Suspense> </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, product_category: product.category,
price: product.price, price: product.price,
quantity: quantity, quantity: quantity,
cart_total: 150.0, // Current cart total cart_total: 150.00, // Current cart total
}); });
// Actual add to cart logic // Actual add to cart logic
// addToCart(product, quantity); // 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_tax: order.tax,
transaction_shipping: order.shipping, transaction_shipping: order.shipping,
product_count: order.items.length, product_count: order.items.length,
products: order.items.map((item) => ({ products: order.items.map(item => ({
product_id: item.product.id, product_id: item.product.id,
product_name: item.product.name, product_name: item.product.name,
quantity: item.quantity, quantity: item.quantity,
@@ -196,19 +200,25 @@ function WishlistButton({ product }) {
const newState = !isInWishlist; const newState = !isInWishlist;
trackEvent( trackEvent(
newState ? AnalyticsEvents.PRODUCT_WISHLIST_ADD : AnalyticsEvents.PRODUCT_WISHLIST_REMOVE, newState
? AnalyticsEvents.PRODUCT_WISHLIST_ADD
: AnalyticsEvents.PRODUCT_WISHLIST_REMOVE,
{ {
product_id: product.id, product_id: product.id,
product_name: product.name, product_name: product.name,
product_category: product.category, product_category: product.category,
}, }
); );
setIsInWishlist(newState); setIsInWishlist(newState);
// Update wishlist in backend // 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>) => { const handleChange = (e: React.ChangeEvent<HTMLInputElement | HTMLTextAreaElement>) => {
setFormData((prev) => ({ setFormData(prev => ({
...prev, ...prev,
[e.target.name]: e.target.value, [e.target.name]: e.target.value,
})); }));
@@ -300,7 +310,9 @@ function NewsletterSignup() {
return ( return (
<div> <div>
<input placeholder="Enter email" /> <input placeholder="Enter email" />
<button onClick={() => handleSubscribe('user@example.com')}>Subscribe</button> <button onClick={() => handleSubscribe('user@example.com')}>
Subscribe
</button>
</div> </div>
); );
} }
@@ -384,12 +396,10 @@ function LoginForm() {
}; };
return ( return (
<form <form onSubmit={(e) => {
onSubmit={(e) => {
e.preventDefault(); e.preventDefault();
handleLogin('user@example.com', 'password'); handleLogin('user@example.com', 'password');
}} }}>
>
{/* Form fields */} {/* Form fields */}
<button type="submit">Login</button> <button type="submit">Login</button>
</form> </form>
@@ -408,7 +418,11 @@ import { AnalyticsEvents } from '@/components/analytics/analytics-events';
function SignupForm() { function SignupForm() {
const { trackEvent } = useAnalytics(); 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, { trackEvent(AnalyticsEvents.USER_SIGNUP, {
user_email: userData.email, user_email: userData.email,
user_name: userData.name, user_name: userData.name,
@@ -422,16 +436,14 @@ function SignupForm() {
}; };
return ( return (
<form <form onSubmit={(e) => {
onSubmit={(e) => {
e.preventDefault(); e.preventDefault();
handleSignup({ handleSignup({
email: 'user@example.com', email: 'user@example.com',
name: 'John Doe', name: 'John Doe',
company: 'ACME Corp', company: 'ACME Corp',
}); });
}} }}>
>
{/* Form fields */} {/* Form fields */}
<button type="submit">Sign Up</button> <button type="submit">Sign Up</button>
</form> </form>
@@ -619,7 +631,11 @@ function VideoPlayer({ videoId, videoTitle }) {
}; };
return ( return (
<video onPlay={handlePlay} onPause={handlePause} onEnded={handleComplete}> <video
onPlay={handlePlay}
onPause={handlePause}
onEnded={handleComplete}
>
<source src="/video.mp4" type="video/mp4" /> <source src="/video.mp4" type="video/mp4" />
</video> </video>
); );
@@ -649,7 +665,11 @@ function DownloadButton({ fileName, fileType, fileSize }) {
// window.location.href = `/downloads/${fileName}`; // window.location.href = `/downloads/${fileName}`;
}; };
return <button onClick={handleDownload}>Download {fileName}</button>; return (
<button onClick={handleDownload}>
Download {fileName}
</button>
);
} }
``` ```
@@ -943,9 +963,15 @@ function CableProductPage({ cable }) {
return ( return (
<div> <div>
<h1>{cable.name}</h1> <h1>{cable.name}</h1>
<button onClick={handleTechnicalSpecDownload}>Download Technical Specs</button> <button onClick={handleTechnicalSpecDownload}>
<button onClick={handleRequestQuote}>Request Quote</button> Download Technical Specs
<button onClick={handleBrochureDownload}>Download Brochure</button> </button>
<button onClick={handleRequestQuote}>
Request Quote
</button>
<button onClick={handleBrochureDownload}>
Download Brochure
</button>
</div> </div>
); );
} }
@@ -984,8 +1010,12 @@ function WindFarmProjectPage({ project }) {
return ( return (
<div> <div>
<h1>{project.name}</h1> <h1>{project.name}</h1>
<button onClick={handleProjectInquiry}>Request Project Consultation</button> <button onClick={handleProjectInquiry}>
<button onClick={handleCableCalculation}>Calculate Cable Requirements</button> Request Project Consultation
</button>
<button onClick={handleCableCalculation}>
Calculate Cable Requirements
</button>
</div> </div>
); );
} }
@@ -1036,7 +1066,7 @@ test('tracks button click', () => {
// [Umami] Tracked pageview: /products/123 // [Umami] Tracked pageview: /products/123
// To test without sending data to Umami: // 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 // 2. Or set it to an empty string
// 3. Check console logs to verify events are being tracked // 3. Check console logs to verify events are being tracked
``` ```
@@ -1139,9 +1169,7 @@ function WebVitalsTracker() {
} }
}); });
observer.observe({ observer.observe({ entryTypes: ['largest-contentful-paint', 'first-input-delay', 'layout-shift'] });
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) -**Business-specific events** (KLZ Cables, wind farms)
Remember to: Remember to:
1. Use the `useAnalytics` hook for client-side tracking 1. Use the `useAnalytics` hook for client-side tracking
2. Import events from `AnalyticsEvents` for consistency 2. Import events from `AnalyticsEvents` for consistency
3. Include relevant context in your events 3. Include relevant context in your events

View File

@@ -2,7 +2,7 @@
## Setup Checklist ## 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 `UmamiScript` is in your layout
- [ ] Verify `AnalyticsProvider` is in your layout - [ ] Verify `AnalyticsProvider` is in your layout
- [ ] Test in development mode - [ ] Test in development mode
@@ -12,7 +12,7 @@
```bash ```bash
# Required # 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) # Optional (defaults to https://analytics.infra.mintel.me/script.js)
NEXT_PUBLIC_UMAMI_SCRIPT_URL=https://analytics.infra.mintel.me/script.js NEXT_PUBLIC_UMAMI_SCRIPT_URL=https://analytics.infra.mintel.me/script.js
@@ -87,7 +87,7 @@ function ProductCard({ product }) {
## Common Events ## Common Events
| Event | When to Use | Example Properties | | Event | When to Use | Example Properties |
| --------------------- | ------------------- | ------------------------------------------------- | |-------|-------------|-------------------|
| `pageview` | Page loads | `{ url: '/products/123' }` | | `pageview` | Page loads | `{ url: '/products/123' }` |
| `button_click` | Button clicked | `{ button_id: 'cta', page: 'homepage' }` | | `button_click` | Button clicked | `{ button_id: 'cta', page: 'homepage' }` |
| `form_submit` | Form submitted | `{ form_id: 'contact', form_name: 'Contact Us' }` | | `form_submit` | Form submitted | `{ form_id: 'contact', form_name: 'Contact Us' }` |
@@ -112,7 +112,7 @@ In development, you'll see console logs:
```bash ```bash
# .env.local # .env.local
# UMAMI_WEBSITE_ID= # NEXT_PUBLIC_UMAMI_WEBSITE_ID=
``` ```
## Troubleshooting ## Troubleshooting
@@ -120,9 +120,8 @@ In development, you'll see console logs:
### Analytics Not Working? ### Analytics Not Working?
1. **Check environment variables:** 1. **Check environment variables:**
```bash ```bash
echo $UMAMI_WEBSITE_ID echo $NEXT_PUBLIC_UMAMI_WEBSITE_ID
``` ```
2. **Verify script is loading:** 2. **Verify script is loading:**
@@ -138,7 +137,7 @@ In development, you'll see console logs:
### Common Issues ### Common Issues
| Issue | Solution | | Issue | Solution |
| ------------------- | ----------------------------------- | |-------|----------|
| No data in Umami | Check website ID and script URL | | No data in Umami | Check website ID and script URL |
| Events not tracking | Verify `useAnalytics` hook is used | | Events not tracking | Verify `useAnalytics` hook is used |
| Script not loading | Check network connection, CORS | | Script not loading | Check network connection, CORS |

View File

@@ -20,7 +20,7 @@ Add these to your `.env` file:
```bash ```bash
# Required: Your Umami website ID # 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) # 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 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 ```yaml
environment: 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} - 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>
);
} }
``` ```
@@ -273,7 +277,11 @@ function ErrorBoundary({ children }) {
}); });
}; };
return <ErrorBoundary onError={handleError}>{children}</ErrorBoundary>; return (
<ErrorBoundary onError={handleError}>
{children}
</ErrorBoundary>
);
} }
``` ```
@@ -282,7 +290,7 @@ function ErrorBoundary({ children }) {
### Common Events ### Common Events
| Event Name | Description | Example Properties | | Event Name | Description | Example Properties |
| --------------------- | --------------------- | ------------------------------------------------------------ | |------------|-------------|-------------------|
| `pageview` | Page view | `{ url: '/products/123' }` | | `pageview` | Page view | `{ url: '/products/123' }` |
| `button_click` | Button clicked | `{ button_id: 'cta-primary', page: 'homepage' }` | | `button_click` | Button clicked | `{ button_id: 'cta-primary', page: 'homepage' }` |
| `link_click` | Link clicked | `{ link_url: '/products', link_text: 'View Products' }` | | `link_click` | Link clicked | `{ link_url: '/products', link_text: 'View Products' }` |
@@ -377,9 +385,8 @@ The analytics system includes development mode logging:
### Analytics Not Working ### Analytics Not Working
1. **Check environment variables:** 1. **Check environment variables:**
```bash ```bash
echo $UMAMI_WEBSITE_ID echo $NEXT_PUBLIC_UMAMI_WEBSITE_ID
``` ```
2. **Verify the script is loading:** 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 ### 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 ```bash
# .env.local (not committed to git) # .env.local (not committed to git)
# UMAMI_WEBSITE_ID= # NEXT_PUBLIC_UMAMI_WEBSITE_ID=
``` ```
## Performance ## Performance
@@ -431,7 +438,6 @@ The analytics implementation is optimized for performance:
## Support ## Support
For issues or questions about the analytics implementation, check: For issues or questions about the analytics implementation, check:
1. This README for usage examples 1. This README for usage examples
2. The component source code for implementation details 2. The component source code for implementation details
3. The Umami documentation for platform-specific questions 3. The Umami documentation for platform-specific questions

View File

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

View File

@@ -29,14 +29,10 @@ export default function TrackedLink({
const { trackEvent } = useAnalytics(); const { trackEvent } = useAnalytics();
const handleClick = (e: React.MouseEvent) => { const handleClick = (e: React.MouseEvent) => {
try {
trackEvent(eventName, { trackEvent(eventName, {
href, href,
...eventProperties, ...eventProperties,
}); });
} catch (_e) {
// Analytics tracking should not block navigation, so we catch and ignore errors.
}
if (onClick) onClick(); 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) { if (containerRef.current) {
@@ -49,12 +49,10 @@ export default function AnimatedImage({
ref={containerRef} ref={containerRef}
className={`relative overflow-hidden rounded-2xl shadow-2xl my-16 group ${className}`} className={`relative overflow-hidden rounded-2xl shadow-2xl my-16 group ${className}`}
> >
<div <div className={`
className={`
absolute inset-0 bg-primary/10 z-10 pointer-events-none transition-opacity duration-1000 absolute inset-0 bg-primary/10 z-10 pointer-events-none transition-opacity duration-1000
${isLoaded && isInView ? 'opacity-0' : 'opacity-100'} ${isLoaded && isInView ? 'opacity-0' : 'opacity-100'}
`} `} />
/>
<Image <Image
src={src} src={src}
@@ -72,6 +70,14 @@ export default function AnimatedImage({
{/* Subtle reflection overlay */} {/* 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" /> <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> </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,22 +1,14 @@
import React from 'react'; import React from 'react';
import Link from 'next/link'; import Link from 'next/link';
import { PostData } from '@/lib/blog'; import { PostMdx } from '@/lib/blog';
interface PostNavigationProps { interface PostNavigationProps {
prev: PostData | null; prev: PostMdx | null;
next: PostData | null; next: PostMdx | null;
isPrevRandom?: boolean;
isNextRandom?: boolean;
locale: string; locale: string;
} }
export default function PostNavigation({ export default function PostNavigation({ prev, next, locale }: PostNavigationProps) {
prev,
next,
isPrevRandom,
isNextRandom,
locale,
}: PostNavigationProps) {
if (!prev && !next) return null; if (!prev && !next) return null;
return ( return (
@@ -30,11 +22,8 @@ export default function PostNavigation({
{/* Background Image */} {/* Background Image */}
{prev.frontmatter.featuredImage ? ( {prev.frontmatter.featuredImage ? (
<div <div
className="absolute inset-0 z-0 bg-cover transition-transform duration-1000 group-hover:scale-105" className="absolute inset-0 bg-cover bg-center transition-transform duration-700 group-hover:scale-110"
style={{ style={{ backgroundImage: `url(${prev.frontmatter.featuredImage})` }}
backgroundImage: `url(${prev.frontmatter.featuredImage.split('?')[0]})`,
backgroundPosition: `${prev.frontmatter.focalX ?? 50}% ${prev.frontmatter.focalY ?? 50}%`,
}}
/> />
) : ( ) : (
<div className="absolute inset-0 bg-neutral-100" /> <div className="absolute inset-0 bg-neutral-100" />
@@ -45,14 +34,8 @@ export default function PostNavigation({
{/* Content */} {/* Content */}
<div className="absolute inset-0 flex flex-col justify-center p-8 md:p-12 text-white z-10"> <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"> <span className="text-sm font-bold uppercase tracking-wider mb-2 opacity-70">
{isPrevRandom {locale === 'de' ? 'Vorheriger Beitrag' : 'Previous Post'}
? locale === 'de'
? 'Weiterer Artikel'
: 'More Article'
: locale === 'de'
? 'Vorheriger Beitrag'
: 'Previous Post'}
</span> </span>
<h3 className="text-xl md:text-2xl font-bold leading-tight group-hover:underline decoration-2 underline-offset-4"> <h3 className="text-xl md:text-2xl font-bold leading-tight group-hover:underline decoration-2 underline-offset-4">
{prev.frontmatter.title} {prev.frontmatter.title}
@@ -60,14 +43,9 @@ export default function PostNavigation({
</div> </div>
{/* Arrow Icon */} {/* 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"> <svg className="w-8 h-8" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path <path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M15 19l-7-7 7-7" />
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M15 19l-7-7 7-7"
/>
</svg> </svg>
</div> </div>
</Link> </Link>
@@ -84,11 +62,8 @@ export default function PostNavigation({
{/* Background Image */} {/* Background Image */}
{next.frontmatter.featuredImage ? ( {next.frontmatter.featuredImage ? (
<div <div
className="absolute inset-0 z-0 bg-cover transition-transform duration-1000 group-hover:scale-105" className="absolute inset-0 bg-cover bg-center transition-transform duration-700 group-hover:scale-110"
style={{ style={{ backgroundImage: `url(${next.frontmatter.featuredImage})` }}
backgroundImage: `url(${next.frontmatter.featuredImage.split('?')[0]})`,
backgroundPosition: `${next.frontmatter.focalX ?? 50}% ${next.frontmatter.focalY ?? 50}%`,
}}
/> />
) : ( ) : (
<div className="absolute inset-0 bg-neutral-100" /> <div className="absolute inset-0 bg-neutral-100" />
@@ -99,14 +74,8 @@ export default function PostNavigation({
{/* Content */} {/* Content */}
<div className="absolute inset-0 flex flex-col justify-center items-end text-right p-8 md:p-12 text-white z-10"> <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"> <span className="text-sm font-bold uppercase tracking-wider mb-2 opacity-70">
{isNextRandom {locale === 'de' ? 'Nächster Beitrag' : 'Next Post'}
? locale === 'de'
? 'Weiterer Artikel'
: 'More Article'
: locale === 'de'
? 'Nächster Beitrag'
: 'Next Post'}
</span> </span>
<h3 className="text-xl md:text-2xl font-bold leading-tight group-hover:underline decoration-2 underline-offset-4"> <h3 className="text-xl md:text-2xl font-bold leading-tight group-hover:underline decoration-2 underline-offset-4">
{next.frontmatter.title} {next.frontmatter.title}

View File

@@ -28,7 +28,7 @@ export default function PowerCTA({ locale }: PowerCTAProps) {
<span className="text-accent block">{isDe ? 'Energiewende?' : 'Energy Transition?'}</span> <span className="text-accent block">{isDe ? 'Energiewende?' : 'Energy Transition?'}</span>
</h3> </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 {isDe
? 'Von der Planung von Wind- und Solarparks bis zur Lieferung hochwertiger Energiekabel erwecken wir Ihre Projekte zum Leben.' ? '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.'} : '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' ? 'Zertifizierte Qualität nach EU-Standards'
: 'Certified quality according to EU standards', : 'Certified quality according to EU standards',
].map((item, i) => ( ].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"> <div className="w-6 h-6 rounded-full bg-accent/20 flex items-center justify-center flex-shrink-0">
<svg <svg
className="w-3 h-3 text-accent" className="w-3 h-3 text-accent"
@@ -88,7 +88,7 @@ export default function PowerCTA({ locale }: PowerCTAProps) {
/> />
</svg> </svg>
</Link> </Link>
<p className="text-white/80 text-sm font-medium"> <p className="text-white/50 text-sm font-medium">
{isDe {isDe
? 'Kostenlose Erstberatung für Ihr Vorhaben.' ? 'Kostenlose Erstberatung für Ihr Vorhaben.'
: 'Free initial consultation for your project.'} : 'Free initial consultation for your project.'}

View File

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

View File

@@ -53,7 +53,7 @@ export default function TableOfContents({ headings, locale }: TableOfContentsPro
return ( return (
<nav className="hidden lg:block w-full ml-12"> <nav className="hidden lg:block w-full ml-12">
<div className="relative pl-6 border-l border-neutral-200"> <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'} {locale === 'de' ? 'Inhalt' : 'Table of Contents'}
</h4> </h4>
<ul className="space-y-4"> <ul className="space-y-4">

View File

@@ -19,17 +19,12 @@ export default function VisualLinkPreview({ url, title, summary, image }: Visual
})(); })();
return ( return (
<Link <Link href={url} target="_blank" rel="noopener noreferrer" className="block my-12 no-underline group">
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="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"> <div className="relative w-full md:w-64 h-48 md:h-auto flex-shrink-0 bg-neutral-50 overflow-hidden">
{image ? ( {image ? (
<Image <Image
src={image.split('?')[0]} src={image}
alt={title} alt={title}
fill fill
unoptimized unoptimized
@@ -37,18 +32,8 @@ export default function VisualLinkPreview({ url, title, summary, image }: Visual
/> />
) : ( ) : (
<div className="w-full h-full flex items-center justify-center bg-primary/5"> <div className="w-full h-full flex items-center justify-center bg-primary/5">
<svg <svg className="w-12 h-12 text-primary/20" fill="none" stroke="currentColor" viewBox="0 0 24 24">
className="w-12 h-12 text-primary/20" <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" />
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> </svg>
</div> </div>
)} )}
@@ -61,10 +46,10 @@ export default function VisualLinkPreview({ url, title, summary, image }: Visual
<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="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"> <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 External Link
</span> </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} {hostname}
</span> </span>
</div> </div>
@@ -79,18 +64,8 @@ export default function VisualLinkPreview({ url, title, summary, image }: Visual
<div className="flex items-center gap-2 text-primary font-bold text-xs uppercase tracking-widest"> <div className="flex items-center gap-2 text-primary font-bold text-xs uppercase tracking-widest">
<span>Read more</span> <span>Read more</span>
<svg <svg className="w-4 h-4 transition-transform group-hover:translate-x-1" fill="none" stroke="currentColor" viewBox="0 0 24 24">
className="w-4 h-4 transition-transform group-hover:translate-x-1" <path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M17 8l4 4m0 0l-4 4m4-4H3" />
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> </svg>
</div> </div>
</div> </div>

View File

@@ -2,7 +2,7 @@ import React from 'react';
import { useTranslations, useLocale } from 'next-intl'; import { useTranslations, useLocale } from 'next-intl';
import { Section, Container, Button, Heading } from '../../components/ui'; 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 t = useTranslations('Home.cta');
const locale = useLocale(); const locale = useLocale();
@@ -14,16 +14,16 @@ export default function CTA({ data }: { data?: any }) {
<Container className="relative z-10"> <Container className="relative z-10">
<div className="flex flex-col lg:flex-row items-center justify-between gap-16"> <div className="flex flex-col lg:flex-row items-center justify-between gap-16">
<div className="max-w-3xl text-center lg:text-left"> <div className="max-w-3xl text-center lg:text-left">
<Heading level={2} subtitle={data?.subtitle || t('subtitle')} className="text-white mb-6"> <Heading level={2} subtitle={t('subtitle')} className="text-white mb-6">
<span className="text-white">{data?.title || t('title')}</span> <span className="text-white">{t('title')}</span>
</Heading> </Heading>
<p className="text-lg md:text-xl text-white/70 leading-relaxed"> <p className="text-lg md:text-xl text-white/70 leading-relaxed">
{data?.description || t('description')} {t('description')}
</p> </p>
</div> </div>
<div className="flex-shrink-0"> <div className="flex-shrink-0">
<Button href={`/${locale}/contact`} variant="accent" size="xl" className="group px-12"> <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> <span className="ml-3 transition-transform group-hover:translate-x-2">&rarr;</span>
</Button> </Button>
</div> </div>

View File

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

View File

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

View File

@@ -1,13 +1,15 @@
'use client'; 'use client';
import Scribble from '@/components/Scribble';
import { Button, Container, Heading, Section } from '@/components/ui'; import { Button, Container, Heading, Section } from '@/components/ui';
import { motion } from 'framer-motion';
import { useTranslations, useLocale } from 'next-intl'; import { useTranslations, useLocale } from 'next-intl';
import dynamic from 'next/dynamic'; import dynamic from 'next/dynamic';
import { useAnalytics } from '../analytics/useAnalytics'; import { useAnalytics } from '../analytics/useAnalytics';
import { AnalyticsEvents } from '../analytics/analytics-events'; import { AnalyticsEvents } from '../analytics/analytics-events';
const HeroIllustration = dynamic(() => import('./HeroIllustration'), { ssr: false }); const HeroIllustration = dynamic(() => import('./HeroIllustration'), { ssr: false });
export default function Hero({ data }: { data?: any }) { export default function Hero() {
const t = useTranslations('Home.hero'); const t = useTranslations('Home.hero');
const locale = useLocale(); const locale = useLocale();
const { trackEvent } = useAnalytics(); const { trackEvent } = useAnalytics();
@@ -15,84 +17,182 @@ export default function Hero({ data }: { data?: any }) {
return ( return (
<Section className="relative min-h-[85vh] md:h-[90vh] flex flex-col items-center justify-center overflow-hidden bg-primary py-12 md:py-0 lg:py-0"> <Section 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"> <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"> <motion.div
<div> className="max-w-5xl mx-auto md:mx-0"
initial="hidden"
animate="visible"
variants={containerVariants}
>
<motion.div variants={headingVariants}>
<Heading <Heading
level={1} 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 ? ( {t.rich('title', {
<span green: (chunks) => (
dangerouslySetInnerHTML={{ <span className="relative inline-block">
__html: data.title <motion.span
.replace(/<green>/g, '<span class="text-accent italic">') className="relative z-10 text-accent italic"
.replace(/<\/green>/g, '</span>'), variants={accentVariants}
}} >
/> {chunks}
) : ( </motion.span>
t.rich('title', { <motion.div
green: (chunks) => <span className="text-accent italic">{chunks}</span>, 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> </Heading>
</div> </motion.div>
<div> <motion.div variants={subtitleVariants}>
<p className="text-lg md:text-xl text-white leading-relaxed max-w-2xl mb-10 md:mb-12"> <p className="text-lg md:text-xl text-white/90 leading-relaxed max-w-2xl mb-10 md:mb-12">
{data?.subtitle || t('subtitle')} {t('subtitle')}
</p> </p>
</div> </motion.div>
<div className="flex flex-col sm:flex-row justify-center md:justify-start gap-4 md:gap-6"> <motion.div
<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 <Button
href="/contact" href="/contact"
variant="accent" variant="accent"
size="lg" size="lg"
className="group w-full sm:w-auto h-14 md:h-16 px-8 md:px-10 text-base md:text-lg hover:scale-105 transition-transform" className="group w-full sm:w-auto h-14 md:h-16 px-8 md:px-10 text-base md:text-lg"
onClick={() => onClick={() =>
trackEvent(AnalyticsEvents.BUTTON_CLICK, { trackEvent(AnalyticsEvents.BUTTON_CLICK, {
label: data?.ctaLabel || t('cta'), label: t('cta'),
location: 'home_hero_primary', location: 'home_hero_primary',
}) })
} }
> >
{data?.ctaLabel || t('cta')} {t('cta')}
<span className="transition-transform group-hover/btn:translate-x-1 ml-2"> <span className="transition-transform group-hover/btn:translate-x-1">&rarr;</span>
&rarr;
</span>
</Button> </Button>
</div> </motion.div>
<div> <motion.div variants={buttonVariants}>
<Button <Button
href={`/${locale}/${locale === 'de' ? 'produkte' : 'products'}`} href={`/${locale}/${locale === 'de' ? 'produkte' : 'products'}`}
variant="white" variant="white"
size="lg" size="lg"
className="group w-full sm:w-auto h-14 md:h-16 px-8 md:px-10 text-base md:text-lg 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={() => onClick={() =>
trackEvent(AnalyticsEvents.BUTTON_CLICK, { trackEvent(AnalyticsEvents.BUTTON_CLICK, {
label: data?.secondaryCtaLabel || t('exploreProducts'), label: t('exploreProducts'),
location: 'home_hero_secondary', location: 'home_hero_secondary',
}) })
} }
> >
{data?.secondaryCtaLabel || t('exploreProducts')} {t('exploreProducts')}
</Button> </Button>
</div> </motion.div>
</div> </motion.div>
</div> </motion.div>
</Container> </Container>
<div className="relative md:absolute inset-0 z-0 w-full h-[40vh] md:h-full order-1 md:order-none mb-[-80px] md:mb-0 mt-[80px] md:mt-0 overflow-visible pointer-events-none animate-in fade-in zoom-in-95 duration-1000 ease-out fill-mode-both"> <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 /> <HeroIllustration />
</div> </motion.div>
<div <motion.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" className="absolute bottom-6 md:bottom-10 left-1/2 -translate-x-1/2 hidden sm:block"
style={{ animationDelay: '2000ms' }} 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-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
</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> </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'; 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.44 : 1;
const scale = isMobile ? 1.6 : 1; const opacity = isMobile ? 0.6 : 0.85;
const opacity = isMobile ? 0.9 : 0.85;
return ( return (
<div className="absolute inset-0 z-0 overflow-visible bg-primary w-full h-full"> <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="70%" stopColor="white" stopOpacity="0.4" />
<stop offset="100%" stopColor="white" stopOpacity="0" /> <stop offset="100%" stopColor="white" stopOpacity="0" />
</linearGradient> </linearGradient>
<filter id="glow" x="-50%" y="-50%" width="200%" height="200%"> <filter id="glow" x="-20%" y="-20%" width="140%" height="140%">
<feGaussianBlur stdDeviation="3" result="blur" /> <feGaussianBlur stdDeviation="1.5" result="blur" />
<feMerge> <feMerge>
<feMergeNode in="blur" /> <feMergeNode in="blur" />
<feMergeNode in="SourceGraphic" /> <feMergeNode in="SourceGraphic" />
</feMerge> </feMerge>
</filter> </filter>
<filter id="soft-glow" x="-100%" y="-100%" width="300%" height="300%"> <filter id="soft-glow" x="-50%" y="-50%" width="200%" height="200%">
<feGaussianBlur stdDeviation="2" result="blur" /> <feGaussianBlur stdDeviation="1" result="blur" />
<feMerge> <feMerge>
<feMergeNode in="blur" /> <feMergeNode in="blur" />
<feMergeNode in="SourceGraphic" /> <feMergeNode in="SourceGraphic" />
@@ -216,10 +215,10 @@ export default function HeroIllustration() {
</g> </g>
{/* ANIMATED ENERGY FLOW */} {/* ANIMATED ENERGY FLOW */}
<g filter="url(#glow)"> <g>
{POWER_LINES.map((line, i) => { {POWER_LINES.map((line, i) => {
// Only animate a subset of lines to reduce main-thread work // Only animate a small subset of lines to reduce main-thread work significantly
if (i % 2 !== 0) return null; if (i % 5 !== 0) return null;
const from = gridToScreen(line.from.col, line.from.row); const from = gridToScreen(line.from.col, line.from.row);
const to = gridToScreen(line.to.col, line.to.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)); 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 { useTranslations, useLocale } from 'next-intl';
import { Section, Container, Button, Heading } from '../../components/ui'; 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 t = useTranslations('Home.meetTheTeam');
const teamT = useTranslations('Team'); const teamT = useTranslations('Team');
const locale = useLocale(); const locale = useLocale();
@@ -13,11 +13,10 @@ export default function MeetTheTeam({ data }: { data?: any }) {
<div className="absolute inset-0 z-0"> <div className="absolute inset-0 z-0">
<Image <Image
src="/uploads/2024/12/DSC08036-Large.webp" src="/uploads/2024/12/DSC08036-Large.webp"
alt={data?.subtitle || t('subtitle')} alt={t('subtitle')}
fill fill
className="object-cover scale-105 animate-slow-zoom" className="object-cover scale-105 animate-slow-zoom"
sizes="100vw" sizes="100vw"
quality={100}
/> />
<div className="absolute inset-0 bg-primary/70 mix-blend-multiply" /> <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" /> <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"> <Container className="relative z-10">
<div className="max-w-3xl text-white animate-slide-up"> <div className="max-w-3xl text-white animate-slide-up">
<Heading level={2} subtitle={data?.subtitle || t('subtitle')} className="text-white mb-8"> <Heading level={2} subtitle={t('subtitle')} className="text-white mb-8">
<span className="text-white">{data?.title || t('title')}</span> <span className="text-white">{t('title')}</span>
</Heading> </Heading>
<div className="relative mb-12"> <div className="relative mb-12">
<div className="absolute -left-8 top-0 bottom-0 w-1 bg-accent rounded-full" /> <div className="absolute -left-8 top-0 bottom-0 w-1 bg-accent rounded-full" />
<p className="text-xl md:text-2xl leading-relaxed font-medium italic text-white/90 pl-8"> <p className="text-xl md:text-2xl leading-relaxed font-medium italic text-white/90 pl-8">
"{data?.description || t('description')}" "{t('description')}"
</p> </p>
</div> </div>
<div className="flex flex-wrap gap-8 items-center"> <div className="flex flex-wrap gap-8 items-center">
<Button href={`/${locale}/team`} variant="accent" size="xl" className="group"> <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> <span className="ml-3 transition-transform group-hover:translate-x-2">&rarr;</span>
</Button> </Button>
@@ -62,7 +61,7 @@ export default function MeetTheTeam({ data }: { data?: any }) {
</div> </div>
</div> </div>
<span className="text-white/60 font-bold text-xs md:text-sm uppercase tracking-widest"> <span className="text-white/60 font-bold text-xs md:text-sm uppercase tracking-widest">
{data?.networkLabel || t('andNetwork')} {t('andNetwork')}
</span> </span>
</div> </div>
</div> </div>

View File

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

View File

@@ -3,108 +3,99 @@ import Image from 'next/image';
import Link from 'next/link'; import Link from 'next/link';
import { getAllPosts } from '@/lib/blog'; import { getAllPosts } from '@/lib/blog';
import { getTranslations } from 'next-intl/server'; import { getTranslations } from 'next-intl/server';
import { Section, Container, Heading } from '../../components/ui'; import { Section, Container, Heading, Card, Badge } from '../../components/ui';
interface RecentPostsProps { interface RecentPostsProps {
locale: string; 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 t = await getTranslations('Blog');
const posts = await getAllPosts(locale); const posts = await getAllPosts(locale);
const recentPosts = posts.slice(0, 4); const recentPosts = posts.slice(0, 3);
if (recentPosts.length === 0) return null; if (recentPosts.length === 0) return null;
const title = data?.title || t('allArticles');
const subtitle = data?.subtitle || t('latestNews');
return ( return (
<Section className="bg-neutral-light py-0 md:py-0 lg:py-0"> <Section className="bg-neutral py-16 md:py-24">
<Container className="py-12 md:py-16"> <Container>
<div className="flex flex-col md:flex-row items-start md:items-end justify-between gap-6"> <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={subtitle} className="mb-0 text-primary"> <Heading level={2} subtitle={t('latestNews')} className="mb-0 text-primary">
{title} {t('allArticles')}
</Heading> </Heading>
<Link <Link
href={`/${locale}/blog`} href={`/${locale}/blog`}
className="group flex items-center text-primary font-bold text-base md:text-lg touch-target" 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> <span className="ml-2 transition-transform group-hover:translate-x-2">&rarr;</span>
</Link> </Link>
</div> </div>
</Container>
<ul className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 m-0 p-0 list-none"> <ul className="grid grid-cols-1 md:grid-cols-3 gap-8 md:gap-10 list-none p-0 m-0">
{recentPosts.map((post, idx) => ( {recentPosts.map((post) => (
<li key={`${post.slug}-${idx}`} className="block"> <li key={post.slug}>
<Link <Link href={`/${locale}/blog/${post.slug}`} className="group block h-full">
href={`/${locale}/blog/${post.slug}`} <Card
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" tag="article"
className="h-full flex flex-col border-none shadow-lg hover:shadow-2xl transition-all duration-500 rounded-3xl"
> >
{post.frontmatter.featuredImage && ( {post.frontmatter.featuredImage && (
<> <div className="relative h-64 overflow-hidden">
<Image <Image
src={post.frontmatter.featuredImage.split('?')[0]} src={post.frontmatter.featuredImage}
alt={post.frontmatter.title} alt={post.frontmatter.title}
fill fill
className="object-cover transition-transform duration-1000 group-hover:scale-110" className="w-full h-full object-cover transition-transform duration-700 group-hover:scale-110"
style={{ sizes="(max-width: 768px) 100vw, 33vw"
objectPosition: `${post.frontmatter.focalX ?? 50}% ${post.frontmatter.focalY ?? 50}%`,
}}
sizes="(max-width: 768px) 100vw, (max-width: 1024px) 50vw, 25vw"
loading="lazy" 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 image-overlay-gradient opacity-0 group-hover:opacity-100 transition-opacity 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 && ( {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"> <Badge variant="accent" className="absolute top-4 left-4 shadow-md">
{post.frontmatter.category} {post.frontmatter.category}
</span> </Badge>
)}
<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',
},
)}
</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>
<h3 className="text-xl md:text-2xl font-bold mb-2 md:mb-4 leading-tight drop-shadow-md"> )}
<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} {post.frontmatter.title}
</h3> </h3>
</div> <div className="mt-auto flex items-center text-primary font-bold group-hover:underline decoration-2 underline-offset-4">
<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')}
{t('readMore')}{' '} <svg
<span className="ml-2 transition-transform group-hover:translate-x-2"> className="ml-2 w-5 h-5 transition-transform group-hover:translate-x-2"
&rarr; fill="none"
</span> 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>
</div> </div>
</Card>
</Link> </Link>
</li> </li>
))} ))}
</ul> </ul>
</Container>
</Section> </Section>
); );
} }

View File

@@ -1,9 +1,10 @@
'use client'; 'use client';
import React, { useState, useEffect, useRef } from 'react'; import React, { useState, useEffect, useRef } from 'react';
import Scribble from '@/components/Scribble';
import { useTranslations } from 'next-intl'; import { useTranslations } from 'next-intl';
export default function VideoSection({ data }: { data?: any }) { export default function VideoSection() {
const t = useTranslations('Home.video'); const t = useTranslations('Home.video');
const [isVisible, setIsVisible] = useState(false); const [isVisible, setIsVisible] = useState(false);
const sectionRef = useRef<HTMLElement>(null); 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="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"> <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]"> <h2 className="text-3xl md:text-4xl lg:text-5xl font-extrabold text-white leading-[1.1]">
{data?.title ? ( {t.rich('title', {
<span future: (chunks) => (
dangerouslySetInnerHTML={{ <span className="relative inline-block mx-2">
__html: data.title <span className="relative z-10 italic text-accent">{chunks}</span>
.replace(/<future>/g, '<span class="italic text-accent">') <Scribble
.replace(/<\/future>/g, '</span>'), variant="underline"
}} className="w-full h-4 -bottom-2 left-0 text-accent/40"
/> />
) : ( </span>
t.rich('title', { ),
future: (chunks) => <span className="italic text-accent">{chunks}</span>, })}
})
)}
</h2> </h2>
</div> </div>
</div> </div>

View File

@@ -2,35 +2,30 @@ import React from 'react';
import { useTranslations } from 'next-intl'; import { useTranslations } from 'next-intl';
import { Section, Container, Heading } from '../../components/ui'; import { Section, Container, Heading } from '../../components/ui';
export default function WhatWeDo({ data }: { data?: any }) { export default function WhatWeDo() {
const t = useTranslations('Home.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 ( return (
<Section className="bg-white"> <Section className="bg-white">
<Container> <Container>
<div className="sticky-narrative-container"> <div className="sticky-narrative-container">
<div className="sticky-narrative-sidebar"> <div className="sticky-narrative-sidebar">
<div className="lg:sticky lg:top-32"> <div className="lg:sticky lg:top-32">
<Heading level={2} subtitle={data?.expertiseLabel || t('expertise')} className="text-primary-dark"> <Heading level={2} subtitle={t('expertise')} className="text-primary-dark">
{data?.title || t('title')} {t('title')}
</Heading> </Heading>
<p className="text-base md:text-lg text-text-secondary leading-relaxed"> <p className="text-base md:text-lg text-text-secondary leading-relaxed">
{data?.subtitle || t('subtitle')} {t('subtitle')}
</p> </p>
<div className="mt-8 md:mt-12 p-6 md:p-8 bg-saturated/10 rounded-2xl border border-saturated/10"> <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"> <p className="text-saturated font-bold text-base md:text-base italic">
"{data?.quote || t('quote')}" "{t('quote')}"
</p> </p>
</div> </div>
</div> </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"> <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 key={idx} className="group">
<div className="flex items-center gap-4 mb-4 md:mb-6"> <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"> <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> </span>
<div className="h-px flex-grow bg-neutral-medium" /> <div className="h-px flex-grow bg-neutral-medium" />
</div> </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> <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">{item.description}</p> <p className="text-text-secondary text-base md:text-base leading-relaxed">{t(`items.${idx}.description`)}</p>
</div> </div>
))} ))}
</div> </div>

View File

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

View File

@@ -26,7 +26,7 @@ export function Button({
...props ...props
}: ButtonProps) { }: ButtonProps) {
const baseStyles = 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 = { const variants = {
primary: 'bg-primary text-white shadow-md hover:shadow-primary/30 hover:shadow-2xl', primary: 'bg-primary text-white shadow-md hover:shadow-primary/30 hover:shadow-2xl',
@@ -45,8 +45,8 @@ export function Button({
const sizes = { const sizes = {
sm: 'h-9 px-4 text-sm md:text-base', sm: 'h-9 px-4 text-sm md:text-base',
md: 'h-11 px-6 text-base md:text-lg', md: 'h-11 px-6 text-base md:text-lg',
lg: 'h-14 px-5 md:px-8 text-base md:text-lg', lg: 'h-14 px-8 text-base md:text-lg',
xl: 'h-16 px-6 md:px-10 text-lg md:text-xl', xl: 'h-16 px-10 text-lg md:text-xl',
}; };
const styles = cn(baseStyles, variants[variant], sizes[size], className); const styles = cn(baseStyles, variants[variant], sizes[size], className);
@@ -82,13 +82,8 @@ export function Button({
); );
if (href) { if (href) {
const { type, disabled, ...linkProps } = props as any;
return ( return (
<Link <Link href={href} className={styles}>
href={disabled ? '#' : href}
className={cn(styles, disabled && 'pointer-events-none opacity-50')}
{...linkProps}
>
{content} {content}
</Link> </Link>
); );

View File

@@ -13,7 +13,7 @@
"categories:performance": [ "categories:performance": [
"error", "error",
{ {
"minScore": 0.7 "minScore": 0.8
} }
], ],
"categories:accessibility": [ "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