Compare commits

..

25 Commits

Author SHA1 Message Date
8ba1c7ea38 style(pdf): align AGB layout with technical datasheet hero and spacing
All checks were successful
Build & Deploy / 🔍 Prepare (push) Successful in 6s
Build & Deploy / 🧪 QA (push) Successful in 53s
Build & Deploy / 🏗️ Build (push) Successful in 2m13s
Build & Deploy / 🚀 Deploy (push) Successful in 15s
Build & Deploy / 🧪 Post-Deploy Verification (push) Successful in 4m16s
Build & Deploy / 🔔 Notify (push) Successful in 1s
2026-03-16 07:53:29 +01:00
a546ffe69c fix(pdf): remove broken helvetica font registration causing 500 error
Some checks failed
Build & Deploy / 🔍 Prepare (push) Successful in 6s
Build & Deploy / 🧪 QA (push) Successful in 56s
Build & Deploy / 🏗️ Build (push) Successful in 2m29s
Build & Deploy / 🚀 Deploy (push) Failing after 15s
Build & Deploy / 🧪 Post-Deploy Verification (push) Has been skipped
Build & Deploy / 🔔 Notify (push) Successful in 2s
2026-03-16 07:47:47 +01:00
15740db51e chore(ci): re-trigger pipeline after testing db schema hotfix
All checks were successful
Build & Deploy / 🔍 Prepare (push) Successful in 6s
Build & Deploy / 🧪 QA (push) Successful in 51s
Build & Deploy / 🏗️ Build (push) Successful in 2m20s
Build & Deploy / 🚀 Deploy (push) Successful in 15s
Build & Deploy / 🧪 Post-Deploy Verification (push) Successful in 4m7s
Build & Deploy / 🔔 Notify (push) Successful in 1s
2026-03-16 07:38:54 +01:00
13ab755857 fix(docker): bypass internal registry for base images to prevent 404s
Some checks failed
Build & Deploy / 🔍 Prepare (push) Successful in 6s
Build & Deploy / 🧪 QA (push) Successful in 51s
Build & Deploy / 🏗️ Build (push) Successful in 2m23s
Build & Deploy / 🚀 Deploy (push) Successful in 18s
Build & Deploy / 🧪 Post-Deploy Verification (push) Successful in 3m33s
Build & Deploy / 🔔 Notify (push) Successful in 2s
Nightly QA / 🔗 Links & Deps (push) Successful in 2m57s
Nightly QA / 🎭 Lighthouse (push) Successful in 3m32s
Nightly QA / 🔍 Static Analysis (push) Failing after 4m49s
Nightly QA / ♿ Accessibility (push) Successful in 5m12s
Nightly QA / 🔔 Notify (push) Successful in 19s
2026-03-15 23:39:22 +01:00
1a68af0eec fix(pdf): align AGB page PDF layout with datasheet design tokens
Some checks failed
Nightly QA / 🔗 Links & Deps (push) Successful in 2m21s
Nightly QA / 🎭 Lighthouse (push) Successful in 4m15s
Nightly QA / 🔍 Static Analysis (push) Failing after 4m45s
Nightly QA / ♿ Accessibility (push) Successful in 5m16s
Nightly QA / 🔔 Notify (push) Successful in 3s
Build & Deploy / 🔍 Prepare (push) Successful in 7s
Build & Deploy / 🧪 QA (push) Successful in 57s
Build & Deploy / 🏗️ Build (push) Failing after 18s
Build & Deploy / 🚀 Deploy (push) Has been skipped
Build & Deploy / 🧪 Post-Deploy Verification (push) Has been skipped
Build & Deploy / 🔔 Notify (push) Successful in 2s
2026-03-13 22:24:33 +01:00
275784745d feat(db): add migration for pages redirect fields
Some checks failed
Build & Deploy / 🔍 Prepare (push) Successful in 6s
Build & Deploy / 🧪 QA (push) Successful in 54s
Build & Deploy / 🏗️ Build (push) Failing after 15s
Build & Deploy / 🚀 Deploy (push) Has been skipped
Build & Deploy / 🧪 Post-Deploy Verification (push) Has been skipped
Build & Deploy / 🔔 Notify (push) Successful in 2s
2026-03-13 22:19:29 +01:00
4aef49cf2c fix: remove agbs rewrite rules that conflict with slug-mapping (redirect loop)
Some checks failed
Build & Deploy / 🔍 Prepare (push) Successful in 6s
Build & Deploy / 🧪 QA (push) Successful in 56s
Build & Deploy / 🏗️ Build (push) Failing after 16s
Build & Deploy / 🚀 Deploy (push) Has been skipped
Build & Deploy / 🧪 Post-Deploy Verification (push) Has been skipped
Build & Deploy / 🔔 Notify (push) Successful in 2s
2026-03-13 15:35:01 +01:00
8ad3abb6f3 fix(docker): restore valid v1.8.20 base image tag
Some checks failed
Build & Deploy / 🔍 Prepare (push) Successful in 6s
Build & Deploy / 🧪 QA (push) Successful in 1m0s
Build & Deploy / 🏗️ Build (push) Failing after 15s
Build & Deploy / 🚀 Deploy (push) Has been skipped
Build & Deploy / 🧪 Post-Deploy Verification (push) Has been skipped
Build & Deploy / 🔔 Notify (push) Successful in 1s
Nightly QA / 🔗 Links & Deps (push) Successful in 2m32s
Nightly QA / 🎭 Lighthouse (push) Successful in 3m3s
Nightly QA / 🔍 Static Analysis (push) Failing after 4m43s
Nightly QA / ♿ Accessibility (push) Successful in 5m20s
Nightly QA / 🔔 Notify (push) Successful in 14s
2026-03-12 19:12:56 +01:00
1d75b60236 fix(docker): use correctly versioned mintel v2 base images
Some checks failed
Build & Deploy / 🔍 Prepare (push) Successful in 5s
Build & Deploy / 🧪 QA (push) Successful in 53s
Build & Deploy / 🏗️ Build (push) Failing after 15s
Build & Deploy / 🚀 Deploy (push) Has been skipped
Build & Deploy / 🧪 Post-Deploy Verification (push) Has been skipped
Build & Deploy / 🔔 Notify (push) Successful in 1s
2026-03-12 14:39:05 +01:00
3dff19eca2 chore: update auto-generated types
Some checks failed
Build & Deploy / 🔍 Prepare (push) Successful in 11s
Build & Deploy / 🧪 QA (push) Successful in 2m9s
Build & Deploy / 🏗️ Build (push) Successful in 5m38s
Build & Deploy / 🚀 Deploy (push) Successful in 27s
Build & Deploy / 🧪 Post-Deploy Verification (push) Failing after 5m12s
Build & Deploy / 🔔 Notify (push) Successful in 2s
2026-03-12 13:41:23 +01:00
07b01c622a fix(deps): update pnpm-lock.yaml to fix CI registry checksums
Some checks failed
Build & Deploy / 🔍 Prepare (push) Successful in 14s
Build & Deploy / 🏗️ Build (push) Has been cancelled
Build & Deploy / 🚀 Deploy (push) Has been cancelled
Build & Deploy / 🧪 Post-Deploy Verification (push) Has been cancelled
Build & Deploy / 🔔 Notify (push) Has been cancelled
Build & Deploy / 🧪 QA (push) Has been cancelled
2026-03-12 13:34:32 +01:00
50de18c09c fix(docker): use latest tags for base images
Some checks failed
Build & Deploy / 🔍 Prepare (push) Successful in 5s
Build & Deploy / 🧪 QA (push) Successful in 2m7s
Build & Deploy / 🚀 Deploy (push) Has been cancelled
Build & Deploy / 🧪 Post-Deploy Verification (push) Has been cancelled
Build & Deploy / 🔔 Notify (push) Has been cancelled
Build & Deploy / 🏗️ Build (push) Has been cancelled
2026-03-12 13:29:45 +01:00
dbee0cd8bc fix(docker): use correct mmintel namespace for base images
Some checks failed
Build & Deploy / 🔍 Prepare (push) Successful in 5s
Build & Deploy / 🧪 QA (push) Successful in 2m12s
Build & Deploy / 🏗️ Build (push) Failing after 14s
Build & Deploy / 🚀 Deploy (push) Has been skipped
Build & Deploy / 🧪 Post-Deploy Verification (push) Has been skipped
Build & Deploy / 🔔 Notify (push) Successful in 1s
2026-03-12 13:24:33 +01:00
f30f8ddd8d fix(docker): migrate base image to git.infra.mintel.me registry
Some checks failed
Build & Deploy / 🔍 Prepare (push) Successful in 6s
Build & Deploy / 🧪 QA (push) Successful in 2m11s
Build & Deploy / 🏗️ Build (push) Failing after 14s
Build & Deploy / 🚀 Deploy (push) Has been skipped
Build & Deploy / 🧪 Post-Deploy Verification (push) Has been skipped
Build & Deploy / 🔔 Notify (push) Successful in 1s
2026-03-12 13:20:48 +01:00
bb9fd65dbb fix(og): convert font buffers to ArrayBuffer for Satori compatibility
Some checks failed
Build & Deploy / 🔍 Prepare (push) Successful in 6s
Build & Deploy / 🧪 QA (push) Successful in 2m8s
Build & Deploy / 🏗️ Build (push) Failing after 14s
Build & Deploy / 🚀 Deploy (push) Has been skipped
Build & Deploy / 🧪 Post-Deploy Verification (push) Has been skipped
Build & Deploy / 🔔 Notify (push) Successful in 1s
2026-03-12 13:17:18 +01:00
036fba8b53 feat(payload): add redirect settings to pages
Some checks failed
Build & Deploy / 🔍 Prepare (push) Successful in 5s
Build & Deploy / 🧪 QA (push) Successful in 2m6s
Build & Deploy / 🏗️ Build (push) Failing after 15s
Build & Deploy / 🚀 Deploy (push) Has been skipped
Build & Deploy / 🧪 Post-Deploy Verification (push) Has been skipped
Build & Deploy / 🔔 Notify (push) Successful in 1s
2026-03-12 13:12:13 +01:00
3e8d5ad8b6 chore: backup script
Some checks failed
Build & Deploy / 🔍 Prepare (push) Successful in 6s
Build & Deploy / 🧪 QA (push) Successful in 2m13s
Build & Deploy / 🏗️ Build (push) Failing after 15s
Build & Deploy / 🚀 Deploy (push) Has been skipped
Build & Deploy / 🧪 Post-Deploy Verification (push) Has been skipped
Build & Deploy / 🔔 Notify (push) Successful in 1s
2026-03-12 13:05:19 +01:00
70ad2e3041 fix(build): remove swcMinify and fix staleTimes/serverActions config object to pass Next.js validation
Some checks failed
Build & Deploy / 🔍 Prepare (push) Successful in 5s
Build & Deploy / 🧪 QA (push) Successful in 2m14s
Build & Deploy / 🏗️ Build (push) Failing after 14s
Build & Deploy / 🚀 Deploy (push) Has been skipped
Build & Deploy / 🧪 Post-Deploy Verification (push) Has been skipped
Build & Deploy / 🔔 Notify (push) Successful in 1s
2026-03-12 12:52:51 +01:00
5376b939d5 fix(cache): disable client router cache and fix terms routing
Some checks failed
Build & Deploy / 🔍 Prepare (push) Successful in 6s
Build & Deploy / 🧪 QA (push) Successful in 2m8s
Build & Deploy / 🏗️ Build (push) Failing after 16s
Build & Deploy / 🚀 Deploy (push) Has been skipped
Build & Deploy / 🧪 Post-Deploy Verification (push) Has been skipped
Build & Deploy / 🔔 Notify (push) Successful in 1s
2026-03-12 12:44:42 +01:00
6f80e72c1d style: align PDF Page component with KLZ brand Design System
Some checks failed
Build & Deploy / 🔍 Prepare (push) Successful in 12s
Build & Deploy / 🚀 Deploy (push) Has been skipped
Build & Deploy / 🧪 Post-Deploy Verification (push) Has been skipped
Build & Deploy / 🔔 Notify (push) Successful in 4s
Build & Deploy / 🧪 QA (push) Failing after 1m15s
Build & Deploy / 🏗️ Build (push) Has been skipped
Nightly QA / 🔗 Links & Deps (push) Successful in 3m32s
Nightly QA / 🎭 Lighthouse (push) Successful in 5m5s
Nightly QA / ♿ Accessibility (push) Successful in 5m28s
Nightly QA / 🔍 Static Analysis (push) Failing after 6m0s
Nightly QA / 🔔 Notify (push) Successful in 2s
2026-03-05 22:57:16 +01:00
d9334f558d fix(cms): add missing featured_image_id column to products via migration 2026-03-05 22:04:14 +01:00
cb436d31d0 fix(cms): disable migrationDir in production to prevent runtime TS import crashes 2026-03-05 21:51:55 +01:00
4b3ef49522 feat: register PDF download block and fix gotify notifications
All checks were successful
Build & Deploy / 🔍 Prepare (push) Successful in 7s
Build & Deploy / 🧪 QA (push) Successful in 2m24s
Build & Deploy / 🏗️ Build (push) Successful in 4m3s
Build & Deploy / 🚀 Deploy (push) Successful in 17s
Build & Deploy / 🧪 Post-Deploy Verification (push) Successful in 9m3s
Build & Deploy / 🔔 Notify (push) Successful in 2s
2026-03-05 16:56:09 +01:00
301e112488 fix(workflow): remove push trigger from qa.yml to prevent race conditions with deploy 2026-03-05 16:56:09 +01:00
2d4919cc1f feat: add modular dynamic PDF generation for Payload pages
Some checks failed
Build & Deploy / 🔍 Prepare (push) Successful in 6s
Build & Deploy / 🧪 QA (push) Has been cancelled
Build & Deploy / 🏗️ Build (push) Has been cancelled
Build & Deploy / 🚀 Deploy (push) Has been cancelled
Build & Deploy / 🧪 Post-Deploy Verification (push) Has been cancelled
Build & Deploy / 🔔 Notify (push) Has been cancelled
Nightly QA / 🔍 Static Analysis (push) Failing after 3m14s
Nightly QA / 🔗 Links & Deps (push) Successful in 3m59s
Nightly QA / 🎭 Lighthouse (push) Successful in 4m44s
Nightly QA / ♿ Accessibility (push) Successful in 5m48s
Nightly QA / 🔔 Notify (push) Successful in 3s
2026-03-05 13:53:59 +01:00
261 changed files with 1813 additions and 30974 deletions

51
.gitea/workflows/ci.yml Normal file
View File

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

View File

@@ -37,8 +37,6 @@ jobs:
next_public_url: ${{ steps.determine.outputs.next_public_url }}
project_name: ${{ steps.determine.outputs.project_name }}
short_sha: ${{ steps.determine.outputs.short_sha }}
slug: ${{ steps.determine.outputs.slug }}
gatekeeper_host: ${{ steps.determine.outputs.gatekeeper_host }}
container:
image: catthehacker/ubuntu:act-latest
steps:
@@ -85,7 +83,7 @@ jobs:
SLUG=$(echo "$REF" | tr '[:upper:]' '[:lower:]' | sed 's/[^a-z0-9]/-/g' | sed 's/--*/-/g' | sed 's/^-//;s/-$//')
IMAGE_TAG="branch-${SLUG}-${SHORT_SHA}"
ENV_FILE=".env.branch-${SLUG}"
TRAEFIK_HOST="${SLUG}.branch.klz-cables.com"
TRAEFIK_HOST="${SLUG}.branch.mintel.me"
fi
# Standardize Traefik Rule (escaped backticks for Traefik v3)
@@ -115,7 +113,6 @@ jobs:
echo "project_name=$PRJ-$TARGET"
fi
echo "short_sha=$SHORT_SHA"
echo "slug=$SLUG"
} >> "$GITHUB_OUTPUT"
# ⏳ Wait for Upstream Packages/Images if Tagged
@@ -159,8 +156,6 @@ jobs:
needs: prepare
if: needs.prepare.outputs.target != 'skip'
runs-on: docker
env:
PUPPETEER_EXECUTABLE_PATH: /usr/bin/chromium
container:
image: catthehacker/ubuntu:act-latest
steps:
@@ -186,12 +181,12 @@ jobs:
- name: 🔒 Security Audit
run: pnpm audit --audit-level high || echo "⚠️ Audit found vulnerabilities (non-blocking)"
- name: 🧪 QA Checks
if: github.event.inputs.skip_checks != 'true'
env:
TURBO_TELEMETRY_DISABLED: "1"
run: npx turbo run lint typecheck test --cache-dir=".turbo"
# ──────────────────────────────────────────────────────────────────────────────
# JOB 3: Build & Push
# ──────────────────────────────────────────────────────────────────────────────
@@ -208,8 +203,7 @@ jobs:
- name: 🐳 Set up Docker Buildx
uses: docker/setup-buildx-action@v3
- name: 🔐 Registry Login
run: |
echo "${{ secrets.REGISTRY_PASS }}" | docker login registry.infra.mintel.me -u "${{ secrets.REGISTRY_USER }}" --password-stdin
run: echo "${{ secrets.REGISTRY_PASS }}" | docker login registry.infra.mintel.me -u "${{ secrets.REGISTRY_USER }}" --password-stdin
- name: 🏗️ Build and Push
uses: docker/build-push-action@v5
with:
@@ -225,7 +219,7 @@ jobs:
NPM_TOKEN=${{ secrets.NPM_TOKEN }}
tags: registry.infra.mintel.me/mintel/klz-2026:${{ needs.prepare.outputs.image_tag }}
secrets: |
NPM_TOKEN=${{ secrets.NPM_TOKEN }}
"NPM_TOKEN=${{ secrets.NPM_TOKEN }}"
# ──────────────────────────────────────────────────────────────────────────────
# JOB 4: Deploy
@@ -243,7 +237,6 @@ jobs:
NEXT_PUBLIC_BASE_URL: ${{ needs.prepare.outputs.next_public_url }}
TRAEFIK_HOST: ${{ needs.prepare.outputs.traefik_host }}
GATEKEEPER_HOST: ${{ needs.prepare.outputs.gatekeeper_host }}
SLUG: ${{ needs.prepare.outputs.slug }}
# Secrets mapping (Payload CMS)
PAYLOAD_SECRET: ${{ secrets.PAYLOAD_SECRET || vars.PAYLOAD_SECRET || 'you-need-to-set-a-payload-secret' }}
@@ -256,8 +249,8 @@ jobs:
MAIL_PORT: ${{ secrets.SMTP_PORT || vars.SMTP_PORT || '587' }}
MAIL_USERNAME: ${{ secrets.SMTP_USER || vars.SMTP_USER }}
MAIL_PASSWORD: ${{ secrets.SMTP_PASS || vars.SMTP_PASS }}
MAIL_FROM: ${{ secrets.SMTP_FROM || vars.SMTP_FROM || 'noreply@klz-cables.com' }}
MAIL_RECIPIENTS: ${{ secrets.CONTACT_RECIPIENT || vars.CONTACT_RECIPIENT || 'info@klz-cables.com' }}
MAIL_FROM: ${{ secrets.SMTP_FROM || vars.SMTP_FROM }}
MAIL_RECIPIENTS: ${{ secrets.CONTACT_RECIPIENT || vars.CONTACT_RECIPIENT }}
# Monitoring
SENTRY_DSN: ${{ secrets.SENTRY_DSN || vars.SENTRY_DSN }}
@@ -268,15 +261,6 @@ jobs:
# Analytics
UMAMI_WEBSITE_ID: ${{ secrets.UMAMI_WEBSITE_ID || vars.UMAMI_WEBSITE_ID }}
UMAMI_API_ENDPOINT: ${{ secrets.UMAMI_API_ENDPOINT || vars.UMAMI_API_ENDPOINT || 'https://analytics.infra.mintel.me' }}
# Search & AI
OPENROUTER_API_KEY: ${{ secrets.OPENROUTER_API_KEY || vars.OPENROUTER_API_KEY }}
QDRANT_URL: ${{ secrets.QDRANT_URL || vars.QDRANT_URL || 'http://klz-qdrant:6333' }}
QDRANT_API_KEY: ${{ secrets.QDRANT_API_KEY || vars.QDRANT_API_KEY }}
REDIS_URL: ${{ secrets.REDIS_URL || vars.REDIS_URL || 'redis://klz-redis:6379' }}
# Container Registry (standalone)
REGISTRY_USER: ${{ secrets.REGISTRY_USER }}
REGISTRY_PASS: ${{ secrets.REGISTRY_PASS }}
steps:
- name: Checkout repository
uses: actions/checkout@v4
@@ -335,12 +319,6 @@ jobs:
echo "UMAMI_WEBSITE_ID=$UMAMI_WEBSITE_ID"
echo "UMAMI_API_ENDPOINT=$UMAMI_API_ENDPOINT"
echo ""
echo "# Search & AI"
echo "OPENROUTER_API_KEY=$OPENROUTER_API_KEY"
echo "QDRANT_URL=$QDRANT_URL"
echo "QDRANT_API_KEY=$QDRANT_API_KEY"
echo "REDIS_URL=$REDIS_URL"
echo ""
echo "TARGET=$TARGET"
echo "SENTRY_ENVIRONMENT=$TARGET"
echo "PROJECT_NAME=$PROJECT_NAME"
@@ -360,39 +338,9 @@ jobs:
cat .env.deploy
echo "----------------------------"
- name: 🔐 Registry Auth
id: auth
run: |
echo "Testing available secrets against git.infra.mintel.me Docker registry..."
TOKENS="${{ secrets.GITEA_PAT }} ${{ secrets.MINTEL_PRIVATE_TOKEN }} ${{ secrets.NPM_TOKEN }}"
USERS="${{ github.repository_owner }} ${{ github.actor }} marcmintel mintel mmintel"
VALID_TOKEN=""
VALID_USER=""
for T in $TOKENS; do
if [ -n "$T" ]; then
for U in $USERS; do
if [ -n "$U" ]; then
if echo "$T" | docker login git.infra.mintel.me -u "$U" --password-stdin > /dev/null 2>&1; then
VALID_TOKEN="$T"
VALID_USER="$U"
break 2
fi
fi
done
fi
done
if [ -z "$VALID_TOKEN" ]; then echo "❌ All tokens failed to authenticate!"; exit 1; fi
echo "token=$VALID_TOKEN" >> $GITHUB_OUTPUT
echo "user=$VALID_USER" >> $GITHUB_OUTPUT
- name: 🚀 SSH Deploy
shell: bash
env:
TARGET: ${{ needs.prepare.outputs.target }}
SLUG: ${{ needs.prepare.outputs.slug }}
IMAGE_TAG: ${{ needs.prepare.outputs.image_tag }}
PROJECT_NAME: ${{ needs.prepare.outputs.project_name }}
ENV_FILE: ${{ needs.prepare.outputs.env_file }}
run: |
mkdir -p ~/.ssh
@@ -400,9 +348,6 @@ jobs:
chmod 600 ~/.ssh/id_ed25519
ssh-keyscan -H alpha.mintel.me >> ~/.ssh/known_hosts 2>/dev/null
# Determine deployment paths
echo "Preparing deployment for $TARGET..."
# Transfer and Restart
if [[ "$TARGET" == "production" ]]; then
SITE_DIR="/home/deploy/sites/klz-cables.com"
@@ -411,55 +356,63 @@ jobs:
elif [[ "$TARGET" == "staging" ]]; then
SITE_DIR="/home/deploy/sites/staging.klz-cables.com"
else
SITE_DIR="/home/deploy/sites/branch.klz-cables.com/$SLUG"
SITE_DIR="/home/deploy/sites/branch.klz-cables.com/${SLUG:-unknown}"
fi
# Transfer files
ssh root@alpha.mintel.me "mkdir -p $SITE_DIR"
scp .env.deploy root@alpha.mintel.me:$SITE_DIR/$ENV_FILE
scp docker-compose.yml root@alpha.mintel.me:$SITE_DIR/docker-compose.yml
APP_CONTAINER="${{ needs.prepare.outputs.project_name }}-klz-app-1"
DB_CONTAINER="${{ needs.prepare.outputs.project_name }}-klz-db-1"
ssh root@alpha.mintel.me "cd $SITE_DIR && echo '${{ secrets.REGISTRY_PASS }}' | docker login registry.infra.mintel.me -u '${{ secrets.REGISTRY_USER }}' --password-stdin"
ssh root@alpha.mintel.me "cd $SITE_DIR && docker compose -p '${{ needs.prepare.outputs.project_name }}' --env-file '$ENV_FILE' pull"
ssh root@alpha.mintel.me "cd $SITE_DIR && docker compose -p '${{ needs.prepare.outputs.project_name }}' --env-file '$ENV_FILE' up -d --remove-orphans"
# Branch Seeding Logic (Production -> Branch)
if [[ "$TARGET" == "branch" ]]; then
echo "🌱 Seeding Branch Environment from Production Database..."
ssh root@alpha.mintel.me "cd $SITE_DIR && docker compose -p '${{ needs.prepare.outputs.project_name }}' --env-file $ENV_FILE pull && docker compose -p '${{ needs.prepare.outputs.project_name }}' --env-file $ENV_FILE up -d klz-db"
# Wait for DB to be healthy with a 60s timeout
echo "⏳ Waiting for branch database to be ready..."
ssh root@alpha.mintel.me "
for i in {1..30}; do
if docker exec $DB_CONTAINER pg_isready -U payload >/dev/null 2>&1; then
exit 0
fi
sleep 2
done
echo '❌ Database failed to become ready after 60 seconds'
exit 1
" || exit 1
# Copy Production Payload DB to Branch Payload DB & ensure media is copied
echo "📦 Syncing Production DB into Branch DB..."
ssh root@alpha.mintel.me "
set -e -o pipefail
docker exec klz-cablescom-klz-db-1 pg_dump -U payload -d payload --clean --if-exists | docker exec -i $DB_CONTAINER psql -U payload -d payload --quiet
rsync -a --delete /var/lib/docker/volumes/klz-cablescom_klz_media_data/_data/ /var/lib/docker/volumes/${{ needs.prepare.outputs.project_name }}_klz_media_data/_data/
" || exit 1
echo "✅ Branch database and media synced successfully."
# Sanitize Payload Migrations: Replace 'dev' push entries with proper migration names.
# Without this, Payload prompts interactively for confirmation and blocks forever in Docker.
DB_CONTAINER="${{ needs.prepare.outputs.project_name }}-klz-db-1"
echo "⏳ Waiting for database container to be ready..."
for i in $(seq 1 15); do
if ssh root@alpha.mintel.me "docker exec $DB_CONTAINER pg_isready -U payload -q 2>/dev/null"; then
echo "✅ Database is ready."
break
fi
echo " Attempt $i/15..."
sleep 2
done
echo "🔧 Sanitizing payload_migrations table (if exists)..."
REMOTE_DB_USER=$(ssh root@alpha.mintel.me "grep -h '^PAYLOAD_DB_USER=' $SITE_DIR/.env* 2>/dev/null | tail -1 | cut -d= -f2" || echo "payload")
REMOTE_DB_NAME=$(ssh root@alpha.mintel.me "grep -h '^PAYLOAD_DB_NAME=' $SITE_DIR/.env* 2>/dev/null | tail -1 | cut -d= -f2" || echo "payload")
REMOTE_DB_USER="${REMOTE_DB_USER:-payload}"
REMOTE_DB_NAME="${REMOTE_DB_NAME:-payload}"
# Auto-detect migrations from src/migrations/*.ts
BATCH=1
VALUES=""
for f in $(ls src/migrations/*.ts 2>/dev/null | sort); do
NAME=$(basename "$f" .ts)
[ -n "$VALUES" ] && VALUES="$VALUES,"
VALUES="$VALUES ('$NAME', $BATCH)"
((BATCH++))
done
if [ -n "$VALUES" ]; then
ssh root@alpha.mintel.me "docker exec $DB_CONTAINER psql -U $REMOTE_DB_USER -d $REMOTE_DB_NAME -c \"
DO \\\$\\\$ BEGIN
DELETE FROM payload_migrations WHERE batch = -1;
INSERT INTO payload_migrations (name, batch)
SELECT name, batch FROM (VALUES $VALUES) AS v(name, batch)
WHERE NOT EXISTS (SELECT 1 FROM payload_migrations pm WHERE pm.name = v.name);
EXCEPTION WHEN undefined_table THEN
RAISE NOTICE 'payload_migrations table does not exist yet — skipping sanitization';
END \\\$\\\$;
\"" || echo "⚠️ Migration sanitization skipped (table may not exist yet)"
fi
# Execute remote commands — alpha is pre-logged into registry.infra.mintel.me
ssh root@alpha.mintel.me "cd $SITE_DIR && docker compose -p '${{ needs.prepare.outputs.project_name }}' --env-file $ENV_FILE pull && docker compose -p '${{ needs.prepare.outputs.project_name }}' --env-file $ENV_FILE up -d --remove-orphans"
# Restart app to pick up clean migration state
APP_CONTAINER="${{ needs.prepare.outputs.project_name }}-klz-app-1"
ssh root@alpha.mintel.me "docker restart $APP_CONTAINER"
# Generate Excel Datasheets
echo "📊 Generating Excel Datasheets on live container..."
ssh root@alpha.mintel.me "docker exec $APP_CONTAINER pnpm run excel:datasheets" || echo "⚠️ Excel generation failed (non-blocking)"
ssh root@alpha.mintel.me "docker system prune -f --filter 'until=24h'"
- name: 🧹 Post-Deploy Cleanup (Runner)
@@ -472,7 +425,7 @@ jobs:
post_deploy_checks:
name: 🧪 Post-Deploy Verification
needs: [prepare, deploy]
if: needs.deploy.result == 'success' && true
if: needs.deploy.result == 'success' && needs.prepare.outputs.target != 'branch'
runs-on: docker
container:
image: catthehacker/ubuntu:act-latest
@@ -565,15 +518,6 @@ jobs:
GATEKEEPER_PASSWORD: ${{ secrets.GATEKEEPER_PASSWORD || 'klz2026' }}
UMAMI_API_ENDPOINT: ${{ secrets.UMAMI_API_ENDPOINT || vars.UMAMI_API_ENDPOINT || 'https://analytics.infra.mintel.me' }}
SENTRY_DSN: ${{ secrets.SENTRY_DSN || vars.SENTRY_DSN }}
- name: 📊 Excel Datasheet Accessibility Check
if: always() && steps.deps.outcome == 'success'
env:
TEST_URL: ${{ needs.prepare.outputs.next_public_url }}
run: |
echo "Checking if datasheets directory is reachable..."
# This checks if the /datasheets/ directory returns a valid response (200, 403, or 404 is technically reachable, but we'd prefer 200/403)
# Since the files are in public/datasheets/products/, we check that path.
curl -I -L -s -o /dev/null -w "%{http_code}" "$TEST_URL/datasheets/products/" | grep -E "200|301|302|403|404"
- name: 📝 E2E Form Submission Test
if: always() && steps.deps.outcome == 'success'
@@ -627,7 +571,7 @@ jobs:
STATUS_LINE="All checks passed"
fi
TITLE="$EMOJI klz-cables.com $VERSION -> $TARGET"
TITLE="$EMOJI klz-cables.com $VERSION $TARGET"
MESSAGE="$STATUS_LINE
Deploy: $DEPLOY | Smoke: $SMOKE | Perf: $PERF
$URL"

View File

@@ -198,7 +198,7 @@ jobs:
notify:
name: 🔔 Notify
needs: [static, a11y, lighthouse, links]
if: failure()
if: always()
runs-on: docker
container:
image: catthehacker/ubuntu:act-latest

View File

@@ -1,16 +1,22 @@
# Stage 1: Builder
FROM git.infra.mintel.me/mmintel/nextjs:latest AS base
FROM node:20-alpine AS base
RUN apk add --no-cache libc6-compat curl
# Enable pnpm
RUN corepack enable && corepack prepare pnpm@10.3.0 --activate
WORKDIR /app
# Arguments for build-time configuration
ARG NEXT_PUBLIC_BASE_URL
ARG NEXT_PUBLIC_TARGET
ARG DIRECTUS_URL
ARG UMAMI_WEBSITE_ID
ARG UMAMI_API_ENDPOINT
# Environment variables for Next.js build
ENV NEXT_PUBLIC_BASE_URL=$NEXT_PUBLIC_BASE_URL
ENV NEXT_PUBLIC_TARGET=$NEXT_PUBLIC_TARGET
ENV DIRECTUS_URL=$DIRECTUS_URL
ENV UMAMI_WEBSITE_ID=$UMAMI_WEBSITE_ID
ENV UMAMI_API_ENDPOINT=$UMAMI_API_ENDPOINT
ENV SKIP_RUNTIME_ENV_VALIDATION=true
@@ -48,12 +54,21 @@ ENV RAYON_NUM_THREADS=3
ENV UV_THREADPOOL_SIZE=3
RUN pnpm build
# Excel generation moved to post-deploy
# Stage 2: Runner
FROM git.infra.mintel.me/mmintel/runtime:latest AS runner
FROM node:20-alpine AS runner
WORKDIR /app
# Install curl for health checks
RUN apk add --no-cache curl
# Create nextjs user and group (standardized in runtime image but ensuring local ownership)
RUN addgroup --system --gid 1001 nodejs && \
adduser --system --uid 1001 nextjs && \
chown -R nextjs:nodejs /app
USER nextjs
ENV HOSTNAME="0.0.0.0"
ENV PORT=3000
ENV NODE_ENV=production
@@ -65,4 +80,3 @@ COPY --from=builder --chown=nextjs:nodejs /app/.next/static ./.next/static
COPY --from=builder --chown=nextjs:nodejs /app/.next/cache ./.next/cache
CMD ["node", "server.js"]

View File

@@ -1,8 +1,8 @@
import { notFound, redirect } from 'next/navigation';
import { notFound, redirect, permanentRedirect } from 'next/navigation';
import { Container, Badge, Heading } from '@/components/ui';
import { getTranslations, setRequestLocale } from 'next-intl/server';
import { Metadata } from 'next';
import { getPageBySlug } from '@/lib/pages';
import { getPageBySlug, getAllPages } from '@/lib/pages';
import { mapSlugToFileSlug, mapFileSlugToTranslated } from '@/lib/slugs';
import PayloadRichText from '@/components/PayloadRichText';
import { SITE_URL } from '@/lib/schema';
@@ -62,6 +62,15 @@ export default async function StandardPage({ params }: PageProps) {
notFound();
}
// Handle explicit CMS redirects (e.g. /en/terms -> /de/terms)
if (pageData.redirectUrl) {
if (pageData.redirectPermanent) {
permanentRedirect(pageData.redirectUrl);
} else {
redirect(pageData.redirectUrl);
}
}
// Redirect if accessed via a different locale's slug
const fileSlug = await mapSlugToFileSlug(pageData.slug || slug, locale);
const correctSlug = await mapFileSlugToTranslated(fileSlug, locale);

View File

@@ -134,13 +134,13 @@ export default async function BlogPost({ params }: BlogPostProps) {
<span>{getReadingTime(rawTextContent)} min read</span>
{(new Date(post.frontmatter.date) > new Date() ||
post.frontmatter.public === false) && (
<>
<span className="w-1 h-1 bg-white/30 rounded-full" />
<span className="px-2 py-0.5 border border-white/40 text-white/80 rounded uppercase tracking-widest text-[10px] md:text-xs font-bold">
Draft Preview
</span>
</>
)}
<>
<span 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>
@@ -171,13 +171,13 @@ export default async function BlogPost({ params }: BlogPostProps) {
<span>{getReadingTime(rawTextContent)} min read</span>
{(new Date(post.frontmatter.date) > new Date() ||
post.frontmatter.public === false) && (
<>
<span className="w-1 h-1 bg-neutral-300 rounded-full" />
<span className="px-2 py-0.5 border border-orange-500/50 text-orange-600 rounded uppercase tracking-widest text-[10px] md:text-xs font-bold">
Draft Preview
</span>
</>
)}
<>
<span className="w-1 h-1 bg-neutral-300 rounded-full" />
<span className="px-2 py-0.5 border border-orange-500/50 text-orange-600 rounded uppercase tracking-widest text-[10px] md:text-xs font-bold">
Draft Preview
</span>
</>
)}
</div>
</div>
</header>

View File

@@ -14,7 +14,7 @@ interface BlogIndexProps {
}>;
}
export async function generateMetadata({ params }: BlogIndexProps): Promise<Metadata> {
export async function generateMetadata({ params }: BlogIndexProps) {
const { locale } = await params;
const t = await getTranslations({ locale, namespace: 'Blog.meta' });
return {

View File

@@ -5,7 +5,7 @@ import { Container, Heading, Section } from '@/components/ui';
import { Metadata } from 'next';
import { getTranslations, setRequestLocale } from 'next-intl/server';
import { SITE_URL } from '@/lib/schema';
import { getOGImageMetadata } from '@/lib/metadata';
import { Suspense } from 'react';
import ContactMap from '@/components/ContactMap';
import ObfuscatedEmail from '@/components/ObfuscatedEmail';

View File

@@ -7,8 +7,10 @@ import AnalyticsShell from '@/components/analytics/AnalyticsShell';
import { Metadata, Viewport } from 'next';
import { NextIntlClientProvider } from 'next-intl';
import { getMessages } from 'next-intl/server';
import { Suspense } from 'react';
import '../../styles/globals.css';
import { SITE_URL } from '@/lib/schema';
import { config } from '@/lib/config';
import FeedbackClientWrapper from '@/components/FeedbackClientWrapper';
import { setRequestLocale } from 'next-intl/server';
import { Inter } from 'next/font/google';
@@ -59,7 +61,6 @@ export const viewport: Viewport = {
themeColor: '#001a4d',
};
import AutoBrochureModal from '@/components/AutoBrochureModal';
export default async function Layout(props: {
children: React.ReactNode;
params: Promise<{ locale: string }>;
@@ -76,7 +77,7 @@ export default async function Layout(props: {
let messages: Record<string, any> = {};
try {
messages = await getMessages();
} catch {
} catch (error) {
messages = {};
}
@@ -90,7 +91,6 @@ export default async function Layout(props: {
'Home',
'Error',
'StandardPage',
'Brochure',
];
const clientMessages: Record<string, any> = {};
for (const key of clientKeys) {
@@ -132,11 +132,7 @@ export default async function Layout(props: {
const feedbackEnabled = process.env.NEXT_PUBLIC_FEEDBACK_ENABLED === 'true';
return (
<html
lang={safeLocale}
className={`scroll-smooth overflow-x-hidden ${inter.variable}`}
data-scroll-behavior="smooth"
>
<html lang={safeLocale} className={`overflow-x-hidden ${inter.variable}`}>
<head>
<link rel="preconnect" href="https://fonts.googleapis.com" />
<link rel="preconnect" href="https://fonts.gstatic.com" crossOrigin="anonymous" />
@@ -160,8 +156,6 @@ export default async function Layout(props: {
<AnalyticsShell />
<FeedbackClientWrapper feedbackEnabled={feedbackEnabled} />
<AutoBrochureModal />
</NextIntlClientProvider>
</body>
</html>

View File

@@ -72,7 +72,7 @@ export default async function NotFound() {
}
suggestedUrl = '/' + pathParts.join('/');
}
} catch {
} catch (e) {
// Ignore Payload errors in 404
}
}

View File

@@ -1,11 +1,12 @@
import JsonLd from '@/components/JsonLd';
import { SITE_URL } from '@/lib/schema';
import ProductSidebar from '@/components/ProductSidebar';
import ExcelDownload from '@/components/ExcelDownload';
import ProductTabs from '@/components/ProductTabs';
import ProductTechnicalData from '@/components/ProductTechnicalData';
import RelatedProducts from '@/components/RelatedProducts';
import DatasheetDownload from '@/components/DatasheetDownload';
import { Badge, Card, Container, Heading, Section } from '@/components/ui';
import { getDatasheetPath, getExcelDatasheetPath } from '@/lib/datasheets';
import { getDatasheetPath } from '@/lib/datasheets';
import { getAllProducts, getProductBySlug } from '@/lib/products';
import { mapFileSlugToTranslated, mapSlugToFileSlug } from '@/lib/slugs';
import { Metadata } from 'next';
@@ -277,7 +278,6 @@ export default async function ProductPage({ params }: ProductPageProps) {
}
const datasheetPath = getDatasheetPath(productSlug, locale);
const excelPath = getExcelDatasheetPath(productSlug, locale);
const isFallback = (product.frontmatter as any).isFallback;
const categorySlug = slug[0];
const categoryFileSlug = await mapSlugToFileSlug(categorySlug, locale);
@@ -343,7 +343,6 @@ export default async function ProductPage({ params }: ProductPageProps) {
productName={product.frontmatter.title}
productImage={product.frontmatter.images?.[0]}
datasheetPath={datasheetPath}
excelPath={excelPath}
/>
);
@@ -497,15 +496,7 @@ export default async function ProductPage({ params }: ProductPageProps) {
</h2>
<div className="h-1.5 w-24 bg-accent rounded-full" />
</div>
<div className="flex flex-row flex-wrap items-center gap-4 max-w-2xl">
<DatasheetDownload
datasheetPath={datasheetPath}
className="mt-0 w-full sm:w-auto"
/>
{excelPath && (
<ExcelDownload excelPath={excelPath} className="mt-0 w-full sm:w-auto" />
)}
</div>
<DatasheetDownload datasheetPath={datasheetPath} />
</div>
)}

View File

@@ -2,7 +2,7 @@ import { getTranslations, setRequestLocale } from 'next-intl/server';
import { Metadata } from 'next';
import JsonLd from '@/components/JsonLd';
import { getBreadcrumbSchema, SITE_URL } from '@/lib/schema';
import { Section, Container, Heading, Badge } from '@/components/ui';
import { Section, Container, Heading, Badge, Button } from '@/components/ui';
import Image from 'next/image';
import Reveal from '@/components/Reveal';
import Gallery from '@/components/team/Gallery';

View File

@@ -1,123 +0,0 @@
'use server';
import { getServerAppServices } from '@/lib/services/create-services.server';
export async function requestBrochureAction(formData: FormData) {
const services = getServerAppServices();
const logger = services.logger.child({ action: 'requestBrochureAction' });
const { headers } = await import('next/headers');
const requestHeaders = await headers();
if ('setServerContext' in services.analytics) {
(services.analytics as any).setServerContext({
userAgent: requestHeaders.get('user-agent') || undefined,
language: requestHeaders.get('accept-language')?.split(',')[0] || undefined,
referrer: requestHeaders.get('referer') || undefined,
ip: requestHeaders.get('x-forwarded-for')?.split(',')[0] || undefined,
});
}
services.analytics.track('brochure-request-attempt');
const email = formData.get('email') as string;
const locale = (formData.get('locale') as string) || 'en';
// Anti-spam Honeypot Check
const honeypot = formData.get('company_website') as string;
if (honeypot) {
logger.warn('Spam detected via honeypot in brochure request', { email });
// Silently succeed to fool the bot without doing actual work
return { success: true };
}
if (!email) {
logger.warn('Missing email in brochure request');
return { success: false, error: 'Missing email address' };
}
// Basic email validation
if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email)) {
return { success: false, error: 'Invalid email address' };
}
// 1. Save to CMS
try {
const { getPayload } = await import('payload');
const configPromise = (await import('@payload-config')).default;
const payload = await getPayload({ config: configPromise });
await payload.create({
collection: 'form-submissions',
data: {
name: email.split('@')[0],
email,
message: `Brochure download request (${locale})`,
type: 'brochure_download' as any,
},
overrideAccess: true,
});
logger.info('Successfully saved brochure request to Payload CMS', { email });
} catch (error) {
logger.error('Failed to store brochure request in Payload CMS', { error });
services.errors.captureException(error, { action: 'payload_store_brochure_request' });
}
// 2. Notify via Gotify
try {
await services.notifications.notify({
title: '📑 Brochure Download Request',
message: `New brochure download request from ${email} (${locale})`,
priority: 3,
});
} catch (error) {
logger.error('Failed to send notification', { error });
}
// 3. Send Brochure via Email
const brochureUrl = `https://klz-cables.com/brochure/klz-product-catalog-${locale}.pdf`;
try {
const { sendEmail } = await import('@/lib/mail/mailer');
const { render } = await import('@mintel/mail');
const React = await import('react');
const { BrochureDeliveryEmail } = await import('@/components/emails/BrochureDeliveryEmail');
const html = await render(
React.createElement(BrochureDeliveryEmail, {
_email: email,
brochureUrl,
locale: locale as 'en' | 'de',
}),
);
const emailResult = await sendEmail({
to: email,
subject: locale === 'de' ? 'Ihr KLZ Kabelkatalog' : 'Your KLZ Cable Catalog',
html,
});
if (emailResult.success) {
logger.info('Brochure email sent successfully', { email });
} else {
logger.error('Failed to send brochure email', { error: emailResult.error, email });
services.errors.captureException(new Error(`Brochure email failed: ${emailResult.error}`), {
action: 'requestBrochureAction_email',
email,
});
return { success: false, error: 'Failed to send email. Please try again later.' };
}
} catch (error) {
logger.error('Exception while sending brochure email', { error });
return { success: false, error: 'Failed to send email. Please try again later.' };
}
// 4. Track success
services.analytics.track('brochure-request-success', {
locale,
delivery_method: 'email',
});
return { success: true };
}

View File

@@ -25,14 +25,6 @@ export async function sendContactFormAction(formData: FormData) {
// Track attempt
services.analytics.track('contact-form-attempt');
// Anti-spam Honeypot Check
const honeypot = formData.get('company_website') as string;
if (honeypot) {
logger.warn('Spam detected via honeypot in contact request', { email: formData.get('email') });
// Silently succeed to fool the bot without doing actual work
return { success: true };
}
const name = formData.get('name') as string;
const email = formData.get('email') as string;
const message = formData.get('message') as string;
@@ -62,7 +54,6 @@ export async function sendContactFormAction(formData: FormData) {
type: productName ? 'product_quote' : 'contact',
productName: productName || undefined,
},
overrideAccess: true,
});
logger.info('Successfully saved form submission to Payload CMS', {

View File

@@ -16,14 +16,6 @@ export async function GET() {
const payload = await getPayload({ config: configPromise });
checks.init = 'ok';
// Ensure migrations are applied on startup (reliable for standalone builds)
try {
await payload.db.migrate();
} catch (e: any) {
console.error('Migration failed:', e.message);
// We continue to check the collections even if migration fails
}
// Verify each collection can be queried (catches missing locale tables, broken migrations)
const collections = ['posts', 'products', 'pages', 'media'] as const;
for (const collection of collections) {
@@ -35,7 +27,7 @@ export async function GET() {
}
}
const hasErrors = Object.values(checks).some((v) => v.startsWith('error'));
const hasErrors = Object.values(checks).some(v => v.startsWith('error'));
return NextResponse.json(
{ status: hasErrors ? 'degraded' : 'ok', checks },
{ status: hasErrors ? 503 : 200 },

View File

@@ -1,28 +0,0 @@
'use client';
import { useState, useEffect } from 'react';
import dynamic from 'next/dynamic';
const BrochureModal = dynamic(() => import('./BrochureModal'), { ssr: false });
export default function AutoBrochureModal() {
const [isOpen, setIsOpen] = useState(false);
useEffect(() => {
// Check if user has already seen or interacted with the modal
const hasSeenModal = localStorage.getItem('klz_brochure_modal_seen');
if (!hasSeenModal) {
// Auto-open after 5 seconds to not interrupt immediate page load
const timer = setTimeout(() => {
setIsOpen(true);
// Mark as seen so it doesn't bother them again on next page load
localStorage.setItem('klz_brochure_modal_seen', 'true');
}, 5000);
return () => clearTimeout(timer);
}
}, []);
return <BrochureModal isOpen={isOpen} onClose={() => setIsOpen(false)} />;
}

View File

@@ -1,88 +0,0 @@
'use client';
import { useState } from 'react';
import { useTranslations } from 'next-intl';
import { cn } from '@/components/ui/utils';
import dynamic from 'next/dynamic';
const BrochureModal = dynamic(() => import('./BrochureModal'), { ssr: false });
interface Props {
className?: string;
compact?: boolean;
}
/**
* BrochureCTA — Shows a button that opens a modal asking for an email address.
* The full-catalog PDF is ONLY revealed after email submission.
* No direct download link is exposed anywhere.
*/
export default function BrochureCTA({ className, compact = false }: Props) {
const t = useTranslations('Brochure');
const [open, setOpen] = useState(false);
return (
<>
<div className={cn(className)}>
<button
type="button"
onClick={() => setOpen(true)}
className={cn(
'group relative flex w-full items-center gap-4 overflow-hidden rounded-[28px] bg-[#000d26] border border-white/[0.08] text-left cursor-pointer',
'transition-all duration-300 hover:border-[#82ed20]/30 hover:shadow-[0_8px_30px_rgba(0,0,0,0.3)]',
compact ? 'p-4 md:p-5' : 'p-6 md:p-8',
)}
>
{/* Green top accent */}
<span className="pointer-events-none absolute inset-x-0 top-0 h-px bg-gradient-to-r from-transparent via-[#82ed20]/50 to-transparent" />
{/* Icon */}
<span className="flex h-11 w-11 shrink-0 items-center justify-center rounded-xl bg-[#82ed20]/10 border border-[#82ed20]/20 group-hover:bg-[#82ed20] transition-colors duration-300">
<svg
className="h-5 w-5 text-[#82ed20] group-hover:text-[#000d26] transition-colors duration-300"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={1.5}
d="M12 6.253v13m0-13C10.832 5.477 9.246 5 7.5 5S4.168 5.477 3 6.253v13C4.168 18.477 5.754 18 7.5 18s3.332.477 4.5 1.253m0-13C13.168 5.477 14.754 5 16.5 5c1.747 0 3.332.477 4.5 1.253v13C19.832 18.477 18.247 18 16.5 18c-1.746 0-3.332.477-4.5 1.253"
/>
</svg>
</span>
{/* Labels */}
<span className="flex-1 min-w-0">
<span className="block text-[9px] font-black uppercase tracking-[0.2em] text-[#82ed20] mb-0.5">
PDF Katalog
</span>
<span
className={cn(
'block font-black text-white uppercase tracking-tight group-hover:text-[#82ed20] transition-colors duration-200',
compact ? 'text-base' : 'text-lg md:text-xl',
)}
>
{t('ctaTitle')}
</span>
</span>
{/* Arrow */}
<span className="flex h-8 w-8 shrink-0 items-center justify-center rounded-full bg-white/5 text-white/20 group-hover:bg-[#82ed20] group-hover:text-[#000d26] transition-all duration-300">
<svg className="h-4 w-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2.5}
d="M9 5l7 7-7 7"
/>
</svg>
</span>
</button>
</div>
<BrochureModal isOpen={open} onClose={() => setOpen(false)} />
</>
);
}

View File

@@ -1,256 +0,0 @@
'use client';
import { useState, useRef, useEffect } from 'react';
import { createPortal } from 'react-dom';
import { useTranslations, useLocale } from 'next-intl';
import { cn } from '@/components/ui/utils';
import { requestBrochureAction } from '@/app/actions/brochure';
import { useAnalytics } from './analytics/useAnalytics';
import { AnalyticsEvents } from './analytics/analytics-events';
interface BrochureModalProps {
isOpen: boolean;
onClose: () => void;
}
export default function BrochureModal({ isOpen, onClose }: BrochureModalProps) {
const t = useTranslations('Brochure');
const locale = useLocale();
const { trackEvent } = useAnalytics();
const formRef = useRef<HTMLFormElement>(null);
const modalRef = useRef<HTMLDivElement>(null);
const [state, setState] = useState<'idle' | 'submitting' | 'success' | 'error'>('idle');
const [errorMsg, setErrorMsg] = useState('');
// Close on escape + lock scroll + focus trap
useEffect(() => {
if (!isOpen) return;
// Auto-focus input when opened
const firstInput = document.getElementById('brochure-email');
if (firstInput) {
setTimeout(() => firstInput.focus(), 50);
}
const handleKeyDown = (e: KeyboardEvent) => {
if (e.key === 'Escape') onClose();
if (e.key === 'Tab' && modalRef.current) {
const focusable = modalRef.current.querySelectorAll(
'button, [href], input, select, textarea, [tabindex]:not([tabindex="-1"])',
) as NodeListOf<HTMLElement>;
if (focusable.length > 0) {
const first = focusable[0];
const last = focusable[focusable.length - 1];
if (e.shiftKey && document.activeElement === first) {
last.focus();
e.preventDefault();
} else if (!e.shiftKey && document.activeElement === last) {
first.focus();
e.preventDefault();
}
}
}
};
document.addEventListener('keydown', handleKeyDown);
// Strict overflow lock on mobile as well
document.documentElement.style.setProperty('overflow', 'hidden', 'important');
document.body.style.setProperty('overflow', 'hidden', 'important');
return () => {
document.removeEventListener('keydown', handleKeyDown);
document.documentElement.style.overflow = '';
document.body.style.overflow = '';
};
}, [isOpen, onClose]);
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
if (!formRef.current) return;
setState('submitting');
setErrorMsg('');
try {
const formData = new FormData(formRef.current);
formData.set('locale', locale);
const result = await requestBrochureAction(formData);
if (result.success) {
setState('success');
trackEvent(AnalyticsEvents.DOWNLOAD, {
file_name: `klz-product-catalog-${locale}.pdf`,
file_type: 'brochure',
location: 'brochure_modal',
});
} else {
setState('error');
setErrorMsg(result.error || 'Something went wrong');
}
} catch {
setState('error');
setErrorMsg('Network error');
}
};
const handleClose = () => {
setState('idle');
setErrorMsg('');
onClose();
};
if (!isOpen) return null;
const modal = (
<div
className="fixed inset-0 z-[9999] flex items-center justify-center p-4"
role="dialog"
aria-modal="true"
>
{/* Backdrop */}
<div
className="absolute inset-0 bg-black/70 backdrop-blur-sm"
onClick={handleClose}
aria-hidden="true"
/>
{/* Modal Panel */}
<div
ref={modalRef}
className="relative z-10 w-full max-w-md rounded-[28px] bg-[#000d26] border border-white/10 shadow-[0_40px_80px_rgba(0,0,0,0.6)] overflow-hidden"
>
{/* Accent bar at top */}
<div className="h-1 w-full bg-gradient-to-r from-[#82ed20] via-[#5cb516] to-[#82ed20]" />
{/* Close Button */}
<button
type="button"
onClick={handleClose}
className="absolute top-4 right-4 z-10 flex h-8 w-8 items-center justify-center rounded-full bg-white/5 text-white/40 hover:bg-white/10 hover:text-white transition-colors cursor-pointer"
aria-label={t('close')}
>
<svg className="h-4 w-4" 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 className="p-8 pt-7">
{/* Icon + Header */}
<div className="mb-7">
<div className="flex h-12 w-12 items-center justify-center rounded-2xl bg-[#82ed20]/10 border border-[#82ed20]/20 mb-4">
<svg
className="h-6 w-6 text-[#82ed20]"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={1.5}
d="M12 6.253v13m0-13C10.832 5.477 9.246 5 7.5 5S4.168 5.477 3 6.253v13C4.168 18.477 5.754 18 7.5 18s3.332.477 4.5 1.253m0-13C13.168 5.477 14.754 5 16.5 5c1.747 0 3.332.477 4.5 1.253v13C19.832 18.477 18.247 18 16.5 18c-1.746 0-3.332.477-4.5 1.253"
/>
</svg>
</div>
<h2 className="text-2xl font-black text-white uppercase tracking-tight leading-none mb-2">
{t('title')}
</h2>
<p className="text-sm text-white/50 leading-relaxed">{t('subtitle')}</p>
</div>
{state === 'success' ? (
<div>
<div className="flex items-center gap-3 mb-6 p-4 rounded-2xl bg-[#82ed20]/10 border border-[#82ed20]/20">
<div className="flex h-9 w-9 shrink-0 items-center justify-center rounded-xl bg-[#82ed20]/20">
<svg
className="h-5 w-5 text-[#82ed20]"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M5 13l4 4L19 7"
/>
</svg>
</div>
<div>
<p className="text-sm font-bold text-[#82ed20]">
{locale === 'de' ? 'Erfolgreich gesendet' : 'Successfully sent'}
</p>
<p className="text-xs text-white/50 mt-0.5">
{locale === 'de'
? 'Bitte prüfen Sie Ihren Posteingang.'
: 'Please check your inbox.'}
</p>
</div>
</div>
<button
type="button"
onClick={handleClose}
className="flex items-center justify-center gap-3 w-full py-4 px-6 rounded-2xl bg-white/10 hover:bg-white/20 text-white font-black text-sm uppercase tracking-widest transition-colors"
>
{t('close')}
</button>
</div>
) : (
<form ref={formRef} onSubmit={handleSubmit}>
<div className="mb-5">
<label
htmlFor="brochure-email"
className="block text-[10px] font-black uppercase tracking-[0.2em] text-white/40 mb-2"
>
{t('emailLabel')}
</label>
<input
id="brochure-email"
name="email"
type="email"
required
autoComplete="email"
placeholder={t('emailPlaceholder')}
className="w-full rounded-xl bg-white/5 border border-white/10 px-4 py-3.5 text-white placeholder:text-white/20 text-sm font-medium focus:outline-none focus:border-[#82ed20]/40 transition-colors"
disabled={state === 'submitting'}
/>
</div>
{state === 'error' && errorMsg && (
<p className="text-red-400 text-xs mb-4 font-medium">{errorMsg}</p>
)}
<button
type="submit"
disabled={state === 'submitting'}
className={cn(
'w-full py-4 px-6 rounded-2xl font-black text-sm uppercase tracking-widest transition-colors',
state === 'submitting'
? 'bg-white/10 text-white/40 cursor-wait'
: 'bg-[#82ed20] hover:bg-[#6dd318] text-[#000d26]',
)}
>
{state === 'submitting' ? t('submitting') : t('submit')}
</button>
<p className="mt-4 text-[10px] text-white/25 text-center leading-relaxed">
{t('privacyNote')}
</p>
</form>
)}
</div>
</div>
</div>
);
return createPortal(modal, document.body);
}

View File

@@ -15,12 +15,10 @@ export default function CMSConnectivityNotice() {
const isDebug = new URLSearchParams(window.location.search).has('cms_debug');
const isLocal = config.isDevelopment;
const isTesting = config.isTesting;
const target = process.env.NEXT_PUBLIC_TARGET || '';
const isBranch = target === 'branch';
// Only proceed with check if it's developer context (Local, Testing, or Branch preview)
// 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 && !isBranch && !isDebug) return;
if (!isLocal && !isTesting && !isDebug) return;
try {
const response = await fetch('/api/health/cms');
@@ -60,8 +58,8 @@ export default function CMSConnectivityNotice() {
<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 run migrations for this environment.'
: 'A content service is unavailable. Check the deployment logs for details.'}
? '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

View File

@@ -138,20 +138,7 @@ export default function ContactForm() {
<Heading level={3} subtitle={t('form.subtitle')} className="mb-6 md:mb-10">
{t('form.title')}
</Heading>
<form
id="contact-form"
onSubmit={handleSubmit}
className="grid grid-cols-1 md:grid-cols-2 gap-4 md:gap-8"
>
{/* Anti-spam Honeypot */}
<input
type="text"
name="company_website"
tabIndex={-1}
autoComplete="off"
style={{ display: 'none' }}
aria-hidden="true"
/>
<form onSubmit={handleSubmit} className="grid grid-cols-1 md:grid-cols-2 gap-4 md:gap-8">
<div className="space-y-1 md:space-y-2">
<Label htmlFor="contact-name">{t('form.name')}</Label>
<Input

View File

@@ -33,12 +33,12 @@ export default function DatasheetDownload({ datasheetPath, className }: Datashee
<div className="absolute inset-0 bg-gradient-to-r from-accent via-saturated to-accent opacity-20 group-hover:opacity-40 transition-opacity duration-500 animate-gradient-x" />
{/* Inner Content */}
<div className="relative flex items-center gap-5 rounded-[31px] bg-primary-dark/90 backdrop-blur-xl p-6 md:px-6 md:py-6 border border-white/10">
<div className="relative flex items-center gap-6 rounded-[31px] bg-primary-dark/90 backdrop-blur-xl p-6 md:p-8 border border-white/10">
{/* Icon Container */}
<div className="relative flex h-14 w-14 flex-shrink-0 items-center justify-center rounded-2xl bg-white/5 border border-white/10 group-hover:bg-accent group-hover:border-white/20 transition-all duration-500">
<div className="relative flex h-16 w-16 flex-shrink-0 items-center justify-center rounded-2xl bg-white/5 border border-white/10 group-hover:bg-accent group-hover:border-white/20 transition-all duration-500">
<div className="absolute inset-0 rounded-2xl bg-accent/20 blur-xl opacity-0 group-hover:opacity-100 transition-opacity duration-500" />
<svg
className="relative h-7 w-7 text-white transition-transform duration-500 group-hover:scale-110 group-hover:rotate-3"
className="relative h-8 w-8 text-white transition-transform duration-500 group-hover:scale-110 group-hover:rotate-3"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
@@ -54,13 +54,13 @@ export default function DatasheetDownload({ datasheetPath, className }: Datashee
</div>
{/* Text Content */}
<div className="flex-1 min-w-0">
<div className="flex-1">
<div className="flex items-center gap-2 mb-1">
<span className="text-[10px] font-black uppercase tracking-[0.2em] text-accent">
PDF Datasheet
</span>
</div>
<h3 className="text-xl font-black text-white uppercase tracking-tighter leading-none group-hover:text-accent transition-colors duration-300">
<h3 className="text-xl md:text-2xl font-black text-white uppercase tracking-tighter leading-none group-hover:text-accent transition-colors duration-300">
{t('downloadDatasheet')}
</h3>
<p className="mt-2 text-sm font-medium text-white/60 leading-relaxed">
@@ -69,9 +69,9 @@ export default function DatasheetDownload({ datasheetPath, className }: Datashee
</div>
{/* Arrow Icon */}
<div className="flex h-10 w-10 flex-shrink-0 items-center justify-center rounded-full bg-white/5 text-white/20 group-hover:bg-accent group-hover:text-white group-hover:translate-x-1 transition-all duration-500">
<div className="flex h-10 w-10 items-center justify-center rounded-full bg-white/5 text-white/20 group-hover:bg-accent group-hover:text-white group-hover:translate-x-1 transition-all duration-500">
<svg
className="h-5 w-5"
className="h-6 w-6"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"

View File

@@ -1,94 +0,0 @@
'use client';
import { cn } from '@/components/ui/utils';
import { useTranslations } from 'next-intl';
import { useAnalytics } from './analytics/useAnalytics';
import { AnalyticsEvents } from './analytics/analytics-events';
interface ExcelDownloadProps {
excelPath: string;
className?: string;
}
export default function ExcelDownload({ excelPath, className }: ExcelDownloadProps) {
const t = useTranslations('Products');
const { trackEvent } = useAnalytics();
return (
<div className={cn('mt-4 animate-slight-fade-in-from-bottom', className)}>
<a
href={excelPath}
target="_blank"
rel="noopener noreferrer"
onClick={() =>
trackEvent(AnalyticsEvents.DOWNLOAD, {
file_name: excelPath.split('/').pop(),
file_path: excelPath,
file_type: 'excel',
location: 'product_page',
})
}
className="group relative block w-full overflow-hidden rounded-[32px] bg-primary-dark p-1 transition-all duration-500 hover:shadow-[0_20px_50px_rgba(0,0,0,0.2)] hover:-translate-y-1"
>
{/* Animated Background Gradient */}
<div className="absolute inset-0 bg-gradient-to-r from-emerald-500 via-teal-400 to-emerald-500 opacity-20 group-hover:opacity-40 transition-opacity duration-500 animate-gradient-x" />
{/* Inner Content */}
<div className="relative flex items-center gap-5 rounded-[31px] bg-primary-dark/90 backdrop-blur-xl p-6 md:px-6 md:py-6 border border-white/10">
{/* Icon Container */}
<div className="relative flex h-14 w-14 flex-shrink-0 items-center justify-center rounded-2xl bg-white/5 border border-white/10 group-hover:bg-emerald-600 group-hover:border-white/20 transition-all duration-500">
<div className="absolute inset-0 rounded-2xl bg-emerald-500/20 blur-xl opacity-0 group-hover:opacity-100 transition-opacity duration-500" />
{/* Spreadsheet/Table Icon */}
<svg
className="relative h-7 w-7 text-white transition-transform duration-500 group-hover:scale-110 group-hover:rotate-3"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
aria-hidden="true"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={1.5}
d="M3 10h18M3 14h18M10 3v18M3 6a3 3 0 013-3h12a3 3 0 013 3v12a3 3 0 01-3 3H6a3 3 0 01-3-3V6z"
/>
</svg>
</div>
{/* Text Content */}
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2 mb-1">
<span className="text-[10px] font-black uppercase tracking-[0.2em] text-emerald-400">
Excel Datasheet
</span>
</div>
<h3 className="text-xl font-black text-white uppercase tracking-tighter leading-none group-hover:text-emerald-400 transition-colors duration-300">
{t('downloadExcel')}
</h3>
<p className="mt-2 text-sm font-medium text-white/60 leading-relaxed">
{t('downloadExcelDesc')}
</p>
</div>
{/* Arrow Icon */}
<div className="flex h-10 w-10 flex-shrink-0 items-center justify-center rounded-full bg-white/5 text-white/20 group-hover:bg-emerald-600 group-hover:text-white group-hover:translate-x-1 transition-all duration-500">
<svg
className="h-5 w-5"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
aria-hidden="true"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2.5}
d="M9 5l7 7-7 7"
/>
</svg>
</div>
</div>
</a>
</div>
);
}

View File

@@ -6,7 +6,6 @@ import { useTranslations, useLocale } from 'next-intl';
import { Container } from './ui';
import { useAnalytics } from './analytics/useAnalytics';
import { AnalyticsEvents } from './analytics/analytics-events';
import FooterBrochureForm from './FooterBrochureForm';
export default function Footer() {
const t = useTranslations('Footer');
@@ -244,10 +243,6 @@ export default function Footer() {
</div>
</div>
<div className="mb-12 md:mb-16">
<FooterBrochureForm />
</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">
<p>{t('copyright', { year: currentYear })}</p>
<div className="flex gap-8">

View File

@@ -1,134 +0,0 @@
'use client';
import { useState, useRef } from 'react';
import { useTranslations, useLocale } from 'next-intl';
import { requestBrochureAction } from '@/app/actions/brochure';
import { cn } from '@/components/ui/utils';
import { useAnalytics } from './analytics/useAnalytics';
import { AnalyticsEvents } from './analytics/analytics-events';
interface Props {
className?: string;
}
export default function FooterBrochureForm({ className }: Props) {
const t = useTranslations('Brochure');
const locale = useLocale();
const { trackEvent } = useAnalytics();
const formRef = useRef<HTMLFormElement>(null);
const [phase, setPhase] = useState<'idle' | 'loading' | 'success' | 'error'>('idle');
const [err, setErr] = useState('');
async function handleSubmit(e: React.FormEvent) {
e.preventDefault();
if (!formRef.current) return;
setPhase('loading');
const fd = new FormData(formRef.current);
fd.set('locale', locale);
try {
const res = await requestBrochureAction(fd);
if (res.success) {
setPhase('success');
trackEvent(AnalyticsEvents.DOWNLOAD, {
file_name: `klz-product-catalog-${locale}.pdf`,
file_type: 'brochure',
location: 'footer_inline',
});
} else {
setErr(res.error || 'Error');
setPhase('error');
}
} catch {
setErr('Network error');
setPhase('error');
}
}
if (phase === 'success') {
return (
<div
className={cn(
'flex flex-col sm:flex-row items-center gap-4 bg-white/5 border border-[#82ed20]/20 rounded-2xl p-6',
className,
)}
>
<div className="flex h-10 w-10 shrink-0 items-center justify-center rounded-full bg-[#82ed20]/20 text-[#82ed20]">
<svg className="h-5 w-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M5 13l4 4L19 7" />
</svg>
</div>
<div>
<h4 className="text-white font-bold mb-1">
{locale === 'de' ? 'Erfolgreich angefordert!' : 'Successfully requested!'}
</h4>
<p className="text-white/60 text-sm">
{locale === 'de'
? 'Wir haben Ihnen den Katalog soeben per E-Mail zugesendet.'
: 'We have just sent the catalog to your email.'}
</p>
</div>
</div>
);
}
return (
<div
className={cn(
'bg-white/5 border border-white/10 rounded-3xl p-6 md:p-8 flex flex-col md:flex-row items-start md:items-center justify-between gap-6 md:gap-12',
className,
)}
>
<div className="flex-1 max-w-xl">
<h4 className="text-lg font-black text-white uppercase tracking-tight mb-2">
{t('ctaTitle')}
</h4>
<p className="text-sm text-white/60 leading-relaxed mb-0">{t('subtitle')}</p>
</div>
<form
ref={formRef}
onSubmit={handleSubmit}
className="w-full md:w-auto flex flex-col sm:flex-row gap-3"
>
{/* Anti-spam Honeypot */}
<input
type="text"
name="company_website"
tabIndex={-1}
autoComplete="off"
style={{ display: 'none' }}
aria-hidden="true"
/>
<div className="relative w-full sm:w-64">
<input
name="email"
type="email"
required
placeholder={t('emailPlaceholder')}
disabled={phase === 'loading'}
className="w-full bg-primary-dark border border-white/20 rounded-xl px-4 py-3 text-sm text-white placeholder:text-white/30 focus:outline-none focus:border-[#82ed20]/50 transition-colors"
/>
</div>
<button
type="submit"
disabled={phase === 'loading'}
className={cn(
'flex items-center justify-center shrink-0 px-6 py-3 rounded-xl font-bold text-sm uppercase tracking-widest transition-colors',
phase === 'loading'
? 'bg-white/10 text-white/40 cursor-wait'
: 'bg-[#82ed20] text-[#000d26] hover:bg-[#6dd318] cursor-pointer',
)}
>
{phase === 'loading' ? t('submitting') : t('submit')}
</button>
</form>
{phase === 'error' && err && (
<div className="absolute mt-16 text-red-400 text-xs font-medium">{err}</div>
)}
</div>
);
}

View File

@@ -36,7 +36,6 @@ export default function Header() {
// Prevent scroll when mobile menu is open and handle focus trap
useEffect(() => {
if (isMobileMenuOpen) {
document.documentElement.style.overflow = 'hidden';
document.body.style.overflow = 'hidden';
// Focus trap logic
const focusableElements = mobileMenuRef.current?.querySelectorAll(
@@ -81,8 +80,7 @@ export default function Header() {
};
}
} else {
document.documentElement.style.overflow = '';
document.body.style.overflow = '';
document.body.style.overflow = 'unset';
}
}, [isMobileMenuOpen]);

View File

@@ -23,7 +23,7 @@ export default function Lightbox({ isOpen, images, initialIndex, onClose }: Ligh
const previousFocusRef = useRef<HTMLElement | null>(null);
useEffect(() => {
setMounted(true);
setMounted(true); // eslint-disable-line react-hooks/set-state-in-effect
return () => setMounted(false);
}, []);
@@ -61,7 +61,7 @@ export default function Lightbox({ isOpen, images, initialIndex, onClose }: Ligh
if (photoParam !== null) {
const index = parseInt(photoParam, 10);
if (!isNaN(index) && index >= 0 && index < images.length) {
setCurrentIndex(index);
setCurrentIndex(index); // eslint-disable-line react-hooks/set-state-in-effect
}
}
}, [searchParams, images.length]);
@@ -125,17 +125,13 @@ export default function Lightbox({ isOpen, images, initialIndex, onClose }: Ligh
};
// Lock scroll
const originalBodyStyle = window.getComputedStyle(document.body).overflow;
const originalHtmlStyle = window.getComputedStyle(document.documentElement).overflow;
document.documentElement.style.setProperty('overflow', 'hidden', 'important');
document.body.style.setProperty('overflow', 'hidden', 'important');
const originalStyle = window.getComputedStyle(document.body).overflow;
document.body.style.overflow = 'hidden';
window.addEventListener('keydown', handleKeyDown);
return () => {
document.documentElement.style.overflow = originalHtmlStyle;
document.body.style.overflow = originalBodyStyle;
document.body.style.overflow = originalStyle;
window.removeEventListener('keydown', handleKeyDown);
};
}, [isOpen, prevImage, nextImage, handleClose]);
@@ -143,7 +139,7 @@ export default function Lightbox({ isOpen, images, initialIndex, onClose }: Ligh
if (!mounted) return null;
return createPortal(
<LazyMotion strict features={() => import('@/lib/framer-features').then((res) => res.default)}>
<LazyMotion strict features={() => import('@/lib/framer-features').then(res => res.default)}>
<AnimatePresence>
{isOpen && (
<div

View File

@@ -17,7 +17,6 @@ export default function ObfuscatedEmail({ email, className = '', children }: Obf
const [mounted, setMounted] = useState(false);
useEffect(() => {
// eslint-disable-next-line react-hooks/set-state-in-effect
setMounted(true);
}, []);

View File

@@ -16,7 +16,6 @@ export default function ObfuscatedPhone({ phone, className = '', children }: Obf
const [mounted, setMounted] = useState(false);
useEffect(() => {
// eslint-disable-next-line react-hooks/set-state-in-effect
setMounted(true);
}, []);

View File

@@ -793,8 +793,8 @@ const jsxConverters: JSXConverters = {
</Section>
);
},
imageGallery: () => <Gallery />,
'block-imageGallery': () => <Gallery />,
imageGallery: ({ node }: any) => <Gallery />,
'block-imageGallery': ({ node }: any) => <Gallery />,
categoryGrid: ({ node }: any) => {
const cats = node.fields.categories || [];
return (

View File

@@ -4,7 +4,6 @@ import Image from 'next/image';
import { useTranslations } from 'next-intl';
import RequestQuoteForm from '@/components/RequestQuoteForm';
import DatasheetDownload from '@/components/DatasheetDownload';
import ExcelDownload from '@/components/ExcelDownload';
import Scribble from '@/components/Scribble';
import { cn } from '@/components/ui/utils';
@@ -12,7 +11,6 @@ interface ProductSidebarProps {
productName: string;
productImage?: string;
datasheetPath?: string | null;
excelPath?: string | null;
className?: string;
}
@@ -20,7 +18,6 @@ export default function ProductSidebar({
productName,
productImage,
datasheetPath,
excelPath,
className,
}: ProductSidebarProps) {
const t = useTranslations('Products');
@@ -73,9 +70,6 @@ export default function ProductSidebar({
{/* Datasheet Download */}
{datasheetPath && <DatasheetDownload datasheetPath={datasheetPath} className="mt-0" />}
{/* Excel Download right below datasheet */}
{excelPath && <ExcelDownload excelPath={excelPath} className="mt-0" />}
</aside>
);
}

View File

@@ -2,7 +2,6 @@
import React, { useState } from 'react';
import { useTranslations } from 'next-intl';
import { formatTechnicalValue } from '@/lib/utils/technical';
interface KeyValueItem {
label: string;
@@ -46,40 +45,22 @@ export default function ProductTechnicalData({ data }: ProductTechnicalDataProps
<div className="w-2 h-8 bg-accent rounded-full" />
General Data
</h3>
<dl className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-x-6 gap-y-6 md:gap-x-12 md:gap-y-8">
{technicalItems.map((item, idx) => {
const formatted = formatTechnicalValue(item.value);
return (
<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">
{item.label}
</dt>
<dd className="text-lg font-semibold text-text-primary">
{formatted.isList ? (
<div className="flex flex-wrap gap-2 mt-1">
{formatted.parts.map((p, pIdx) => (
<span
key={pIdx}
className="inline-block px-3 py-1 bg-neutral-light border border-neutral-dark/10 rounded-lg text-xs font-bold text-primary shadow-sm hover:border-accent/40 transition-colors"
>
{p}
</span>
))}
</div>
) : (
<>
{item.value}{' '}
{item.unit && (
<span className="text-sm font-normal text-text-secondary ml-1">
{item.unit}
</span>
)}
</>
)}
</dd>
</div>
);
})}
<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">
{technicalItems.map((item, idx) => (
<div key={idx} className="flex flex-col group">
<dt className="text-sm font-bold uppercase tracking-widest text-primary/40 mb-2 group-hover:text-accent transition-colors">
{item.label}
</dt>
<dd className="text-lg font-semibold text-text-primary">
{item.value}{' '}
{item.unit && (
<span className="text-sm font-normal text-text-secondary ml-1">
{item.unit}
</span>
)}
</dd>
</div>
))}
</dl>
</div>
)}
@@ -96,7 +77,7 @@ export default function ProductTechnicalData({ data }: ProductTechnicalDataProps
<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" />
{table.voltageLabel !== 'Voltage unknown' &&
table.voltageLabel !== 'Spannung unbekannt'
table.voltageLabel !== 'Spannung unbekannt'
? table.voltageLabel
: 'Technical Specifications'}
</h3>
@@ -121,8 +102,9 @@ export default function ProductTechnicalData({ data }: ProductTechnicalDataProps
<div className="pointer-events-none absolute right-0 top-0 h-full w-8 bg-gradient-to-l from-white to-transparent z-20 md:hidden" />
<div
id={`voltage-table-${idx}`}
className={`overflow-x-auto -mx-5 md:-mx-12 px-5 md:px-12 transition-all duration-500 ease-in-out ${!isExpanded && hasManyRows ? 'max-h-[400px] overflow-y-hidden' : 'max-h-[none]'
}`}
className={`overflow-x-auto -mx-5 md:-mx-12 px-5 md:px-12 transition-all duration-500 ease-in-out ${
!isExpanded && hasManyRows ? 'max-h-[400px] overflow-y-hidden' : 'max-h-[none]'
}`}
>
<table className="min-w-full border-separate border-spacing-0">
<thead>

View File

@@ -164,17 +164,7 @@ export default function RequestQuoteForm({ productName }: RequestQuoteFormProps)
}
return (
<form id="quote-request-form" onSubmit={handleSubmit} className="space-y-3 !mt-0">
{/* Anti-spam Honeypot */}
<input
type="text"
name="company_website"
tabIndex={-1}
autoComplete="off"
style={{ display: 'none' }}
aria-hidden="true"
/>
<form onSubmit={handleSubmit} className="space-y-3 !mt-0">
<div className="space-y-2 !mt-0">
<div className="space-y-1 !mt-0">
<label htmlFor={emailId} className="sr-only">

View File

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

View File

@@ -1,5 +1,5 @@
import React from 'react';
import Scribble from '@/components/Scribble';
import { formatTechnicalValue } from '@/lib/utils/technical';
interface TechnicalGridItem {
label: string;
@@ -18,41 +18,25 @@ export default function TechnicalGrid({ title, items }: TechnicalGridProps) {
<h3 className="text-2xl font-bold text-text-primary mb-8 flex items-center gap-4 relative">
<span className="relative inline-block">
{title}
<Scribble
variant="underline"
className="absolute -bottom-2 left-0 w-full h-3 text-accent/40"
<Scribble
variant="underline"
className="absolute -bottom-2 left-0 w-full h-3 text-accent/40"
/>
</span>
</h3>
)}
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
{items.map((item, index) => {
const formatted = formatTechnicalValue(item.value);
return (
<div key={index} className="bg-white p-8 rounded-2xl border border-neutral-200 shadow-sm hover:shadow-md transition-all duration-300 group relative overflow-hidden">
<div className="absolute top-0 right-0 w-16 h-16 bg-primary/5 -mr-8 -mt-8 rotate-45 transition-transform group-hover:scale-110" />
<span className="block text-xs font-bold text-primary uppercase tracking-[0.2em] mb-3 opacity-70">
{item.label}
</span>
<div className="text-lg text-text-secondary leading-relaxed group-hover:text-text-primary transition-colors">
{formatted.isList ? (
<div className="flex flex-wrap gap-2 mt-2">
{formatted.parts.map((p, pIdx) => (
<span
key={pIdx}
className="inline-block px-3 py-1 bg-neutral-light border border-neutral-dark/10 rounded-lg text-xs font-bold text-primary shadow-sm group-hover:border-accent/40 transition-colors"
>
{p}
</span>
))}
</div>
) : (
item.value
)}
</div>
</div>
);
})}
{items.map((item, index) => (
<div key={index} className="bg-white p-8 rounded-2xl border border-neutral-200 shadow-sm hover:shadow-md transition-all duration-300 group relative overflow-hidden">
<div className="absolute top-0 right-0 w-16 h-16 bg-primary/5 -mr-8 -mt-8 rotate-45 transition-transform group-hover:scale-110" />
<span className="block text-xs font-bold text-primary uppercase tracking-[0.2em] mb-3 opacity-70">
{item.label}
</span>
<span className="text-lg text-text-secondary leading-relaxed group-hover:text-text-primary transition-colors">
{item.value}
</span>
</div>
))}
</div>
</div>
);

View File

@@ -1,145 +0,0 @@
import {
Body,
Container,
Head,
Heading,
Hr,
Html,
Preview,
Section,
Text,
Button,
} from '@react-email/components';
import * as React from 'react';
interface BrochureDeliveryEmailProps {
_email: string;
brochureUrl: string;
locale: 'en' | 'de';
}
export const BrochureDeliveryEmail = ({
_email,
brochureUrl,
locale = 'en',
}: BrochureDeliveryEmailProps) => {
const t =
locale === 'de'
? {
subject: 'Ihr KLZ Kabelkatalog',
greeting: 'Vielen Dank für Ihr Interesse an KLZ Cables.',
body: 'Anbei erhalten Sie den Link zu unserem aktuellen Produktkatalog. Dieser enthält alle wichtigen technischen Spezifikationen und detaillierten Produktdaten.',
button: 'Katalog herunterladen',
footer: 'Diese E-Mail wurde von klz-cables.com gesendet.',
}
: {
subject: 'Your KLZ Cable Catalog',
greeting: 'Thank you for your interest in KLZ Cables.',
body: 'Below you will find the link to our current product catalog. It contains all key technical specifications and detailed product data.',
button: 'Download Catalog',
footer: 'This email was sent from klz-cables.com.',
};
return (
<Html>
<Head />
<Preview>{t.subject}</Preview>
<Body style={main}>
<Container style={container}>
<Section style={headerSection}>
<Heading style={h1}>{t.subject}</Heading>
</Section>
<Section style={section}>
<Text style={text}>
<strong>{t.greeting}</strong>
</Text>
<Text style={text}>{t.body}</Text>
<Section style={buttonContainer}>
<Button style={button} href={brochureUrl}>
{t.button}
</Button>
</Section>
<Hr style={hr} />
</Section>
<Text style={footer}>{t.footer}</Text>
</Container>
</Body>
</Html>
);
};
export default BrochureDeliveryEmail;
const main = {
backgroundColor: '#f6f9fc',
fontFamily:
'-apple-system,BlinkMacSystemFont,"Segoe UI",Roboto,"Helvetica Neue",Ubuntu,sans-serif',
};
const container = {
backgroundColor: '#ffffff',
margin: '0 auto',
padding: '0 0 48px',
marginBottom: '64px',
borderRadius: '8px',
overflow: 'hidden',
border: '1px solid #e6ebf1',
};
const headerSection = {
backgroundColor: '#000d26',
padding: '32px 48px',
borderBottom: '4px solid #4da612',
};
const h1 = {
color: '#ffffff',
fontSize: '24px',
fontWeight: 'bold',
margin: '0',
};
const section = {
padding: '32px 48px 0',
};
const text = {
color: '#333',
fontSize: '16px',
lineHeight: '24px',
textAlign: 'left' as const,
};
const buttonContainer = {
textAlign: 'center' as const,
marginTop: '32px',
marginBottom: '32px',
};
const button = {
backgroundColor: '#4da612',
borderRadius: '4px',
color: '#ffffff',
fontSize: '16px',
fontWeight: 'bold',
textDecoration: 'none',
textAlign: 'center' as const,
display: 'inline-block',
padding: '16px 32px',
};
const hr = {
borderColor: '#e6ebf1',
margin: '20px 0',
};
const footer = {
color: '#8898aa',
fontSize: '12px',
lineHeight: '16px',
textAlign: 'center' as const,
marginTop: '20px',
};

View File

@@ -74,14 +74,11 @@ export default async function RecentPosts({ locale, data }: RecentPostsProps) {
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(
['en', 'de'].includes(locale) ? locale : 'de',
{
year: 'numeric',
month: 'short',
day: 'numeric',
},
)}
{new Date(post.frontmatter.date).toLocaleDateString(locale || 'de', {
year: 'numeric',
month: 'short',
day: 'numeric',
})}
</time>
{(new Date(post.frontmatter.date) > new Date() ||
post.frontmatter.public === false) && (

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

File diff suppressed because it is too large Load Diff

View File

@@ -14,7 +14,6 @@ services:
PAYLOAD_SECRET: ${PAYLOAD_SECRET:-fallback-secret-for-production-needs-change}
volumes:
- klz_media_data:/app/public/media
- klz_datasheets:/app/public/datasheets
labels:
- "traefik.enable=true"
# HTTP ⇒ HTTPS redirect
@@ -112,5 +111,3 @@ volumes:
external: false
klz_media_data:
external: false
klz_datasheets:
external: false

File diff suppressed because it is too large Load Diff

View File

@@ -7,7 +7,7 @@ import path from 'path';
*/
export function getDatasheetPath(slug: string, locale: string): string | null {
const datasheetsDir = path.join(process.cwd(), 'public', 'datasheets');
if (!fs.existsSync(datasheetsDir)) {
return null;
}
@@ -16,21 +16,16 @@ export function getDatasheetPath(slug: string, locale: string): string | null {
const normalizedSlug = slug.replace(/-hv$|-mv$/, '');
// Subdirectories to search in
const subdirs = ['', 'products', 'low-voltage', 'medium-voltage', 'high-voltage', 'solar'];
const subdirs = ['', 'low-voltage', 'medium-voltage', 'high-voltage', 'solar'];
// List of patterns to try for the current locale
// Also try with -mv and -hv suffixes since some product slugs omit the voltage class
const patterns = [
`${slug}-${locale}.pdf`,
`${slug}-2-${locale}.pdf`,
`${slug}-3-${locale}.pdf`,
`${slug}-mv-${locale}.pdf`,
`${slug}-hv-${locale}.pdf`,
`${normalizedSlug}-${locale}.pdf`,
`${normalizedSlug}-2-${locale}.pdf`,
`${normalizedSlug}-3-${locale}.pdf`,
`${normalizedSlug}-mv-${locale}.pdf`,
`${normalizedSlug}-hv-${locale}.pdf`,
];
for (const subdir of subdirs) {
@@ -49,70 +44,9 @@ export function getDatasheetPath(slug: string, locale: string): string | null {
`${slug}-en.pdf`,
`${slug}-2-en.pdf`,
`${slug}-3-en.pdf`,
`${slug}-mv-en.pdf`,
`${slug}-hv-en.pdf`,
`${normalizedSlug}-en.pdf`,
`${normalizedSlug}-2-en.pdf`,
`${normalizedSlug}-3-en.pdf`,
`${normalizedSlug}-mv-en.pdf`,
`${normalizedSlug}-hv-en.pdf`,
];
for (const subdir of subdirs) {
for (const pattern of enPatterns) {
const relativePath = path.join(subdir, pattern);
const filePath = path.join(datasheetsDir, relativePath);
if (fs.existsSync(filePath)) {
return `/datasheets/${relativePath}`;
}
}
}
}
return null;
}
/**
* Finds the datasheet Excel path for a given product slug and locale.
* Checks public/datasheets for matching .xlsx files.
*/
export function getExcelDatasheetPath(slug: string, locale: string): string | null {
const datasheetsDir = path.join(process.cwd(), 'public', 'datasheets');
if (!fs.existsSync(datasheetsDir)) {
return null;
}
const normalizedSlug = slug.replace(/-hv$|-mv$/, '');
const subdirs = ['', 'products', 'low-voltage', 'medium-voltage', 'high-voltage', 'solar'];
const patterns = [
`${slug}-${locale}.xlsx`,
`${slug}-2-${locale}.xlsx`,
`${slug}-3-${locale}.xlsx`,
`${normalizedSlug}-${locale}.xlsx`,
`${normalizedSlug}-2-${locale}.xlsx`,
`${normalizedSlug}-3-${locale}.xlsx`,
];
for (const subdir of subdirs) {
for (const pattern of patterns) {
const relativePath = path.join(subdir, pattern);
const filePath = path.join(datasheetsDir, relativePath);
if (fs.existsSync(filePath)) {
return `/datasheets/${relativePath}`;
}
}
}
// Fallback to English if locale is not 'en'
if (locale !== 'en') {
const enPatterns = [
`${slug}-en.xlsx`,
`${slug}-2-en.xlsx`,
`${slug}-3-en.xlsx`,
`${normalizedSlug}-en.xlsx`,
`${normalizedSlug}-2-en.xlsx`,
`${normalizedSlug}-3-en.xlsx`,
];
for (const subdir of subdirs) {
for (const pattern of enPatterns) {

View File

@@ -11,10 +11,21 @@ export async function getOgFonts() {
try {
console.log(`[OG] Loading fonts: bold=${boldFontPath}, regular=${regularFontPath}`);
const boldFont = readFileSync(boldFontPath);
const regularFont = readFileSync(regularFontPath);
const boldFontBuffer = readFileSync(boldFontPath);
const regularFontBuffer = readFileSync(regularFontPath);
// Satori (Vercel OG) strictly requires an ArrayBuffer, not a Node Buffer view.
const boldFont = boldFontBuffer.buffer.slice(
boldFontBuffer.byteOffset,
boldFontBuffer.byteOffset + boldFontBuffer.byteLength,
);
const regularFont = regularFontBuffer.buffer.slice(
regularFontBuffer.byteOffset,
regularFontBuffer.byteOffset + regularFontBuffer.byteLength,
);
console.log(
`[OG] Fonts loaded successfully (${boldFont.length} and ${regularFont.length} bytes)`,
`[OG] Fonts loaded successfully (${boldFont.byteLength} and ${regularFont.byteLength} bytes)`,
);
return [

View File

@@ -15,6 +15,8 @@ export interface PageFrontmatter {
export interface PageData {
slug: string;
redirectUrl?: string;
redirectPermanent?: boolean;
frontmatter: PageFrontmatter;
content: any; // Lexical AST Document
}
@@ -96,6 +98,8 @@ export async function getPageBySlug(slug: string, locale: string): Promise<PageD
return {
slug: doc.slug,
redirectUrl: doc.redirectUrl,
redirectPermanent: doc.redirectPermanent ?? true,
frontmatter: {
title: doc.title,
excerpt: doc.excerpt || '',

File diff suppressed because it is too large Load Diff

View File

@@ -1,220 +1,273 @@
import * as React from 'react';
import { Document, Page, View, Text, Image, StyleSheet, Font } from '@react-pdf/renderer';
import type { DatasheetVoltageTable, KeyValueItem } from '../scripts/pdf/model/types';
// Standard built-in fonts are used.
Font.registerHyphenationCallback((word) => [word]);
const C = {
navy: '#001a4d',
navyDeep: '#000d26',
green: '#4da612',
greenLight: '#e8f5d8',
white: '#FFFFFF',
offWhite: '#f8f9fa',
gray100: '#f3f4f6',
gray200: '#e5e7eb',
gray300: '#d1d5db',
gray400: '#9ca3af',
gray600: '#4b5563',
gray900: '#111827',
};
const MARGIN = 56;
// Standard fonts like Helvetica are built-in to PDF and don't require registration
// unless we want to use specific TTF files. Using built-in Helvetica for maximum stability.
// Industrial/technical/restrained design - STYLEGUIDE.md compliant
const styles = StyleSheet.create({
page: {
paddingHorizontal: MARGIN,
paddingBottom: 80,
paddingTop: 40,
color: '#111827', // Text Primary
lineHeight: 1.5,
backgroundColor: '#FFFFFF',
paddingTop: 0,
paddingBottom: 100,
fontFamily: 'Helvetica',
backgroundColor: C.white,
color: C.gray900,
},
hero: { paddingBottom: 20, marginBottom: 10 },
// Hero-style header
hero: {
backgroundColor: '#FFFFFF',
paddingTop: 24,
paddingBottom: 0,
paddingHorizontal: 72,
marginBottom: 20,
position: 'relative',
borderBottomWidth: 0,
borderBottomColor: '#e5e7eb',
},
header: {
flexDirection: 'row',
justifyContent: 'space-between',
alignItems: 'center',
marginBottom: 16,
},
logoText: {
fontSize: 22,
fontSize: 24,
fontWeight: 700,
color: C.navyDeep,
letterSpacing: 2,
color: '#000d26',
letterSpacing: 1,
textTransform: 'uppercase',
},
docTitle: {
fontSize: 8,
fontSize: 10,
fontWeight: 700,
color: C.green,
color: '#001a4d',
letterSpacing: 2,
textTransform: 'uppercase',
},
productRow: { flexDirection: 'row', alignItems: 'center', gap: 20 },
productInfoCol: { flex: 1, justifyContent: 'center' },
productRow: {
flexDirection: 'row',
alignItems: 'center',
gap: 20,
},
productInfoCol: {
flex: 1,
justifyContent: 'center',
},
productImageCol: {
flex: 1,
height: 120,
justifyContent: 'center',
alignItems: 'center',
borderRadius: 4,
borderRadius: 8,
borderWidth: 1,
borderColor: C.gray200,
backgroundColor: C.white,
borderColor: '#e5e7eb',
backgroundColor: '#FFFFFF',
overflow: 'hidden',
},
// Product Hero Info
productHero: {
marginTop: 0,
},
productName: {
fontSize: 24,
fontWeight: 700,
color: C.navyDeep,
color: '#000d26',
marginBottom: 0,
textTransform: 'uppercase',
letterSpacing: -0.5,
},
productMeta: {
fontSize: 10,
color: C.gray600,
color: '#4b5563',
fontWeight: 700,
textTransform: 'uppercase',
letterSpacing: 1,
marginBottom: 2,
},
heroImage: { width: '100%', height: '100%', objectFit: 'contain' },
noImage: { fontSize: 8, color: C.gray400, textAlign: 'center' },
content: {},
section: { marginBottom: 20 },
sectionTitle: {
fontSize: 8,
fontWeight: 700,
color: C.green,
marginBottom: 6,
textTransform: 'uppercase',
letterSpacing: 1.5,
heroImage: {
width: '100%',
height: '100%',
objectFit: 'contain',
},
noImage: {
fontSize: 8,
color: '#9ca3af',
textAlign: 'center',
},
// Content Area
content: {
paddingHorizontal: 72,
},
// Content sections
section: {
marginBottom: 20,
},
sectionTitle: {
fontSize: 14,
fontWeight: 700,
color: '#000d26', // Primary Dark
marginBottom: 8,
textTransform: 'uppercase',
letterSpacing: -0.2,
},
sectionAccent: {
width: 30,
height: 2,
backgroundColor: C.green,
height: 3,
backgroundColor: '#82ed20', // Accent Green
marginBottom: 8,
borderRadius: 1,
borderRadius: 1.5,
},
description: { fontSize: 10, lineHeight: 1.7, color: C.gray600 },
categories: { flexDirection: 'row', flexWrap: 'wrap', gap: 6 },
categoryTag: {
backgroundColor: C.offWhite,
paddingHorizontal: 10,
paddingVertical: 4,
borderWidth: 0.5,
borderColor: C.gray200,
borderRadius: 3,
description: {
fontSize: 11,
lineHeight: 1.7,
color: '#4b5563', // Text Secondary
},
// Technical data table
specsTable: {
marginTop: 8,
border: '1px solid #e5e7eb',
borderRadius: 8,
overflow: 'hidden',
},
specsTableRow: {
flexDirection: 'row',
borderBottomWidth: 1,
borderBottomColor: '#e5e7eb',
},
specsTableRowLast: {
borderBottomWidth: 0,
},
specsTableLabelCell: {
flex: 1,
paddingVertical: 4,
paddingHorizontal: 16,
backgroundColor: '#f8f9fa',
borderRightWidth: 1,
borderRightColor: '#e5e7eb',
},
specsTableValueCell: {
flex: 1,
paddingVertical: 4,
paddingHorizontal: 16,
},
specsTableLabelText: {
fontSize: 9,
fontWeight: 700,
color: '#000d26',
textTransform: 'uppercase',
letterSpacing: 0.5,
},
specsTableValueText: {
fontSize: 10,
color: '#111827',
fontWeight: 500,
},
// Categories
categories: {
flexDirection: 'row',
flexWrap: 'wrap',
gap: 8,
},
categoryTag: {
backgroundColor: '#f8f9fa',
paddingHorizontal: 12,
paddingVertical: 6,
border: '1px solid #e5e7eb',
borderRadius: 100,
},
categoryText: {
fontSize: 7,
color: C.gray600,
fontSize: 8,
color: '#4b5563',
fontWeight: 700,
textTransform: 'uppercase',
letterSpacing: 0.5,
},
// Footer
footer: {
position: 'absolute',
bottom: 28,
left: MARGIN,
right: MARGIN,
bottom: 40,
left: 72,
right: 72,
flexDirection: 'row',
justifyContent: 'space-between',
alignItems: 'center',
paddingTop: 12,
borderTopWidth: 2,
borderTopColor: C.green,
paddingTop: 24,
borderTop: '1px solid #e5e7eb',
},
footerText: {
fontSize: 7,
color: C.gray400,
fontWeight: 400,
fontSize: 8,
color: '#9ca3af',
fontWeight: 500,
textTransform: 'uppercase',
letterSpacing: 0.8,
letterSpacing: 1,
},
footerBrand: {
fontSize: 9,
fontSize: 10,
fontWeight: 700,
color: C.navyDeep,
color: '#000d26',
textTransform: 'uppercase',
letterSpacing: 1.5,
letterSpacing: 1,
},
kvGrid: { width: '100%', borderWidth: 1, borderColor: C.gray200 },
kvRow: { flexDirection: 'row', borderBottomWidth: 1, borderBottomColor: C.gray200 },
kvRowAlt: { backgroundColor: C.offWhite },
kvRowLast: { borderBottomWidth: 0 },
kvCell: { paddingVertical: 6, paddingHorizontal: 8 },
kvMidDivider: { borderRightWidth: 1, borderRightColor: C.gray200 },
kvLabelText: { fontSize: 8.5, fontWeight: 700, color: C.gray600 },
kvValueText: { fontSize: 9.5, color: C.gray900 },
tableWrap: { width: '100%', borderWidth: 1, borderColor: C.gray200, marginBottom: 14 },
tableHeader: {
width: '100%',
flexDirection: 'row',
backgroundColor: C.white,
borderBottomWidth: 1,
borderBottomColor: C.gray200,
},
tableHeaderCell: {
paddingVertical: 5,
paddingHorizontal: 4,
fontSize: 6.6,
fontWeight: 700,
color: C.navyDeep,
},
tableHeaderCellCfg: { paddingHorizontal: 6 },
tableHeaderCellDivider: { borderRightWidth: 1, borderRightColor: C.gray200 },
tableRow: {
width: '100%',
flexDirection: 'row',
borderBottomWidth: 1,
borderBottomColor: C.gray200,
},
tableRowAlt: { backgroundColor: C.offWhite },
tableCell: { paddingVertical: 4, paddingHorizontal: 4, fontSize: 6.6, color: C.gray900 },
tableCellCfg: { paddingHorizontal: 6 },
tableCellDivider: { borderRightWidth: 1, borderRightColor: C.gray200 },
});
interface ProductData {
id: number;
name: string;
sku: string;
categoriesLine?: string;
descriptionText?: string;
heroSrc?: string | null;
productUrl?: string;
shortDescriptionHtml?: string;
descriptionHtml?: string;
shortDescriptionHtml: string;
descriptionHtml: string;
applicationHtml?: string;
images?: string[];
featuredImage?: string | null;
logoDataUrl?: string | null;
categories?: Array<{ name: string }>;
attributes?: Array<{ name: string; options: string[] }>;
images: string[];
featuredImage: string | null;
sku: string;
categories: Array<{ name: string }>;
attributes: Array<{
name: string;
options: string[];
}>;
}
export interface PDFDatasheetProps {
interface PDFDatasheetProps {
product: ProductData;
locale: 'en' | 'de';
logoDataUrl?: string | null;
technicalItems?: KeyValueItem[];
voltageTables?: DatasheetVoltageTable[];
legendItems?: KeyValueItem[];
logoUrl?: string;
}
const stripHtml = (html: string): string => html.replace(/<[^>]*>/g, '');
// Helper to strip HTML tags
const stripHtml = (html: string): string => {
return html.replace(/<[^>]*>/g, '');
};
const getLabels = (locale: 'en' | 'de') =>
({
// Helper to get translated labels
const getLabels = (locale: 'en' | 'de') => {
const labels = {
en: {
productDatasheet: 'Technical Datasheet',
description: 'APPLICATION',
@@ -222,9 +275,6 @@ const getLabels = (locale: 'en' | 'de') =>
categories: 'CATEGORIES',
sku: 'SKU',
noImage: 'No image available',
crossSection: 'Configurations',
slug_cs: 'Cores & CS',
abbreviations: 'ABBREVIATIONS',
},
de: {
productDatasheet: 'Technisches Datenblatt',
@@ -233,278 +283,39 @@ const getLabels = (locale: 'en' | 'de') =>
categories: 'KATEGORIEN',
sku: 'ARTIKELNUMMER',
noImage: 'Kein Bild verfügbar',
crossSection: 'Konfigurationen',
slug_cs: 'Adern & QS',
abbreviations: 'ABKÜRZUNGEN',
},
})[locale];
};
return labels[locale];
};
function clamp(n: number, min: number, max: number) {
return Math.max(min, Math.min(max, n));
}
function normTextForMeasure(v: unknown) {
return String(v ?? '')
.replace(/\s+/g, ' ')
.trim();
}
function textLen(v: unknown) {
return normTextForMeasure(v).length;
}
function distributeWithMinMax(
weights: number[],
total: number,
minEach: number,
maxEach: number,
): number[] {
const n = weights.length;
if (!n) return [];
const mins = Array.from({ length: n }, () => minEach);
const maxs = Array.from({ length: n }, () => maxEach);
const minSum = mins.reduce((a, b) => a + b, 0);
if (minSum > total) return mins.map((m) => m * (total / minSum));
const result = mins.slice();
let remaining = total - minSum;
let remainingIdx = Array.from({ length: n }, (_, i) => i);
while (remaining > 1e-9 && remainingIdx.length) {
const wSum = remainingIdx.reduce((acc, i) => acc + Math.max(0, weights[i] || 0), 0);
if (wSum <= 1e-9) {
const even = remaining / remainingIdx.length;
for (const i of remainingIdx) result[i] += even;
remaining = 0;
break;
}
const nextIdx: number[] = [];
for (const i of remainingIdx) {
const w = Math.max(0, weights[i] || 0);
const add = (w / wSum) * remaining;
const capped = Math.min(result[i] + add, maxs[i]);
const used = capped - result[i];
result[i] = capped;
remaining -= used;
if (result[i] + 1e-9 < maxs[i]) nextIdx.push(i);
}
remainingIdx = nextIdx;
}
const sum = result.reduce((a, b) => a + b, 0);
const drift = total - sum;
if (Math.abs(drift) > 1e-9) result[result.length - 1] += drift;
return result;
}
function KeyValueGrid({ items }: { items: KeyValueItem[] }) {
const filtered = (items || []).filter((i) => i.label && i.value);
if (!filtered.length) return null;
const rows: Array<[KeyValueItem, KeyValueItem | null]> = [];
for (let i = 0; i < filtered.length; i += 2) rows.push([filtered[i], filtered[i + 1] || null]);
return (
<View style={styles.kvGrid}>
{rows.map(([left, right], rowIndex) => {
const isLast = rowIndex === rows.length - 1;
const leftValue = left.unit ? `${left.value} ${left.unit}` : left.value;
const rightValue = right ? (right.unit ? `${right.value} ${right.unit}` : right.value) : '';
return (
<View
key={`${left.label}-${rowIndex}`}
style={[
styles.kvRow,
rowIndex % 2 === 0 ? styles.kvRowAlt : null,
isLast ? styles.kvRowLast : null,
]}
wrap={false}
>
<View style={[styles.kvCell, { width: '23%' }]}>
<Text style={styles.kvLabelText}>{left.label}</Text>
</View>
<View style={[styles.kvCell, styles.kvMidDivider, { width: '27%' }]}>
<Text style={styles.kvValueText}>{leftValue}</Text>
</View>
<View style={[styles.kvCell, { width: '23%' }]}>
<Text style={styles.kvLabelText}>{right?.label || ''}</Text>
</View>
<View style={[styles.kvCell, { width: '27%' }]}>
<Text style={styles.kvValueText}>{rightValue}</Text>
</View>
</View>
);
})}
</View>
);
}
function DenseTable({
table,
firstColLabel,
}: {
table: Pick<DatasheetVoltageTable, 'columns' | 'rows'>;
firstColLabel: string;
}) {
const cols = table.columns;
const rows = table.rows;
const headerText = (label: string) =>
String(label || '')
.replace(/\s+/g, '\u00A0')
.trim();
const cfgMin = 0.14,
cfgMax = 0.23;
const cfgContentLen = Math.max(
textLen(firstColLabel),
...rows.map((r) => textLen(r.configuration)),
8,
);
const dataContentLens = cols.map((c, ci) => {
const headerL = textLen(c.label);
let cellMax = 0;
for (const r of rows) cellMax = Math.max(cellMax, textLen(r.cells[ci]));
return Math.max(headerL * 1.15, cellMax, 3);
});
const cfgWeight = cfgContentLen * 1.05;
const dataWeights = dataContentLens.map((l) => l);
const dataWeightSum = dataWeights.reduce((a, b) => a + b, 0);
const rawCfgPct = dataWeightSum > 0 ? cfgWeight / (cfgWeight + dataWeightSum) : 0.28;
let cfgPct = clamp(rawCfgPct, cfgMin, cfgMax);
const minDataPct =
cols.length >= 14 ? 0.045 : cols.length >= 12 ? 0.05 : cols.length >= 10 ? 0.055 : 0.06;
const cfgPctMaxForMinData = 1 - cols.length * minDataPct;
if (Number.isFinite(cfgPctMaxForMinData)) cfgPct = Math.min(cfgPct, cfgPctMaxForMinData);
cfgPct = clamp(cfgPct, cfgMin, cfgMax);
const dataTotal = Math.max(0, 1 - cfgPct);
const maxDataPct = Math.min(0.24, Math.max(minDataPct * 2.8, dataTotal * 0.55));
const dataPcts = distributeWithMinMax(dataWeights, dataTotal, minDataPct, maxDataPct);
const cfgW = `${(cfgPct * 100).toFixed(4)}%`;
const dataWs = dataPcts.map((p, idx) => {
if (idx === dataPcts.length - 1) {
const used = dataPcts.slice(0, -1).reduce((a, b) => a + b, 0);
const remainder = Math.max(0, dataTotal - used);
return `${(remainder * 100).toFixed(4)}%`;
}
return `${(p * 100).toFixed(4)}%`;
});
const headerFontSize =
cols.length >= 14 ? 5.7 : cols.length >= 12 ? 5.9 : cols.length >= 10 ? 6.2 : 6.6;
return (
<View style={styles.tableWrap} break={false}>
<View style={styles.tableHeader} wrap={false}>
<View style={{ width: cfgW }}>
<Text
style={[
styles.tableHeaderCell,
styles.tableHeaderCellCfg,
{ fontSize: headerFontSize, paddingHorizontal: 3 },
cols.length ? styles.tableHeaderCellDivider : null,
]}
wrap={false}
>
{headerText(firstColLabel)}
</Text>
</View>
{cols.map((c, idx) => {
const isLast = idx === cols.length - 1;
return (
<View key={c.key} style={{ width: dataWs[idx] }}>
<Text
style={[
styles.tableHeaderCell,
{ fontSize: headerFontSize, paddingHorizontal: 3 },
!isLast ? styles.tableHeaderCellDivider : null,
]}
wrap={false}
>
{headerText(c.label)}
</Text>
</View>
);
})}
</View>
{rows.map((r, ri) => (
<View
key={`${r.configuration}-${ri}`}
style={[styles.tableRow, ri % 2 === 0 ? styles.tableRowAlt : null]}
wrap={false}
minPresenceAhead={16}
>
<View style={{ width: cfgW }} wrap={false}>
<Text
style={[
styles.tableCell,
styles.tableCellCfg,
{ fontSize: 6.2, paddingHorizontal: 3 },
cols.length ? styles.tableCellDivider : null,
]}
wrap={false}
>
{r.configuration}
</Text>
</View>
{r.cells.map((cell, ci) => (
<View key={`${cols[ci]?.key || ci}`} style={{ width: dataWs[ci] }} wrap={false}>
<Text
style={[
styles.tableCell,
ci !== r.cells.length - 1 ? styles.tableCellDivider : null,
]}
wrap={false}
>
{cell}
</Text>
</View>
))}
</View>
))}
</View>
);
}
export const PDFDatasheet: React.FC<PDFDatasheetProps> = ({
product,
locale,
technicalItems = [],
voltageTables = [],
legendItems = [],
}) => {
export const PDFDatasheet: React.FC<PDFDatasheetProps> = ({ product, locale }) => {
const labels = getLabels(locale);
const description = stripHtml(
product.applicationHtml ||
product.shortDescriptionHtml ||
product.descriptionHtml ||
product.descriptionText ||
'',
);
return (
<Document>
<Page size="A4" style={styles.page}>
{/* Hero Header */}
<View style={styles.hero}>
<View style={styles.header}>
<View style={{ width: 80 }}>
{product.logoDataUrl || (product as any).logoDataUrl ? (
<Image
src={product.logoDataUrl || (product as any).logoDataUrl}
style={{ width: '100%', objectFit: 'contain' }}
/>
) : (
<Text style={styles.logoText}>KLZ</Text>
)}
<View>
<Text style={styles.logoText}>KLZ</Text>
</View>
<Text style={styles.docTitle}>{labels.productDatasheet}</Text>
</View>
<View style={styles.productRow}>
<View style={styles.productInfoCol}>
<Text style={styles.productMeta}>
{product.categoriesLine ||
(product.categories || []).map((c) => c.name).join(' • ')}
</Text>
<Text style={styles.productName}>{product.name}</Text>
<View style={styles.productHero}>
<View style={styles.categories}>
{product.categories.map((cat, index) => (
<Text key={index} style={styles.productMeta}>
{cat.name}
{index < product.categories.length - 1 ? ' • ' : ''}
</Text>
))}
</View>
<Text style={styles.productName}>{product.name}</Text>
</View>
</View>
<View style={styles.productImageCol}>
{product.featuredImage ? (
@@ -517,93 +328,66 @@ export const PDFDatasheet: React.FC<PDFDatasheetProps> = ({
</View>
<View style={styles.content}>
{description && (
{/* Description section */}
{(product.applicationHtml || product.shortDescriptionHtml || product.descriptionHtml) && (
<View style={styles.section}>
<Text style={styles.sectionTitle}>{labels.description}</Text>
<View style={styles.sectionAccent} />
<Text style={styles.description}>{description}</Text>
<Text style={styles.description}>
{stripHtml(
product.applicationHtml ||
product.shortDescriptionHtml ||
product.descriptionHtml,
)}
</Text>
</View>
)}
{technicalItems.length > 0 && (
{/* Technical specifications */}
{product.attributes && product.attributes.length > 0 && (
<View style={styles.section}>
<Text style={styles.sectionTitle}>{labels.specifications}</Text>
<View style={styles.sectionAccent} />
<KeyValueGrid items={technicalItems} />
</View>
)}
{voltageTables.map((table, idx) => (
<View key={idx} style={styles.section} break={false}>
<Text
style={styles.sectionTitle}
>{`${labels.crossSection}${table.voltageLabel}`}</Text>
<View style={styles.sectionAccent} />
<DenseTable table={table} firstColLabel={labels.slug_cs} />
</View>
))}
{legendItems.length > 0 && (
<View style={styles.section} break={false}>
<Text style={styles.sectionTitle}>{labels.abbreviations}</Text>
<View style={styles.sectionAccent} />
<KeyValueGrid items={legendItems} />
</View>
)}
{!technicalItems.length &&
!voltageTables.length &&
product.attributes &&
product.attributes.length > 0 && (
<View style={styles.section}>
<Text style={styles.sectionTitle}>{labels.specifications}</Text>
<View style={styles.sectionAccent} />
<View style={{ borderWidth: 1, borderColor: C.gray200 }}>
{product.attributes.map((attr, index) => (
<View
key={index}
style={{
flexDirection: 'row',
borderBottomWidth: index === product.attributes!.length - 1 ? 0 : 1,
borderBottomColor: C.gray200,
backgroundColor: index % 2 === 0 ? C.offWhite : C.white,
}}
>
<View
style={{
flex: 1,
padding: 6,
borderRightWidth: 1,
borderRightColor: C.gray200,
}}
>
<Text style={{ fontSize: 8.5, fontWeight: 700, color: C.gray600 }}>
{attr.name}
</Text>
</View>
<View style={{ flex: 1, padding: 6 }}>
<Text style={{ fontSize: 9.5, color: C.gray900 }}>
{attr.options.join(', ')}
</Text>
</View>
<View style={styles.specsTable}>
{product.attributes.map((attr, index) => (
<View
key={index}
style={[
styles.specsTableRow,
index === product.attributes.length - 1 && styles.specsTableRowLast,
]}
>
<View style={styles.specsTableLabelCell}>
<Text style={styles.specsTableLabelText}>{attr.name}</Text>
</View>
))}
</View>
<View style={styles.specsTableValueCell}>
<Text style={styles.specsTableValueText}>{attr.options.join(', ')}</Text>
</View>
</View>
))}
</View>
)}
</View>
)}
{/* Categories as clean tags */}
{product.categories && product.categories.length > 0 && (
<View style={styles.section}>
<Text style={styles.sectionTitle}>{labels.categories}</Text>
<View style={styles.sectionAccent} />
<View style={styles.categories}>
{product.categories.map((cat, index) => (
<View key={index} style={styles.categoryTag}>
<Text style={styles.categoryText}>{cat.name}</Text>
</View>
))}
</View>
</View>
)}
</View>
{/* Minimal footer */}
<View style={styles.footer} fixed>
<View style={{ width: 60 }}>
{product.logoDataUrl || (product as any).logoDataUrl ? (
<Image
src={product.logoDataUrl || (product as any).logoDataUrl}
style={{ width: '100%', objectFit: 'contain' }}
/>
) : (
<Text style={styles.footerBrand}>KLZ CABLES</Text>
)}
</View>
<Text style={styles.footerBrand}>KLZ CABLES</Text>
<Text style={styles.footerText}>
{new Date().toLocaleDateString(locale === 'en' ? 'en-US' : 'de-DE', {
year: 'numeric',

View File

@@ -1,21 +1,14 @@
import * as React from 'react';
import { Document, Page, View, Text, StyleSheet, Font, Link } from '@react-pdf/renderer';
// Register fonts (using system fonts for now, can be customized)
Font.register({
family: 'Helvetica',
fonts: [
{ src: '/fonts/Helvetica.ttf', fontWeight: 400 },
{ src: '/fonts/Helvetica-Bold.ttf', fontWeight: 700 },
],
});
// Standard fonts like Helvetica are built-in to PDF and don't require registration
// unless we want to use specific TTF files. Using built-in Helvetica for maximum stability.
// ─── Brand Tokens (matching datasheet) ──────────────────────────────────
const C = {
navy: '#001a4d',
navyDeep: '#000d26',
green: '#4da612',
greenLight: '#e8f5d8',
accent: '#82ed20',
white: '#FFFFFF',
offWhite: '#f8f9fa',
gray100: '#f3f4f6',
@@ -26,7 +19,7 @@ const C = {
gray900: '#111827',
};
const MARGIN = 56;
const MARGIN = 72;
const styles = StyleSheet.create({
page: {
@@ -34,7 +27,7 @@ const styles = StyleSheet.create({
lineHeight: 1.5,
backgroundColor: C.white,
paddingTop: 0,
paddingBottom: 80,
paddingBottom: 100,
fontFamily: 'Helvetica',
},
@@ -42,11 +35,12 @@ const styles = StyleSheet.create({
hero: {
backgroundColor: C.white,
paddingTop: 24,
paddingBottom: 0,
paddingBottom: 20,
paddingHorizontal: MARGIN,
marginBottom: 20,
position: 'relative',
borderBottomWidth: 0,
borderBottomWidth: 1,
borderBottomColor: C.gray200,
},
header: {
@@ -57,42 +51,45 @@ const styles = StyleSheet.create({
},
logoText: {
fontSize: 22,
fontSize: 24,
fontWeight: 700,
color: C.navyDeep,
letterSpacing: 2,
letterSpacing: 1,
textTransform: 'uppercase',
},
docTitle: {
fontSize: 8,
fontSize: 10,
fontWeight: 700,
color: C.green,
color: C.navy,
letterSpacing: 2,
textTransform: 'uppercase',
},
// Content Area
content: {
paddingHorizontal: MARGIN,
productHero: {
marginTop: 0,
},
pageTitle: {
fontSize: 24,
fontWeight: 700,
color: C.navyDeep,
marginBottom: 8,
marginTop: 10,
marginBottom: 0,
textTransform: 'uppercase',
letterSpacing: -0.5,
},
accentBar: {
width: 30,
height: 2,
backgroundColor: C.green,
marginBottom: 20,
borderRadius: 1,
height: 3,
backgroundColor: C.accent,
marginTop: 8,
borderRadius: 1.5,
},
// Content Area
content: {
paddingHorizontal: MARGIN,
},
// Lexical Elements
@@ -136,7 +133,7 @@ const styles = StyleSheet.create({
listItemBullet: {
width: 12,
fontSize: 10,
color: C.green,
color: C.accent,
fontWeight: 700,
},
listItemContent: {
@@ -146,7 +143,7 @@ const styles = StyleSheet.create({
lineHeight: 1.7,
},
link: {
color: C.green,
color: C.accent,
textDecoration: 'none',
},
textBold: {
@@ -161,31 +158,31 @@ const styles = StyleSheet.create({
// Footer — matches brochure style
footer: {
position: 'absolute',
bottom: 28,
bottom: 40,
left: MARGIN,
right: MARGIN,
flexDirection: 'row',
justifyContent: 'space-between',
alignItems: 'center',
paddingTop: 12,
borderTopWidth: 2,
borderTopColor: C.green,
paddingTop: 24,
borderTopWidth: 1,
borderTopColor: C.gray200,
},
footerText: {
fontSize: 7,
fontSize: 8,
color: C.gray400,
fontWeight: 400,
fontWeight: 500,
textTransform: 'uppercase',
letterSpacing: 0.8,
letterSpacing: 1,
},
footerBrand: {
fontSize: 9,
fontSize: 10,
fontWeight: 700,
color: C.navyDeep,
textTransform: 'uppercase',
letterSpacing: 1.5,
letterSpacing: 1,
},
});
@@ -305,12 +302,14 @@ export const PDFPage: React.FC<PDFPageProps> = ({ page, locale = 'de' }) => {
</View>
<Text style={styles.docTitle}>{locale === 'en' ? 'Document' : 'Dokument'}</Text>
</View>
<View style={styles.productHero}>
<Text style={styles.pageTitle}>{page.title}</Text>
<View style={styles.accentBar} />
</View>
</View>
<View style={styles.content}>
<Text style={styles.pageTitle}>{page.title}</Text>
<View style={styles.accentBar} />
<View>
{page.content?.root?.children?.map((node: any, i: number) =>
renderLexicalNode(node, i),

View File

@@ -1,104 +0,0 @@
/**
* Utility for formatting technical data values.
* Handles long lists of standards and simplifies repetitive strings.
*/
export interface FormattedTechnicalValue {
original: string;
isList: boolean;
parts: string[];
displayValue: string;
}
/**
* Formats a technical value string.
* Detects if it's a list (separated by / or ,) and tries to clean it up.
*/
export function formatTechnicalValue(value: string | null | undefined): FormattedTechnicalValue {
if (!value) {
return { original: '', isList: false, parts: [], displayValue: '' };
}
const str = String(value).trim();
// Detect list separators
let parts: string[] = [];
if (str.includes(' / ')) {
parts = str.split(' / ').map((p) => p.trim());
} else if (str.includes(' /')) {
parts = str.split(' /').map((p) => p.trim());
} else if (str.includes('/ ')) {
parts = str.split('/ ').map((p) => p.trim());
} else if (str.split('/').length > 2) {
// Check if it's actually many standards separated by / without spaces
// e.g. EN123/EN456/EN789
const split = str.split('/');
if (split.length > 3) {
parts = split.map((p) => p.trim());
}
}
// If no parts found yet, try comma
if (parts.length === 0 && str.includes(', ')) {
parts = str.split(', ').map((p) => p.trim());
}
// Filter out empty parts
parts = parts.filter(Boolean);
// If we have parts, let's see if we can simplify them
if (parts.length > 2) {
// Find common prefix to condense repetitive standards
let commonPrefix = '';
const first = parts[0];
const last = parts[parts.length - 1];
let i = 0;
while (i < first.length && first.charAt(i) === last.charAt(i)) {
i++;
}
commonPrefix = first.substring(0, i);
// If a meaningful prefix exists (e.g., "EN 60 332-1-")
if (commonPrefix.length > 4) {
const suffixParts: string[] = [];
for (let idx = 0; idx < parts.length; idx++) {
if (idx === 0) {
suffixParts.push(parts[idx]);
} else {
const suffix = parts[idx].substring(commonPrefix.length).trim();
if (suffix) {
suffixParts.push(suffix);
}
}
}
// Condense into a single string like "EN 60 332-1-2 / -3 / -4"
// Wait, returning a single string might still wrap badly.
// Instead, we return them as chunks or just a condensed string.
const condensedString = suffixParts[0] + ' / -' + suffixParts.slice(1).join(' / -');
return {
original: str,
isList: false, // Turn off badge rendering to use text block instead
parts: [condensedString],
displayValue: condensedString,
};
}
// If no common prefix, return as list so UI can render badges
return {
original: str,
isList: true,
parts,
displayValue: parts.join(', '),
};
}
return {
original: str,
isList: false,
parts: [str],
displayValue: str,
};
}

BIN
lychee

Binary file not shown.

View File

@@ -3,7 +3,7 @@
"pages": {
"impressum": "impressum",
"datenschutz": "datenschutz",
"agbs": "agbs",
"agbs": "terms",
"kontakt": "contact",
"team": "team",
"blog": "blog",
@@ -74,7 +74,7 @@
"privacyPolicy": "Datenschutz",
"privacyPolicySlug": "datenschutz",
"terms": "AGB",
"termsSlug": "agbs",
"termsSlug": "terms",
"products": "Produkte",
"lowVoltage": "Niederspannungskabel",
"mediumVoltage": "Mittelspannungskabel",
@@ -226,10 +226,6 @@
"requestQuoteDesc": "Erhalten Sie technische Spezifikationen und Preise für Ihr Projekt.",
"downloadDatasheet": "Datenblatt herunterladen",
"downloadDatasheetDesc": "Erhalten Sie die vollständigen technischen Spezifikationen als PDF.",
"downloadExcel": "Excel herunterladen",
"downloadExcelDesc": "Erhalten Sie die technischen Daten als editierbare Tabelle.",
"downloadBrochure": "Produktbroschüre",
"downloadBrochureDesc": "Laden Sie unseren kompletten Produktkatalog mit allen technischen Spezifikationen herunter.",
"form": {
"contactInfo": "Kontaktinformationen",
"projectDetails": "Projektdetails",
@@ -399,21 +395,5 @@
"description": "Es scheint, als wäre das Kabel zu dieser Seite unterbrochen worden. Wir konnten die gesuchte Ressource nicht finden.",
"cta": "Zurück zur Sicherheit"
}
},
"Brochure": {
"title": "Produktkatalog",
"subtitle": "Erhalten Sie unsere komplette Produktbroschüre mit allen technischen Spezifikationen und Kabellösungen.",
"emailPlaceholder": "ihre@email.de",
"emailLabel": "E-Mail-Adresse",
"submit": "Broschüre erhalten",
"submitting": "Wird gesendet...",
"successTitle": "Ihre Broschüre ist bereit!",
"successDesc": "Vielen Dank für Ihr Interesse. Klicken Sie unten, um den kompletten KLZ-Produktkatalog herunterzuladen.",
"download": "Broschüre herunterladen",
"privacyNote": "Mit dem Absenden erklären Sie sich mit unserer Datenschutzerklärung einverstanden.",
"close": "Schließen",
"ctaTitle": "Kompletter Produktkatalog",
"ctaDesc": "Alle Datenblätter in einem Premium-PDF — technische Spezifikationen, Kabellösungen & mehr.",
"ctaButton": "Kostenlose Broschüre erhalten"
}
}
}

View File

@@ -3,7 +3,7 @@
"pages": {
"legal-notice": "impressum",
"privacy-policy": "datenschutz",
"terms": "agbs",
"terms": "terms",
"contact": "contact",
"team": "team",
"blog": "blog",
@@ -226,10 +226,6 @@
"requestQuoteDesc": "Get technical specifications and pricing for your project.",
"downloadDatasheet": "Download Datasheet",
"downloadDatasheetDesc": "Get the full technical specifications in PDF format.",
"downloadExcel": "Download Excel",
"downloadExcelDesc": "Get the technical data as editable spreadsheet.",
"downloadBrochure": "Product Brochure",
"downloadBrochureDesc": "Download our complete product catalog with all technical specifications.",
"form": {
"contactInfo": "Contact Information",
"projectDetails": "Project Details",
@@ -399,21 +395,5 @@
"description": "It seems the cable to this page has been disconnected. We couldn't find the resource you were looking for.",
"cta": "Back to Safety"
}
},
"Brochure": {
"title": "Product Catalog",
"subtitle": "Get our complete product brochure with all technical specifications and cable solutions.",
"emailPlaceholder": "your@email.com",
"emailLabel": "Email Address",
"submit": "Get Brochure",
"submitting": "Sending...",
"successTitle": "Your brochure is ready!",
"successDesc": "Thank you for your interest. Click below to download the complete KLZ product catalog.",
"download": "Download Brochure",
"privacyNote": "By submitting you agree to our privacy policy.",
"close": "Close",
"ctaTitle": "Complete Product Catalog",
"ctaDesc": "All datasheets in one premium PDF — technical specifications, cable solutions & more.",
"ctaButton": "Get Free Brochure"
}
}
}

View File

@@ -102,7 +102,7 @@ export default async function middleware(request: NextRequest) {
export const config = {
matcher: [
'/((?!api|_next/static|_next/image|favicon.ico|admin|manifest.webmanifest|.*\\.(?:svg|png|jpg|jpeg|gif|webp|pdf|xlsx|txt|vcf|xml|webm|mp4|map)$).*)',
'/((?!api|_next/static|_next/image|favicon.ico|admin|manifest.webmanifest|.*\\.(?:svg|png|jpg|jpeg|gif|webp|pdf|txt|vcf|xml|webm|mp4|map)$).*)',
'/(de|en)/:path*',
],
};

2
next-env.d.ts vendored
View File

@@ -1,6 +1,6 @@
/// <reference types="next" />
/// <reference types="next/image-types/global" />
import "./.next/types/routes.d.ts";
import "./.next/dev/types/routes.d.ts";
// NOTE: This file should not be edited
// see https://nextjs.org/docs/app/api-reference/config/typescript for more information.

View File

@@ -12,15 +12,18 @@ const nextConfig = {
maxInactiveAge: 60 * 1000,
},
experimental: {
staleTimes: {
dynamic: 0,
static: 30,
},
optimizePackageImports: ['lucide-react', 'framer-motion', '@/components/ui'],
cpus: 3,
workerThreads: false,
},
serverActions: {
allowedOrigins: ["*.klz-cables.com", "*.branch.klz-cables.com", "localhost:3000", "klz.localhost"],
serverActions: {
allowedOrigins: ["*.klz-cables.com", "*.branch.klz-cables.com", "localhost:3000", "klz.localhost"],
},
},
reactStrictMode: false,
swcMinify: true,
productionBrowserSourceMaps: false,
logging: {
fetches: {
@@ -28,7 +31,6 @@ const nextConfig = {
},
},
...(isProd ? { output: 'standalone' } : {}),
// Rewrites moved to bottom merged function
async headers() {
const isProd = process.env.NODE_ENV === 'production';
const umamiDomain = new URL(process.env.UMAMI_API_ENDPOINT || 'https://analytics.infra.mintel.me').origin;
@@ -83,7 +85,7 @@ const nextConfig = {
},
{
key: 'Strict-Transport-Security',
value: isProd ? 'max-age=63072000; includeSubDomains; preload' : 'max-age=0',
value: 'max-age=63072000; includeSubDomains; preload',
},
];
@@ -397,7 +399,6 @@ const nextConfig = {
];
},
images: {
qualities: [75, 100],
formats: ['image/webp'],
deviceSizes: [640, 750, 828, 1080, 1200, 1920, 2048, 3840],
remotePatterns: [
@@ -430,22 +431,6 @@ const nextConfig = {
async rewrites() {
return {
beforeFiles: [
{
source: '/:locale/datasheets/:path*',
destination: '/api/datasheets/:path*',
},
{
source: '/:locale/brochures/:path*',
destination: '/api/brochures/:path*',
},
{
source: '/datasheets/:path*',
destination: '/api/datasheets/:path*',
},
{
source: '/brochures/:path*',
destination: '/api/brochures/:path*',
},
{
source: '/de/produkte',
destination: '/de/products',
@@ -479,10 +464,6 @@ const nextConfig = {
source: '/en/datenschutz',
destination: '/en/privacy-policy',
},
{
source: '/en/agbs',
destination: '/en/terms',
},
],
afterFiles: [],
fallback: [],

View File

@@ -13,7 +13,7 @@
"@payloadcms/next": "^3.77.0",
"@payloadcms/richtext-lexical": "^3.77.0",
"@payloadcms/ui": "^3.77.0",
"@react-email/components": "1.0.8",
"@react-email/components": "^1.0.7",
"@react-pdf/renderer": "^4.3.2",
"@sentry/nextjs": "^10.39.0",
"@types/recharts": "^2.0.1",
@@ -89,8 +89,7 @@
"tsx": "^4.21.0",
"turbo": "^2.8.10",
"typescript": "^5.7.2",
"vitest": "^4.0.16",
"xlsx-cli": "^1.1.3"
"vitest": "^4.0.16"
},
"scripts": {
"dev": "bash -c 'trap \"COMPOSE_PROJECT_NAME=klz-2026 docker-compose -f docker-compose.dev.yml down\" EXIT INT TERM; docker network create infra 2>/dev/null || true && COMPOSE_PROJECT_NAME=klz-2026 docker-compose -f docker-compose.dev.yml down && COMPOSE_PROJECT_NAME=klz-2026 docker-compose -f docker-compose.dev.yml up klz-app klz-db --remove-orphans'",
@@ -102,7 +101,6 @@
"typecheck": "tsc --noEmit",
"test": "vitest run --passWithNoTests",
"test:og": "vitest run tests/og-image.test.ts",
"test:e2e": "vitest run tests/*.e2e.test.ts",
"check:og": "tsx scripts/check-og-images.ts",
"check:a11y": "pa11y-ci",
"check:wcag": "tsx ./scripts/wcag-sitemap.ts",
@@ -115,11 +113,9 @@
"check:assets": "tsx ./scripts/check-broken-assets.ts",
"check:forms": "tsx ./scripts/check-forms.ts",
"check:apis": "tsx ./scripts/check-apis.ts",
"pdf:datasheets": "tsx ./scripts/generate-pdf-datasheets.tsx",
"pdf:datasheets": "tsx ./scripts/generate-pdf-datasheets.ts",
"pdf:datasheets:legacy": "tsx ./scripts/generate-pdf-datasheets-pdf-lib.ts",
"excel:datasheets": "tsx ./scripts/generate-excel-datasheets.ts",
"brochure:generate": "tsx ./scripts/generate-brochure.ts",
"cms:migrate": "tsx ./node_modules/payload/bin.js migrate",
"cms:migrate": "payload migrate",
"cms:seed": "tsx ./scripts/seed-payload.ts",
"assets:push:testing": "bash ./scripts/assets-sync.sh local testing",
"assets:push:staging": "bash ./scripts/assets-sync.sh local staging",

View File

@@ -254,7 +254,7 @@ export interface FormSubmission {
id: number;
name: string;
email: string;
type: 'contact' | 'product_quote' | 'brochure_download';
type: 'contact' | 'product_quote';
/**
* The specific KLZ product the user requested a quote for.
*/
@@ -333,6 +333,14 @@ export interface Page {
layout?: ('default' | 'fullBleed') | null;
excerpt?: string | null;
featuredImage?: (number | null) | Media;
/**
* If set, visiting this page will immediately redirect the user to this URL (e.g. /de/terms).
*/
redirectUrl?: string | null;
/**
* Check for a permanent (301) redirect. Uncheck for a temporary (302) redirect.
*/
redirectPermanent?: boolean | null;
content: {
root: {
type: string;
@@ -579,6 +587,8 @@ export interface PagesSelect<T extends boolean = true> {
layout?: T;
excerpt?: T;
featuredImage?: T;
redirectUrl?: T;
redirectPermanent?: T;
content?: T;
updatedAt?: T;
createdAt?: T;

View File

@@ -71,6 +71,8 @@ export default buildConfig({
},
db: postgresAdapter({
prodMigrations: migrations,
migrationDir:
process.env.NODE_ENV === 'production' ? undefined : path.resolve(dirname, 'src/migrations'),
pool: {
connectionString:
process.env.DATABASE_URI ||

1219
pnpm-lock.yaml generated

File diff suppressed because it is too large Load Diff

Binary file not shown.

Binary file not shown.

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