Compare commits
120 Commits
v2.2.0-rc.
...
feature/ex
| Author | SHA1 | Date | |
|---|---|---|---|
| b0d66c4192 | |||
| 040809812a | |||
| 678ca784a1 | |||
| 03d10f9a83 | |||
| 4f464f8bb7 | |||
| 975ac79059 | |||
| eae46d3048 | |||
| 7e0e01ecac | |||
| a5db900d3f | |||
| dd27f77c71 | |||
| 53d1e62b42 | |||
| 3f6bbff409 | |||
| d575e5924a | |||
| 7583540de2 | |||
| c979647287 | |||
| 20051244d9 | |||
| b80136894c | |||
| 9c4c8e28e9 | |||
| 761c6be80a | |||
| 663aaefc4f | |||
| 7d3737a88d | |||
| e32446fedb | |||
| 5552d952aa | |||
| 3f67e1c333 | |||
| ca2839017e | |||
| e6c4af1606 | |||
| 34d341f5ae | |||
| 42c287f519 | |||
| fe3cb37351 | |||
| c614cf9867 | |||
| a32fff7d20 | |||
| ce7fefd99f | |||
| 2d2958301a | |||
| 27aaf3b0ca | |||
| a2729689d5 | |||
| f1d0227260 | |||
| 69b8ae9067 | |||
| 49d9902dc3 | |||
| 4ff50603e4 | |||
| cb47add128 | |||
| 35db587a0d | |||
| 6e80c91f7d | |||
| 2e706b1946 | |||
| 90542c9388 | |||
| 296ead2c74 | |||
| a1a6992f8e | |||
| 9a72306227 | |||
| 4aa179df4c | |||
| b248af400b | |||
| 63884ff258 | |||
| 6d0d086622 | |||
| 561d1938c5 | |||
| 949cac8bf8 | |||
| c16b0e01cb | |||
| 99a0e05499 | |||
| bbbad1fbc7 | |||
| 21c1c6282f | |||
| 371e835853 | |||
| 001ebe28ef | |||
| a670c5fd65 | |||
| 70f189b0c9 | |||
| d5dd66b832 | |||
| f8fc6fcbbe | |||
| 4e0d8a0f3a | |||
| 11723bf184 | |||
| 1756b630ef | |||
| daabf8bb63 | |||
| e524c9faf6 | |||
| 15279c8be1 | |||
| 583a3797f3 | |||
| 655f33091f | |||
| 34bb91c04b | |||
| 449b7bc8aa | |||
| b033142599 | |||
| 02be8e59b2 | |||
| d2418b5720 | |||
| 501f9659a1 | |||
| e9ceae3989 | |||
| 6a748a3ac8 | |||
| d69e0eebe6 | |||
| 1577bfd2ec | |||
| 6440d893f0 | |||
| d8e3c7d9a3 | |||
| aa14f39dba | |||
| 1cfc0523f3 | |||
| 3ff20fd2c9 | |||
| 549ee34490 | |||
| 8a8e30400c | |||
| 4faed38f47 | |||
| 1e0886144f | |||
| c933d9b886 | |||
| 5c56d8babf | |||
| c4c6fb3b07 | |||
| ff685b9933 | |||
| 980258af5c | |||
| 57b6963efe | |||
| 1a136540d0 | |||
| 92bc88dfbd | |||
| ec3f2cf8c9 | |||
| fb3ec6e10a | |||
| 3a61d01384 | |||
| acf642d7e6 | |||
| 17ebde407e | |||
| d5da2a91c8 | |||
| ebe664f984 | |||
| 9c7324ee92 | |||
| 0c8d9ea669 | |||
| 1bb0efc85b | |||
| 56cd1fb1ba | |||
| 437dd35c9c | |||
| 4adf547265 | |||
| 0cb96dfbac | |||
| ec227d614f | |||
| cb07b739b8 | |||
| 55e9531698 | |||
| 089ce13c59 | |||
| a2cf9791ae | |||
| aa4e3aab4f | |||
| ce719a1d70 | |||
| bd2f92125b |
1
.env
1
.env
@@ -7,6 +7,7 @@ SENTRY_DSN=https://c10957d0182245b1a2a806ac2d34a197@errors.infra.mintel.me/1
|
|||||||
LOG_LEVEL=info
|
LOG_LEVEL=info
|
||||||
NEXT_PUBLIC_FEEDBACK_ENABLED=false
|
NEXT_PUBLIC_FEEDBACK_ENABLED=false
|
||||||
NEXT_PUBLIC_RECORD_MODE_ENABLED=false
|
NEXT_PUBLIC_RECORD_MODE_ENABLED=false
|
||||||
|
NPM_TOKEN=263e7f75d8ada27f3a2e71fd6bd9d95298d48a4d
|
||||||
|
|
||||||
# SMTP Configuration
|
# SMTP Configuration
|
||||||
MAIL_HOST=smtp.eu.mailgun.org
|
MAIL_HOST=smtp.eu.mailgun.org
|
||||||
|
|||||||
@@ -1,51 +0,0 @@
|
|||||||
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
|
|
||||||
@@ -37,6 +37,8 @@ jobs:
|
|||||||
next_public_url: ${{ steps.determine.outputs.next_public_url }}
|
next_public_url: ${{ steps.determine.outputs.next_public_url }}
|
||||||
project_name: ${{ steps.determine.outputs.project_name }}
|
project_name: ${{ steps.determine.outputs.project_name }}
|
||||||
short_sha: ${{ steps.determine.outputs.short_sha }}
|
short_sha: ${{ steps.determine.outputs.short_sha }}
|
||||||
|
slug: ${{ steps.determine.outputs.slug }}
|
||||||
|
gatekeeper_host: ${{ steps.determine.outputs.gatekeeper_host }}
|
||||||
container:
|
container:
|
||||||
image: catthehacker/ubuntu:act-latest
|
image: catthehacker/ubuntu:act-latest
|
||||||
steps:
|
steps:
|
||||||
@@ -83,7 +85,7 @@ jobs:
|
|||||||
SLUG=$(echo "$REF" | tr '[:upper:]' '[:lower:]' | sed 's/[^a-z0-9]/-/g' | sed 's/--*/-/g' | sed 's/^-//;s/-$//')
|
SLUG=$(echo "$REF" | tr '[:upper:]' '[:lower:]' | sed 's/[^a-z0-9]/-/g' | sed 's/--*/-/g' | sed 's/^-//;s/-$//')
|
||||||
IMAGE_TAG="branch-${SLUG}-${SHORT_SHA}"
|
IMAGE_TAG="branch-${SLUG}-${SHORT_SHA}"
|
||||||
ENV_FILE=".env.branch-${SLUG}"
|
ENV_FILE=".env.branch-${SLUG}"
|
||||||
TRAEFIK_HOST="${SLUG}.branch.mintel.me"
|
TRAEFIK_HOST="${SLUG}.branch.klz-cables.com"
|
||||||
fi
|
fi
|
||||||
|
|
||||||
# Standardize Traefik Rule (escaped backticks for Traefik v3)
|
# Standardize Traefik Rule (escaped backticks for Traefik v3)
|
||||||
@@ -113,6 +115,7 @@ jobs:
|
|||||||
echo "project_name=$PRJ-$TARGET"
|
echo "project_name=$PRJ-$TARGET"
|
||||||
fi
|
fi
|
||||||
echo "short_sha=$SHORT_SHA"
|
echo "short_sha=$SHORT_SHA"
|
||||||
|
echo "slug=$SLUG"
|
||||||
} >> "$GITHUB_OUTPUT"
|
} >> "$GITHUB_OUTPUT"
|
||||||
|
|
||||||
# ⏳ Wait for Upstream Packages/Images if Tagged
|
# ⏳ Wait for Upstream Packages/Images if Tagged
|
||||||
@@ -156,6 +159,8 @@ jobs:
|
|||||||
needs: prepare
|
needs: prepare
|
||||||
if: needs.prepare.outputs.target != 'skip'
|
if: needs.prepare.outputs.target != 'skip'
|
||||||
runs-on: docker
|
runs-on: docker
|
||||||
|
env:
|
||||||
|
PUPPETEER_EXECUTABLE_PATH: /usr/bin/chromium
|
||||||
container:
|
container:
|
||||||
image: catthehacker/ubuntu:act-latest
|
image: catthehacker/ubuntu:act-latest
|
||||||
steps:
|
steps:
|
||||||
@@ -181,12 +186,12 @@ jobs:
|
|||||||
|
|
||||||
- name: 🔒 Security Audit
|
- name: 🔒 Security Audit
|
||||||
run: pnpm audit --audit-level high || echo "⚠️ Audit found vulnerabilities (non-blocking)"
|
run: pnpm audit --audit-level high || echo "⚠️ Audit found vulnerabilities (non-blocking)"
|
||||||
|
|
||||||
- name: 🧪 QA Checks
|
- name: 🧪 QA Checks
|
||||||
if: github.event.inputs.skip_checks != 'true'
|
if: github.event.inputs.skip_checks != 'true'
|
||||||
env:
|
env:
|
||||||
TURBO_TELEMETRY_DISABLED: "1"
|
TURBO_TELEMETRY_DISABLED: "1"
|
||||||
run: npx turbo run lint typecheck test --cache-dir=".turbo"
|
run: npx turbo run lint typecheck test --cache-dir=".turbo"
|
||||||
|
|
||||||
# ──────────────────────────────────────────────────────────────────────────────
|
# ──────────────────────────────────────────────────────────────────────────────
|
||||||
# JOB 3: Build & Push
|
# JOB 3: Build & Push
|
||||||
# ──────────────────────────────────────────────────────────────────────────────
|
# ──────────────────────────────────────────────────────────────────────────────
|
||||||
@@ -203,7 +208,8 @@ jobs:
|
|||||||
- name: 🐳 Set up Docker Buildx
|
- name: 🐳 Set up Docker Buildx
|
||||||
uses: docker/setup-buildx-action@v3
|
uses: docker/setup-buildx-action@v3
|
||||||
- name: 🔐 Registry Login
|
- name: 🔐 Registry Login
|
||||||
run: echo "${{ secrets.REGISTRY_PASS }}" | docker login registry.infra.mintel.me -u "${{ secrets.REGISTRY_USER }}" --password-stdin
|
run: |
|
||||||
|
echo "${{ secrets.REGISTRY_PASS }}" | docker login registry.infra.mintel.me -u "${{ secrets.REGISTRY_USER }}" --password-stdin
|
||||||
- name: 🏗️ Build and Push
|
- name: 🏗️ Build and Push
|
||||||
uses: docker/build-push-action@v5
|
uses: docker/build-push-action@v5
|
||||||
with:
|
with:
|
||||||
@@ -219,7 +225,7 @@ jobs:
|
|||||||
NPM_TOKEN=${{ secrets.NPM_TOKEN }}
|
NPM_TOKEN=${{ secrets.NPM_TOKEN }}
|
||||||
tags: registry.infra.mintel.me/mintel/klz-2026:${{ needs.prepare.outputs.image_tag }}
|
tags: registry.infra.mintel.me/mintel/klz-2026:${{ needs.prepare.outputs.image_tag }}
|
||||||
secrets: |
|
secrets: |
|
||||||
"NPM_TOKEN=${{ secrets.NPM_TOKEN }}"
|
NPM_TOKEN=${{ secrets.NPM_TOKEN }}
|
||||||
|
|
||||||
# ──────────────────────────────────────────────────────────────────────────────
|
# ──────────────────────────────────────────────────────────────────────────────
|
||||||
# JOB 4: Deploy
|
# JOB 4: Deploy
|
||||||
@@ -237,6 +243,7 @@ jobs:
|
|||||||
NEXT_PUBLIC_BASE_URL: ${{ needs.prepare.outputs.next_public_url }}
|
NEXT_PUBLIC_BASE_URL: ${{ needs.prepare.outputs.next_public_url }}
|
||||||
TRAEFIK_HOST: ${{ needs.prepare.outputs.traefik_host }}
|
TRAEFIK_HOST: ${{ needs.prepare.outputs.traefik_host }}
|
||||||
GATEKEEPER_HOST: ${{ needs.prepare.outputs.gatekeeper_host }}
|
GATEKEEPER_HOST: ${{ needs.prepare.outputs.gatekeeper_host }}
|
||||||
|
SLUG: ${{ needs.prepare.outputs.slug }}
|
||||||
|
|
||||||
# Secrets mapping (Payload CMS)
|
# Secrets mapping (Payload CMS)
|
||||||
PAYLOAD_SECRET: ${{ secrets.PAYLOAD_SECRET || vars.PAYLOAD_SECRET || 'you-need-to-set-a-payload-secret' }}
|
PAYLOAD_SECRET: ${{ secrets.PAYLOAD_SECRET || vars.PAYLOAD_SECRET || 'you-need-to-set-a-payload-secret' }}
|
||||||
@@ -249,8 +256,8 @@ jobs:
|
|||||||
MAIL_PORT: ${{ secrets.SMTP_PORT || vars.SMTP_PORT || '587' }}
|
MAIL_PORT: ${{ secrets.SMTP_PORT || vars.SMTP_PORT || '587' }}
|
||||||
MAIL_USERNAME: ${{ secrets.SMTP_USER || vars.SMTP_USER }}
|
MAIL_USERNAME: ${{ secrets.SMTP_USER || vars.SMTP_USER }}
|
||||||
MAIL_PASSWORD: ${{ secrets.SMTP_PASS || vars.SMTP_PASS }}
|
MAIL_PASSWORD: ${{ secrets.SMTP_PASS || vars.SMTP_PASS }}
|
||||||
MAIL_FROM: ${{ secrets.SMTP_FROM || vars.SMTP_FROM }}
|
MAIL_FROM: ${{ secrets.SMTP_FROM || vars.SMTP_FROM || 'noreply@klz-cables.com' }}
|
||||||
MAIL_RECIPIENTS: ${{ secrets.CONTACT_RECIPIENT || vars.CONTACT_RECIPIENT }}
|
MAIL_RECIPIENTS: ${{ secrets.CONTACT_RECIPIENT || vars.CONTACT_RECIPIENT || 'info@klz-cables.com' }}
|
||||||
|
|
||||||
# Monitoring
|
# Monitoring
|
||||||
SENTRY_DSN: ${{ secrets.SENTRY_DSN || vars.SENTRY_DSN }}
|
SENTRY_DSN: ${{ secrets.SENTRY_DSN || vars.SENTRY_DSN }}
|
||||||
@@ -261,6 +268,15 @@ jobs:
|
|||||||
# Analytics
|
# Analytics
|
||||||
UMAMI_WEBSITE_ID: ${{ secrets.UMAMI_WEBSITE_ID || vars.UMAMI_WEBSITE_ID }}
|
UMAMI_WEBSITE_ID: ${{ secrets.UMAMI_WEBSITE_ID || vars.UMAMI_WEBSITE_ID }}
|
||||||
UMAMI_API_ENDPOINT: ${{ secrets.UMAMI_API_ENDPOINT || vars.UMAMI_API_ENDPOINT || 'https://analytics.infra.mintel.me' }}
|
UMAMI_API_ENDPOINT: ${{ secrets.UMAMI_API_ENDPOINT || vars.UMAMI_API_ENDPOINT || 'https://analytics.infra.mintel.me' }}
|
||||||
|
|
||||||
|
# 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:
|
steps:
|
||||||
- name: Checkout repository
|
- name: Checkout repository
|
||||||
uses: actions/checkout@v4
|
uses: actions/checkout@v4
|
||||||
@@ -319,6 +335,12 @@ jobs:
|
|||||||
echo "UMAMI_WEBSITE_ID=$UMAMI_WEBSITE_ID"
|
echo "UMAMI_WEBSITE_ID=$UMAMI_WEBSITE_ID"
|
||||||
echo "UMAMI_API_ENDPOINT=$UMAMI_API_ENDPOINT"
|
echo "UMAMI_API_ENDPOINT=$UMAMI_API_ENDPOINT"
|
||||||
echo ""
|
echo ""
|
||||||
|
echo "# 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 "TARGET=$TARGET"
|
||||||
echo "SENTRY_ENVIRONMENT=$TARGET"
|
echo "SENTRY_ENVIRONMENT=$TARGET"
|
||||||
echo "PROJECT_NAME=$PROJECT_NAME"
|
echo "PROJECT_NAME=$PROJECT_NAME"
|
||||||
@@ -338,9 +360,39 @@ jobs:
|
|||||||
cat .env.deploy
|
cat .env.deploy
|
||||||
echo "----------------------------"
|
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
|
- name: 🚀 SSH Deploy
|
||||||
shell: bash
|
shell: bash
|
||||||
env:
|
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 }}
|
ENV_FILE: ${{ needs.prepare.outputs.env_file }}
|
||||||
run: |
|
run: |
|
||||||
mkdir -p ~/.ssh
|
mkdir -p ~/.ssh
|
||||||
@@ -348,6 +400,9 @@ jobs:
|
|||||||
chmod 600 ~/.ssh/id_ed25519
|
chmod 600 ~/.ssh/id_ed25519
|
||||||
ssh-keyscan -H alpha.mintel.me >> ~/.ssh/known_hosts 2>/dev/null
|
ssh-keyscan -H alpha.mintel.me >> ~/.ssh/known_hosts 2>/dev/null
|
||||||
|
|
||||||
|
# Determine deployment paths
|
||||||
|
echo "Preparing deployment for $TARGET..."
|
||||||
|
|
||||||
# Transfer and Restart
|
# Transfer and Restart
|
||||||
if [[ "$TARGET" == "production" ]]; then
|
if [[ "$TARGET" == "production" ]]; then
|
||||||
SITE_DIR="/home/deploy/sites/klz-cables.com"
|
SITE_DIR="/home/deploy/sites/klz-cables.com"
|
||||||
@@ -356,63 +411,55 @@ jobs:
|
|||||||
elif [[ "$TARGET" == "staging" ]]; then
|
elif [[ "$TARGET" == "staging" ]]; then
|
||||||
SITE_DIR="/home/deploy/sites/staging.klz-cables.com"
|
SITE_DIR="/home/deploy/sites/staging.klz-cables.com"
|
||||||
else
|
else
|
||||||
SITE_DIR="/home/deploy/sites/branch.klz-cables.com/${SLUG:-unknown}"
|
SITE_DIR="/home/deploy/sites/branch.klz-cables.com/$SLUG"
|
||||||
fi
|
fi
|
||||||
|
# Transfer files
|
||||||
ssh root@alpha.mintel.me "mkdir -p $SITE_DIR"
|
ssh root@alpha.mintel.me "mkdir -p $SITE_DIR"
|
||||||
|
|
||||||
scp .env.deploy root@alpha.mintel.me:$SITE_DIR/$ENV_FILE
|
scp .env.deploy root@alpha.mintel.me:$SITE_DIR/$ENV_FILE
|
||||||
scp docker-compose.yml root@alpha.mintel.me:$SITE_DIR/docker-compose.yml
|
scp docker-compose.yml root@alpha.mintel.me:$SITE_DIR/docker-compose.yml
|
||||||
|
|
||||||
ssh root@alpha.mintel.me "cd $SITE_DIR && echo '${{ secrets.REGISTRY_PASS }}' | docker login registry.infra.mintel.me -u '${{ secrets.REGISTRY_USER }}' --password-stdin"
|
APP_CONTAINER="${{ needs.prepare.outputs.project_name }}-klz-app-1"
|
||||||
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"
|
|
||||||
|
|
||||||
# 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"
|
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)..."
|
# Branch Seeding Logic (Production -> Branch)
|
||||||
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")
|
if [[ "$TARGET" == "branch" ]]; then
|
||||||
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")
|
echo "🌱 Seeding Branch Environment from Production Database..."
|
||||||
REMOTE_DB_USER="${REMOTE_DB_USER:-payload}"
|
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"
|
||||||
REMOTE_DB_NAME="${REMOTE_DB_NAME:-payload}"
|
|
||||||
|
# Wait for DB to be healthy with a 60s timeout
|
||||||
# Auto-detect migrations from src/migrations/*.ts
|
echo "⏳ Waiting for branch database to be ready..."
|
||||||
BATCH=1
|
ssh root@alpha.mintel.me "
|
||||||
VALUES=""
|
for i in {1..30}; do
|
||||||
for f in $(ls src/migrations/*.ts 2>/dev/null | sort); do
|
if docker exec $DB_CONTAINER pg_isready -U payload >/dev/null 2>&1; then
|
||||||
NAME=$(basename "$f" .ts)
|
exit 0
|
||||||
[ -n "$VALUES" ] && VALUES="$VALUES,"
|
fi
|
||||||
VALUES="$VALUES ('$NAME', $BATCH)"
|
sleep 2
|
||||||
((BATCH++))
|
done
|
||||||
done
|
echo '❌ Database failed to become ready after 60 seconds'
|
||||||
|
exit 1
|
||||||
if [ -n "$VALUES" ]; then
|
" || exit 1
|
||||||
ssh root@alpha.mintel.me "docker exec $DB_CONTAINER psql -U $REMOTE_DB_USER -d $REMOTE_DB_NAME -c \"
|
|
||||||
DO \\\$\\\$ BEGIN
|
# Copy Production Payload DB to Branch Payload DB & ensure media is copied
|
||||||
DELETE FROM payload_migrations WHERE batch = -1;
|
echo "📦 Syncing Production DB into Branch DB..."
|
||||||
INSERT INTO payload_migrations (name, batch)
|
ssh root@alpha.mintel.me "
|
||||||
SELECT name, batch FROM (VALUES $VALUES) AS v(name, batch)
|
set -e -o pipefail
|
||||||
WHERE NOT EXISTS (SELECT 1 FROM payload_migrations pm WHERE pm.name = v.name);
|
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
|
||||||
EXCEPTION WHEN undefined_table THEN
|
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/
|
||||||
RAISE NOTICE 'payload_migrations table does not exist yet — skipping sanitization';
|
" || exit 1
|
||||||
END \\\$\\\$;
|
|
||||||
\"" || echo "⚠️ Migration sanitization skipped (table may not exist yet)"
|
echo "✅ Branch database and media synced successfully."
|
||||||
fi
|
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
|
# 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"
|
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'"
|
ssh root@alpha.mintel.me "docker system prune -f --filter 'until=24h'"
|
||||||
|
|
||||||
- name: 🧹 Post-Deploy Cleanup (Runner)
|
- name: 🧹 Post-Deploy Cleanup (Runner)
|
||||||
@@ -425,7 +472,7 @@ jobs:
|
|||||||
post_deploy_checks:
|
post_deploy_checks:
|
||||||
name: 🧪 Post-Deploy Verification
|
name: 🧪 Post-Deploy Verification
|
||||||
needs: [prepare, deploy]
|
needs: [prepare, deploy]
|
||||||
if: needs.deploy.result == 'success' && needs.prepare.outputs.target != 'branch'
|
if: needs.deploy.result == 'success' && true
|
||||||
runs-on: docker
|
runs-on: docker
|
||||||
container:
|
container:
|
||||||
image: catthehacker/ubuntu:act-latest
|
image: catthehacker/ubuntu:act-latest
|
||||||
@@ -518,6 +565,15 @@ jobs:
|
|||||||
GATEKEEPER_PASSWORD: ${{ secrets.GATEKEEPER_PASSWORD || 'klz2026' }}
|
GATEKEEPER_PASSWORD: ${{ secrets.GATEKEEPER_PASSWORD || 'klz2026' }}
|
||||||
UMAMI_API_ENDPOINT: ${{ secrets.UMAMI_API_ENDPOINT || vars.UMAMI_API_ENDPOINT || 'https://analytics.infra.mintel.me' }}
|
UMAMI_API_ENDPOINT: ${{ secrets.UMAMI_API_ENDPOINT || vars.UMAMI_API_ENDPOINT || 'https://analytics.infra.mintel.me' }}
|
||||||
SENTRY_DSN: ${{ secrets.SENTRY_DSN || vars.SENTRY_DSN }}
|
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
|
- name: 📝 E2E Form Submission Test
|
||||||
if: always() && steps.deps.outcome == 'success'
|
if: always() && steps.deps.outcome == 'success'
|
||||||
@@ -571,11 +627,16 @@ jobs:
|
|||||||
STATUS_LINE="All checks passed"
|
STATUS_LINE="All checks passed"
|
||||||
fi
|
fi
|
||||||
|
|
||||||
TITLE="$EMOJI klz-cables.com $VERSION → $TARGET"
|
TITLE="$EMOJI klz-cables.com $VERSION -> $TARGET"
|
||||||
MESSAGE="$STATUS_LINE
|
MESSAGE="$STATUS_LINE
|
||||||
Deploy: $DEPLOY | Smoke: $SMOKE | Perf: $PERF
|
Deploy: $DEPLOY | Smoke: $SMOKE | Perf: $PERF
|
||||||
$URL"
|
$URL"
|
||||||
|
|
||||||
|
if [[ -z "${{ secrets.GOTIFY_URL }}" || -z "${{ secrets.GOTIFY_TOKEN }}" ]]; then
|
||||||
|
echo "⚠️ Gotify credentials missing, skipping notification."
|
||||||
|
exit 0
|
||||||
|
fi
|
||||||
|
|
||||||
curl -s -k -X POST "${{ secrets.GOTIFY_URL }}/message?token=${{ secrets.GOTIFY_TOKEN }}" \
|
curl -s -k -X POST "${{ secrets.GOTIFY_URL }}/message?token=${{ secrets.GOTIFY_TOKEN }}" \
|
||||||
-F "title=$TITLE" \
|
-F "title=$TITLE" \
|
||||||
-F "message=$MESSAGE" \
|
-F "message=$MESSAGE" \
|
||||||
|
|||||||
@@ -5,13 +5,232 @@ on:
|
|||||||
- cron: '0 3 * * *'
|
- cron: '0 3 * * *'
|
||||||
workflow_dispatch:
|
workflow_dispatch:
|
||||||
|
|
||||||
|
env:
|
||||||
|
TARGET_URL: 'https://testing.klz-cables.com'
|
||||||
|
PROJECT_NAME: 'klz-2026'
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
call-qa-workflow:
|
# ────────────────────────────────────────────────────
|
||||||
uses: mmintel/at-mintel/.gitea/workflows/quality-assurance-template.yml@main
|
# 1. Static Checks (HTML, Assets, HTTP)
|
||||||
with:
|
# ────────────────────────────────────────────────────
|
||||||
TARGET_URL: 'https://testing.klz-cables.com'
|
static:
|
||||||
PROJECT_NAME: 'klz-2026'
|
name: 🔍 Static Analysis
|
||||||
secrets:
|
runs-on: docker
|
||||||
GOTIFY_URL: ${{ secrets.GOTIFY_URL }}
|
container:
|
||||||
GOTIFY_TOKEN: ${{ secrets.GOTIFY_TOKEN }}
|
image: catthehacker/ubuntu:act-latest
|
||||||
GATEKEEPER_PASSWORD: ${{ secrets.GATEKEEPER_PASSWORD || 'klz2026' }}
|
steps:
|
||||||
|
- uses: actions/checkout@v4
|
||||||
|
- uses: pnpm/action-setup@v3
|
||||||
|
with:
|
||||||
|
version: 10
|
||||||
|
- uses: actions/setup-node@v4
|
||||||
|
with:
|
||||||
|
node-version: 20
|
||||||
|
- name: 🔐 Registry Auth
|
||||||
|
run: |
|
||||||
|
echo "@mintel:registry=https://git.infra.mintel.me/api/packages/mmintel/npm" > .npmrc
|
||||||
|
echo "//git.infra.mintel.me/api/packages/mmintel/npm/:_authToken=${{ secrets.NPM_TOKEN }}" >> .npmrc
|
||||||
|
- name: 📦 Cache node_modules
|
||||||
|
uses: actions/cache@v4
|
||||||
|
id: cache-deps
|
||||||
|
with:
|
||||||
|
path: node_modules
|
||||||
|
key: pnpm-${{ hashFiles('pnpm-lock.yaml') }}
|
||||||
|
- name: Install
|
||||||
|
if: steps.cache-deps.outputs.cache-hit != 'true'
|
||||||
|
run: |
|
||||||
|
pnpm store prune
|
||||||
|
pnpm install --no-frozen-lockfile
|
||||||
|
- name: 🌐 Install Chrome & Dependencies
|
||||||
|
run: |
|
||||||
|
apt-get update && apt-get install -y libnss3 libnspr4 libatk1.0-0 libatk-bridge2.0-0 libcups2 libdrm2 libxkbcommon0 libxcomposite1 libxdamage1 libxext6 libxfixes3 libxrandr2 libgbm1 libasound2t64 libpango-1.0-0 libcairo2
|
||||||
|
npx puppeteer browsers install chrome
|
||||||
|
- name: 🌐 HTML Validation
|
||||||
|
env:
|
||||||
|
NEXT_PUBLIC_BASE_URL: ${{ env.TARGET_URL }}
|
||||||
|
GATEKEEPER_PASSWORD: ${{ secrets.GATEKEEPER_PASSWORD }}
|
||||||
|
run: pnpm run check:html
|
||||||
|
- name: 🖼️ Broken Assets
|
||||||
|
env:
|
||||||
|
NEXT_PUBLIC_BASE_URL: ${{ env.TARGET_URL }}
|
||||||
|
GATEKEEPER_PASSWORD: ${{ secrets.GATEKEEPER_PASSWORD }}
|
||||||
|
ASSET_CHECK_LIMIT: 10
|
||||||
|
run: pnpm run check:assets
|
||||||
|
- name: 🔒 HTTP Headers
|
||||||
|
env:
|
||||||
|
NEXT_PUBLIC_BASE_URL: ${{ env.TARGET_URL }}
|
||||||
|
GATEKEEPER_PASSWORD: ${{ secrets.GATEKEEPER_PASSWORD }}
|
||||||
|
run: pnpm run check:http
|
||||||
|
|
||||||
|
# ────────────────────────────────────────────────────
|
||||||
|
# 2. Accessibility (WCAG)
|
||||||
|
# ────────────────────────────────────────────────────
|
||||||
|
a11y:
|
||||||
|
name: ♿ Accessibility
|
||||||
|
runs-on: docker
|
||||||
|
container:
|
||||||
|
image: catthehacker/ubuntu:act-latest
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v4
|
||||||
|
- uses: pnpm/action-setup@v3
|
||||||
|
with:
|
||||||
|
version: 10
|
||||||
|
- uses: actions/setup-node@v4
|
||||||
|
with:
|
||||||
|
node-version: 20
|
||||||
|
- name: 🔐 Registry Auth
|
||||||
|
run: |
|
||||||
|
echo "@mintel:registry=https://git.infra.mintel.me/api/packages/mmintel/npm" > .npmrc
|
||||||
|
echo "//git.infra.mintel.me/api/packages/mmintel/npm/:_authToken=${{ secrets.NPM_TOKEN }}" >> .npmrc
|
||||||
|
- name: 📦 Cache node_modules
|
||||||
|
uses: actions/cache@v4
|
||||||
|
id: cache-deps
|
||||||
|
with:
|
||||||
|
path: node_modules
|
||||||
|
key: pnpm-${{ hashFiles('pnpm-lock.yaml') }}
|
||||||
|
- name: Install
|
||||||
|
if: steps.cache-deps.outputs.cache-hit != 'true'
|
||||||
|
run: |
|
||||||
|
pnpm store prune
|
||||||
|
pnpm install --no-frozen-lockfile
|
||||||
|
- name: 🌐 Install Chrome & Dependencies
|
||||||
|
run: |
|
||||||
|
apt-get update && apt-get install -y libnss3 libnspr4 libatk1.0-0 libatk-bridge2.0-0 libcups2 libdrm2 libxkbcommon0 libxcomposite1 libxdamage1 libxext6 libxfixes3 libxrandr2 libgbm1 libasound2t64 libpango-1.0-0 libcairo2
|
||||||
|
npx puppeteer browsers install chrome
|
||||||
|
- name: ♿ WCAG Scan
|
||||||
|
continue-on-error: true
|
||||||
|
env:
|
||||||
|
NEXT_PUBLIC_BASE_URL: ${{ env.TARGET_URL }}
|
||||||
|
GATEKEEPER_PASSWORD: ${{ secrets.GATEKEEPER_PASSWORD }}
|
||||||
|
run: pnpm run check:wcag
|
||||||
|
|
||||||
|
# ────────────────────────────────────────────────────
|
||||||
|
# 3. Performance (Lighthouse)
|
||||||
|
# ────────────────────────────────────────────────────
|
||||||
|
lighthouse:
|
||||||
|
name: 🎭 Lighthouse
|
||||||
|
runs-on: docker
|
||||||
|
container:
|
||||||
|
image: catthehacker/ubuntu:act-latest
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v4
|
||||||
|
- uses: pnpm/action-setup@v3
|
||||||
|
with:
|
||||||
|
version: 10
|
||||||
|
- uses: actions/setup-node@v4
|
||||||
|
with:
|
||||||
|
node-version: 20
|
||||||
|
- name: 🔐 Registry Auth
|
||||||
|
run: |
|
||||||
|
echo "@mintel:registry=https://git.infra.mintel.me/api/packages/mmintel/npm" > .npmrc
|
||||||
|
echo "//git.infra.mintel.me/api/packages/mmintel/npm/:_authToken=${{ secrets.NPM_TOKEN }}" >> .npmrc
|
||||||
|
- name: 📦 Cache node_modules
|
||||||
|
uses: actions/cache@v4
|
||||||
|
id: cache-deps
|
||||||
|
with:
|
||||||
|
path: node_modules
|
||||||
|
key: pnpm-${{ hashFiles('pnpm-lock.yaml') }}
|
||||||
|
- name: Install
|
||||||
|
if: steps.cache-deps.outputs.cache-hit != 'true'
|
||||||
|
run: |
|
||||||
|
pnpm store prune
|
||||||
|
pnpm install --no-frozen-lockfile
|
||||||
|
- name: 🌐 Install Chrome & Dependencies
|
||||||
|
run: |
|
||||||
|
apt-get update && apt-get install -y libnss3 libnspr4 libatk1.0-0 libatk-bridge2.0-0 libcups2 libdrm2 libxkbcommon0 libxcomposite1 libxdamage1 libxext6 libxfixes3 libxrandr2 libgbm1 libasound2t64 libpango-1.0-0 libcairo2
|
||||||
|
npx puppeteer browsers install chrome
|
||||||
|
- name: 🎭 Desktop
|
||||||
|
env:
|
||||||
|
NEXT_PUBLIC_BASE_URL: ${{ env.TARGET_URL }}
|
||||||
|
GATEKEEPER_PASSWORD: ${{ secrets.GATEKEEPER_PASSWORD }}
|
||||||
|
PAGESPEED_LIMIT: 5
|
||||||
|
run: pnpm run pagespeed:test -- --collect.settings.preset=desktop
|
||||||
|
- name: 📱 Mobile
|
||||||
|
env:
|
||||||
|
NEXT_PUBLIC_BASE_URL: ${{ env.TARGET_URL }}
|
||||||
|
GATEKEEPER_PASSWORD: ${{ secrets.GATEKEEPER_PASSWORD }}
|
||||||
|
PAGESPEED_LIMIT: 5
|
||||||
|
run: pnpm run pagespeed:test -- --collect.settings.preset=mobile
|
||||||
|
|
||||||
|
# ────────────────────────────────────────────────────
|
||||||
|
# 4. Link Check & Dependency Audit
|
||||||
|
# ────────────────────────────────────────────────────
|
||||||
|
links:
|
||||||
|
name: 🔗 Links & Deps
|
||||||
|
runs-on: docker
|
||||||
|
container:
|
||||||
|
image: catthehacker/ubuntu:act-latest
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v4
|
||||||
|
- uses: pnpm/action-setup@v3
|
||||||
|
with:
|
||||||
|
version: 10
|
||||||
|
- uses: actions/setup-node@v4
|
||||||
|
with:
|
||||||
|
node-version: 20
|
||||||
|
- name: 🔐 Registry Auth
|
||||||
|
run: |
|
||||||
|
echo "@mintel:registry=https://git.infra.mintel.me/api/packages/mmintel/npm" > .npmrc
|
||||||
|
echo "//git.infra.mintel.me/api/packages/mmintel/npm/:_authToken=${{ secrets.NPM_TOKEN }}" >> .npmrc
|
||||||
|
- name: 📦 Cache node_modules
|
||||||
|
uses: actions/cache@v4
|
||||||
|
id: cache-deps
|
||||||
|
with:
|
||||||
|
path: node_modules
|
||||||
|
key: pnpm-${{ hashFiles('pnpm-lock.yaml') }}
|
||||||
|
- name: Install
|
||||||
|
if: steps.cache-deps.outputs.cache-hit != 'true'
|
||||||
|
run: |
|
||||||
|
pnpm store prune
|
||||||
|
pnpm install --no-frozen-lockfile
|
||||||
|
- name: 📦 Depcheck
|
||||||
|
continue-on-error: true
|
||||||
|
run: pnpm dlx depcheck --ignores="*eslint*,*typescript*,*tailwindcss*,*postcss*,*prettier*,*@types/*,*husky*,*lint-staged*,*@next/*,*@lhci/*,*commitlint*,*cspell*,*rimraf*,*@payloadcms/*,*start-server-and-test*,*html-validate*,*critters*,*dotenv*,*turbo*" || true
|
||||||
|
- name: 🔗 Lychee Link Check
|
||||||
|
uses: lycheeverse/lychee-action@v2
|
||||||
|
with:
|
||||||
|
args: --accept 200,204,429 --timeout 15 --insecure --exclude "file://*" --exclude "https://logs.infra.***.me/*" --exclude "https://git.infra.***.me/*" --exclude "https://umami.is/docs/best-practices" --exclude "https://***/*" .
|
||||||
|
fail: true
|
||||||
|
|
||||||
|
# ────────────────────────────────────────────────────
|
||||||
|
# 5. Notification
|
||||||
|
# ────────────────────────────────────────────────────
|
||||||
|
notify:
|
||||||
|
name: 🔔 Notify
|
||||||
|
needs: [static, a11y, lighthouse, links]
|
||||||
|
if: failure()
|
||||||
|
runs-on: docker
|
||||||
|
container:
|
||||||
|
image: catthehacker/ubuntu:act-latest
|
||||||
|
steps:
|
||||||
|
- name: 🔔 Gotify
|
||||||
|
shell: bash
|
||||||
|
run: |
|
||||||
|
STATIC="${{ needs.static.result }}"
|
||||||
|
A11Y="${{ needs.a11y.result }}"
|
||||||
|
LIGHTHOUSE="${{ needs.lighthouse.result }}"
|
||||||
|
LINKS="${{ needs.links.result }}"
|
||||||
|
|
||||||
|
if [[ "$STATIC" != "success" || "$LIGHTHOUSE" != "success" ]]; then
|
||||||
|
PRIORITY=8
|
||||||
|
EMOJI="🚨"
|
||||||
|
STATUS="Failed"
|
||||||
|
else
|
||||||
|
PRIORITY=2
|
||||||
|
EMOJI="✅"
|
||||||
|
STATUS="Passed"
|
||||||
|
fi
|
||||||
|
|
||||||
|
TITLE="$EMOJI ${{ env.PROJECT_NAME }} QA $STATUS"
|
||||||
|
MESSAGE="Static: $STATIC | A11y: $A11Y | Lighthouse: $LIGHTHOUSE | Links: $LINKS
|
||||||
|
${{ env.TARGET_URL }}"
|
||||||
|
|
||||||
|
if [[ -z "${{ secrets.GOTIFY_URL }}" || -z "${{ secrets.GOTIFY_TOKEN }}" ]]; then
|
||||||
|
echo "⚠️ Gotify credentials missing, skipping notification."
|
||||||
|
exit 0
|
||||||
|
fi
|
||||||
|
|
||||||
|
curl -s -k -X POST "${{ secrets.GOTIFY_URL }}/message?token=${{ secrets.GOTIFY_TOKEN }}" \
|
||||||
|
-F "title=$TITLE" \
|
||||||
|
-F "message=$MESSAGE" \
|
||||||
|
-F "priority=$PRIORITY" || true
|
||||||
|
|||||||
@@ -17,6 +17,10 @@
|
|||||||
"valid-id": "off",
|
"valid-id": "off",
|
||||||
"element-required-attributes": "off",
|
"element-required-attributes": "off",
|
||||||
"attribute-empty-style": "off",
|
"attribute-empty-style": "off",
|
||||||
"element-permitted-content": "off"
|
"element-permitted-content": "off",
|
||||||
|
"element-required-content": "off",
|
||||||
|
"element-permitted-parent": "off",
|
||||||
|
"no-implicit-close": "off",
|
||||||
|
"close-order": "off"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
4
.npmrc
4
.npmrc
@@ -1,2 +1,2 @@
|
|||||||
@mintel:registry=https://npm.infra.mintel.me/
|
@mintel:registry=https://git.infra.mintel.me/api/packages/mmintel/npm/
|
||||||
//npm.infra.mintel.me/:_authToken=${NPM_TOKEN}
|
//git.infra.mintel.me/api/packages/mmintel/npm/:_authToken=${NPM_TOKEN}
|
||||||
|
|||||||
13
Dockerfile
13
Dockerfile
@@ -1,18 +1,16 @@
|
|||||||
# Stage 1: Builder
|
# Stage 1: Builder
|
||||||
FROM registry.infra.mintel.me/mintel/nextjs:v1.8.20 AS base
|
FROM git.infra.mintel.me/mmintel/nextjs:latest AS base
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
|
|
||||||
# Arguments for build-time configuration
|
# Arguments for build-time configuration
|
||||||
ARG NEXT_PUBLIC_BASE_URL
|
ARG NEXT_PUBLIC_BASE_URL
|
||||||
ARG NEXT_PUBLIC_TARGET
|
ARG NEXT_PUBLIC_TARGET
|
||||||
ARG DIRECTUS_URL
|
|
||||||
ARG UMAMI_WEBSITE_ID
|
ARG UMAMI_WEBSITE_ID
|
||||||
ARG UMAMI_API_ENDPOINT
|
ARG UMAMI_API_ENDPOINT
|
||||||
|
|
||||||
# Environment variables for Next.js build
|
# Environment variables for Next.js build
|
||||||
ENV NEXT_PUBLIC_BASE_URL=$NEXT_PUBLIC_BASE_URL
|
ENV NEXT_PUBLIC_BASE_URL=$NEXT_PUBLIC_BASE_URL
|
||||||
ENV NEXT_PUBLIC_TARGET=$NEXT_PUBLIC_TARGET
|
ENV NEXT_PUBLIC_TARGET=$NEXT_PUBLIC_TARGET
|
||||||
ENV DIRECTUS_URL=$DIRECTUS_URL
|
|
||||||
ENV UMAMI_WEBSITE_ID=$UMAMI_WEBSITE_ID
|
ENV UMAMI_WEBSITE_ID=$UMAMI_WEBSITE_ID
|
||||||
ENV UMAMI_API_ENDPOINT=$UMAMI_API_ENDPOINT
|
ENV UMAMI_API_ENDPOINT=$UMAMI_API_ENDPOINT
|
||||||
ENV SKIP_RUNTIME_ENV_VALIDATION=true
|
ENV SKIP_RUNTIME_ENV_VALIDATION=true
|
||||||
@@ -50,16 +48,12 @@ ENV RAYON_NUM_THREADS=3
|
|||||||
ENV UV_THREADPOOL_SIZE=3
|
ENV UV_THREADPOOL_SIZE=3
|
||||||
|
|
||||||
RUN pnpm build
|
RUN pnpm build
|
||||||
|
# Excel generation moved to post-deploy
|
||||||
|
|
||||||
# Stage 2: Runner
|
# Stage 2: Runner
|
||||||
FROM registry.infra.mintel.me/mintel/runtime:v1.8.20 AS runner
|
FROM git.infra.mintel.me/mmintel/runtime:latest AS runner
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
|
|
||||||
# Create nextjs user and group (standardized in runtime image but ensuring local ownership)
|
|
||||||
USER root
|
|
||||||
RUN chown -R nextjs:nodejs /app
|
|
||||||
USER nextjs
|
|
||||||
|
|
||||||
ENV HOSTNAME="0.0.0.0"
|
ENV HOSTNAME="0.0.0.0"
|
||||||
ENV PORT=3000
|
ENV PORT=3000
|
||||||
ENV NODE_ENV=production
|
ENV NODE_ENV=production
|
||||||
@@ -71,3 +65,4 @@ COPY --from=builder --chown=nextjs:nodejs /app/.next/static ./.next/static
|
|||||||
COPY --from=builder --chown=nextjs:nodejs /app/.next/cache ./.next/cache
|
COPY --from=builder --chown=nextjs:nodejs /app/.next/cache ./.next/cache
|
||||||
|
|
||||||
CMD ["node", "server.js"]
|
CMD ["node", "server.js"]
|
||||||
|
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ import { notFound, redirect } from 'next/navigation';
|
|||||||
import { Container, Badge, Heading } from '@/components/ui';
|
import { Container, Badge, Heading } from '@/components/ui';
|
||||||
import { getTranslations, setRequestLocale } from 'next-intl/server';
|
import { getTranslations, setRequestLocale } from 'next-intl/server';
|
||||||
import { Metadata } from 'next';
|
import { Metadata } from 'next';
|
||||||
import { getPageBySlug, getAllPages } from '@/lib/pages';
|
import { getPageBySlug } from '@/lib/pages';
|
||||||
import { mapSlugToFileSlug, mapFileSlugToTranslated } from '@/lib/slugs';
|
import { mapSlugToFileSlug, mapFileSlugToTranslated } from '@/lib/slugs';
|
||||||
import PayloadRichText from '@/components/PayloadRichText';
|
import PayloadRichText from '@/components/PayloadRichText';
|
||||||
import { SITE_URL } from '@/lib/schema';
|
import { SITE_URL } from '@/lib/schema';
|
||||||
|
|||||||
@@ -8,6 +8,20 @@ export const size = OG_IMAGE_SIZE;
|
|||||||
export const contentType = 'image/png';
|
export const contentType = 'image/png';
|
||||||
export const runtime = 'nodejs';
|
export const runtime = 'nodejs';
|
||||||
|
|
||||||
|
async function fetchImageAsBase64(url: string) {
|
||||||
|
try {
|
||||||
|
const res = await fetch(url);
|
||||||
|
if (!res.ok) return undefined;
|
||||||
|
const arrayBuffer = await res.arrayBuffer();
|
||||||
|
const buffer = Buffer.from(arrayBuffer);
|
||||||
|
const contentType = res.headers.get('content-type') || 'image/jpeg';
|
||||||
|
return `data:${contentType};base64,${buffer.toString('base64')}`;
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to fetch OG image:', url, error);
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
export default async function Image({
|
export default async function Image({
|
||||||
params,
|
params,
|
||||||
}: {
|
}: {
|
||||||
@@ -32,12 +46,19 @@ export default async function Image({
|
|||||||
: `${SITE_URL}${post.frontmatter.featuredImage}`
|
: `${SITE_URL}${post.frontmatter.featuredImage}`
|
||||||
: undefined;
|
: undefined;
|
||||||
|
|
||||||
|
// Fetch image explicitly and convert to base64 because Satori sometimes struggles
|
||||||
|
// fetching remote URLs directly inside ImageResponse correctly in various environments.
|
||||||
|
let base64Image: string | undefined = undefined;
|
||||||
|
if (featuredImage) {
|
||||||
|
base64Image = await fetchImageAsBase64(featuredImage);
|
||||||
|
}
|
||||||
|
|
||||||
return new ImageResponse(
|
return new ImageResponse(
|
||||||
<OGImageTemplate
|
<OGImageTemplate
|
||||||
title={post.frontmatter.title}
|
title={post.frontmatter.title}
|
||||||
description={post.frontmatter.excerpt}
|
description={post.frontmatter.excerpt}
|
||||||
label={post.frontmatter.category || 'Blog'}
|
label={post.frontmatter.category || 'Blog'}
|
||||||
image={featuredImage}
|
image={base64Image || featuredImage}
|
||||||
/>,
|
/>,
|
||||||
{
|
{
|
||||||
...OG_IMAGE_SIZE,
|
...OG_IMAGE_SIZE,
|
||||||
|
|||||||
@@ -1,12 +1,18 @@
|
|||||||
import { notFound, redirect } from 'next/navigation';
|
import { notFound, redirect } from 'next/navigation';
|
||||||
import JsonLd from '@/components/JsonLd';
|
import JsonLd from '@/components/JsonLd';
|
||||||
import { SITE_URL } from '@/lib/schema';
|
import { SITE_URL } from '@/lib/schema';
|
||||||
import { getPostBySlug, getAdjacentPosts, getReadingTime } from '@/lib/blog';
|
import {
|
||||||
|
getPostBySlug,
|
||||||
|
getAdjacentPosts,
|
||||||
|
getReadingTime,
|
||||||
|
extractLexicalHeadings,
|
||||||
|
} from '@/lib/blog';
|
||||||
import { Metadata } from 'next';
|
import { Metadata } from 'next';
|
||||||
import Link from 'next/link';
|
import Link from 'next/link';
|
||||||
import Image from 'next/image';
|
import Image from 'next/image';
|
||||||
import PostNavigation from '@/components/blog/PostNavigation';
|
import PostNavigation from '@/components/blog/PostNavigation';
|
||||||
import PowerCTA from '@/components/blog/PowerCTA';
|
import PowerCTA from '@/components/blog/PowerCTA';
|
||||||
|
import TableOfContents from '@/components/blog/TableOfContents';
|
||||||
import { Heading } from '@/components/ui';
|
import { Heading } from '@/components/ui';
|
||||||
import { setRequestLocale } from 'next-intl/server';
|
import { setRequestLocale } from 'next-intl/server';
|
||||||
import BlogEngagementTracker from '@/components/analytics/BlogEngagementTracker';
|
import BlogEngagementTracker from '@/components/analytics/BlogEngagementTracker';
|
||||||
@@ -67,6 +73,10 @@ export default async function BlogPost({ params }: BlogPostProps) {
|
|||||||
|
|
||||||
const { prev, next, isPrevRandom, isNextRandom } = await getAdjacentPosts(post.slug, locale);
|
const { prev, next, isPrevRandom, isNextRandom } = await getAdjacentPosts(post.slug, locale);
|
||||||
|
|
||||||
|
// Convert Lexical content into a plain string to estimate reading time roughly
|
||||||
|
// Extract headings for TOC
|
||||||
|
const headings = extractLexicalHeadings(post.content?.root || post.content);
|
||||||
|
|
||||||
// Convert Lexical content into a plain string to estimate reading time roughly
|
// Convert Lexical content into a plain string to estimate reading time roughly
|
||||||
const rawTextContent = JSON.stringify(post.content);
|
const rawTextContent = JSON.stringify(post.content);
|
||||||
|
|
||||||
@@ -88,6 +98,7 @@ export default async function BlogPost({ params }: BlogPostProps) {
|
|||||||
alt={post.frontmatter.title}
|
alt={post.frontmatter.title}
|
||||||
fill
|
fill
|
||||||
priority
|
priority
|
||||||
|
quality={100}
|
||||||
className="object-cover"
|
className="object-cover"
|
||||||
sizes="100vw"
|
sizes="100vw"
|
||||||
style={{
|
style={{
|
||||||
@@ -113,7 +124,7 @@ export default async function BlogPost({ params }: BlogPostProps) {
|
|||||||
</Heading>
|
</Heading>
|
||||||
<div className="flex flex-wrap items-center gap-6 text-white/80 text-sm md:text-base font-medium">
|
<div className="flex flex-wrap items-center gap-6 text-white/80 text-sm md:text-base font-medium">
|
||||||
<time dateTime={post.frontmatter.date} suppressHydrationWarning>
|
<time dateTime={post.frontmatter.date} suppressHydrationWarning>
|
||||||
{new Date(post.frontmatter.date).toLocaleDateString(locale, {
|
{new Date(post.frontmatter.date).toLocaleDateString(locale || 'de', {
|
||||||
year: 'numeric',
|
year: 'numeric',
|
||||||
month: 'long',
|
month: 'long',
|
||||||
day: 'numeric',
|
day: 'numeric',
|
||||||
@@ -150,7 +161,7 @@ export default async function BlogPost({ params }: BlogPostProps) {
|
|||||||
</Heading>
|
</Heading>
|
||||||
<div className="flex items-center gap-6 text-text-primary/80 font-medium">
|
<div className="flex items-center gap-6 text-text-primary/80 font-medium">
|
||||||
<time dateTime={post.frontmatter.date} suppressHydrationWarning>
|
<time dateTime={post.frontmatter.date} suppressHydrationWarning>
|
||||||
{new Date(post.frontmatter.date).toLocaleDateString(locale, {
|
{new Date(post.frontmatter.date).toLocaleDateString(locale || 'de', {
|
||||||
year: 'numeric',
|
year: 'numeric',
|
||||||
month: 'long',
|
month: 'long',
|
||||||
day: 'numeric',
|
day: 'numeric',
|
||||||
@@ -231,10 +242,10 @@ export default async function BlogPost({ params }: BlogPostProps) {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Right Column: Sticky Sidebar - Temporarily Hidden without ToC */}
|
{/* Right Column: Sticky Sidebar - TOC */}
|
||||||
<aside className="sticky-narrative-sidebar hidden lg:block">
|
<aside className="sticky-narrative-sidebar hidden lg:block">
|
||||||
<div className="space-y-12">
|
<div className="space-y-12 lg:sticky lg:top-32">
|
||||||
{/* Future Payload Table of Contents Implementation */}
|
<TableOfContents headings={headings} locale={locale} />
|
||||||
</div>
|
</div>
|
||||||
</aside>
|
</aside>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -14,7 +14,7 @@ interface BlogIndexProps {
|
|||||||
}>;
|
}>;
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function generateMetadata({ params }: BlogIndexProps) {
|
export async function generateMetadata({ params }: BlogIndexProps): Promise<Metadata> {
|
||||||
const { locale } = await params;
|
const { locale } = await params;
|
||||||
const t = await getTranslations({ locale, namespace: 'Blog.meta' });
|
const t = await getTranslations({ locale, namespace: 'Blog.meta' });
|
||||||
return {
|
return {
|
||||||
@@ -198,7 +198,7 @@ export default async function BlogIndex({ params }: BlogIndexProps) {
|
|||||||
|
|
||||||
<div className="flex items-center gap-3 text-xs md:text-sm font-bold text-white/80 mb-3 tracking-widest uppercase">
|
<div className="flex items-center gap-3 text-xs md:text-sm font-bold text-white/80 mb-3 tracking-widest uppercase">
|
||||||
<time dateTime={post.frontmatter.date} suppressHydrationWarning>
|
<time dateTime={post.frontmatter.date} suppressHydrationWarning>
|
||||||
{new Date(post.frontmatter.date).toLocaleDateString(locale, {
|
{new Date(post.frontmatter.date).toLocaleDateString(locale || 'de', {
|
||||||
year: 'numeric',
|
year: 'numeric',
|
||||||
month: 'long',
|
month: 'long',
|
||||||
day: 'numeric',
|
day: 'numeric',
|
||||||
|
|||||||
@@ -5,9 +5,10 @@ import { Container, Heading, Section } from '@/components/ui';
|
|||||||
import { Metadata } from 'next';
|
import { Metadata } from 'next';
|
||||||
import { getTranslations, setRequestLocale } from 'next-intl/server';
|
import { getTranslations, setRequestLocale } from 'next-intl/server';
|
||||||
import { SITE_URL } from '@/lib/schema';
|
import { SITE_URL } from '@/lib/schema';
|
||||||
import { getOGImageMetadata } from '@/lib/metadata';
|
|
||||||
import { Suspense } from 'react';
|
import { Suspense } from 'react';
|
||||||
import ContactMap from '@/components/ContactMap';
|
import ContactMap from '@/components/ContactMap';
|
||||||
|
import ObfuscatedEmail from '@/components/ObfuscatedEmail';
|
||||||
|
|
||||||
interface ContactPageProps {
|
interface ContactPageProps {
|
||||||
params: Promise<{
|
params: Promise<{
|
||||||
@@ -204,12 +205,10 @@ export default async function ContactPage({ params }: ContactPageProps) {
|
|||||||
<h4 className="text-base md:text-xl font-bold text-primary mb-1 md:mb-2">
|
<h4 className="text-base md:text-xl font-bold text-primary mb-1 md:mb-2">
|
||||||
{t('info.email')}
|
{t('info.email')}
|
||||||
</h4>
|
</h4>
|
||||||
<a
|
<ObfuscatedEmail
|
||||||
href="mailto:info@klz-cables.com"
|
email="info@klz-cables.com"
|
||||||
className="text-sm md:text-lg text-text-secondary hover:text-primary transition-colors font-medium touch-target"
|
className="text-sm md:text-lg text-text-secondary hover:text-primary transition-colors font-medium touch-target"
|
||||||
>
|
/>
|
||||||
info@klz-cables.com
|
|
||||||
</a>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</address>
|
</address>
|
||||||
|
|||||||
@@ -7,10 +7,8 @@ import AnalyticsShell from '@/components/analytics/AnalyticsShell';
|
|||||||
import { Metadata, Viewport } from 'next';
|
import { Metadata, Viewport } from 'next';
|
||||||
import { NextIntlClientProvider } from 'next-intl';
|
import { NextIntlClientProvider } from 'next-intl';
|
||||||
import { getMessages } from 'next-intl/server';
|
import { getMessages } from 'next-intl/server';
|
||||||
import { Suspense } from 'react';
|
|
||||||
import '../../styles/globals.css';
|
import '../../styles/globals.css';
|
||||||
import { SITE_URL } from '@/lib/schema';
|
import { SITE_URL } from '@/lib/schema';
|
||||||
import { config } from '@/lib/config';
|
|
||||||
import FeedbackClientWrapper from '@/components/FeedbackClientWrapper';
|
import FeedbackClientWrapper from '@/components/FeedbackClientWrapper';
|
||||||
import { setRequestLocale } from 'next-intl/server';
|
import { setRequestLocale } from 'next-intl/server';
|
||||||
import { Inter } from 'next/font/google';
|
import { Inter } from 'next/font/google';
|
||||||
@@ -61,6 +59,7 @@ export const viewport: Viewport = {
|
|||||||
themeColor: '#001a4d',
|
themeColor: '#001a4d',
|
||||||
};
|
};
|
||||||
|
|
||||||
|
import AutoBrochureModal from '@/components/AutoBrochureModal';
|
||||||
export default async function Layout(props: {
|
export default async function Layout(props: {
|
||||||
children: React.ReactNode;
|
children: React.ReactNode;
|
||||||
params: Promise<{ locale: string }>;
|
params: Promise<{ locale: string }>;
|
||||||
@@ -77,7 +76,7 @@ export default async function Layout(props: {
|
|||||||
let messages: Record<string, any> = {};
|
let messages: Record<string, any> = {};
|
||||||
try {
|
try {
|
||||||
messages = await getMessages();
|
messages = await getMessages();
|
||||||
} catch (error) {
|
} catch {
|
||||||
messages = {};
|
messages = {};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -91,6 +90,7 @@ export default async function Layout(props: {
|
|||||||
'Home',
|
'Home',
|
||||||
'Error',
|
'Error',
|
||||||
'StandardPage',
|
'StandardPage',
|
||||||
|
'Brochure',
|
||||||
];
|
];
|
||||||
const clientMessages: Record<string, any> = {};
|
const clientMessages: Record<string, any> = {};
|
||||||
for (const key of clientKeys) {
|
for (const key of clientKeys) {
|
||||||
@@ -160,6 +160,8 @@ export default async function Layout(props: {
|
|||||||
|
|
||||||
<AnalyticsShell />
|
<AnalyticsShell />
|
||||||
<FeedbackClientWrapper feedbackEnabled={feedbackEnabled} />
|
<FeedbackClientWrapper feedbackEnabled={feedbackEnabled} />
|
||||||
|
|
||||||
|
<AutoBrochureModal />
|
||||||
</NextIntlClientProvider>
|
</NextIntlClientProvider>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|||||||
@@ -72,7 +72,7 @@ export default async function NotFound() {
|
|||||||
}
|
}
|
||||||
suggestedUrl = '/' + pathParts.join('/');
|
suggestedUrl = '/' + pathParts.join('/');
|
||||||
}
|
}
|
||||||
} catch (e) {
|
} catch {
|
||||||
// Ignore Payload errors in 404
|
// Ignore Payload errors in 404
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,12 +1,11 @@
|
|||||||
import JsonLd from '@/components/JsonLd';
|
import JsonLd from '@/components/JsonLd';
|
||||||
import { SITE_URL } from '@/lib/schema';
|
import { SITE_URL } from '@/lib/schema';
|
||||||
import ProductSidebar from '@/components/ProductSidebar';
|
import ProductSidebar from '@/components/ProductSidebar';
|
||||||
import ProductTabs from '@/components/ProductTabs';
|
import ExcelDownload from '@/components/ExcelDownload';
|
||||||
import ProductTechnicalData from '@/components/ProductTechnicalData';
|
|
||||||
import RelatedProducts from '@/components/RelatedProducts';
|
import RelatedProducts from '@/components/RelatedProducts';
|
||||||
import DatasheetDownload from '@/components/DatasheetDownload';
|
import DatasheetDownload from '@/components/DatasheetDownload';
|
||||||
import { Badge, Card, Container, Heading, Section } from '@/components/ui';
|
import { Badge, Card, Container, Heading, Section } from '@/components/ui';
|
||||||
import { getDatasheetPath } from '@/lib/datasheets';
|
import { getDatasheetPath, getExcelDatasheetPath } from '@/lib/datasheets';
|
||||||
import { getAllProducts, getProductBySlug } from '@/lib/products';
|
import { getAllProducts, getProductBySlug } from '@/lib/products';
|
||||||
import { mapFileSlugToTranslated, mapSlugToFileSlug } from '@/lib/slugs';
|
import { mapFileSlugToTranslated, mapSlugToFileSlug } from '@/lib/slugs';
|
||||||
import { Metadata } from 'next';
|
import { Metadata } from 'next';
|
||||||
@@ -278,6 +277,7 @@ export default async function ProductPage({ params }: ProductPageProps) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const datasheetPath = getDatasheetPath(productSlug, locale);
|
const datasheetPath = getDatasheetPath(productSlug, locale);
|
||||||
|
const excelPath = getExcelDatasheetPath(productSlug, locale);
|
||||||
const isFallback = (product.frontmatter as any).isFallback;
|
const isFallback = (product.frontmatter as any).isFallback;
|
||||||
const categorySlug = slug[0];
|
const categorySlug = slug[0];
|
||||||
const categoryFileSlug = await mapSlugToFileSlug(categorySlug, locale);
|
const categoryFileSlug = await mapSlugToFileSlug(categorySlug, locale);
|
||||||
@@ -322,6 +322,8 @@ export default async function ProductPage({ params }: ProductPageProps) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
console.log(`[DEBUG PAGE] Slug: ${productSlug}, children count: ${descriptionChildren.length}`);
|
||||||
|
|
||||||
const descriptionContent = {
|
const descriptionContent = {
|
||||||
root: {
|
root: {
|
||||||
...product.content.root,
|
...product.content.root,
|
||||||
@@ -341,6 +343,7 @@ export default async function ProductPage({ params }: ProductPageProps) {
|
|||||||
productName={product.frontmatter.title}
|
productName={product.frontmatter.title}
|
||||||
productImage={product.frontmatter.images?.[0]}
|
productImage={product.frontmatter.images?.[0]}
|
||||||
datasheetPath={datasheetPath}
|
datasheetPath={datasheetPath}
|
||||||
|
excelPath={excelPath}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -353,29 +356,31 @@ export default async function ProductPage({ params }: ProductPageProps) {
|
|||||||
categories={product.frontmatter.categories}
|
categories={product.frontmatter.categories}
|
||||||
sku={product.frontmatter.sku}
|
sku={product.frontmatter.sku}
|
||||||
/>
|
/>
|
||||||
<section className="relative pt-40 pb-24 overflow-hidden bg-primary-dark">
|
<section className="relative pt-28 md:pt-40 pb-12 md:pb-24 overflow-hidden bg-primary-dark">
|
||||||
{/* Background Decorative Elements */}
|
{/* Background Decorative Elements */}
|
||||||
<div className="absolute top-0 right-0 w-1/2 h-full bg-gradient-to-l from-accent/5 to-transparent pointer-events-none" />
|
<div className="absolute top-0 right-0 w-1/2 h-full bg-gradient-to-l from-accent/5 to-transparent pointer-events-none" />
|
||||||
<div className="absolute -top-24 -right-24 w-96 h-96 bg-accent/10 rounded-full blur-3xl pointer-events-none" />
|
<div className="absolute -top-24 -right-24 w-96 h-96 bg-accent/10 rounded-full blur-3xl pointer-events-none" />
|
||||||
|
|
||||||
<Container className="relative z-10">
|
<Container className="relative z-10">
|
||||||
<div className="max-w-4xl animate-slide-up">
|
<div className="max-w-4xl animate-slide-up">
|
||||||
<nav className="flex items-center mb-12 text-white/40 text-[10px] font-black uppercase tracking-[0.2em]">
|
<nav className="flex flex-wrap items-center gap-y-1 mb-6 md:mb-12 text-white/40 text-[10px] font-black uppercase tracking-[0.2em]">
|
||||||
<Link
|
<Link
|
||||||
href={`/${locale}/${productsSlug}`}
|
href={`/${locale}/${productsSlug}`}
|
||||||
className="hover:text-accent transition-colors"
|
className="hover:text-accent transition-colors shrink-0"
|
||||||
>
|
>
|
||||||
{t.has('breadcrumb') ? t('breadcrumb') : 'Products'}
|
{t.has('breadcrumb') ? t('breadcrumb') : 'Products'}
|
||||||
</Link>
|
</Link>
|
||||||
<span className="mx-4 opacity-20">/</span>
|
<span className="mx-2 md:mx-4 opacity-20">/</span>
|
||||||
<Link
|
<Link
|
||||||
href={`/${locale}/${productsSlug}/${categorySlug}`}
|
href={`/${locale}/${productsSlug}/${categorySlug}`}
|
||||||
className="hover:text-accent transition-colors"
|
className="hover:text-accent transition-colors shrink-0 max-w-[140px] truncate"
|
||||||
>
|
>
|
||||||
{categoryTitle}
|
{categoryTitle}
|
||||||
</Link>
|
</Link>
|
||||||
<span className="mx-4 opacity-20">/</span>
|
<span className="mx-2 md:mx-4 opacity-20">/</span>
|
||||||
<span className="text-white/90">{product.frontmatter.title}</span>
|
<span className="text-white/90 truncate max-w-[140px] md:max-w-none">
|
||||||
|
{product.frontmatter.title}
|
||||||
|
</span>
|
||||||
</nav>
|
</nav>
|
||||||
|
|
||||||
<div className="flex flex-col lg:flex-row lg:items-center justify-between gap-12">
|
<div className="flex flex-col lg:flex-row lg:items-center justify-between gap-12">
|
||||||
@@ -386,7 +391,7 @@ export default async function ProductPage({ params }: ProductPageProps) {
|
|||||||
{t('englishVersion')}
|
{t('englishVersion')}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
<div className="flex flex-wrap gap-3 mb-8">
|
<div className="flex flex-wrap gap-2 mb-4 md:mb-8">
|
||||||
{product.frontmatter.categories.map((cat, idx) => (
|
{product.frontmatter.categories.map((cat, idx) => (
|
||||||
<Badge
|
<Badge
|
||||||
key={idx}
|
key={idx}
|
||||||
@@ -397,10 +402,10 @@ export default async function ProductPage({ params }: ProductPageProps) {
|
|||||||
</Badge>
|
</Badge>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
<Heading level={1} className="text-white mb-8 uppercase">
|
<Heading level={1} className="text-white mb-4 md:mb-8 uppercase">
|
||||||
{product.frontmatter.title}
|
{product.frontmatter.title}
|
||||||
</Heading>
|
</Heading>
|
||||||
<p className="text-xl md:text-2xl text-white/60 max-w-2xl leading-relaxed font-medium">
|
<p className="text-base md:text-xl lg:text-2xl text-white/60 max-w-2xl leading-relaxed font-medium">
|
||||||
{product.frontmatter.description}
|
{product.frontmatter.description}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
@@ -414,11 +419,11 @@ export default async function ProductPage({ params }: ProductPageProps) {
|
|||||||
{/* Large Product Image Section */}
|
{/* Large Product Image Section */}
|
||||||
{product.frontmatter.images && product.frontmatter.images.length > 0 && (
|
{product.frontmatter.images && product.frontmatter.images.length > 0 && (
|
||||||
<div
|
<div
|
||||||
className="relative -mt-32 mb-32 animate-slide-up"
|
className="relative md:-mt-32 mb-8 md:mb-32 animate-slide-up"
|
||||||
style={{ animationDelay: '200ms' }}
|
style={{ animationDelay: '200ms' }}
|
||||||
>
|
>
|
||||||
<div className="bg-white shadow-[0_32px_64px_-12px_rgba(0,0,0,0.1)] rounded-[48px] border border-neutral-dark/5 overflow-hidden p-12 md:p-20 lg:p-24">
|
<div className="bg-white shadow-[0_32px_64px_-12px_rgba(0,0,0,0.1)] rounded-[24px] md:rounded-[48px] border border-neutral-dark/5 overflow-hidden p-6 md:p-20 lg:p-24">
|
||||||
<div className="relative w-full aspect-[21/9]">
|
<div className="relative w-full aspect-[4/3] md:aspect-[21/9]">
|
||||||
<Image
|
<Image
|
||||||
src={product.frontmatter.images[0]}
|
src={product.frontmatter.images[0]}
|
||||||
alt={product.frontmatter.title}
|
alt={product.frontmatter.title}
|
||||||
@@ -453,10 +458,10 @@ export default async function ProductPage({ params }: ProductPageProps) {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<div className="grid grid-cols-1 lg:grid-cols-12 gap-12 lg:gap-20">
|
<div className="grid grid-cols-1 lg:grid-cols-12 gap-8 lg:gap-20">
|
||||||
{/* Description Area Next to Sidebar */}
|
{/* Description Area Next to Sidebar */}
|
||||||
<div className="lg:col-span-8">
|
<div className="lg:col-span-8">
|
||||||
<div className="max-w-none prose prose-primary prose-lg md:prose-xl mb-16 pb-16 border-b border-neutral-dark/5">
|
<div className="max-w-none prose prose-primary prose-base md:prose-lg xl:prose-xl mb-8 md:mb-16 pb-8 md:pb-16 border-b border-neutral-dark/5">
|
||||||
{descriptionChildren.length > 0 ? (
|
{descriptionChildren.length > 0 ? (
|
||||||
<PayloadRichText data={descriptionContent} />
|
<PayloadRichText data={descriptionContent} />
|
||||||
) : product.frontmatter.description ? (
|
) : product.frontmatter.description ? (
|
||||||
@@ -464,6 +469,12 @@ export default async function ProductPage({ params }: ProductPageProps) {
|
|||||||
{product.frontmatter.description}
|
{product.frontmatter.description}
|
||||||
</p>
|
</p>
|
||||||
) : null}
|
) : null}
|
||||||
|
|
||||||
|
{product.application?.root?.children?.length > 0 && (
|
||||||
|
<div className="mt-12">
|
||||||
|
<PayloadRichText data={product.application} />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -472,7 +483,7 @@ export default async function ProductPage({ params }: ProductPageProps) {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Full-width Technical Data Below */}
|
{/* Full-width Technical Data Below */}
|
||||||
<div className="mt-16 pt-16 border-t-0">
|
<div className="mt-8 md:mt-16 pt-8 md:pt-16 border-t-0">
|
||||||
<div className="max-w-none prose prose-primary prose-lg md:prose-xl">
|
<div className="max-w-none prose prose-primary prose-lg md:prose-xl">
|
||||||
<PayloadRichText data={technicalContent} />
|
<PayloadRichText data={technicalContent} />
|
||||||
</div>
|
</div>
|
||||||
@@ -486,7 +497,15 @@ export default async function ProductPage({ params }: ProductPageProps) {
|
|||||||
</h2>
|
</h2>
|
||||||
<div className="h-1.5 w-24 bg-accent rounded-full" />
|
<div className="h-1.5 w-24 bg-accent rounded-full" />
|
||||||
</div>
|
</div>
|
||||||
<DatasheetDownload datasheetPath={datasheetPath} />
|
<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>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
@@ -530,7 +549,7 @@ export default async function ProductPage({ params }: ProductPageProps) {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Related Products Section */}
|
{/* Related Products Section */}
|
||||||
<div className="mt-16 pt-16 border-t border-neutral-dark/5">
|
<div className="mt-10 md:mt-16 pt-10 md:pt-16 border-t border-neutral-dark/5">
|
||||||
<RelatedProducts
|
<RelatedProducts
|
||||||
currentSlug={productSlug}
|
currentSlug={productSlug}
|
||||||
categories={product.frontmatter.categories}
|
categories={product.frontmatter.categories}
|
||||||
|
|||||||
@@ -1,5 +1,4 @@
|
|||||||
import Reveal from '@/components/Reveal';
|
import Reveal from '@/components/Reveal';
|
||||||
import Scribble from '@/components/Scribble';
|
|
||||||
import { Badge, Button, Card, Container, Heading, Section } from '@/components/ui';
|
import { Badge, Button, Card, Container, Heading, Section } from '@/components/ui';
|
||||||
import { getTranslations, setRequestLocale } from 'next-intl/server';
|
import { getTranslations, setRequestLocale } from 'next-intl/server';
|
||||||
import { Metadata } from 'next';
|
import { Metadata } from 'next';
|
||||||
@@ -95,7 +94,7 @@ export default async function ProductsPage({ params }: ProductsPageProps) {
|
|||||||
return (
|
return (
|
||||||
<div className="flex flex-col min-h-screen bg-neutral-light">
|
<div className="flex flex-col min-h-screen bg-neutral-light">
|
||||||
{/* Hero Section */}
|
{/* Hero Section */}
|
||||||
<section className="relative min-h-[50vh] md:min-h-[70vh] flex items-center pt-32 pb-20 md:pt-40 md:pb-32 overflow-hidden bg-primary-dark">
|
<section className="relative flex items-center pt-32 pb-16 md:pt-40 md:pb-24 overflow-hidden bg-primary-dark">
|
||||||
<Container className="relative z-10">
|
<Container className="relative z-10">
|
||||||
<div className="max-w-4xl animate-slide-up">
|
<div className="max-w-4xl animate-slide-up">
|
||||||
<Badge
|
<Badge
|
||||||
@@ -106,15 +105,7 @@ export default async function ProductsPage({ params }: ProductsPageProps) {
|
|||||||
</Badge>
|
</Badge>
|
||||||
<Heading level={1} className="text-white mb-4 md:mb-8">
|
<Heading level={1} className="text-white mb-4 md:mb-8">
|
||||||
{t.rich('title', {
|
{t.rich('title', {
|
||||||
green: (chunks) => (
|
green: (chunks) => <span className="text-accent italic">{chunks}</span>,
|
||||||
<span className="relative inline-block">
|
|
||||||
<span className="relative z-10 text-accent italic">{chunks}</span>
|
|
||||||
<Scribble
|
|
||||||
variant="circle"
|
|
||||||
className="w-[140%] h-[140%] -top-[20%] -left-[20%] text-accent/30 hidden md:block"
|
|
||||||
/>
|
|
||||||
</span>
|
|
||||||
),
|
|
||||||
})}
|
})}
|
||||||
</Heading>
|
</Heading>
|
||||||
<p className="text-lg md:text-xl text-white/70 leading-relaxed max-w-2xl mb-8 md:mb-12 line-clamp-2 md:line-clamp-none">
|
<p className="text-lg md:text-xl text-white/70 leading-relaxed max-w-2xl mb-8 md:mb-12 line-clamp-2 md:line-clamp-none">
|
||||||
@@ -223,7 +214,7 @@ export default async function ProductsPage({ params }: ProductsPageProps) {
|
|||||||
<div className="absolute top-0 right-0 w-1/2 h-full bg-accent/5 -skew-x-12 translate-x-1/4" />
|
<div className="absolute top-0 right-0 w-1/2 h-full bg-accent/5 -skew-x-12 translate-x-1/4" />
|
||||||
<div className="relative z-10 flex flex-col lg:flex-row items-center justify-between gap-6 md:gap-12">
|
<div className="relative z-10 flex flex-col lg:flex-row items-center justify-between gap-6 md:gap-12">
|
||||||
<div className="max-w-2xl text-center lg:text-left">
|
<div className="max-w-2xl text-center lg:text-left">
|
||||||
<h2 className="text-2xl md:text-5xl lg:text-6xl font-bold text-white mb-4 md:mb-8 tracking-tight">
|
<h2 className="text-2xl md:text-4xl font-bold text-white mb-4 md:mb-8 tracking-tight">
|
||||||
{t('cta.title')}
|
{t('cta.title')}
|
||||||
</h2>
|
</h2>
|
||||||
<p className="text-base md:text-xl text-white/70 leading-relaxed">
|
<p className="text-base md:text-xl text-white/70 leading-relaxed">
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ import { getTranslations, setRequestLocale } from 'next-intl/server';
|
|||||||
import { Metadata } from 'next';
|
import { Metadata } from 'next';
|
||||||
import JsonLd from '@/components/JsonLd';
|
import JsonLd from '@/components/JsonLd';
|
||||||
import { getBreadcrumbSchema, SITE_URL } from '@/lib/schema';
|
import { getBreadcrumbSchema, SITE_URL } from '@/lib/schema';
|
||||||
import { Section, Container, Heading, Badge, Button } from '@/components/ui';
|
import { Section, Container, Heading, Badge } from '@/components/ui';
|
||||||
import Image from 'next/image';
|
import Image from 'next/image';
|
||||||
import Reveal from '@/components/Reveal';
|
import Reveal from '@/components/Reveal';
|
||||||
import Gallery from '@/components/team/Gallery';
|
import Gallery from '@/components/team/Gallery';
|
||||||
@@ -122,12 +122,12 @@ export default async function TeamPage({ params }: TeamPageProps) {
|
|||||||
<Badge variant="accent" className="mb-4 md:mb-8">
|
<Badge variant="accent" className="mb-4 md:mb-8">
|
||||||
{t('michael.role')}
|
{t('michael.role')}
|
||||||
</Badge>
|
</Badge>
|
||||||
<Heading level={2} className="text-white mb-6 md:mb-10 text-3xl md:text-5xl">
|
<Heading level={2} className="text-white mb-6 md:mb-10 text-2xl md:text-4xl">
|
||||||
<span className="text-white">{t('michael.name')}</span>
|
<span className="text-white">{t('michael.name')}</span>
|
||||||
</Heading>
|
</Heading>
|
||||||
<div className="relative mb-6 md:mb-12">
|
<div className="relative mb-6 md:mb-12">
|
||||||
<div className="absolute -left-4 md:-left-8 top-0 bottom-0 w-1 md:w-1.5 bg-accent rounded-full" />
|
<div className="absolute -left-4 md:-left-8 top-0 bottom-0 w-1 md:w-1.5 bg-accent rounded-full" />
|
||||||
<p className="text-lg md:text-2xl font-bold italic leading-relaxed pl-5 md:pl-8 text-white/90">
|
<p className="text-base md:text-xl font-bold italic leading-relaxed pl-5 md:pl-8 text-white/90">
|
||||||
{t('michael.quote')}
|
{t('michael.quote')}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
@@ -156,6 +156,7 @@ export default async function TeamPage({ params }: TeamPageProps) {
|
|||||||
alt={t('michael.name')}
|
alt={t('michael.name')}
|
||||||
fill
|
fill
|
||||||
className="object-cover scale-105 hover:scale-100 transition-transform duration-1000"
|
className="object-cover scale-105 hover:scale-100 transition-transform duration-1000"
|
||||||
|
quality={100}
|
||||||
sizes="(max-width: 1024px) 100vw, 50vw"
|
sizes="(max-width: 1024px) 100vw, 50vw"
|
||||||
/>
|
/>
|
||||||
<div className="absolute inset-0 bg-gradient-to-t from-primary-dark/60 lg:bg-gradient-to-r lg:from-primary-dark/20 to-transparent" />
|
<div className="absolute inset-0 bg-gradient-to-t from-primary-dark/60 lg:bg-gradient-to-r lg:from-primary-dark/20 to-transparent" />
|
||||||
@@ -225,6 +226,7 @@ export default async function TeamPage({ params }: TeamPageProps) {
|
|||||||
alt={t('klaus.name')}
|
alt={t('klaus.name')}
|
||||||
fill
|
fill
|
||||||
className="object-cover scale-105 hover:scale-100 transition-transform duration-1000"
|
className="object-cover scale-105 hover:scale-100 transition-transform duration-1000"
|
||||||
|
quality={100}
|
||||||
sizes="(max-width: 1024px) 100vw, 50vw"
|
sizes="(max-width: 1024px) 100vw, 50vw"
|
||||||
/>
|
/>
|
||||||
<div className="absolute inset-0 bg-gradient-to-t from-white/60 lg:bg-gradient-to-l lg:from-primary-dark/20 to-transparent" />
|
<div className="absolute inset-0 bg-gradient-to-t from-white/60 lg:bg-gradient-to-l lg:from-primary-dark/20 to-transparent" />
|
||||||
@@ -235,12 +237,12 @@ export default async function TeamPage({ params }: TeamPageProps) {
|
|||||||
<Badge variant="saturated" className="mb-4 md:mb-8">
|
<Badge variant="saturated" className="mb-4 md:mb-8">
|
||||||
{t('klaus.role')}
|
{t('klaus.role')}
|
||||||
</Badge>
|
</Badge>
|
||||||
<Heading level={2} className="text-saturated mb-6 md:mb-10 text-3xl md:text-6xl">
|
<Heading level={2} className="text-saturated mb-6 md:mb-10 text-2xl md:text-4xl">
|
||||||
{t('klaus.name')}
|
{t('klaus.name')}
|
||||||
</Heading>
|
</Heading>
|
||||||
<div className="relative mb-6 md:mb-12">
|
<div className="relative mb-6 md:mb-12">
|
||||||
<div className="absolute -left-4 md:-left-8 top-0 bottom-0 w-1 md:w-1.5 bg-saturated rounded-full" />
|
<div className="absolute -left-4 md:-left-8 top-0 bottom-0 w-1 md:w-1.5 bg-saturated rounded-full" />
|
||||||
<p className="text-lg md:text-3xl font-bold italic leading-relaxed pl-5 md:pl-8 text-text-secondary">
|
<p className="text-base md:text-xl font-bold italic leading-relaxed pl-5 md:pl-8 text-text-secondary">
|
||||||
{t('klaus.quote')}
|
{t('klaus.quote')}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
123
app/actions/brochure.ts
Normal file
123
app/actions/brochure.ts
Normal file
@@ -0,0 +1,123 @@
|
|||||||
|
'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 };
|
||||||
|
}
|
||||||
@@ -25,6 +25,14 @@ export async function sendContactFormAction(formData: FormData) {
|
|||||||
// Track attempt
|
// Track attempt
|
||||||
services.analytics.track('contact-form-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 name = formData.get('name') as string;
|
||||||
const email = formData.get('email') as string;
|
const email = formData.get('email') as string;
|
||||||
const message = formData.get('message') as string;
|
const message = formData.get('message') as string;
|
||||||
@@ -54,6 +62,7 @@ export async function sendContactFormAction(formData: FormData) {
|
|||||||
type: productName ? 'product_quote' : 'contact',
|
type: productName ? 'product_quote' : 'contact',
|
||||||
productName: productName || undefined,
|
productName: productName || undefined,
|
||||||
},
|
},
|
||||||
|
overrideAccess: true,
|
||||||
});
|
});
|
||||||
|
|
||||||
logger.info('Successfully saved form submission to Payload CMS', {
|
logger.info('Successfully saved form submission to Payload CMS', {
|
||||||
@@ -72,6 +81,7 @@ export async function sendContactFormAction(formData: FormData) {
|
|||||||
? `Product Inquiry: ${productName}`
|
? `Product Inquiry: ${productName}`
|
||||||
: 'New Contact Form Submission';
|
: 'New Contact Form Submission';
|
||||||
const confirmationSubject = 'Thank you for your inquiry';
|
const confirmationSubject = 'Thank you for your inquiry';
|
||||||
|
const isTestSubmission = email === 'testing@mintel.me';
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// 2a. Send notification to Mintel/Client
|
// 2a. Send notification to Mintel/Client
|
||||||
@@ -84,26 +94,30 @@ export async function sendContactFormAction(formData: FormData) {
|
|||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
|
|
||||||
const notificationResult = await sendEmail({
|
if (!isTestSubmission) {
|
||||||
replyTo: email,
|
const notificationResult = await sendEmail({
|
||||||
subject: notificationSubject,
|
replyTo: email,
|
||||||
html: notificationHtml,
|
|
||||||
});
|
|
||||||
|
|
||||||
if (notificationResult.success) {
|
|
||||||
logger.info('Notification email sent successfully', {
|
|
||||||
messageId: notificationResult.messageId,
|
|
||||||
});
|
|
||||||
} else {
|
|
||||||
logger.error('Notification email FAILED', {
|
|
||||||
error: notificationResult.error,
|
|
||||||
subject: notificationSubject,
|
subject: notificationSubject,
|
||||||
email,
|
html: notificationHtml,
|
||||||
});
|
});
|
||||||
services.errors.captureException(
|
|
||||||
new Error(`Notification email failed: ${notificationResult.error}`),
|
if (notificationResult.success) {
|
||||||
{ action: 'sendContactFormAction_notification', email },
|
logger.info('Notification email sent successfully', {
|
||||||
);
|
messageId: notificationResult.messageId,
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
logger.error('Notification email FAILED', {
|
||||||
|
error: notificationResult.error,
|
||||||
|
subject: notificationSubject,
|
||||||
|
email,
|
||||||
|
});
|
||||||
|
services.errors.captureException(
|
||||||
|
new Error(`Notification email failed: ${notificationResult.error}`),
|
||||||
|
{ action: 'sendContactFormAction_notification', email },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
logger.info('Skipping notification email for test submission', { email });
|
||||||
}
|
}
|
||||||
|
|
||||||
// 2b. Send confirmation to Customer (branded as KLZ Cables)
|
// 2b. Send confirmation to Customer (branded as KLZ Cables)
|
||||||
@@ -115,26 +129,30 @@ export async function sendContactFormAction(formData: FormData) {
|
|||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
|
|
||||||
const confirmationResult = await sendEmail({
|
if (!isTestSubmission) {
|
||||||
to: email,
|
const confirmationResult = await sendEmail({
|
||||||
subject: confirmationSubject,
|
|
||||||
html: confirmationHtml,
|
|
||||||
});
|
|
||||||
|
|
||||||
if (confirmationResult.success) {
|
|
||||||
logger.info('Confirmation email sent successfully', {
|
|
||||||
messageId: confirmationResult.messageId,
|
|
||||||
});
|
|
||||||
} else {
|
|
||||||
logger.error('Confirmation email FAILED', {
|
|
||||||
error: confirmationResult.error,
|
|
||||||
subject: confirmationSubject,
|
|
||||||
to: email,
|
to: email,
|
||||||
|
subject: confirmationSubject,
|
||||||
|
html: confirmationHtml,
|
||||||
});
|
});
|
||||||
services.errors.captureException(
|
|
||||||
new Error(`Confirmation email failed: ${confirmationResult.error}`),
|
if (confirmationResult.success) {
|
||||||
{ action: 'sendContactFormAction_confirmation', email },
|
logger.info('Confirmation email sent successfully', {
|
||||||
);
|
messageId: confirmationResult.messageId,
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
logger.error('Confirmation email FAILED', {
|
||||||
|
error: confirmationResult.error,
|
||||||
|
subject: confirmationSubject,
|
||||||
|
to: email,
|
||||||
|
});
|
||||||
|
services.errors.captureException(
|
||||||
|
new Error(`Confirmation email failed: ${confirmationResult.error}`),
|
||||||
|
{ action: 'sendContactFormAction_confirmation', email },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
logger.info('Skipping confirmation email for test submission', { email });
|
||||||
}
|
}
|
||||||
|
|
||||||
// Notify via Gotify (Internal)
|
// Notify via Gotify (Internal)
|
||||||
|
|||||||
@@ -16,6 +16,14 @@ export async function GET() {
|
|||||||
const payload = await getPayload({ config: configPromise });
|
const payload = await getPayload({ config: configPromise });
|
||||||
checks.init = 'ok';
|
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)
|
// Verify each collection can be queried (catches missing locale tables, broken migrations)
|
||||||
const collections = ['posts', 'products', 'pages', 'media'] as const;
|
const collections = ['posts', 'products', 'pages', 'media'] as const;
|
||||||
for (const collection of collections) {
|
for (const collection of collections) {
|
||||||
@@ -27,7 +35,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(
|
return NextResponse.json(
|
||||||
{ status: hasErrors ? 'degraded' : 'ok', checks },
|
{ status: hasErrors ? 'degraded' : 'ok', checks },
|
||||||
{ status: hasErrors ? 503 : 200 },
|
{ status: hasErrors ? 503 : 200 },
|
||||||
|
|||||||
64
app/api/pages/[slug]/pdf/route.tsx
Normal file
64
app/api/pages/[slug]/pdf/route.tsx
Normal file
@@ -0,0 +1,64 @@
|
|||||||
|
import { NextRequest, NextResponse } from 'next/server';
|
||||||
|
import { getPayload } from 'payload';
|
||||||
|
import configPromise from '@payload-config';
|
||||||
|
import { renderToStream } from '@react-pdf/renderer';
|
||||||
|
import React from 'react';
|
||||||
|
import { PDFPage } from '@/lib/pdf-page';
|
||||||
|
|
||||||
|
export async function GET(req: NextRequest, { params }: { params: Promise<{ slug: string }> }) {
|
||||||
|
try {
|
||||||
|
const { slug } = await params;
|
||||||
|
|
||||||
|
// Get Payload App
|
||||||
|
const payload = await getPayload({ config: configPromise });
|
||||||
|
|
||||||
|
// Fetch the page
|
||||||
|
const pages = await payload.find({
|
||||||
|
collection: 'pages',
|
||||||
|
where: {
|
||||||
|
slug: { equals: slug },
|
||||||
|
_status: { equals: 'published' },
|
||||||
|
},
|
||||||
|
limit: 1,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (pages.totalDocs === 0) {
|
||||||
|
return new NextResponse('Page not found', { status: 404 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const page = pages.docs[0];
|
||||||
|
|
||||||
|
// Determine locale from searchParams or default to 'de'
|
||||||
|
const searchParams = req.nextUrl.searchParams;
|
||||||
|
const locale = (searchParams.get('locale') as 'en' | 'de') || 'de';
|
||||||
|
|
||||||
|
// Render the React-PDF document into a stream
|
||||||
|
const stream = await renderToStream(<PDFPage page={page} locale={locale} />);
|
||||||
|
|
||||||
|
// Pipe the Node.js Readable stream into a valid fetch/Web Response stream
|
||||||
|
const body = new ReadableStream({
|
||||||
|
start(controller) {
|
||||||
|
stream.on('data', (chunk) => controller.enqueue(chunk));
|
||||||
|
stream.on('end', () => controller.close());
|
||||||
|
stream.on('error', (err) => controller.error(err));
|
||||||
|
},
|
||||||
|
cancel() {
|
||||||
|
(stream as any).destroy?.();
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const filename = `${slug}.pdf`;
|
||||||
|
|
||||||
|
return new NextResponse(body, {
|
||||||
|
status: 200,
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/pdf',
|
||||||
|
'Content-Disposition': `attachment; filename="${filename}"`,
|
||||||
|
// Cache control if needed, skip for now.
|
||||||
|
},
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error generating PDF:', error);
|
||||||
|
return new NextResponse('Internal Server Error', { status: 500 });
|
||||||
|
}
|
||||||
|
}
|
||||||
28
components/AutoBrochureModal.tsx
Normal file
28
components/AutoBrochureModal.tsx
Normal file
@@ -0,0 +1,28 @@
|
|||||||
|
'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)} />;
|
||||||
|
}
|
||||||
88
components/BrochureCTA.tsx
Normal file
88
components/BrochureCTA.tsx
Normal file
@@ -0,0 +1,88 @@
|
|||||||
|
'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)} />
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
256
components/BrochureModal.tsx
Normal file
256
components/BrochureModal.tsx
Normal file
@@ -0,0 +1,256 @@
|
|||||||
|
'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);
|
||||||
|
}
|
||||||
@@ -15,10 +15,12 @@ export default function CMSConnectivityNotice() {
|
|||||||
const isDebug = new URLSearchParams(window.location.search).has('cms_debug');
|
const isDebug = new URLSearchParams(window.location.search).has('cms_debug');
|
||||||
const isLocal = config.isDevelopment;
|
const isLocal = config.isDevelopment;
|
||||||
const isTesting = config.isTesting;
|
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 or Testing)
|
// Only proceed with check if it's developer context (Local, Testing, or Branch preview)
|
||||||
// Staging and Production should NEVER see this unless forced with ?cms_debug
|
// Staging and Production should NEVER see this unless forced with ?cms_debug
|
||||||
if (!isLocal && !isTesting && !isDebug) return;
|
if (!isLocal && !isTesting && !isBranch && !isDebug) return;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const response = await fetch('/api/health/cms');
|
const response = await fetch('/api/health/cms');
|
||||||
@@ -58,8 +60,8 @@ export default function CMSConnectivityNotice() {
|
|||||||
<h4 className="font-bold text-sm mb-1">CMS Issue Detected</h4>
|
<h4 className="font-bold text-sm mb-1">CMS Issue Detected</h4>
|
||||||
<p className="text-xs opacity-90 leading-relaxed mb-3">
|
<p className="text-xs opacity-90 leading-relaxed mb-3">
|
||||||
{errorMsg === 'relation "products" does not exist'
|
{errorMsg === 'relation "products" does not exist'
|
||||||
? 'The database schema is missing. Please sync your local data to this environment.'
|
? 'The database schema is missing. Please run migrations for this environment.'
|
||||||
: errorMsg || 'The application cannot connect to the Directus CMS.'}
|
: 'A content service is unavailable. Check the deployment logs for details.'}
|
||||||
</p>
|
</p>
|
||||||
<div className="flex gap-2">
|
<div className="flex gap-2">
|
||||||
<button
|
<button
|
||||||
|
|||||||
@@ -138,7 +138,20 @@ export default function ContactForm() {
|
|||||||
<Heading level={3} subtitle={t('form.subtitle')} className="mb-6 md:mb-10">
|
<Heading level={3} subtitle={t('form.subtitle')} className="mb-6 md:mb-10">
|
||||||
{t('form.title')}
|
{t('form.title')}
|
||||||
</Heading>
|
</Heading>
|
||||||
<form onSubmit={handleSubmit} className="grid grid-cols-1 md:grid-cols-2 gap-4 md:gap-8">
|
<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"
|
||||||
|
/>
|
||||||
<div className="space-y-1 md:space-y-2">
|
<div className="space-y-1 md:space-y-2">
|
||||||
<Label htmlFor="contact-name">{t('form.name')}</Label>
|
<Label htmlFor="contact-name">{t('form.name')}</Label>
|
||||||
<Input
|
<Input
|
||||||
|
|||||||
@@ -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" />
|
<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 */}
|
{/* Inner Content */}
|
||||||
<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">
|
<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 */}
|
{/* Icon Container */}
|
||||||
<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="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="absolute inset-0 rounded-2xl bg-accent/20 blur-xl opacity-0 group-hover:opacity-100 transition-opacity 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
|
<svg
|
||||||
className="relative h-8 w-8 text-white transition-transform duration-500 group-hover:scale-110 group-hover:rotate-3"
|
className="relative h-7 w-7 text-white transition-transform duration-500 group-hover:scale-110 group-hover:rotate-3"
|
||||||
fill="none"
|
fill="none"
|
||||||
stroke="currentColor"
|
stroke="currentColor"
|
||||||
viewBox="0 0 24 24"
|
viewBox="0 0 24 24"
|
||||||
@@ -54,13 +54,13 @@ export default function DatasheetDownload({ datasheetPath, className }: Datashee
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Text Content */}
|
{/* Text Content */}
|
||||||
<div className="flex-1">
|
<div className="flex-1 min-w-0">
|
||||||
<div className="flex items-center gap-2 mb-1">
|
<div className="flex items-center gap-2 mb-1">
|
||||||
<span className="text-[10px] font-black uppercase tracking-[0.2em] text-accent">
|
<span className="text-[10px] font-black uppercase tracking-[0.2em] text-accent">
|
||||||
PDF Datasheet
|
PDF Datasheet
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<h3 className="text-xl md:text-2xl font-black text-white uppercase tracking-tighter leading-none group-hover:text-accent transition-colors duration-300">
|
<h3 className="text-xl font-black text-white uppercase tracking-tighter leading-none group-hover:text-accent transition-colors duration-300">
|
||||||
{t('downloadDatasheet')}
|
{t('downloadDatasheet')}
|
||||||
</h3>
|
</h3>
|
||||||
<p className="mt-2 text-sm font-medium text-white/60 leading-relaxed">
|
<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>
|
</div>
|
||||||
|
|
||||||
{/* Arrow Icon */}
|
{/* Arrow Icon */}
|
||||||
<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">
|
<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">
|
||||||
<svg
|
<svg
|
||||||
className="h-6 w-6"
|
className="h-5 w-5"
|
||||||
fill="none"
|
fill="none"
|
||||||
stroke="currentColor"
|
stroke="currentColor"
|
||||||
viewBox="0 0 24 24"
|
viewBox="0 0 24 24"
|
||||||
|
|||||||
94
components/ExcelDownload.tsx
Normal file
94
components/ExcelDownload.tsx
Normal file
@@ -0,0 +1,94 @@
|
|||||||
|
'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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -6,6 +6,7 @@ import { useTranslations, useLocale } from 'next-intl';
|
|||||||
import { Container } from './ui';
|
import { Container } from './ui';
|
||||||
import { useAnalytics } from './analytics/useAnalytics';
|
import { useAnalytics } from './analytics/useAnalytics';
|
||||||
import { AnalyticsEvents } from './analytics/analytics-events';
|
import { AnalyticsEvents } from './analytics/analytics-events';
|
||||||
|
import FooterBrochureForm from './FooterBrochureForm';
|
||||||
|
|
||||||
export default function Footer() {
|
export default function Footer() {
|
||||||
const t = useTranslations('Footer');
|
const t = useTranslations('Footer');
|
||||||
@@ -15,14 +16,14 @@ export default function Footer() {
|
|||||||
const currentYear = new Date().getFullYear();
|
const currentYear = new Date().getFullYear();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<footer className="bg-primary text-white py-24 relative overflow-hidden content-visibility-auto">
|
<footer className="bg-primary text-white py-14 md:py-24 relative overflow-hidden content-visibility-auto">
|
||||||
<div className="absolute top-0 left-0 w-full h-px bg-gradient-to-r from-transparent via-white/20 to-transparent" />
|
<div className="absolute top-0 left-0 w-full h-px bg-gradient-to-r from-transparent via-white/20 to-transparent" />
|
||||||
|
|
||||||
<Container>
|
<Container>
|
||||||
<h2 className="sr-only">Footer Navigation</h2>
|
<h2 className="sr-only">Footer Navigation</h2>
|
||||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-12 gap-16 mb-20">
|
<div className="grid grid-cols-2 md:grid-cols-2 lg:grid-cols-12 gap-10 md:gap-16 mb-12 md:mb-20">
|
||||||
{/* Brand Column */}
|
{/* Brand Column – full width on mobile */}
|
||||||
<div className="lg:col-span-4 space-y-8">
|
<div className="col-span-2 md:col-span-2 lg:col-span-4 space-y-6 md:space-y-8">
|
||||||
<Link
|
<Link
|
||||||
href={`/${locale}`}
|
href={`/${locale}`}
|
||||||
className="inline-block group"
|
className="inline-block group"
|
||||||
@@ -67,9 +68,9 @@ export default function Footer() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Links Columns */}
|
{/* Legal Column */}
|
||||||
<div className="lg:col-span-2">
|
<div className="col-span-1 lg:col-span-2">
|
||||||
<h3 className="text-accent font-bold uppercase tracking-widest text-xs md:text-sm mb-8">
|
<h3 className="text-accent font-bold uppercase tracking-widest text-xs mb-5 md:mb-8">
|
||||||
{t('legal')}
|
{t('legal')}
|
||||||
</h3>
|
</h3>
|
||||||
<ul className="space-y-4 text-white/70 list-none m-0 p-0">
|
<ul className="space-y-4 text-white/70 list-none m-0 p-0">
|
||||||
@@ -121,8 +122,9 @@ export default function Footer() {
|
|||||||
</ul>
|
</ul>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="lg:col-span-2">
|
{/* Company Column */}
|
||||||
<h3 className="text-accent font-bold uppercase tracking-widest text-xs md:text-sm mb-8">
|
<div className="col-span-1 lg:col-span-2">
|
||||||
|
<h3 className="text-accent font-bold uppercase tracking-widest text-xs mb-5 md:mb-8">
|
||||||
{t('company')}
|
{t('company')}
|
||||||
</h3>
|
</h3>
|
||||||
<ul className="space-y-4 text-white/70 list-none m-0 p-0">
|
<ul className="space-y-4 text-white/70 list-none m-0 p-0">
|
||||||
@@ -189,9 +191,9 @@ export default function Footer() {
|
|||||||
</ul>
|
</ul>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Recent Posts Column */}
|
{/* Recent Posts Column – full width on mobile */}
|
||||||
<div className="lg:col-span-4">
|
<div className="col-span-2 md:col-span-2 lg:col-span-4">
|
||||||
<h3 className="text-accent font-bold uppercase tracking-widest text-xs md:text-sm mb-8">
|
<h3 className="text-accent font-bold uppercase tracking-widest text-xs mb-5 md:mb-8">
|
||||||
{t('recentPosts')}
|
{t('recentPosts')}
|
||||||
</h3>
|
</h3>
|
||||||
<ul className="space-y-6 list-none m-0 p-0">
|
<ul className="space-y-6 list-none m-0 p-0">
|
||||||
@@ -242,7 +244,11 @@ export default function Footer() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="pt-12 border-t border-white/10 flex flex-col md:flex-row justify-between items-center gap-8 text-white/70 text-xs md:text-sm font-medium">
|
<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>
|
<p>{t('copyright', { year: currentYear })}</p>
|
||||||
<div className="flex gap-8">
|
<div className="flex gap-8">
|
||||||
<Link
|
<Link
|
||||||
|
|||||||
134
components/FooterBrochureForm.tsx
Normal file
134
components/FooterBrochureForm.tsx
Normal file
@@ -0,0 +1,134 @@
|
|||||||
|
'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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -36,6 +36,7 @@ export default function Header() {
|
|||||||
// Prevent scroll when mobile menu is open and handle focus trap
|
// Prevent scroll when mobile menu is open and handle focus trap
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (isMobileMenuOpen) {
|
if (isMobileMenuOpen) {
|
||||||
|
document.documentElement.style.overflow = 'hidden';
|
||||||
document.body.style.overflow = 'hidden';
|
document.body.style.overflow = 'hidden';
|
||||||
// Focus trap logic
|
// Focus trap logic
|
||||||
const focusableElements = mobileMenuRef.current?.querySelectorAll(
|
const focusableElements = mobileMenuRef.current?.querySelectorAll(
|
||||||
@@ -80,7 +81,8 @@ export default function Header() {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
document.body.style.overflow = 'unset';
|
document.documentElement.style.overflow = '';
|
||||||
|
document.body.style.overflow = '';
|
||||||
}
|
}
|
||||||
}, [isMobileMenuOpen]);
|
}, [isMobileMenuOpen]);
|
||||||
|
|
||||||
@@ -141,7 +143,8 @@ export default function Header() {
|
|||||||
{
|
{
|
||||||
'bg-primary/95 backdrop-blur-md md:bg-transparent py-3 md:py-8 shadow-2xl md:shadow-none':
|
'bg-primary/95 backdrop-blur-md md:bg-transparent py-3 md:py-8 shadow-2xl md:shadow-none':
|
||||||
isHomePage && !isScrolled && !isMobileMenuOpen,
|
isHomePage && !isScrolled && !isMobileMenuOpen,
|
||||||
'bg-primary py-3 md:py-4 shadow-2xl': !isHomePage || isScrolled || isMobileMenuOpen,
|
'bg-primary/90 backdrop-blur-md py-3 md:py-4 shadow-2xl':
|
||||||
|
!isHomePage || isScrolled || isMobileMenuOpen,
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -152,9 +155,7 @@ export default function Header() {
|
|||||||
<>
|
<>
|
||||||
<header className={headerClass} style={{ animationDuration: '800ms' }}>
|
<header className={headerClass} style={{ animationDuration: '800ms' }}>
|
||||||
<div className="container mx-auto px-4 md:px-12 lg:px-16 max-w-7xl flex items-center justify-between">
|
<div className="container mx-auto px-4 md:px-12 lg:px-16 max-w-7xl flex items-center justify-between">
|
||||||
<div
|
<div className="flex-shrink-0 group touch-target fill-mode-both">
|
||||||
className="flex-shrink-0 group touch-target fill-mode-both"
|
|
||||||
>
|
|
||||||
<Link
|
<Link
|
||||||
href={`/${currentLocale}`}
|
href={`/${currentLocale}`}
|
||||||
onClick={() =>
|
onClick={() =>
|
||||||
@@ -336,115 +337,138 @@ export default function Header() {
|
|||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
{/* Mobile Menu Overlay */}
|
{/* Mobile Menu Overlay */}
|
||||||
<div
|
<div
|
||||||
className={cn(
|
className={cn(
|
||||||
'fixed inset-0 bg-primary z-[60] lg:hidden transition-all duration-500 ease-in-out flex flex-col',
|
'fixed inset-0 bg-primary/95 backdrop-blur-3xl z-[60] lg:hidden transition-all duration-500 ease-in-out flex flex-col',
|
||||||
isMobileMenuOpen
|
isMobileMenuOpen
|
||||||
? 'opacity-100 translate-y-0'
|
? 'opacity-100 translate-y-0'
|
||||||
: 'opacity-0 -translate-y-full pointer-events-none',
|
: 'opacity-0 -translate-y-full pointer-events-none',
|
||||||
)}
|
)}
|
||||||
id="mobile-menu"
|
id="mobile-menu"
|
||||||
role="dialog"
|
role="dialog"
|
||||||
aria-modal="true"
|
aria-modal="true"
|
||||||
aria-label={t('menu')}
|
aria-label={t('menu')}
|
||||||
ref={mobileMenuRef}
|
ref={mobileMenuRef}
|
||||||
inert={isMobileMenuOpen ? undefined : true}
|
inert={isMobileMenuOpen ? undefined : true}
|
||||||
>
|
>
|
||||||
<nav className="flex-grow flex flex-col justify-center items-center p-8 space-y-8">
|
{/* Close Button inside overlay */}
|
||||||
{menuItems.map((item, idx) => (
|
<div className="flex justify-end p-6 pt-8">
|
||||||
<div
|
<button
|
||||||
key={item.href}
|
className="touch-target p-2 rounded-xl bg-white/10 border border-white/20 text-white hover:bg-white/20 transition-all duration-300"
|
||||||
className={cn(
|
aria-label={t('toggleMenu')}
|
||||||
'transition-all duration-500 transform',
|
onClick={() => {
|
||||||
isMobileMenuOpen ? 'opacity-100 translate-y-0' : 'opacity-0 translate-y-8',
|
setIsMobileMenuOpen(false);
|
||||||
)}
|
trackEvent(AnalyticsEvents.BUTTON_CLICK, {
|
||||||
style={{ transitionDelay: `${isMobileMenuOpen ? 200 + idx * 80 : 0}ms` }}
|
type: 'mobile_menu',
|
||||||
>
|
action: 'close',
|
||||||
<Link
|
});
|
||||||
href={`/${currentLocale}${item.href === '/' ? '' : item.href}`}
|
}}
|
||||||
aria-current={
|
>
|
||||||
(
|
<svg className="w-7 h-7" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
item.href === '/'
|
<path
|
||||||
? pathname === `/${currentLocale}` || pathname === '/'
|
strokeLinecap="round"
|
||||||
: pathname.startsWith(`/${currentLocale}${item.href}`)
|
strokeLinejoin="round"
|
||||||
)
|
strokeWidth={2}
|
||||||
? 'page'
|
d="M6 18L18 6M6 6l12 12"
|
||||||
: undefined
|
/>
|
||||||
}
|
</svg>
|
||||||
onClick={() => {
|
</button>
|
||||||
setIsMobileMenuOpen(false);
|
</div>
|
||||||
trackEvent(AnalyticsEvents.LINK_CLICK, {
|
<nav className="flex-grow flex flex-col justify-center items-center p-8 space-y-8">
|
||||||
label: item.label,
|
{menuItems.map((item, idx) => (
|
||||||
href: item.href,
|
|
||||||
location: 'mobile_menu',
|
|
||||||
});
|
|
||||||
}}
|
|
||||||
className={cn(
|
|
||||||
'text-2xl md:text-3xl font-extrabold text-white hover:text-accent transition-all transform block py-4',
|
|
||||||
(item.href === '/'
|
|
||||||
? pathname === `/${currentLocale}` || pathname === '/'
|
|
||||||
: pathname.startsWith(`/${currentLocale}${item.href}`)) && 'text-accent',
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
{item.label}
|
|
||||||
</Link>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
|
|
||||||
<div
|
<div
|
||||||
|
key={item.href}
|
||||||
className={cn(
|
className={cn(
|
||||||
'pt-8 border-t border-white/10 w-full flex flex-col items-center space-y-8 transition-all duration-500',
|
'transition-all duration-500 transform',
|
||||||
isMobileMenuOpen ? 'opacity-100 translate-y-0' : 'opacity-0 translate-y-8',
|
isMobileMenuOpen ? 'opacity-100 translate-y-0' : 'opacity-0 translate-y-8',
|
||||||
)}
|
)}
|
||||||
style={{ transitionDelay: isMobileMenuOpen ? '600ms' : '0ms' }}
|
style={{ transitionDelay: `${isMobileMenuOpen ? 200 + idx * 80 : 0}ms` }}
|
||||||
>
|
>
|
||||||
<div className="flex items-center space-x-8 text-lg md:text-xl font-extrabold tracking-widest uppercase text-white">
|
<Link
|
||||||
<div>
|
href={`/${currentLocale}${item.href === '/' ? '' : item.href}`}
|
||||||
<Link
|
aria-current={
|
||||||
href={getPathForLocale('en')}
|
(
|
||||||
className={`hover:text-accent transition-colors ${currentLocale === 'en' ? 'text-accent' : 'opacity-80'}`}
|
item.href === '/'
|
||||||
>
|
? pathname === `/${currentLocale}` || pathname === '/'
|
||||||
EN
|
: pathname.startsWith(`/${currentLocale}${item.href}`)
|
||||||
</Link>
|
)
|
||||||
</div>
|
? 'page'
|
||||||
<div className="w-px h-6 bg-white/30" />
|
: undefined
|
||||||
<div>
|
}
|
||||||
<Link
|
onClick={() => {
|
||||||
href={getPathForLocale('de')}
|
setIsMobileMenuOpen(false);
|
||||||
className={`hover:text-accent transition-colors ${currentLocale === 'de' ? 'text-accent' : 'opacity-80'}`}
|
trackEvent(AnalyticsEvents.LINK_CLICK, {
|
||||||
>
|
label: item.label,
|
||||||
DE
|
href: item.href,
|
||||||
</Link>
|
location: 'mobile_menu',
|
||||||
</div>
|
});
|
||||||
</div>
|
}}
|
||||||
|
className={cn(
|
||||||
|
'text-2xl md:text-3xl font-extrabold text-white hover:text-accent transition-all transform block py-4',
|
||||||
|
(item.href === '/'
|
||||||
|
? pathname === `/${currentLocale}` || pathname === '/'
|
||||||
|
: pathname.startsWith(`/${currentLocale}${item.href}`)) && 'text-accent',
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{item.label}
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
|
||||||
<div className="w-full max-w-xs">
|
<div
|
||||||
<Button
|
className={cn(
|
||||||
href={`/${currentLocale}/${currentLocale === 'de' ? 'kontakt' : 'contact'}`}
|
'pt-8 border-t border-white/10 w-full flex flex-col items-center space-y-8 transition-all duration-500',
|
||||||
variant="accent"
|
isMobileMenuOpen ? 'opacity-100 translate-y-0' : 'opacity-0 translate-y-8',
|
||||||
size="lg"
|
)}
|
||||||
className="w-full py-6 text-lg md:text-xl shadow-2xl hover:scale-105 transition-transform"
|
style={{ transitionDelay: isMobileMenuOpen ? '600ms' : '0ms' }}
|
||||||
|
>
|
||||||
|
<div className="flex items-center space-x-8 text-lg md:text-xl font-extrabold tracking-widest uppercase text-white">
|
||||||
|
<div>
|
||||||
|
<Link
|
||||||
|
href={getPathForLocale('en')}
|
||||||
|
className={`hover:text-accent transition-colors ${currentLocale === 'en' ? 'text-accent' : 'opacity-80'}`}
|
||||||
>
|
>
|
||||||
{t('contact')}
|
EN
|
||||||
</Button>
|
</Link>
|
||||||
|
</div>
|
||||||
|
<div className="w-px h-6 bg-white/30" />
|
||||||
|
<div>
|
||||||
|
<Link
|
||||||
|
href={getPathForLocale('de')}
|
||||||
|
className={`hover:text-accent transition-colors ${currentLocale === 'de' ? 'text-accent' : 'opacity-80'}`}
|
||||||
|
>
|
||||||
|
DE
|
||||||
|
</Link>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Bottom Branding */}
|
<div className="w-full max-w-xs">
|
||||||
<div
|
<Button
|
||||||
className={cn(
|
href={`/${currentLocale}/${currentLocale === 'de' ? 'kontakt' : 'contact'}`}
|
||||||
'p-12 flex justify-center transition-all duration-700',
|
variant="accent"
|
||||||
isMobileMenuOpen ? 'opacity-20 scale-100' : 'opacity-0 scale-75',
|
size="lg"
|
||||||
)}
|
className="w-full py-6 text-lg md:text-xl shadow-2xl hover:scale-105 transition-transform"
|
||||||
style={{ transitionDelay: isMobileMenuOpen ? '800ms' : '0ms' }}
|
>
|
||||||
>
|
{t('contact')}
|
||||||
<Image src="/logo-white.svg" alt={t('home')} width={80} height={80} unoptimized />
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</nav>
|
</div>
|
||||||
</div>
|
|
||||||
</header>
|
{/* Bottom Branding */}
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
'p-12 flex justify-center transition-all duration-700',
|
||||||
|
isMobileMenuOpen ? 'opacity-20 scale-100' : 'opacity-0 scale-75',
|
||||||
|
)}
|
||||||
|
style={{ transitionDelay: isMobileMenuOpen ? '800ms' : '0ms' }}
|
||||||
|
>
|
||||||
|
<Image src="/logo-white.svg" alt={t('home')} width={80} height={80} unoptimized />
|
||||||
|
</div>
|
||||||
|
</nav>
|
||||||
|
</div>
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -23,7 +23,7 @@ export default function Lightbox({ isOpen, images, initialIndex, onClose }: Ligh
|
|||||||
const previousFocusRef = useRef<HTMLElement | null>(null);
|
const previousFocusRef = useRef<HTMLElement | null>(null);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
setMounted(true); // eslint-disable-line react-hooks/set-state-in-effect
|
setMounted(true);
|
||||||
return () => setMounted(false);
|
return () => setMounted(false);
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
@@ -61,7 +61,7 @@ export default function Lightbox({ isOpen, images, initialIndex, onClose }: Ligh
|
|||||||
if (photoParam !== null) {
|
if (photoParam !== null) {
|
||||||
const index = parseInt(photoParam, 10);
|
const index = parseInt(photoParam, 10);
|
||||||
if (!isNaN(index) && index >= 0 && index < images.length) {
|
if (!isNaN(index) && index >= 0 && index < images.length) {
|
||||||
setCurrentIndex(index); // eslint-disable-line react-hooks/set-state-in-effect
|
setCurrentIndex(index);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}, [searchParams, images.length]);
|
}, [searchParams, images.length]);
|
||||||
@@ -125,13 +125,17 @@ export default function Lightbox({ isOpen, images, initialIndex, onClose }: Ligh
|
|||||||
};
|
};
|
||||||
|
|
||||||
// Lock scroll
|
// Lock scroll
|
||||||
const originalStyle = window.getComputedStyle(document.body).overflow;
|
const originalBodyStyle = window.getComputedStyle(document.body).overflow;
|
||||||
document.body.style.overflow = 'hidden';
|
const originalHtmlStyle = window.getComputedStyle(document.documentElement).overflow;
|
||||||
|
|
||||||
|
document.documentElement.style.setProperty('overflow', 'hidden', 'important');
|
||||||
|
document.body.style.setProperty('overflow', 'hidden', 'important');
|
||||||
|
|
||||||
window.addEventListener('keydown', handleKeyDown);
|
window.addEventListener('keydown', handleKeyDown);
|
||||||
|
|
||||||
return () => {
|
return () => {
|
||||||
document.body.style.overflow = originalStyle;
|
document.documentElement.style.overflow = originalHtmlStyle;
|
||||||
|
document.body.style.overflow = originalBodyStyle;
|
||||||
window.removeEventListener('keydown', handleKeyDown);
|
window.removeEventListener('keydown', handleKeyDown);
|
||||||
};
|
};
|
||||||
}, [isOpen, prevImage, nextImage, handleClose]);
|
}, [isOpen, prevImage, nextImage, handleClose]);
|
||||||
@@ -139,7 +143,7 @@ export default function Lightbox({ isOpen, images, initialIndex, onClose }: Ligh
|
|||||||
if (!mounted) return null;
|
if (!mounted) return null;
|
||||||
|
|
||||||
return createPortal(
|
return createPortal(
|
||||||
<LazyMotion strict features={() => import('@/lib/framer-features').then(res => res.default)}>
|
<LazyMotion strict features={() => import('@/lib/framer-features').then((res) => res.default)}>
|
||||||
<AnimatePresence>
|
<AnimatePresence>
|
||||||
{isOpen && (
|
{isOpen && (
|
||||||
<div
|
<div
|
||||||
|
|||||||
39
components/ObfuscatedEmail.tsx
Normal file
39
components/ObfuscatedEmail.tsx
Normal file
@@ -0,0 +1,39 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import React, { useState, useEffect } from 'react';
|
||||||
|
|
||||||
|
interface ObfuscatedEmailProps {
|
||||||
|
email: string;
|
||||||
|
className?: string;
|
||||||
|
children?: React.ReactNode;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A component that helps protect email addresses from simple spambots.
|
||||||
|
* It uses client-side mounting to render the actual email address,
|
||||||
|
* making it harder for static crawlers to harvest.
|
||||||
|
*/
|
||||||
|
export default function ObfuscatedEmail({ email, className = '', children }: ObfuscatedEmailProps) {
|
||||||
|
const [mounted, setMounted] = useState(false);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
// eslint-disable-next-line react-hooks/set-state-in-effect
|
||||||
|
setMounted(true);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
if (!mounted) {
|
||||||
|
// Show a placeholder or obscured version during SSR
|
||||||
|
return (
|
||||||
|
<span className={className} aria-hidden="true">
|
||||||
|
{children || email.replace('@', ' [at] ').replace(/\./g, ' [dot] ')}
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Once mounted on the client, render the real mailto link
|
||||||
|
return (
|
||||||
|
<a href={`mailto:${email}`} className={className}>
|
||||||
|
{children || email}
|
||||||
|
</a>
|
||||||
|
);
|
||||||
|
}
|
||||||
42
components/ObfuscatedPhone.tsx
Normal file
42
components/ObfuscatedPhone.tsx
Normal file
@@ -0,0 +1,42 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import React, { useState, useEffect } from 'react';
|
||||||
|
|
||||||
|
interface ObfuscatedPhoneProps {
|
||||||
|
phone: string;
|
||||||
|
className?: string;
|
||||||
|
children?: React.ReactNode;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A component that helps protect phone numbers from simple spambots.
|
||||||
|
* It stays obscured during SSR and hydrates into a functional tel: link on the client.
|
||||||
|
*/
|
||||||
|
export default function ObfuscatedPhone({ phone, className = '', children }: ObfuscatedPhoneProps) {
|
||||||
|
const [mounted, setMounted] = useState(false);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
// eslint-disable-next-line react-hooks/set-state-in-effect
|
||||||
|
setMounted(true);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// Format phone number for tel: link (remove spaces, etc.)
|
||||||
|
const telLink = `tel:${phone.replace(/\s+/g, '')}`;
|
||||||
|
|
||||||
|
if (!mounted) {
|
||||||
|
// Show a placeholder or obscured version during SSR
|
||||||
|
// e.g. +49 881 925 [at] 37298
|
||||||
|
const obscured = phone.replace(/(\d{3})(\d{3})$/, ' $1...$2');
|
||||||
|
return (
|
||||||
|
<span className={className} aria-hidden="true">
|
||||||
|
{children || obscured}
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<a href={telLink} className={className}>
|
||||||
|
{children || phone}
|
||||||
|
</a>
|
||||||
|
);
|
||||||
|
}
|
||||||
34
components/PDFDownloadBlock.tsx
Normal file
34
components/PDFDownloadBlock.tsx
Normal file
@@ -0,0 +1,34 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import React from 'react';
|
||||||
|
import { usePathname } from 'next/navigation';
|
||||||
|
|
||||||
|
export const PDFDownloadBlock: React.FC<{ label: string; style: string }> = ({ label, style }) => {
|
||||||
|
const pathname = usePathname();
|
||||||
|
|
||||||
|
// Extract slug from pathname
|
||||||
|
const segments = pathname.split('/').filter(Boolean);
|
||||||
|
// Pathname is usually /[locale]/[slug] or /[locale]/products/[slug]
|
||||||
|
// We want the page slug.
|
||||||
|
const slug = segments[segments.length - 1] || 'home';
|
||||||
|
|
||||||
|
const href = `/api/pages/${slug}/pdf`;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="my-8">
|
||||||
|
<a
|
||||||
|
href={href}
|
||||||
|
className={`inline-flex items-center px-8 py-3.5 font-bold rounded-full transition-all duration-300 shadow-lg hover:shadow-xl group ${
|
||||||
|
style === 'primary'
|
||||||
|
? 'bg-primary text-white hover:bg-primary-dark'
|
||||||
|
: style === 'secondary'
|
||||||
|
? 'bg-accent text-primary-dark hover:bg-neutral-light'
|
||||||
|
: 'border-2 border-primary text-primary hover:bg-primary hover:text-white'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<span className="mr-3 transition-transform group-hover:scale-12 bit-bounce">📄</span>
|
||||||
|
{label}
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -1,7 +1,7 @@
|
|||||||
import { defaultJSXConverters, RichText } from '@payloadcms/richtext-lexical/react';
|
import { defaultJSXConverters, RichText } from '@payloadcms/richtext-lexical/react';
|
||||||
import type { JSXConverters } from '@payloadcms/richtext-lexical/react';
|
import type { JSXConverters } from '@payloadcms/richtext-lexical/react';
|
||||||
import Image from 'next/image';
|
import Image from 'next/image';
|
||||||
import { Suspense } from 'react';
|
import { Suspense, Fragment } from 'react';
|
||||||
|
|
||||||
// Import all custom React components that were previously mapped via Markdown
|
// Import all custom React components that were previously mapped via Markdown
|
||||||
import StickyNarrative from '@/components/blog/StickyNarrative';
|
import StickyNarrative from '@/components/blog/StickyNarrative';
|
||||||
@@ -24,6 +24,8 @@ import Reveal from '@/components/Reveal';
|
|||||||
import { Badge, Container, Heading, Section, Card } from '@/components/ui';
|
import { Badge, Container, Heading, Section, Card } from '@/components/ui';
|
||||||
import TrackedLink from '@/components/analytics/TrackedLink';
|
import TrackedLink from '@/components/analytics/TrackedLink';
|
||||||
import { useLocale } from 'next-intl';
|
import { useLocale } from 'next-intl';
|
||||||
|
import ObfuscatedEmail from '@/components/ObfuscatedEmail';
|
||||||
|
import ObfuscatedPhone from '@/components/ObfuscatedPhone';
|
||||||
|
|
||||||
import HomeHero from '@/components/home/Hero';
|
import HomeHero from '@/components/home/Hero';
|
||||||
import ProductCategories from '@/components/home/ProductCategories';
|
import ProductCategories from '@/components/home/ProductCategories';
|
||||||
@@ -35,123 +37,180 @@ import MeetTheTeam from '@/components/home/MeetTheTeam';
|
|||||||
import GallerySection from '@/components/home/GallerySection';
|
import GallerySection from '@/components/home/GallerySection';
|
||||||
import VideoSection from '@/components/home/VideoSection';
|
import VideoSection from '@/components/home/VideoSection';
|
||||||
import CTA from '@/components/home/CTA';
|
import CTA from '@/components/home/CTA';
|
||||||
|
import { PDFDownloadBlock } from '@/components/PDFDownloadBlock';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Splits a text string on \n and intersperses <br /> elements.
|
||||||
|
* This is needed because Lexical stores newlines as literal \n characters inside
|
||||||
|
* text nodes (e.g. dash-lists typed in the editor), but HTML collapses whitespace.
|
||||||
|
*/
|
||||||
|
function textWithLineBreaks(text: string, key: string) {
|
||||||
|
const parts = text.split('\n');
|
||||||
|
if (parts.length === 1) return text;
|
||||||
|
return parts.map((part, i) => (
|
||||||
|
<Fragment key={`${key}-${i}`}>
|
||||||
|
{part}
|
||||||
|
{i < parts.length - 1 && <br />}
|
||||||
|
</Fragment>
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
const jsxConverters: JSXConverters = {
|
const jsxConverters: JSXConverters = {
|
||||||
...defaultJSXConverters,
|
...defaultJSXConverters,
|
||||||
// Let the default converters handle text nodes to preserve valid formatting
|
// Handle Lexical linebreak nodes (explicit shift+enter)
|
||||||
// If the text node contains raw HTML (from messy migrations), render it as HTML instead of escaping it
|
linebreak: () => <br />,
|
||||||
|
// Custom text converter: preserve \n inside text nodes as <br /> and obfuscate emails
|
||||||
text: ({ node }: any) => {
|
text: ({ node }: any) => {
|
||||||
const text = node.text;
|
let content: React.ReactNode = node.text || '';
|
||||||
// Handle markdown-style lists embedded in text nodes from Markdown migration
|
// Split newlines first
|
||||||
if (text && text.includes('\n- ')) {
|
if (typeof content === 'string' && content.includes('\n')) {
|
||||||
const parts = text.split('\n- ').filter((p: string) => p.trim() !== '');
|
content = textWithLineBreaks(content, `t-${(node.text || '').slice(0, 8)}`);
|
||||||
// If first part doesn't start with "- ", it's a prefix paragraph
|
|
||||||
const startsWithDash = text.trimStart().startsWith('- ');
|
|
||||||
const prefix = startsWithDash ? null : parts.shift();
|
|
||||||
return (
|
|
||||||
<div className="my-4">
|
|
||||||
{prefix && (
|
|
||||||
<div dangerouslySetInnerHTML={prefix.includes('<') ? { __html: prefix } : undefined}>
|
|
||||||
{!prefix.includes('<') ? prefix : undefined}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
<ul className="list-disc pl-6 my-4 space-y-2">
|
|
||||||
{parts.map((item: string, i: number) => {
|
|
||||||
const cleanItem = item.trim();
|
|
||||||
if (cleanItem.includes('<')) {
|
|
||||||
return <li key={i} dangerouslySetInnerHTML={{ __html: cleanItem }} />;
|
|
||||||
}
|
|
||||||
return <li key={i}>{cleanItem}</li>;
|
|
||||||
})}
|
|
||||||
</ul>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if (text && (text.includes('<') || text.includes('data-start'))) {
|
// Obfuscate emails in text content
|
||||||
return <span dangerouslySetInnerHTML={{ __html: text }} />;
|
if (typeof content === 'string' && content.includes('@')) {
|
||||||
}
|
const emailRegex = /([a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,})/g;
|
||||||
|
const parts = content.split(emailRegex);
|
||||||
// Handle markdown-style links [text](url) from Markdown migration
|
content = parts.map((part, i) => {
|
||||||
if (text && /\[([^\]]+)\]\(([^)]+)\)/.test(text)) {
|
if (part.match(emailRegex)) {
|
||||||
const parts: React.ReactNode[] = [];
|
return <ObfuscatedEmail key={`e-${i}`} email={part} />;
|
||||||
const remaining = text;
|
|
||||||
let key = 0;
|
|
||||||
const linkRegex = /\[([^\]]+)\]\(([^)]+)\)/g;
|
|
||||||
let match;
|
|
||||||
let lastIndex = 0;
|
|
||||||
while ((match = linkRegex.exec(remaining)) !== null) {
|
|
||||||
if (match.index > lastIndex) {
|
|
||||||
parts.push(<span key={key++}>{remaining.slice(lastIndex, match.index)}</span>);
|
|
||||||
}
|
}
|
||||||
parts.push(
|
return part;
|
||||||
<a
|
});
|
||||||
key={key++}
|
}
|
||||||
href={match[2]}
|
|
||||||
target="_blank"
|
// Obfuscate phone numbers in text content (simple pattern for +XX XXX ...)
|
||||||
rel="noopener noreferrer"
|
if (typeof content === 'string' && content.match(/\+\d+/)) {
|
||||||
className="text-primary underline decoration-primary/30 hover:decoration-primary transition-colors"
|
const phoneRegex = /(\+\d{1,4}[\d\s-]{5,15})/g;
|
||||||
>
|
const parts = content.split(phoneRegex);
|
||||||
{match[1]}
|
content = parts.map((part, i) => {
|
||||||
</a>,
|
if (part.match(phoneRegex)) {
|
||||||
|
return <ObfuscatedPhone key={`p-${i}`} phone={part} />;
|
||||||
|
}
|
||||||
|
return part;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Handle array content (from previous mappings)
|
||||||
|
if (Array.isArray(content)) {
|
||||||
|
content = content.map((item, idx) => {
|
||||||
|
if (typeof item === 'string') {
|
||||||
|
// Re-apply phone regex to strings in array
|
||||||
|
const phoneRegex = /(\+\d{1,4}[\d\s-]{5,15})/g;
|
||||||
|
if (item.match(phoneRegex)) {
|
||||||
|
const parts = item.split(phoneRegex);
|
||||||
|
return parts.map((part, i) => {
|
||||||
|
if (part.match(phoneRegex)) {
|
||||||
|
return <ObfuscatedPhone key={`p-${idx}-${i}`} phone={part} />;
|
||||||
|
}
|
||||||
|
return part;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return item;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Apply Lexical formatting flags
|
||||||
|
if (node.format) {
|
||||||
|
if (node.format & 1) content = <strong>{content}</strong>;
|
||||||
|
if (node.format & 2) content = <em>{content}</em>;
|
||||||
|
if (node.format & 8) content = <u>{content}</u>;
|
||||||
|
if (node.format & 4) content = <s>{content}</s>;
|
||||||
|
if (node.format & 16)
|
||||||
|
content = (
|
||||||
|
<code className="px-1.5 py-0.5 bg-neutral-100 rounded text-sm font-mono text-primary">
|
||||||
|
{content}
|
||||||
|
</code>
|
||||||
);
|
);
|
||||||
lastIndex = match.index + match[0].length;
|
if (node.format & 32) content = <sub>{content}</sub>;
|
||||||
}
|
if (node.format & 64) content = <sup>{content}</sup>;
|
||||||
if (lastIndex < remaining.length) {
|
|
||||||
parts.push(<span key={key++}>{remaining.slice(lastIndex)}</span>);
|
|
||||||
}
|
|
||||||
return <>{parts}</>;
|
|
||||||
}
|
}
|
||||||
|
return <>{content}</>;
|
||||||
// Handle newlines in text nodes — convert to <br> for proper line breaks
|
|
||||||
if (text && text.includes('\n')) {
|
|
||||||
const lines = text.split('\n');
|
|
||||||
return (
|
|
||||||
<>
|
|
||||||
{lines.map((line: string, i: number) => (
|
|
||||||
<span key={i}>
|
|
||||||
{line}
|
|
||||||
{i < lines.length - 1 && <br />}
|
|
||||||
</span>
|
|
||||||
))}
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (node.format === 1) return <strong key="bold">{text}</strong>;
|
|
||||||
if (node.format === 2) return <em key="italic">{text}</em>;
|
|
||||||
return <span key="text">{text}</span>;
|
|
||||||
},
|
},
|
||||||
// Use div instead of p for paragraphs to allow nested block elements (like the lists above)
|
// Use div instead of p for paragraphs to allow nested block elements (like the lists above)
|
||||||
paragraph: ({ children }: any) => (
|
paragraph: ({ node, nodesToJSX }: any) => {
|
||||||
<div className="mb-6 leading-relaxed text-text-secondary">{children}</div>
|
return (
|
||||||
),
|
<div className="mb-6 leading-relaxed text-text-secondary">
|
||||||
|
{nodesToJSX({ nodes: node.children })}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
},
|
||||||
// Scale headings to prevent multiple H1s (H1 -> H2, etc) and style natively
|
// Scale headings to prevent multiple H1s (H1 -> H2, etc) and style natively
|
||||||
heading: ({ node, children }: any) => {
|
heading: ({ node, nodesToJSX }: any) => {
|
||||||
|
const children = nodesToJSX({ nodes: node.children });
|
||||||
const tag = node?.tag;
|
const tag = node?.tag;
|
||||||
|
|
||||||
|
// Extract text to generate an ID for the TOC
|
||||||
|
// Lexical children might contain various nodes; we need a plain text representation
|
||||||
|
const textContent = node.children ? node.children.map((c: any) => c.text || '').join('') : '';
|
||||||
|
const id = textContent
|
||||||
|
? textContent
|
||||||
|
.toLowerCase()
|
||||||
|
.replace(/ä/g, 'ae')
|
||||||
|
.replace(/ö/g, 'oe')
|
||||||
|
.replace(/ü/g, 'ue')
|
||||||
|
.replace(/ß/g, 'ss')
|
||||||
|
.replace(/[*_`]/g, '')
|
||||||
|
.replace(/[^\w\s-]/g, '')
|
||||||
|
.replace(/\s+/g, '-')
|
||||||
|
.replace(/-+/g, '-')
|
||||||
|
.replace(/^-+|-+$/g, '')
|
||||||
|
: undefined;
|
||||||
|
|
||||||
if (tag === 'h1')
|
if (tag === 'h1')
|
||||||
return (
|
return (
|
||||||
<h2 className="text-3xl md:text-4xl font-bold mt-12 mb-6 text-text-primary">{children}</h2>
|
<h2
|
||||||
|
id={id}
|
||||||
|
className="text-2xl md:text-3xl font-bold mt-10 mb-5 text-text-primary scroll-mt-24"
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</h2>
|
||||||
);
|
);
|
||||||
if (tag === 'h2')
|
if (tag === 'h2')
|
||||||
return (
|
return (
|
||||||
<h3 className="text-2xl md:text-3xl font-bold mt-10 mb-5 text-text-primary">{children}</h3>
|
<h3
|
||||||
|
id={id}
|
||||||
|
className="text-xl md:text-2xl font-bold mt-8 mb-4 text-text-primary scroll-mt-24"
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</h3>
|
||||||
);
|
);
|
||||||
if (tag === 'h3')
|
if (tag === 'h3')
|
||||||
return (
|
return (
|
||||||
<h4 className="text-xl md:text-2xl font-bold mt-8 mb-4 text-text-primary">{children}</h4>
|
<h4
|
||||||
|
id={id}
|
||||||
|
className="text-lg md:text-xl font-bold mt-6 mb-3 text-text-primary scroll-mt-24"
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</h4>
|
||||||
);
|
);
|
||||||
if (tag === 'h4')
|
if (tag === 'h4')
|
||||||
return (
|
return (
|
||||||
<h5 className="text-lg md:text-xl font-bold mt-6 mb-4 text-text-primary">{children}</h5>
|
<h5
|
||||||
|
id={id}
|
||||||
|
className="text-lg md:text-xl font-bold mt-6 mb-4 text-text-primary scroll-mt-24"
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</h5>
|
||||||
);
|
);
|
||||||
if (tag === 'h5')
|
if (tag === 'h5')
|
||||||
return (
|
return (
|
||||||
<h6 className="text-base md:text-lg font-bold mt-6 mb-4 text-text-primary">{children}</h6>
|
<h6
|
||||||
|
id={id}
|
||||||
|
className="text-base md:text-lg font-bold mt-6 mb-4 text-text-primary scroll-mt-24"
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</h6>
|
||||||
);
|
);
|
||||||
return <h6 className="text-base font-bold mt-6 mb-4 text-text-primary">{children}</h6>;
|
return (
|
||||||
|
<h6 id={id} className="text-base font-bold mt-6 mb-4 text-text-primary scroll-mt-24">
|
||||||
|
{children}
|
||||||
|
</h6>
|
||||||
|
);
|
||||||
},
|
},
|
||||||
list: ({ node, children }: any) => {
|
list: ({ node, nodesToJSX }: any) => {
|
||||||
|
const children = nodesToJSX({ nodes: node.children });
|
||||||
if (node?.listType === 'number') {
|
if (node?.listType === 'number') {
|
||||||
return (
|
return (
|
||||||
<ol className="list-decimal pl-6 my-6 space-y-2 text-text-secondary marker:text-primary marker:font-bold">
|
<ol className="list-decimal pl-6 my-6 space-y-2 text-text-secondary marker:text-primary marker:font-bold">
|
||||||
@@ -168,31 +227,47 @@ const jsxConverters: JSXConverters = {
|
|||||||
</ul>
|
</ul>
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
listitem: ({ node, children }: any) => {
|
listitem: ({ node, nodesToJSX }: any) => {
|
||||||
|
const children = nodesToJSX({ nodes: node.children });
|
||||||
if (node?.checked != null) {
|
if (node?.checked != null) {
|
||||||
return (
|
return (
|
||||||
<li className="flex items-center gap-3 mb-2 leading-relaxed">
|
<li className="flex items-start gap-3 mb-2 leading-relaxed">
|
||||||
<input
|
<input
|
||||||
type="checkbox"
|
type="checkbox"
|
||||||
checked={node.checked}
|
checked={node.checked}
|
||||||
readOnly
|
readOnly
|
||||||
className="mt-1 w-4 h-4 text-primary focus:ring-primary border-gray-300 rounded"
|
className="mt-1.5 w-4 h-4 text-primary focus:ring-primary border-gray-300 rounded shrink-0"
|
||||||
/>
|
/>
|
||||||
<span>{children}</span>
|
<div className="flex-1">{children}</div>
|
||||||
</li>
|
</li>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
return <li className="mb-2 leading-relaxed">{children}</li>;
|
return <li className="mb-2 leading-relaxed block">{children}</li>;
|
||||||
},
|
},
|
||||||
quote: ({ children }: any) => (
|
quote: ({ node, nodesToJSX }: any) => {
|
||||||
<blockquote className="border-l-4 border-primary bg-primary/5 rounded-r-2xl pl-6 py-4 my-8 italic text-text-secondary shadow-sm">
|
const children = nodesToJSX({ nodes: node.children });
|
||||||
{children}
|
return (
|
||||||
</blockquote>
|
<blockquote className="border-l-4 border-primary bg-primary/5 rounded-r-2xl pl-6 py-4 my-8 italic text-text-secondary shadow-sm">
|
||||||
),
|
{children}
|
||||||
link: ({ node, children }: any) => {
|
</blockquote>
|
||||||
|
);
|
||||||
|
},
|
||||||
|
link: ({ node, nodesToJSX }: any) => {
|
||||||
|
const children = nodesToJSX({ nodes: node.children });
|
||||||
// Handling Payload CMS link nodes
|
// Handling Payload CMS link nodes
|
||||||
const href = node?.fields?.url || node?.url || '#';
|
const href = node?.fields?.url || node?.url || '#';
|
||||||
const newTab = node?.fields?.newTab || node?.newTab;
|
const newTab = node?.fields?.newTab || node?.newTab;
|
||||||
|
|
||||||
|
if (href.startsWith('mailto:')) {
|
||||||
|
const email = href.replace('mailto:', '');
|
||||||
|
return (
|
||||||
|
<ObfuscatedEmail
|
||||||
|
email={email}
|
||||||
|
className="text-primary no-underline hover:underline font-medium transition-colors"
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<a
|
<a
|
||||||
href={href}
|
href={href}
|
||||||
@@ -355,6 +430,12 @@ const jsxConverters: JSXConverters = {
|
|||||||
{node.fields.content && <RichText data={node.fields.content} converters={jsxConverters} />}
|
{node.fields.content && <RichText data={node.fields.content} converters={jsxConverters} />}
|
||||||
</ProductTabs>
|
</ProductTabs>
|
||||||
),
|
),
|
||||||
|
pdfDownload: ({ node }: any) => (
|
||||||
|
<PDFDownloadBlock label={node.fields.label} style={node.fields.style} />
|
||||||
|
),
|
||||||
|
'block-pdfDownload': ({ node }: any) => (
|
||||||
|
<PDFDownloadBlock label={node.fields.label} style={node.fields.style} />
|
||||||
|
),
|
||||||
// ─── New Page Blocks ───────────────────────────────────────────
|
// ─── New Page Blocks ───────────────────────────────────────────
|
||||||
heroSection: ({ node }: any) => {
|
heroSection: ({ node }: any) => {
|
||||||
const f = node.fields;
|
const f = node.fields;
|
||||||
@@ -712,8 +793,8 @@ const jsxConverters: JSXConverters = {
|
|||||||
</Section>
|
</Section>
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
imageGallery: ({ node }: any) => <Gallery />,
|
imageGallery: () => <Gallery />,
|
||||||
'block-imageGallery': ({ node }: any) => <Gallery />,
|
'block-imageGallery': () => <Gallery />,
|
||||||
categoryGrid: ({ node }: any) => {
|
categoryGrid: ({ node }: any) => {
|
||||||
const cats = node.fields.categories || [];
|
const cats = node.fields.categories || [];
|
||||||
return (
|
return (
|
||||||
@@ -1090,6 +1171,10 @@ export default function PayloadRichText({
|
|||||||
|
|
||||||
if (!data) return null;
|
if (!data) return null;
|
||||||
|
|
||||||
|
if (data.root?.children?.length > 0) {
|
||||||
|
console.log('[PayloadRichText DEBUG] received children', data.root.children.length);
|
||||||
|
}
|
||||||
|
|
||||||
const dynamicConverters: JSXConverters = {
|
const dynamicConverters: JSXConverters = {
|
||||||
...jsxConverters,
|
...jsxConverters,
|
||||||
blocks: {
|
blocks: {
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import Image from 'next/image';
|
|||||||
import { useTranslations } from 'next-intl';
|
import { useTranslations } from 'next-intl';
|
||||||
import RequestQuoteForm from '@/components/RequestQuoteForm';
|
import RequestQuoteForm from '@/components/RequestQuoteForm';
|
||||||
import DatasheetDownload from '@/components/DatasheetDownload';
|
import DatasheetDownload from '@/components/DatasheetDownload';
|
||||||
|
import ExcelDownload from '@/components/ExcelDownload';
|
||||||
import Scribble from '@/components/Scribble';
|
import Scribble from '@/components/Scribble';
|
||||||
import { cn } from '@/components/ui/utils';
|
import { cn } from '@/components/ui/utils';
|
||||||
|
|
||||||
@@ -11,6 +12,7 @@ interface ProductSidebarProps {
|
|||||||
productName: string;
|
productName: string;
|
||||||
productImage?: string;
|
productImage?: string;
|
||||||
datasheetPath?: string | null;
|
datasheetPath?: string | null;
|
||||||
|
excelPath?: string | null;
|
||||||
className?: string;
|
className?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -18,6 +20,7 @@ export default function ProductSidebar({
|
|||||||
productName,
|
productName,
|
||||||
productImage,
|
productImage,
|
||||||
datasheetPath,
|
datasheetPath,
|
||||||
|
excelPath,
|
||||||
className,
|
className,
|
||||||
}: ProductSidebarProps) {
|
}: ProductSidebarProps) {
|
||||||
const t = useTranslations('Products');
|
const t = useTranslations('Products');
|
||||||
@@ -70,6 +73,9 @@ export default function ProductSidebar({
|
|||||||
|
|
||||||
{/* Datasheet Download */}
|
{/* Datasheet Download */}
|
||||||
{datasheetPath && <DatasheetDownload datasheetPath={datasheetPath} className="mt-0" />}
|
{datasheetPath && <DatasheetDownload datasheetPath={datasheetPath} className="mt-0" />}
|
||||||
|
|
||||||
|
{/* Excel Download – right below datasheet */}
|
||||||
|
{excelPath && <ExcelDownload excelPath={excelPath} className="mt-0" />}
|
||||||
</aside>
|
</aside>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,6 +2,7 @@
|
|||||||
|
|
||||||
import React, { useState } from 'react';
|
import React, { useState } from 'react';
|
||||||
import { useTranslations } from 'next-intl';
|
import { useTranslations } from 'next-intl';
|
||||||
|
import { formatTechnicalValue } from '@/lib/utils/technical';
|
||||||
|
|
||||||
interface KeyValueItem {
|
interface KeyValueItem {
|
||||||
label: string;
|
label: string;
|
||||||
@@ -38,29 +39,47 @@ export default function ProductTechnicalData({ data }: ProductTechnicalDataProps
|
|||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-16">
|
<div className="space-y-8 md:space-y-16">
|
||||||
{technicalItems.length > 0 && (
|
{technicalItems.length > 0 && (
|
||||||
<div className="bg-white p-8 md:p-12 rounded-[32px] shadow-sm border border-neutral-dark/5">
|
<div className="bg-white p-5 md:p-12 rounded-[20px] md:rounded-[32px] shadow-sm border border-neutral-dark/5">
|
||||||
<h3 className="text-2xl font-bold text-primary mb-8 flex items-center gap-3">
|
<h3 className="text-2xl font-bold text-primary mb-8 flex items-center gap-3">
|
||||||
<div className="w-2 h-8 bg-accent rounded-full" />
|
<div className="w-2 h-8 bg-accent rounded-full" />
|
||||||
General Data
|
General Data
|
||||||
</h3>
|
</h3>
|
||||||
<dl className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-x-12 gap-y-8">
|
<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) => (
|
{technicalItems.map((item, idx) => {
|
||||||
<div key={idx} className="flex flex-col group">
|
const formatted = formatTechnicalValue(item.value);
|
||||||
<dt className="text-sm font-bold uppercase tracking-widest text-primary/40 mb-2 group-hover:text-accent transition-colors">
|
return (
|
||||||
{item.label}
|
<div key={idx} className="flex flex-col group">
|
||||||
</dt>
|
<dt className="text-sm font-bold uppercase tracking-widest text-primary/40 mb-2 group-hover:text-accent transition-colors">
|
||||||
<dd className="text-lg font-semibold text-text-primary">
|
{item.label}
|
||||||
{item.value}{' '}
|
</dt>
|
||||||
{item.unit && (
|
<dd className="text-lg font-semibold text-text-primary">
|
||||||
<span className="text-sm font-normal text-text-secondary ml-1">
|
{formatted.isList ? (
|
||||||
{item.unit}
|
<div className="flex flex-wrap gap-2 mt-1">
|
||||||
</span>
|
{formatted.parts.map((p, pIdx) => (
|
||||||
)}
|
<span
|
||||||
</dd>
|
key={pIdx}
|
||||||
</div>
|
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>
|
</dl>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
@@ -72,18 +91,18 @@ export default function ProductTechnicalData({ data }: ProductTechnicalDataProps
|
|||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
key={idx}
|
key={idx}
|
||||||
className="bg-white p-8 md:p-12 rounded-[32px] shadow-sm border border-neutral-dark/5 overflow-hidden"
|
className="bg-white p-5 md:p-12 rounded-[20px] md:rounded-[32px] shadow-sm border border-neutral-dark/5 overflow-hidden"
|
||||||
>
|
>
|
||||||
<h3 className="text-2xl font-bold text-primary mb-8 flex items-center gap-3">
|
<h3 className="text-2xl font-bold text-primary mb-8 flex items-center gap-3">
|
||||||
<div className="w-2 h-8 bg-accent rounded-full" />
|
<div className="w-2 h-8 bg-accent rounded-full" />
|
||||||
{table.voltageLabel !== 'Voltage unknown' &&
|
{table.voltageLabel !== 'Voltage unknown' &&
|
||||||
table.voltageLabel !== 'Spannung unbekannt'
|
table.voltageLabel !== 'Spannung unbekannt'
|
||||||
? table.voltageLabel
|
? table.voltageLabel
|
||||||
: 'Technical Specifications'}
|
: 'Technical Specifications'}
|
||||||
</h3>
|
</h3>
|
||||||
|
|
||||||
{table.metaItems.length > 0 && (
|
{table.metaItems.length > 0 && (
|
||||||
<dl className="grid grid-cols-1 sm:grid-cols-2 md:grid-cols-3 lg:grid-cols-4 gap-8 mb-12 bg-neutral-light/50 p-8 rounded-2xl border border-neutral-dark/5">
|
<dl className="grid grid-cols-2 sm:grid-cols-2 md:grid-cols-3 lg:grid-cols-4 gap-4 md:gap-8 mb-6 md:mb-12 bg-neutral-light/50 p-4 md:p-8 rounded-xl md:rounded-2xl border border-neutral-dark/5">
|
||||||
{table.metaItems.map((item, mIdx) => (
|
{table.metaItems.map((item, mIdx) => (
|
||||||
<div key={mIdx}>
|
<div key={mIdx}>
|
||||||
<dt className="text-[10px] font-black uppercase tracking-[0.2em] text-primary/40 mb-1">
|
<dt className="text-[10px] font-black uppercase tracking-[0.2em] text-primary/40 mb-1">
|
||||||
@@ -98,11 +117,12 @@ export default function ProductTechnicalData({ data }: ProductTechnicalDataProps
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
<div className="relative">
|
<div className="relative">
|
||||||
|
{/* Scroll hint gradient on right edge for mobile */}
|
||||||
|
<div className="pointer-events-none absolute right-0 top-0 h-full w-8 bg-gradient-to-l from-white to-transparent z-20 md:hidden" />
|
||||||
<div
|
<div
|
||||||
id={`voltage-table-${idx}`}
|
id={`voltage-table-${idx}`}
|
||||||
className={`overflow-x-auto -mx-8 md:-mx-12 px-8 md:px-12 transition-all duration-500 ease-in-out ${
|
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]'
|
||||||
!isExpanded && hasManyRows ? 'max-h-[400px] overflow-y-hidden' : 'max-h-[none]'
|
}`}
|
||||||
}`}
|
|
||||||
>
|
>
|
||||||
<table className="min-w-full border-separate border-spacing-0">
|
<table className="min-w-full border-separate border-spacing-0">
|
||||||
<thead>
|
<thead>
|
||||||
|
|||||||
@@ -164,7 +164,17 @@ export default function RequestQuoteForm({ productName }: RequestQuoteFormProps)
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<form onSubmit={handleSubmit} className="space-y-3 !mt-0">
|
<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"
|
||||||
|
/>
|
||||||
|
|
||||||
<div className="space-y-2 !mt-0">
|
<div className="space-y-2 !mt-0">
|
||||||
<div className="space-y-1 !mt-0">
|
<div className="space-y-1 !mt-0">
|
||||||
<label htmlFor={emailId} className="sr-only">
|
<label htmlFor={emailId} className="sr-only">
|
||||||
|
|||||||
@@ -9,6 +9,9 @@ const DynamicAnalyticsProvider = dynamic(() => import('./AnalyticsProvider'), {
|
|||||||
const DynamicScrollDepthTracker = dynamic(() => import('./ScrollDepthTracker'), {
|
const DynamicScrollDepthTracker = dynamic(() => import('./ScrollDepthTracker'), {
|
||||||
ssr: false,
|
ssr: false,
|
||||||
});
|
});
|
||||||
|
const DynamicWebVitalsTracker = dynamic(() => import('./WebVitalsTracker'), {
|
||||||
|
ssr: false,
|
||||||
|
});
|
||||||
|
|
||||||
export default function AnalyticsShell() {
|
export default function AnalyticsShell() {
|
||||||
const [shouldLoad, setShouldLoad] = useState(false);
|
const [shouldLoad, setShouldLoad] = useState(false);
|
||||||
@@ -34,6 +37,7 @@ export default function AnalyticsShell() {
|
|||||||
<Suspense fallback={null}>
|
<Suspense fallback={null}>
|
||||||
<DynamicAnalyticsProvider />
|
<DynamicAnalyticsProvider />
|
||||||
<DynamicScrollDepthTracker />
|
<DynamicScrollDepthTracker />
|
||||||
|
<DynamicWebVitalsTracker />
|
||||||
</Suspense>
|
</Suspense>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -28,13 +28,13 @@ export default function TrackedLink({
|
|||||||
}: TrackedLinkProps) {
|
}: TrackedLinkProps) {
|
||||||
const { trackEvent } = useAnalytics();
|
const { trackEvent } = useAnalytics();
|
||||||
|
|
||||||
const handleClick = (e: React.MouseEvent) => {
|
const handleClick = () => {
|
||||||
try {
|
try {
|
||||||
trackEvent(eventName, {
|
trackEvent(eventName, {
|
||||||
href,
|
href,
|
||||||
...eventProperties,
|
...eventProperties,
|
||||||
});
|
});
|
||||||
} catch (_e) {
|
} catch {
|
||||||
// Analytics tracking should not block navigation, so we catch and ignore errors.
|
// Analytics tracking should not block navigation, so we catch and ignore errors.
|
||||||
}
|
}
|
||||||
if (onClick) onClick();
|
if (onClick) onClick();
|
||||||
|
|||||||
54
components/analytics/WebVitalsTracker.tsx
Normal file
54
components/analytics/WebVitalsTracker.tsx
Normal file
@@ -0,0 +1,54 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useReportWebVitals } from 'next/web-vitals';
|
||||||
|
import { useAnalytics } from './useAnalytics';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* WebVitalsTracker component.
|
||||||
|
*
|
||||||
|
* Captures Next.js Web Vitals and reports them to Umami as custom events.
|
||||||
|
* This provides "meaningful" page speed tracking by measuring real user
|
||||||
|
* experiences (LCP, CLS, INP, etc.).
|
||||||
|
*/
|
||||||
|
export default function WebVitalsTracker() {
|
||||||
|
const { trackEvent } = useAnalytics();
|
||||||
|
|
||||||
|
useReportWebVitals((metric) => {
|
||||||
|
const { name, value, id, label } = metric;
|
||||||
|
|
||||||
|
// Determine rating (simplified version of web-vitals standards)
|
||||||
|
let rating: 'good' | 'needs-improvement' | 'poor' = 'good';
|
||||||
|
|
||||||
|
if (name === 'LCP') {
|
||||||
|
if (value > 4000) rating = 'poor';
|
||||||
|
else if (value > 2500) rating = 'needs-improvement';
|
||||||
|
} else if (name === 'CLS') {
|
||||||
|
if (value > 0.25) rating = 'poor';
|
||||||
|
else if (value > 0.1) rating = 'needs-improvement';
|
||||||
|
} else if (name === 'FID') {
|
||||||
|
if (value > 300) rating = 'poor';
|
||||||
|
else if (value > 100) rating = 'needs-improvement';
|
||||||
|
} else if (name === 'FCP') {
|
||||||
|
if (value > 3000) rating = 'poor';
|
||||||
|
else if (value > 1800) rating = 'needs-improvement';
|
||||||
|
} else if (name === 'TTFB') {
|
||||||
|
if (value > 1500) rating = 'poor';
|
||||||
|
else if (value > 800) rating = 'needs-improvement';
|
||||||
|
} else if (name === 'INP') {
|
||||||
|
if (value > 500) rating = 'poor';
|
||||||
|
else if (value > 200) rating = 'needs-improvement';
|
||||||
|
}
|
||||||
|
|
||||||
|
// Report to Umami
|
||||||
|
trackEvent('web-vital', {
|
||||||
|
metric: name,
|
||||||
|
value: Math.round(name === 'CLS' ? value * 1000 : value), // CLS is a score, multiply by 1000 to keep as integer if preferred
|
||||||
|
rating,
|
||||||
|
id,
|
||||||
|
label,
|
||||||
|
path: typeof window !== 'undefined' ? window.location.pathname : undefined,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
@@ -1,5 +1,5 @@
|
|||||||
import React from 'react';
|
|
||||||
import Scribble from '@/components/Scribble';
|
import Scribble from '@/components/Scribble';
|
||||||
|
import { formatTechnicalValue } from '@/lib/utils/technical';
|
||||||
|
|
||||||
interface TechnicalGridItem {
|
interface TechnicalGridItem {
|
||||||
label: string;
|
label: string;
|
||||||
@@ -18,25 +18,41 @@ export default function TechnicalGrid({ title, items }: TechnicalGridProps) {
|
|||||||
<h3 className="text-2xl font-bold text-text-primary mb-8 flex items-center gap-4 relative">
|
<h3 className="text-2xl font-bold text-text-primary mb-8 flex items-center gap-4 relative">
|
||||||
<span className="relative inline-block">
|
<span className="relative inline-block">
|
||||||
{title}
|
{title}
|
||||||
<Scribble
|
<Scribble
|
||||||
variant="underline"
|
variant="underline"
|
||||||
className="absolute -bottom-2 left-0 w-full h-3 text-accent/40"
|
className="absolute -bottom-2 left-0 w-full h-3 text-accent/40"
|
||||||
/>
|
/>
|
||||||
</span>
|
</span>
|
||||||
</h3>
|
</h3>
|
||||||
)}
|
)}
|
||||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
|
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
|
||||||
{items.map((item, index) => (
|
{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">
|
const formatted = formatTechnicalValue(item.value);
|
||||||
<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" />
|
return (
|
||||||
<span className="block text-xs font-bold text-primary uppercase tracking-[0.2em] mb-3 opacity-70">
|
<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">
|
||||||
{item.label}
|
<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>
|
<span className="block text-xs font-bold text-primary uppercase tracking-[0.2em] mb-3 opacity-70">
|
||||||
<span className="text-lg text-text-secondary leading-relaxed group-hover:text-text-primary transition-colors">
|
{item.label}
|
||||||
{item.value}
|
</span>
|
||||||
</span>
|
<div className="text-lg text-text-secondary leading-relaxed group-hover:text-text-primary transition-colors">
|
||||||
</div>
|
{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>
|
||||||
|
);
|
||||||
|
})}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
145
components/emails/BrochureDeliveryEmail.tsx
Normal file
145
components/emails/BrochureDeliveryEmail.tsx
Normal file
@@ -0,0 +1,145 @@
|
|||||||
|
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',
|
||||||
|
};
|
||||||
@@ -15,6 +15,7 @@ export default function Experience({ data }: { data?: any }) {
|
|||||||
fill
|
fill
|
||||||
className="object-cover object-center scale-105 animate-slow-zoom"
|
className="object-cover object-center scale-105 animate-slow-zoom"
|
||||||
sizes="100vw"
|
sizes="100vw"
|
||||||
|
quality={100}
|
||||||
/>
|
/>
|
||||||
<div className="absolute inset-0 bg-primary/80 mix-blend-multiply" />
|
<div className="absolute inset-0 bg-primary/80 mix-blend-multiply" />
|
||||||
<div className="absolute inset-0 bg-gradient-to-r from-primary via-primary/40 to-transparent" />
|
<div className="absolute inset-0 bg-gradient-to-r from-primary via-primary/40 to-transparent" />
|
||||||
|
|||||||
@@ -1,6 +1,5 @@
|
|||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import Scribble from '@/components/Scribble';
|
|
||||||
import { Button, Container, Heading, Section } from '@/components/ui';
|
import { Button, Container, Heading, Section } from '@/components/ui';
|
||||||
import { useTranslations, useLocale } from 'next-intl';
|
import { useTranslations, useLocale } from 'next-intl';
|
||||||
import dynamic from 'next/dynamic';
|
import dynamic from 'next/dynamic';
|
||||||
@@ -20,37 +19,19 @@ export default function Hero({ data }: { data?: any }) {
|
|||||||
<div>
|
<div>
|
||||||
<Heading
|
<Heading
|
||||||
level={1}
|
level={1}
|
||||||
className="text-center md:text-left mb-6 md:mb-8 md:max-w-none text-white text-4xl sm:text-5xl md:text-7xl font-extrabold [text-shadow:_-2px_-2px_0_#002b49,_2px_-2px_0_#002b49,_-2px_2px_0_#002b49,_2px_2px_0_#002b49,_-2px_0_0_#002b49,_2px_0_0_#002b49,_0_-2px_0_#002b49,_0_2px_0_#002b49]"
|
className="text-center md:text-left mb-6 md:mb-8 md:max-w-none text-white text-3xl sm:text-4xl md:text-5xl font-extrabold"
|
||||||
>
|
>
|
||||||
{data?.title ? (
|
{data?.title ? (
|
||||||
<span
|
<span
|
||||||
dangerouslySetInnerHTML={{
|
dangerouslySetInnerHTML={{
|
||||||
__html: data.title
|
__html: data.title
|
||||||
.replace(
|
.replace(/<green>/g, '<span class="text-accent italic">')
|
||||||
/<green>/g,
|
.replace(/<\/green>/g, '</span>'),
|
||||||
'<span class="relative inline-block"><span class="relative z-10 text-accent italic inline-block">',
|
|
||||||
)
|
|
||||||
.replace(
|
|
||||||
/<\/green>/g,
|
|
||||||
'</span><div class="w-[140%] h-[140%] -top-[20%] -left-[20%] text-accent/30 hidden md:block absolute -z-10 animate-in fade-in zoom-in-0 duration-1000 ease-out fill-mode-both" style="animation-delay: 500ms;"><Scribble variant="circle" /></div></span>',
|
|
||||||
),
|
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
) : (
|
) : (
|
||||||
t.rich('title', {
|
t.rich('title', {
|
||||||
green: (chunks) => (
|
green: (chunks) => <span className="text-accent italic">{chunks}</span>,
|
||||||
<span className="relative inline-block">
|
|
||||||
<span className="relative z-10 text-accent italic inline-block">
|
|
||||||
{chunks}
|
|
||||||
</span>
|
|
||||||
<div
|
|
||||||
className="w-[140%] h-[140%] -top-[20%] -left-[20%] text-accent/30 hidden md:block absolute -z-10 animate-in fade-in zoom-in-0 duration-1000 ease-out fill-mode-both"
|
|
||||||
style={{ animationDelay: '500ms' }}
|
|
||||||
>
|
|
||||||
<Scribble variant="circle" />
|
|
||||||
</div>
|
|
||||||
</span>
|
|
||||||
),
|
|
||||||
})
|
})
|
||||||
)}
|
)}
|
||||||
</Heading>
|
</Heading>
|
||||||
|
|||||||
@@ -17,6 +17,7 @@ export default function MeetTheTeam({ data }: { data?: any }) {
|
|||||||
fill
|
fill
|
||||||
className="object-cover scale-105 animate-slow-zoom"
|
className="object-cover scale-105 animate-slow-zoom"
|
||||||
sizes="100vw"
|
sizes="100vw"
|
||||||
|
quality={100}
|
||||||
/>
|
/>
|
||||||
<div className="absolute inset-0 bg-primary/70 mix-blend-multiply" />
|
<div className="absolute inset-0 bg-primary/70 mix-blend-multiply" />
|
||||||
<div className="absolute inset-0 bg-gradient-to-t from-primary via-primary/20 to-transparent" />
|
<div className="absolute inset-0 bg-gradient-to-t from-primary via-primary/20 to-transparent" />
|
||||||
|
|||||||
@@ -74,11 +74,14 @@ export default async function RecentPosts({ locale, data }: RecentPostsProps) {
|
|||||||
suppressHydrationWarning
|
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"
|
className="px-3 py-1 text-white/80 text-[10px] md:text-xs font-bold uppercase tracking-widest border border-white/20 rounded-full bg-white/10 backdrop-blur-md"
|
||||||
>
|
>
|
||||||
{new Date(post.frontmatter.date).toLocaleDateString(locale, {
|
{new Date(post.frontmatter.date).toLocaleDateString(
|
||||||
year: 'numeric',
|
['en', 'de'].includes(locale) ? locale : 'de',
|
||||||
month: 'short',
|
{
|
||||||
day: 'numeric',
|
year: 'numeric',
|
||||||
})}
|
month: 'short',
|
||||||
|
day: 'numeric',
|
||||||
|
},
|
||||||
|
)}
|
||||||
</time>
|
</time>
|
||||||
{(new Date(post.frontmatter.date) > new Date() ||
|
{(new Date(post.frontmatter.date) > new Date() ||
|
||||||
post.frontmatter.public === false) && (
|
post.frontmatter.public === false) && (
|
||||||
|
|||||||
@@ -1,7 +1,6 @@
|
|||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import React, { useState, useEffect, useRef } from 'react';
|
import React, { useState, useEffect, useRef } from 'react';
|
||||||
import Scribble from '@/components/Scribble';
|
|
||||||
import { useTranslations } from 'next-intl';
|
import { useTranslations } from 'next-intl';
|
||||||
|
|
||||||
export default function VideoSection({ data }: { data?: any }) {
|
export default function VideoSection({ data }: { data?: any }) {
|
||||||
@@ -41,18 +40,16 @@ export default function VideoSection({ data }: { data?: any }) {
|
|||||||
<div className="max-w-5xl px-6 text-center animate-slide-up pointer-events-auto">
|
<div className="max-w-5xl px-6 text-center animate-slide-up pointer-events-auto">
|
||||||
<h2 className="text-3xl md:text-4xl lg:text-5xl font-extrabold text-white leading-[1.1]">
|
<h2 className="text-3xl md:text-4xl lg:text-5xl font-extrabold text-white leading-[1.1]">
|
||||||
{data?.title ? (
|
{data?.title ? (
|
||||||
<span dangerouslySetInnerHTML={{ __html: data.title.replace(/<future>/g, '<span class="relative inline-block mx-2"><span class="relative z-10 italic text-accent">').replace(/<\/future>/g, '</span><Scribble variant="underline" class="w-full h-4 -bottom-2 left-0 text-accent/40" /></span>') }} />
|
<span
|
||||||
|
dangerouslySetInnerHTML={{
|
||||||
|
__html: data.title
|
||||||
|
.replace(/<future>/g, '<span class="italic text-accent">')
|
||||||
|
.replace(/<\/future>/g, '</span>'),
|
||||||
|
}}
|
||||||
|
/>
|
||||||
) : (
|
) : (
|
||||||
t.rich('title', {
|
t.rich('title', {
|
||||||
future: (chunks) => (
|
future: (chunks) => <span className="italic text-accent">{chunks}</span>,
|
||||||
<span className="relative inline-block mx-2">
|
|
||||||
<span className="relative z-10 italic text-accent">{chunks}</span>
|
|
||||||
<Scribble
|
|
||||||
variant="underline"
|
|
||||||
className="w-full h-4 -bottom-2 left-0 text-accent/40"
|
|
||||||
/>
|
|
||||||
</span>
|
|
||||||
),
|
|
||||||
})
|
})
|
||||||
)}
|
)}
|
||||||
</h2>
|
</h2>
|
||||||
|
|||||||
@@ -1,15 +1,10 @@
|
|||||||
{
|
{
|
||||||
"ci": {
|
"ci": {
|
||||||
"collect": {
|
"collect": {
|
||||||
"numberOfRuns": 3,
|
"numberOfRuns": 1,
|
||||||
"settings": {
|
"settings": {
|
||||||
"preset": "desktop",
|
"preset": "desktop",
|
||||||
"onlyCategories": [
|
"onlyCategories": ["performance", "accessibility", "best-practices", "seo"],
|
||||||
"performance",
|
|
||||||
"accessibility",
|
|
||||||
"best-practices",
|
|
||||||
"seo"
|
|
||||||
],
|
|
||||||
"chromeFlags": "--no-sandbox --disable-setuid-sandbox"
|
"chromeFlags": "--no-sandbox --disable-setuid-sandbox"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
@@ -18,7 +13,7 @@
|
|||||||
"categories:performance": [
|
"categories:performance": [
|
||||||
"error",
|
"error",
|
||||||
{
|
{
|
||||||
"minScore": 0.9
|
"minScore": 0.7
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"categories:accessibility": [
|
"categories:accessibility": [
|
||||||
@@ -54,4 +49,4 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
BIN
data/excel/high-voltage.xlsx
Normal file
BIN
data/excel/high-voltage.xlsx
Normal file
Binary file not shown.
BIN
data/excel/low-voltage-KM.xlsx
Normal file
BIN
data/excel/low-voltage-KM.xlsx
Normal file
Binary file not shown.
BIN
data/excel/medium-voltage-KM.xlsx
Normal file
BIN
data/excel/medium-voltage-KM.xlsx
Normal file
Binary file not shown.
BIN
data/excel/solar-cables.xlsx
Normal file
BIN
data/excel/solar-cables.xlsx
Normal file
Binary file not shown.
2480
data/processed/products.json
Normal file
2480
data/processed/products.json
Normal file
File diff suppressed because it is too large
Load Diff
@@ -29,7 +29,7 @@ services:
|
|||||||
NEXT_TELEMETRY_DISABLED: "1"
|
NEXT_TELEMETRY_DISABLED: "1"
|
||||||
POSTGRES_URI: postgres://${PAYLOAD_DB_USER:-payload}:${PAYLOAD_DB_PASSWORD:-120in09oenaoinsd9iaidon}@klz-db:5432/${PAYLOAD_DB_NAME:-payload}
|
POSTGRES_URI: postgres://${PAYLOAD_DB_USER:-payload}:${PAYLOAD_DB_PASSWORD:-120in09oenaoinsd9iaidon}@klz-db:5432/${PAYLOAD_DB_NAME:-payload}
|
||||||
PAYLOAD_SECRET: ${PAYLOAD_SECRET:-fallback-secret-for-dev}
|
PAYLOAD_SECRET: ${PAYLOAD_SECRET:-fallback-secret-for-dev}
|
||||||
NODE_OPTIONS: "--max-old-space-size=4096"
|
NODE_OPTIONS: "--max-old-space-size=8192"
|
||||||
UV_THREADPOOL_SIZE: "4"
|
UV_THREADPOOL_SIZE: "4"
|
||||||
NPM_TOKEN: ${NPM_TOKEN:-}
|
NPM_TOKEN: ${NPM_TOKEN:-}
|
||||||
CI: "true"
|
CI: "true"
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ services:
|
|||||||
PAYLOAD_SECRET: ${PAYLOAD_SECRET:-fallback-secret-for-production-needs-change}
|
PAYLOAD_SECRET: ${PAYLOAD_SECRET:-fallback-secret-for-production-needs-change}
|
||||||
volumes:
|
volumes:
|
||||||
- klz_media_data:/app/public/media
|
- klz_media_data:/app/public/media
|
||||||
|
- klz_datasheets:/app/public/datasheets
|
||||||
labels:
|
labels:
|
||||||
- "traefik.enable=true"
|
- "traefik.enable=true"
|
||||||
# HTTP ⇒ HTTPS redirect
|
# HTTP ⇒ HTTPS redirect
|
||||||
@@ -111,3 +112,5 @@ volumes:
|
|||||||
external: false
|
external: false
|
||||||
klz_media_data:
|
klz_media_data:
|
||||||
external: false
|
external: false
|
||||||
|
klz_datasheets:
|
||||||
|
external: false
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
// Sentry initialization move to GlitchtipErrorReportingService to allow lazy-loading
|
// Sentry initialization move to GlitchtipErrorReportingService to allow lazy-loading
|
||||||
// for PageSpeed 100 optimizations. This file is now empty to prevent the SDK
|
// for PageSpeed 100 optimizations. This file is now empty to prevent the SDK
|
||||||
// from being included in the initial JS bundle.
|
// from being included in the initial JS bundle.
|
||||||
export {};
|
import * as Sentry from '@sentry/nextjs';
|
||||||
|
export const onRouterTransitionStart = Sentry.captureRouterTransitionStart;
|
||||||
19157
kabelhandbuch.txt
Normal file
19157
kabelhandbuch.txt
Normal file
File diff suppressed because it is too large
Load Diff
39
lib/blog.ts
39
lib/blog.ts
@@ -116,7 +116,7 @@ export async function getPostBySlug(slug: string, locale: string): Promise<PostD
|
|||||||
category: doc.category || '',
|
category: doc.category || '',
|
||||||
featuredImage:
|
featuredImage:
|
||||||
typeof doc.featuredImage === 'object' && doc.featuredImage !== null
|
typeof doc.featuredImage === 'object' && doc.featuredImage !== null
|
||||||
? doc.featuredImage.sizes?.card?.url || doc.featuredImage.url
|
? doc.featuredImage.url || doc.featuredImage.sizes?.card?.url
|
||||||
: null,
|
: null,
|
||||||
focalX:
|
focalX:
|
||||||
typeof doc.featuredImage === 'object' && doc.featuredImage !== null
|
typeof doc.featuredImage === 'object' && doc.featuredImage !== null
|
||||||
@@ -162,7 +162,7 @@ export async function getAllPosts(locale: string): Promise<PostData[]> {
|
|||||||
category: doc.category || '',
|
category: doc.category || '',
|
||||||
featuredImage:
|
featuredImage:
|
||||||
typeof doc.featuredImage === 'object' && doc.featuredImage !== null
|
typeof doc.featuredImage === 'object' && doc.featuredImage !== null
|
||||||
? doc.featuredImage.sizes?.card?.url || doc.featuredImage.url
|
? doc.featuredImage.url || doc.featuredImage.sizes?.card?.url
|
||||||
: null,
|
: null,
|
||||||
focalX:
|
focalX:
|
||||||
typeof doc.featuredImage === 'object' && doc.featuredImage !== null
|
typeof doc.featuredImage === 'object' && doc.featuredImage !== null
|
||||||
@@ -286,3 +286,38 @@ export function getHeadings(content: string): { id: string; text: string; level:
|
|||||||
return { id, text: cleanText, level };
|
return { id, text: cleanText, level };
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function extractLexicalHeadings(
|
||||||
|
node: any,
|
||||||
|
headings: { id: string; text: string; level: number }[] = [],
|
||||||
|
): { id: string; text: string; level: number }[] {
|
||||||
|
if (!node) return headings;
|
||||||
|
|
||||||
|
if (node.type === 'heading' && node.tag) {
|
||||||
|
const level = parseInt(node.tag.replace('h', ''));
|
||||||
|
const text = getTextContentFromLexical(node);
|
||||||
|
if (text) {
|
||||||
|
headings.push({
|
||||||
|
id: generateHeadingId(text),
|
||||||
|
text,
|
||||||
|
level,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (node.children && Array.isArray(node.children)) {
|
||||||
|
node.children.forEach((child: any) => extractLexicalHeadings(child, headings));
|
||||||
|
}
|
||||||
|
|
||||||
|
return headings;
|
||||||
|
}
|
||||||
|
|
||||||
|
function getTextContentFromLexical(node: any): string {
|
||||||
|
if (node.type === 'text') {
|
||||||
|
return node.text || '';
|
||||||
|
}
|
||||||
|
if (node.children && Array.isArray(node.children)) {
|
||||||
|
return node.children.map(getTextContentFromLexical).join('');
|
||||||
|
}
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ import path from 'path';
|
|||||||
*/
|
*/
|
||||||
export function getDatasheetPath(slug: string, locale: string): string | null {
|
export function getDatasheetPath(slug: string, locale: string): string | null {
|
||||||
const datasheetsDir = path.join(process.cwd(), 'public', 'datasheets');
|
const datasheetsDir = path.join(process.cwd(), 'public', 'datasheets');
|
||||||
|
|
||||||
if (!fs.existsSync(datasheetsDir)) {
|
if (!fs.existsSync(datasheetsDir)) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
@@ -16,16 +16,21 @@ export function getDatasheetPath(slug: string, locale: string): string | null {
|
|||||||
const normalizedSlug = slug.replace(/-hv$|-mv$/, '');
|
const normalizedSlug = slug.replace(/-hv$|-mv$/, '');
|
||||||
|
|
||||||
// Subdirectories to search in
|
// Subdirectories to search in
|
||||||
const subdirs = ['', 'low-voltage', 'medium-voltage', 'high-voltage', 'solar'];
|
const subdirs = ['', 'products', 'low-voltage', 'medium-voltage', 'high-voltage', 'solar'];
|
||||||
|
|
||||||
// List of patterns to try for the current locale
|
// 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 = [
|
const patterns = [
|
||||||
`${slug}-${locale}.pdf`,
|
`${slug}-${locale}.pdf`,
|
||||||
`${slug}-2-${locale}.pdf`,
|
`${slug}-2-${locale}.pdf`,
|
||||||
`${slug}-3-${locale}.pdf`,
|
`${slug}-3-${locale}.pdf`,
|
||||||
|
`${slug}-mv-${locale}.pdf`,
|
||||||
|
`${slug}-hv-${locale}.pdf`,
|
||||||
`${normalizedSlug}-${locale}.pdf`,
|
`${normalizedSlug}-${locale}.pdf`,
|
||||||
`${normalizedSlug}-2-${locale}.pdf`,
|
`${normalizedSlug}-2-${locale}.pdf`,
|
||||||
`${normalizedSlug}-3-${locale}.pdf`,
|
`${normalizedSlug}-3-${locale}.pdf`,
|
||||||
|
`${normalizedSlug}-mv-${locale}.pdf`,
|
||||||
|
`${normalizedSlug}-hv-${locale}.pdf`,
|
||||||
];
|
];
|
||||||
|
|
||||||
for (const subdir of subdirs) {
|
for (const subdir of subdirs) {
|
||||||
@@ -44,9 +49,70 @@ export function getDatasheetPath(slug: string, locale: string): string | null {
|
|||||||
`${slug}-en.pdf`,
|
`${slug}-en.pdf`,
|
||||||
`${slug}-2-en.pdf`,
|
`${slug}-2-en.pdf`,
|
||||||
`${slug}-3-en.pdf`,
|
`${slug}-3-en.pdf`,
|
||||||
|
`${slug}-mv-en.pdf`,
|
||||||
|
`${slug}-hv-en.pdf`,
|
||||||
`${normalizedSlug}-en.pdf`,
|
`${normalizedSlug}-en.pdf`,
|
||||||
`${normalizedSlug}-2-en.pdf`,
|
`${normalizedSlug}-2-en.pdf`,
|
||||||
`${normalizedSlug}-3-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 subdir of subdirs) {
|
||||||
for (const pattern of enPatterns) {
|
for (const pattern of enPatterns) {
|
||||||
|
|||||||
1436
lib/pdf-brochure.tsx
Normal file
1436
lib/pdf-brochure.tsx
Normal file
File diff suppressed because it is too large
Load Diff
@@ -1,287 +1,220 @@
|
|||||||
import * as React from 'react';
|
import * as React from 'react';
|
||||||
import {
|
import { Document, Page, View, Text, Image, StyleSheet, Font } from '@react-pdf/renderer';
|
||||||
Document,
|
import type { DatasheetVoltageTable, KeyValueItem } from '../scripts/pdf/model/types';
|
||||||
Page,
|
|
||||||
View,
|
|
||||||
Text,
|
|
||||||
Image,
|
|
||||||
StyleSheet,
|
|
||||||
Font,
|
|
||||||
} from '@react-pdf/renderer';
|
|
||||||
|
|
||||||
// Register fonts (using system fonts for now, can be customized)
|
// Standard built-in fonts are used.
|
||||||
Font.register({
|
Font.registerHyphenationCallback((word) => [word]);
|
||||||
family: 'Helvetica',
|
|
||||||
fonts: [
|
const C = {
|
||||||
{ src: '/fonts/Helvetica.ttf', fontWeight: 400 },
|
navy: '#001a4d',
|
||||||
{ src: '/fonts/Helvetica-Bold.ttf', fontWeight: 700 },
|
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;
|
||||||
|
|
||||||
// Industrial/technical/restrained design - STYLEGUIDE.md compliant
|
|
||||||
const styles = StyleSheet.create({
|
const styles = StyleSheet.create({
|
||||||
page: {
|
page: {
|
||||||
color: '#111827', // Text Primary
|
paddingHorizontal: MARGIN,
|
||||||
lineHeight: 1.5,
|
paddingBottom: 80,
|
||||||
backgroundColor: '#FFFFFF',
|
paddingTop: 40,
|
||||||
paddingTop: 0,
|
|
||||||
paddingBottom: 100,
|
|
||||||
fontFamily: 'Helvetica',
|
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: {
|
header: {
|
||||||
flexDirection: 'row',
|
flexDirection: 'row',
|
||||||
justifyContent: 'space-between',
|
justifyContent: 'space-between',
|
||||||
alignItems: 'center',
|
alignItems: 'center',
|
||||||
marginBottom: 16,
|
marginBottom: 16,
|
||||||
},
|
},
|
||||||
|
|
||||||
logoText: {
|
logoText: {
|
||||||
fontSize: 24,
|
fontSize: 22,
|
||||||
fontWeight: 700,
|
fontWeight: 700,
|
||||||
color: '#000d26',
|
color: C.navyDeep,
|
||||||
letterSpacing: 1,
|
|
||||||
textTransform: 'uppercase',
|
|
||||||
},
|
|
||||||
|
|
||||||
docTitle: {
|
|
||||||
fontSize: 10,
|
|
||||||
fontWeight: 700,
|
|
||||||
color: '#001a4d',
|
|
||||||
letterSpacing: 2,
|
letterSpacing: 2,
|
||||||
textTransform: 'uppercase',
|
textTransform: 'uppercase',
|
||||||
},
|
},
|
||||||
|
docTitle: {
|
||||||
productRow: {
|
fontSize: 8,
|
||||||
flexDirection: 'row',
|
fontWeight: 700,
|
||||||
alignItems: 'center',
|
color: C.green,
|
||||||
gap: 20,
|
letterSpacing: 2,
|
||||||
},
|
textTransform: 'uppercase',
|
||||||
productInfoCol: {
|
|
||||||
flex: 1,
|
|
||||||
justifyContent: 'center',
|
|
||||||
},
|
},
|
||||||
|
productRow: { flexDirection: 'row', alignItems: 'center', gap: 20 },
|
||||||
|
productInfoCol: { flex: 1, justifyContent: 'center' },
|
||||||
productImageCol: {
|
productImageCol: {
|
||||||
flex: 1,
|
flex: 1,
|
||||||
height: 120,
|
height: 120,
|
||||||
justifyContent: 'center',
|
justifyContent: 'center',
|
||||||
alignItems: 'center',
|
alignItems: 'center',
|
||||||
borderRadius: 8,
|
borderRadius: 4,
|
||||||
borderWidth: 1,
|
borderWidth: 1,
|
||||||
borderColor: '#e5e7eb',
|
borderColor: C.gray200,
|
||||||
backgroundColor: '#FFFFFF',
|
backgroundColor: C.white,
|
||||||
overflow: 'hidden',
|
overflow: 'hidden',
|
||||||
},
|
},
|
||||||
|
|
||||||
// Product Hero Info
|
|
||||||
productHero: {
|
|
||||||
marginTop: 0,
|
|
||||||
},
|
|
||||||
|
|
||||||
productName: {
|
productName: {
|
||||||
fontSize: 24,
|
fontSize: 24,
|
||||||
fontWeight: 700,
|
fontWeight: 700,
|
||||||
color: '#000d26',
|
color: C.navyDeep,
|
||||||
marginBottom: 0,
|
|
||||||
textTransform: 'uppercase',
|
textTransform: 'uppercase',
|
||||||
letterSpacing: -0.5,
|
letterSpacing: -0.5,
|
||||||
},
|
},
|
||||||
|
|
||||||
productMeta: {
|
productMeta: {
|
||||||
fontSize: 10,
|
fontSize: 10,
|
||||||
color: '#4b5563',
|
color: C.gray600,
|
||||||
fontWeight: 700,
|
fontWeight: 700,
|
||||||
textTransform: 'uppercase',
|
textTransform: 'uppercase',
|
||||||
letterSpacing: 1,
|
letterSpacing: 1,
|
||||||
|
marginBottom: 2,
|
||||||
},
|
},
|
||||||
|
heroImage: { width: '100%', height: '100%', objectFit: 'contain' },
|
||||||
|
noImage: { fontSize: 8, color: C.gray400, textAlign: 'center' },
|
||||||
|
|
||||||
heroImage: {
|
content: {},
|
||||||
width: '100%',
|
section: { marginBottom: 20 },
|
||||||
height: '100%',
|
|
||||||
objectFit: 'contain',
|
|
||||||
},
|
|
||||||
|
|
||||||
noImage: {
|
|
||||||
fontSize: 8,
|
|
||||||
color: '#9ca3af',
|
|
||||||
textAlign: 'center',
|
|
||||||
},
|
|
||||||
|
|
||||||
// Content Area
|
|
||||||
content: {
|
|
||||||
paddingHorizontal: 72,
|
|
||||||
},
|
|
||||||
|
|
||||||
// Content sections
|
|
||||||
section: {
|
|
||||||
marginBottom: 20,
|
|
||||||
},
|
|
||||||
|
|
||||||
sectionTitle: {
|
sectionTitle: {
|
||||||
fontSize: 14,
|
fontSize: 8,
|
||||||
fontWeight: 700,
|
fontWeight: 700,
|
||||||
color: '#000d26', // Primary Dark
|
color: C.green,
|
||||||
marginBottom: 8,
|
marginBottom: 6,
|
||||||
textTransform: 'uppercase',
|
textTransform: 'uppercase',
|
||||||
letterSpacing: -0.2,
|
letterSpacing: 1.5,
|
||||||
},
|
},
|
||||||
|
|
||||||
sectionAccent: {
|
sectionAccent: {
|
||||||
width: 30,
|
width: 30,
|
||||||
height: 3,
|
height: 2,
|
||||||
backgroundColor: '#82ed20', // Accent Green
|
backgroundColor: C.green,
|
||||||
marginBottom: 8,
|
marginBottom: 8,
|
||||||
borderRadius: 1.5,
|
borderRadius: 1,
|
||||||
},
|
|
||||||
|
|
||||||
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,
|
|
||||||
},
|
},
|
||||||
|
description: { fontSize: 10, lineHeight: 1.7, color: C.gray600 },
|
||||||
|
|
||||||
|
categories: { flexDirection: 'row', flexWrap: 'wrap', gap: 6 },
|
||||||
categoryTag: {
|
categoryTag: {
|
||||||
backgroundColor: '#f8f9fa',
|
backgroundColor: C.offWhite,
|
||||||
paddingHorizontal: 12,
|
paddingHorizontal: 10,
|
||||||
paddingVertical: 6,
|
paddingVertical: 4,
|
||||||
border: '1px solid #e5e7eb',
|
borderWidth: 0.5,
|
||||||
borderRadius: 100,
|
borderColor: C.gray200,
|
||||||
|
borderRadius: 3,
|
||||||
},
|
},
|
||||||
|
|
||||||
categoryText: {
|
categoryText: {
|
||||||
fontSize: 8,
|
fontSize: 7,
|
||||||
color: '#4b5563',
|
color: C.gray600,
|
||||||
fontWeight: 700,
|
fontWeight: 700,
|
||||||
textTransform: 'uppercase',
|
textTransform: 'uppercase',
|
||||||
letterSpacing: 0.5,
|
letterSpacing: 0.5,
|
||||||
},
|
},
|
||||||
|
|
||||||
// Footer
|
|
||||||
footer: {
|
footer: {
|
||||||
position: 'absolute',
|
position: 'absolute',
|
||||||
bottom: 40,
|
bottom: 28,
|
||||||
left: 72,
|
left: MARGIN,
|
||||||
right: 72,
|
right: MARGIN,
|
||||||
flexDirection: 'row',
|
flexDirection: 'row',
|
||||||
justifyContent: 'space-between',
|
justifyContent: 'space-between',
|
||||||
alignItems: 'center',
|
alignItems: 'center',
|
||||||
paddingTop: 24,
|
paddingTop: 12,
|
||||||
borderTop: '1px solid #e5e7eb',
|
borderTopWidth: 2,
|
||||||
|
borderTopColor: C.green,
|
||||||
},
|
},
|
||||||
|
|
||||||
footerText: {
|
footerText: {
|
||||||
fontSize: 8,
|
fontSize: 7,
|
||||||
color: '#9ca3af',
|
color: C.gray400,
|
||||||
fontWeight: 500,
|
fontWeight: 400,
|
||||||
textTransform: 'uppercase',
|
textTransform: 'uppercase',
|
||||||
letterSpacing: 1,
|
letterSpacing: 0.8,
|
||||||
|
},
|
||||||
|
footerBrand: {
|
||||||
|
fontSize: 9,
|
||||||
|
fontWeight: 700,
|
||||||
|
color: C.navyDeep,
|
||||||
|
textTransform: 'uppercase',
|
||||||
|
letterSpacing: 1.5,
|
||||||
},
|
},
|
||||||
|
|
||||||
footerBrand: {
|
kvGrid: { width: '100%', borderWidth: 1, borderColor: C.gray200 },
|
||||||
fontSize: 10,
|
kvRow: { flexDirection: 'row', borderBottomWidth: 1, borderBottomColor: C.gray200 },
|
||||||
fontWeight: 700,
|
kvRowAlt: { backgroundColor: C.offWhite },
|
||||||
color: '#000d26',
|
kvRowLast: { borderBottomWidth: 0 },
|
||||||
textTransform: 'uppercase',
|
kvCell: { paddingVertical: 6, paddingHorizontal: 8 },
|
||||||
letterSpacing: 1,
|
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 {
|
interface ProductData {
|
||||||
id: number;
|
id: number;
|
||||||
name: string;
|
name: string;
|
||||||
shortDescriptionHtml: string;
|
|
||||||
descriptionHtml: string;
|
|
||||||
applicationHtml?: string;
|
|
||||||
images: string[];
|
|
||||||
featuredImage: string | null;
|
|
||||||
sku: string;
|
sku: string;
|
||||||
categories: Array<{ name: string }>;
|
categoriesLine?: string;
|
||||||
attributes: Array<{
|
descriptionText?: string;
|
||||||
name: string;
|
heroSrc?: string | null;
|
||||||
options: string[];
|
productUrl?: 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[] }>;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface PDFDatasheetProps {
|
export interface PDFDatasheetProps {
|
||||||
product: ProductData;
|
product: ProductData;
|
||||||
locale: 'en' | 'de';
|
locale: 'en' | 'de';
|
||||||
logoUrl?: string;
|
logoDataUrl?: string | null;
|
||||||
|
technicalItems?: KeyValueItem[];
|
||||||
|
voltageTables?: DatasheetVoltageTable[];
|
||||||
|
legendItems?: KeyValueItem[];
|
||||||
}
|
}
|
||||||
|
|
||||||
// Helper to strip HTML tags
|
const stripHtml = (html: string): string => html.replace(/<[^>]*>/g, '');
|
||||||
const stripHtml = (html: string): string => {
|
|
||||||
return html.replace(/<[^>]*>/g, '');
|
|
||||||
};
|
|
||||||
|
|
||||||
// Helper to get translated labels
|
const getLabels = (locale: 'en' | 'de') =>
|
||||||
const getLabels = (locale: 'en' | 'de') => {
|
({
|
||||||
const labels = {
|
|
||||||
en: {
|
en: {
|
||||||
productDatasheet: 'Technical Datasheet',
|
productDatasheet: 'Technical Datasheet',
|
||||||
description: 'APPLICATION',
|
description: 'APPLICATION',
|
||||||
@@ -289,6 +222,9 @@ const getLabels = (locale: 'en' | 'de') => {
|
|||||||
categories: 'CATEGORIES',
|
categories: 'CATEGORIES',
|
||||||
sku: 'SKU',
|
sku: 'SKU',
|
||||||
noImage: 'No image available',
|
noImage: 'No image available',
|
||||||
|
crossSection: 'Configurations',
|
||||||
|
slug_cs: 'Cores & CS',
|
||||||
|
abbreviations: 'ABBREVIATIONS',
|
||||||
},
|
},
|
||||||
de: {
|
de: {
|
||||||
productDatasheet: 'Technisches Datenblatt',
|
productDatasheet: 'Technisches Datenblatt',
|
||||||
@@ -297,52 +233,283 @@ const getLabels = (locale: 'en' | 'de') => {
|
|||||||
categories: 'KATEGORIEN',
|
categories: 'KATEGORIEN',
|
||||||
sku: 'ARTIKELNUMMER',
|
sku: 'ARTIKELNUMMER',
|
||||||
noImage: 'Kein Bild verfügbar',
|
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> = ({
|
export const PDFDatasheet: React.FC<PDFDatasheetProps> = ({
|
||||||
product,
|
product,
|
||||||
locale,
|
locale,
|
||||||
|
technicalItems = [],
|
||||||
|
voltageTables = [],
|
||||||
|
legendItems = [],
|
||||||
}) => {
|
}) => {
|
||||||
const labels = getLabels(locale);
|
const labels = getLabels(locale);
|
||||||
|
const description = stripHtml(
|
||||||
|
product.applicationHtml ||
|
||||||
|
product.shortDescriptionHtml ||
|
||||||
|
product.descriptionHtml ||
|
||||||
|
product.descriptionText ||
|
||||||
|
'',
|
||||||
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Document>
|
<Document>
|
||||||
<Page size="A4" style={styles.page}>
|
<Page size="A4" style={styles.page}>
|
||||||
{/* Hero Header */}
|
|
||||||
<View style={styles.hero}>
|
<View style={styles.hero}>
|
||||||
<View style={styles.header}>
|
<View style={styles.header}>
|
||||||
<View>
|
<View style={{ width: 80 }}>
|
||||||
<Text style={styles.logoText}>KLZ</Text>
|
{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>
|
</View>
|
||||||
<Text style={styles.docTitle}>
|
<Text style={styles.docTitle}>{labels.productDatasheet}</Text>
|
||||||
{labels.productDatasheet}
|
|
||||||
</Text>
|
|
||||||
</View>
|
</View>
|
||||||
|
|
||||||
<View style={styles.productRow}>
|
<View style={styles.productRow}>
|
||||||
<View style={styles.productInfoCol}>
|
<View style={styles.productInfoCol}>
|
||||||
<View style={styles.productHero}>
|
<Text style={styles.productMeta}>
|
||||||
<View style={styles.categories}>
|
{product.categoriesLine ||
|
||||||
{product.categories.map((cat, index) => (
|
(product.categories || []).map((c) => c.name).join(' • ')}
|
||||||
<Text key={index} style={styles.productMeta}>
|
</Text>
|
||||||
{cat.name}{index < product.categories.length - 1 ? ' • ' : ''}
|
<Text style={styles.productName}>{product.name}</Text>
|
||||||
</Text>
|
|
||||||
))}
|
|
||||||
</View>
|
|
||||||
<Text style={styles.productName}>{product.name}</Text>
|
|
||||||
</View>
|
|
||||||
</View>
|
</View>
|
||||||
<View style={styles.productImageCol}>
|
<View style={styles.productImageCol}>
|
||||||
{product.featuredImage ? (
|
{product.featuredImage ? (
|
||||||
<Image
|
<Image src={product.featuredImage} style={styles.heroImage} />
|
||||||
src={product.featuredImage}
|
|
||||||
style={styles.heroImage}
|
|
||||||
/>
|
|
||||||
) : (
|
) : (
|
||||||
|
|
||||||
<Text style={styles.noImage}>{labels.noImage}</Text>
|
<Text style={styles.noImage}>{labels.noImage}</Text>
|
||||||
)}
|
)}
|
||||||
</View>
|
</View>
|
||||||
@@ -350,65 +517,93 @@ export const PDFDatasheet: React.FC<PDFDatasheetProps> = ({
|
|||||||
</View>
|
</View>
|
||||||
|
|
||||||
<View style={styles.content}>
|
<View style={styles.content}>
|
||||||
{/* Description section */}
|
{description && (
|
||||||
{(product.applicationHtml || product.shortDescriptionHtml || product.descriptionHtml) && (
|
|
||||||
<View style={styles.section}>
|
<View style={styles.section}>
|
||||||
<Text style={styles.sectionTitle}>{labels.description}</Text>
|
<Text style={styles.sectionTitle}>{labels.description}</Text>
|
||||||
<View style={styles.sectionAccent} />
|
<View style={styles.sectionAccent} />
|
||||||
<Text style={styles.description}>
|
<Text style={styles.description}>{description}</Text>
|
||||||
{stripHtml(product.applicationHtml || product.shortDescriptionHtml || product.descriptionHtml)}
|
|
||||||
</Text>
|
|
||||||
</View>
|
</View>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Technical specifications */}
|
{technicalItems.length > 0 && (
|
||||||
{product.attributes && product.attributes.length > 0 && (
|
|
||||||
<View style={styles.section}>
|
<View style={styles.section}>
|
||||||
<Text style={styles.sectionTitle}>{labels.specifications}</Text>
|
<Text style={styles.sectionTitle}>{labels.specifications}</Text>
|
||||||
<View style={styles.sectionAccent} />
|
<View style={styles.sectionAccent} />
|
||||||
<View style={styles.specsTable}>
|
<KeyValueGrid items={technicalItems} />
|
||||||
{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 style={styles.specsTableValueCell}>
|
|
||||||
<Text style={styles.specsTableValueText}>
|
|
||||||
{attr.options.join(', ')}
|
|
||||||
</Text>
|
|
||||||
</View>
|
|
||||||
</View>
|
|
||||||
))}
|
|
||||||
</View>
|
|
||||||
</View>
|
</View>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Categories as clean tags */}
|
{voltageTables.map((table, idx) => (
|
||||||
{product.categories && product.categories.length > 0 && (
|
<View key={idx} style={styles.section} break={false}>
|
||||||
<View style={styles.section}>
|
<Text
|
||||||
<Text style={styles.sectionTitle}>{labels.categories}</Text>
|
style={styles.sectionTitle}
|
||||||
|
>{`${labels.crossSection} — ${table.voltageLabel}`}</Text>
|
||||||
<View style={styles.sectionAccent} />
|
<View style={styles.sectionAccent} />
|
||||||
<View style={styles.categories}>
|
<DenseTable table={table} firstColLabel={labels.slug_cs} />
|
||||||
{product.categories.map((cat, index) => (
|
</View>
|
||||||
<View key={index} style={styles.categoryTag}>
|
))}
|
||||||
<Text style={styles.categoryText}>{cat.name}</Text>
|
|
||||||
</View>
|
{legendItems.length > 0 && (
|
||||||
))}
|
<View style={styles.section} break={false}>
|
||||||
</View>
|
<Text style={styles.sectionTitle}>{labels.abbreviations}</Text>
|
||||||
|
<View style={styles.sectionAccent} />
|
||||||
|
<KeyValueGrid items={legendItems} />
|
||||||
</View>
|
</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>
|
||||||
|
))}
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
</View>
|
</View>
|
||||||
|
|
||||||
{/* Minimal footer */}
|
|
||||||
<View style={styles.footer} fixed>
|
<View style={styles.footer} fixed>
|
||||||
<Text style={styles.footerBrand}>KLZ CABLES</Text>
|
<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.footerText}>
|
<Text style={styles.footerText}>
|
||||||
{new Date().toLocaleDateString(locale === 'en' ? 'en-US' : 'de-DE', {
|
{new Date().toLocaleDateString(locale === 'en' ? 'en-US' : 'de-DE', {
|
||||||
year: 'numeric',
|
year: 'numeric',
|
||||||
|
|||||||
329
lib/pdf-page.tsx
Normal file
329
lib/pdf-page.tsx
Normal file
@@ -0,0 +1,329 @@
|
|||||||
|
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 },
|
||||||
|
],
|
||||||
|
});
|
||||||
|
|
||||||
|
// ─── Brand Tokens (matching datasheet) ──────────────────────────────────
|
||||||
|
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;
|
||||||
|
|
||||||
|
const styles = StyleSheet.create({
|
||||||
|
page: {
|
||||||
|
color: C.gray900,
|
||||||
|
lineHeight: 1.5,
|
||||||
|
backgroundColor: C.white,
|
||||||
|
paddingTop: 0,
|
||||||
|
paddingBottom: 80,
|
||||||
|
fontFamily: 'Helvetica',
|
||||||
|
},
|
||||||
|
|
||||||
|
// Hero-style header
|
||||||
|
hero: {
|
||||||
|
backgroundColor: C.white,
|
||||||
|
paddingTop: 24,
|
||||||
|
paddingBottom: 0,
|
||||||
|
paddingHorizontal: MARGIN,
|
||||||
|
marginBottom: 20,
|
||||||
|
position: 'relative',
|
||||||
|
borderBottomWidth: 0,
|
||||||
|
},
|
||||||
|
|
||||||
|
header: {
|
||||||
|
flexDirection: 'row',
|
||||||
|
justifyContent: 'space-between',
|
||||||
|
alignItems: 'center',
|
||||||
|
marginBottom: 16,
|
||||||
|
},
|
||||||
|
|
||||||
|
logoText: {
|
||||||
|
fontSize: 22,
|
||||||
|
fontWeight: 700,
|
||||||
|
color: C.navyDeep,
|
||||||
|
letterSpacing: 2,
|
||||||
|
textTransform: 'uppercase',
|
||||||
|
},
|
||||||
|
|
||||||
|
docTitle: {
|
||||||
|
fontSize: 8,
|
||||||
|
fontWeight: 700,
|
||||||
|
color: C.green,
|
||||||
|
letterSpacing: 2,
|
||||||
|
textTransform: 'uppercase',
|
||||||
|
},
|
||||||
|
|
||||||
|
// Content Area
|
||||||
|
content: {
|
||||||
|
paddingHorizontal: MARGIN,
|
||||||
|
},
|
||||||
|
|
||||||
|
pageTitle: {
|
||||||
|
fontSize: 24,
|
||||||
|
fontWeight: 700,
|
||||||
|
color: C.navyDeep,
|
||||||
|
marginBottom: 8,
|
||||||
|
marginTop: 10,
|
||||||
|
textTransform: 'uppercase',
|
||||||
|
letterSpacing: -0.5,
|
||||||
|
},
|
||||||
|
|
||||||
|
accentBar: {
|
||||||
|
width: 30,
|
||||||
|
height: 2,
|
||||||
|
backgroundColor: C.green,
|
||||||
|
marginBottom: 20,
|
||||||
|
borderRadius: 1,
|
||||||
|
},
|
||||||
|
|
||||||
|
// Lexical Elements
|
||||||
|
paragraph: {
|
||||||
|
fontSize: 10,
|
||||||
|
color: C.gray600,
|
||||||
|
lineHeight: 1.7,
|
||||||
|
marginBottom: 12,
|
||||||
|
},
|
||||||
|
heading1: {
|
||||||
|
fontSize: 16,
|
||||||
|
fontWeight: 700,
|
||||||
|
color: C.navyDeep,
|
||||||
|
marginTop: 20,
|
||||||
|
marginBottom: 10,
|
||||||
|
textTransform: 'uppercase',
|
||||||
|
letterSpacing: 0.5,
|
||||||
|
},
|
||||||
|
heading2: {
|
||||||
|
fontSize: 12,
|
||||||
|
fontWeight: 700,
|
||||||
|
color: C.navyDeep,
|
||||||
|
marginTop: 16,
|
||||||
|
marginBottom: 8,
|
||||||
|
},
|
||||||
|
heading3: {
|
||||||
|
fontSize: 10,
|
||||||
|
fontWeight: 700,
|
||||||
|
color: C.navyDeep,
|
||||||
|
marginTop: 12,
|
||||||
|
marginBottom: 6,
|
||||||
|
},
|
||||||
|
list: {
|
||||||
|
marginBottom: 12,
|
||||||
|
marginLeft: 8,
|
||||||
|
},
|
||||||
|
listItem: {
|
||||||
|
flexDirection: 'row',
|
||||||
|
marginBottom: 4,
|
||||||
|
},
|
||||||
|
listItemBullet: {
|
||||||
|
width: 12,
|
||||||
|
fontSize: 10,
|
||||||
|
color: C.green,
|
||||||
|
fontWeight: 700,
|
||||||
|
},
|
||||||
|
listItemContent: {
|
||||||
|
flex: 1,
|
||||||
|
fontSize: 10,
|
||||||
|
color: C.gray600,
|
||||||
|
lineHeight: 1.7,
|
||||||
|
},
|
||||||
|
link: {
|
||||||
|
color: C.green,
|
||||||
|
textDecoration: 'none',
|
||||||
|
},
|
||||||
|
textBold: {
|
||||||
|
fontWeight: 700,
|
||||||
|
fontFamily: 'Helvetica-Bold',
|
||||||
|
color: C.navyDeep,
|
||||||
|
},
|
||||||
|
textItalic: {
|
||||||
|
fontStyle: 'italic',
|
||||||
|
},
|
||||||
|
|
||||||
|
// Footer — matches brochure style
|
||||||
|
footer: {
|
||||||
|
position: 'absolute',
|
||||||
|
bottom: 28,
|
||||||
|
left: MARGIN,
|
||||||
|
right: MARGIN,
|
||||||
|
flexDirection: 'row',
|
||||||
|
justifyContent: 'space-between',
|
||||||
|
alignItems: 'center',
|
||||||
|
paddingTop: 12,
|
||||||
|
borderTopWidth: 2,
|
||||||
|
borderTopColor: C.green,
|
||||||
|
},
|
||||||
|
|
||||||
|
footerText: {
|
||||||
|
fontSize: 7,
|
||||||
|
color: C.gray400,
|
||||||
|
fontWeight: 400,
|
||||||
|
textTransform: 'uppercase',
|
||||||
|
letterSpacing: 0.8,
|
||||||
|
},
|
||||||
|
|
||||||
|
footerBrand: {
|
||||||
|
fontSize: 9,
|
||||||
|
fontWeight: 700,
|
||||||
|
color: C.navyDeep,
|
||||||
|
textTransform: 'uppercase',
|
||||||
|
letterSpacing: 1.5,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
// ─── Lexical to React-PDF Renderer ────────────────────────────────
|
||||||
|
|
||||||
|
const renderLexicalNode = (node: any, idx: number): React.ReactNode => {
|
||||||
|
if (!node) return null;
|
||||||
|
|
||||||
|
switch (node.type) {
|
||||||
|
case 'text': {
|
||||||
|
const format = node.format || 0;
|
||||||
|
const isBold = (format & 1) !== 0;
|
||||||
|
const isItalic = (format & 2) !== 0;
|
||||||
|
|
||||||
|
let elementStyle: any = {};
|
||||||
|
if (isBold) elementStyle = { ...elementStyle, ...styles.textBold };
|
||||||
|
if (isItalic) elementStyle = { ...elementStyle, ...styles.textItalic };
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Text key={idx} style={elementStyle}>
|
||||||
|
{node.text}
|
||||||
|
</Text>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
case 'paragraph': {
|
||||||
|
return (
|
||||||
|
<Text key={idx} style={styles.paragraph}>
|
||||||
|
{node.children?.map((child: any, i: number) => renderLexicalNode(child, i))}
|
||||||
|
</Text>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
case 'heading': {
|
||||||
|
let hStyle = styles.heading3;
|
||||||
|
if (node.tag === 'h1') hStyle = styles.heading1;
|
||||||
|
if (node.tag === 'h2') hStyle = styles.heading2;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Text key={idx} style={hStyle}>
|
||||||
|
{node.children?.map((child: any, i: number) => renderLexicalNode(child, i))}
|
||||||
|
</Text>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
case 'list': {
|
||||||
|
return (
|
||||||
|
<View key={idx} style={styles.list}>
|
||||||
|
{node.children?.map((child: any, i: number) => {
|
||||||
|
if (child.type === 'listitem') {
|
||||||
|
return (
|
||||||
|
<View key={i} style={styles.listItem}>
|
||||||
|
<Text style={styles.listItemBullet}>
|
||||||
|
{node.listType === 'number' ? `${i + 1}.` : '•'}
|
||||||
|
</Text>
|
||||||
|
<Text style={styles.listItemContent}>
|
||||||
|
{child.children?.map((c: any, ci: number) => renderLexicalNode(c, ci))}
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return renderLexicalNode(child, i);
|
||||||
|
})}
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
case 'link': {
|
||||||
|
const href = node.fields?.url || node.url || '#';
|
||||||
|
return (
|
||||||
|
<Link key={idx} src={href} style={styles.link}>
|
||||||
|
{node.children?.map((child: any, i: number) => renderLexicalNode(child, i))}
|
||||||
|
</Link>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
case 'linebreak': {
|
||||||
|
return <Text key={idx}>{'\n'}</Text>;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Ignore payload blocks recursively to avoid crashing
|
||||||
|
case 'block':
|
||||||
|
return null;
|
||||||
|
|
||||||
|
default:
|
||||||
|
if (node.children) {
|
||||||
|
return (
|
||||||
|
<Text key={idx}>
|
||||||
|
{node.children.map((child: any, i: number) => renderLexicalNode(child, i))}
|
||||||
|
</Text>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
interface PDFPageProps {
|
||||||
|
page: any;
|
||||||
|
locale?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const PDFPage: React.FC<PDFPageProps> = ({ page, locale = 'de' }) => {
|
||||||
|
const dateStr = new Date().toLocaleDateString(locale === 'en' ? 'en-US' : 'de-DE', {
|
||||||
|
year: 'numeric',
|
||||||
|
month: 'long',
|
||||||
|
day: 'numeric',
|
||||||
|
});
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Document>
|
||||||
|
<Page size="A4" style={styles.page}>
|
||||||
|
{/* Hero Header */}
|
||||||
|
<View style={styles.hero} fixed>
|
||||||
|
<View style={styles.header}>
|
||||||
|
<View>
|
||||||
|
<Text style={styles.logoText}>KLZ</Text>
|
||||||
|
</View>
|
||||||
|
<Text style={styles.docTitle}>{locale === 'en' ? 'Document' : 'Dokument'}</Text>
|
||||||
|
</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>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
{/* Minimal footer */}
|
||||||
|
<View style={styles.footer} fixed>
|
||||||
|
<Text style={styles.footerBrand}>KLZ CABLES</Text>
|
||||||
|
<Text style={styles.footerText}>{dateStr}</Text>
|
||||||
|
</View>
|
||||||
|
</Page>
|
||||||
|
</Document>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -18,6 +18,7 @@ export interface ProductData {
|
|||||||
slug: string;
|
slug: string;
|
||||||
frontmatter: ProductFrontmatter;
|
frontmatter: ProductFrontmatter;
|
||||||
content: any; // Lexical AST from Payload
|
content: any; // Lexical AST from Payload
|
||||||
|
application?: any; // Lexical AST for Application field
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function getProductMetadata(
|
export async function getProductMetadata(
|
||||||
@@ -113,6 +114,7 @@ export async function getProductBySlug(slug: string, locale: string): Promise<Pr
|
|||||||
: 50,
|
: 50,
|
||||||
},
|
},
|
||||||
content: doc.content,
|
content: doc.content,
|
||||||
|
application: doc.application,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -195,6 +197,7 @@ export async function getAllProducts(locale: string): Promise<ProductData[]> {
|
|||||||
: 50,
|
: 50,
|
||||||
},
|
},
|
||||||
content: null,
|
content: null,
|
||||||
|
application: null,
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -65,7 +65,15 @@ export function getServerAppServices(): AppServices {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const errors = config.errors.glitchtip.enabled
|
const errors = config.errors.glitchtip.enabled
|
||||||
? new GlitchtipErrorReportingService({ enabled: true }, logger, notifications)
|
? new GlitchtipErrorReportingService(
|
||||||
|
{
|
||||||
|
enabled: true,
|
||||||
|
dsn: config.errors.glitchtip.dsn,
|
||||||
|
tracesSampleRate: 1.0, // Server-side we usually want higher visibility
|
||||||
|
},
|
||||||
|
logger,
|
||||||
|
notifications,
|
||||||
|
)
|
||||||
: new NoopErrorReportingService();
|
: new NoopErrorReportingService();
|
||||||
|
|
||||||
if (config.errors.glitchtip.enabled) {
|
if (config.errors.glitchtip.enabled) {
|
||||||
|
|||||||
@@ -69,7 +69,15 @@ export function getAppServices(): AppServices {
|
|||||||
|
|
||||||
// Create error reporting service (GlitchTip/Sentry or no-op)
|
// Create error reporting service (GlitchTip/Sentry or no-op)
|
||||||
const errors = sentryEnabled
|
const errors = sentryEnabled
|
||||||
? new GlitchtipErrorReportingService({ enabled: true }, logger, notifications)
|
? new GlitchtipErrorReportingService(
|
||||||
|
{
|
||||||
|
enabled: true,
|
||||||
|
dsn: config.errors.glitchtip.dsn,
|
||||||
|
tracesSampleRate: 0.1, // Default to 10% sampling
|
||||||
|
},
|
||||||
|
logger,
|
||||||
|
notifications,
|
||||||
|
)
|
||||||
: new NoopErrorReportingService();
|
: new NoopErrorReportingService();
|
||||||
|
|
||||||
if (sentryEnabled) {
|
if (sentryEnabled) {
|
||||||
|
|||||||
@@ -8,6 +8,8 @@ import type { LoggerService } from '../logging/logger-service';
|
|||||||
|
|
||||||
export type GlitchtipErrorReportingServiceOptions = {
|
export type GlitchtipErrorReportingServiceOptions = {
|
||||||
enabled: boolean;
|
enabled: boolean;
|
||||||
|
dsn?: string;
|
||||||
|
tracesSampleRate?: number;
|
||||||
};
|
};
|
||||||
|
|
||||||
// GlitchTip speaks the Sentry protocol; @sentry/nextjs can send to GlitchTip via DSN.
|
// GlitchTip speaks the Sentry protocol; @sentry/nextjs can send to GlitchTip via DSN.
|
||||||
@@ -46,12 +48,12 @@ export class GlitchtipErrorReportingService implements ErrorReportingService {
|
|||||||
if (!this.sentryPromise) {
|
if (!this.sentryPromise) {
|
||||||
this.sentryPromise = import('@sentry/nextjs').then((Sentry) => {
|
this.sentryPromise = import('@sentry/nextjs').then((Sentry) => {
|
||||||
// Client-side initialization must happen here since sentry.client.config.ts is empty
|
// Client-side initialization must happen here since sentry.client.config.ts is empty
|
||||||
if (typeof window !== 'undefined' && process.env.NODE_ENV === 'production') {
|
if (typeof window !== 'undefined' && this.options.enabled) {
|
||||||
Sentry.init({
|
Sentry.init({
|
||||||
dsn: 'https://public@errors.infra.mintel.me/1',
|
dsn: this.options.dsn || 'https://public@errors.infra.mintel.me/1',
|
||||||
tunnel: '/errors/api/relay',
|
tunnel: '/errors/api/relay',
|
||||||
enabled: true,
|
enabled: true,
|
||||||
tracesSampleRate: 0,
|
tracesSampleRate: this.options.tracesSampleRate ?? 0.1,
|
||||||
replaysOnErrorSampleRate: 1.0,
|
replaysOnErrorSampleRate: 1.0,
|
||||||
replaysSessionSampleRate: 0.1,
|
replaysSessionSampleRate: 0.1,
|
||||||
});
|
});
|
||||||
|
|||||||
104
lib/utils/technical.ts
Normal file
104
lib/utils/technical.ts
Normal file
@@ -0,0 +1,104 @@
|
|||||||
|
/**
|
||||||
|
* 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,
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -226,6 +226,10 @@
|
|||||||
"requestQuoteDesc": "Erhalten Sie technische Spezifikationen und Preise für Ihr Projekt.",
|
"requestQuoteDesc": "Erhalten Sie technische Spezifikationen und Preise für Ihr Projekt.",
|
||||||
"downloadDatasheet": "Datenblatt herunterladen",
|
"downloadDatasheet": "Datenblatt herunterladen",
|
||||||
"downloadDatasheetDesc": "Erhalten Sie die vollständigen technischen Spezifikationen als PDF.",
|
"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": {
|
"form": {
|
||||||
"contactInfo": "Kontaktinformationen",
|
"contactInfo": "Kontaktinformationen",
|
||||||
"projectDetails": "Projektdetails",
|
"projectDetails": "Projektdetails",
|
||||||
@@ -395,5 +399,21 @@
|
|||||||
"description": "Es scheint, als wäre das Kabel zu dieser Seite unterbrochen worden. Wir konnten die gesuchte Ressource nicht finden.",
|
"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"
|
"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"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -226,6 +226,10 @@
|
|||||||
"requestQuoteDesc": "Get technical specifications and pricing for your project.",
|
"requestQuoteDesc": "Get technical specifications and pricing for your project.",
|
||||||
"downloadDatasheet": "Download Datasheet",
|
"downloadDatasheet": "Download Datasheet",
|
||||||
"downloadDatasheetDesc": "Get the full technical specifications in PDF format.",
|
"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": {
|
"form": {
|
||||||
"contactInfo": "Contact Information",
|
"contactInfo": "Contact Information",
|
||||||
"projectDetails": "Project Details",
|
"projectDetails": "Project Details",
|
||||||
@@ -395,5 +399,21 @@
|
|||||||
"description": "It seems the cable to this page has been disconnected. We couldn't find the resource you were looking for.",
|
"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"
|
"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"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -102,7 +102,7 @@ export default async function middleware(request: NextRequest) {
|
|||||||
|
|
||||||
export const config = {
|
export const config = {
|
||||||
matcher: [
|
matcher: [
|
||||||
'/((?!api|_next/static|_next/image|favicon.ico|admin|manifest.webmanifest|.*\\.(?:svg|png|jpg|jpeg|gif|webp|pdf|txt|vcf|xml|webm|mp4|map)$).*)',
|
'/((?!api|_next/static|_next/image|favicon.ico|admin|manifest.webmanifest|.*\\.(?:svg|png|jpg|jpeg|gif|webp|pdf|xlsx|txt|vcf|xml|webm|mp4|map)$).*)',
|
||||||
'/(de|en)/:path*',
|
'/(de|en)/:path*',
|
||||||
],
|
],
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -16,7 +16,11 @@ const nextConfig = {
|
|||||||
cpus: 3,
|
cpus: 3,
|
||||||
workerThreads: false,
|
workerThreads: false,
|
||||||
},
|
},
|
||||||
|
serverActions: {
|
||||||
|
allowedOrigins: ["*.klz-cables.com", "*.branch.klz-cables.com", "localhost:3000", "klz.localhost"],
|
||||||
|
},
|
||||||
reactStrictMode: false,
|
reactStrictMode: false,
|
||||||
|
swcMinify: true,
|
||||||
productionBrowserSourceMaps: false,
|
productionBrowserSourceMaps: false,
|
||||||
logging: {
|
logging: {
|
||||||
fetches: {
|
fetches: {
|
||||||
@@ -24,6 +28,7 @@ const nextConfig = {
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
...(isProd ? { output: 'standalone' } : {}),
|
...(isProd ? { output: 'standalone' } : {}),
|
||||||
|
// Rewrites moved to bottom merged function
|
||||||
async headers() {
|
async headers() {
|
||||||
const isProd = process.env.NODE_ENV === 'production';
|
const isProd = process.env.NODE_ENV === 'production';
|
||||||
const umamiDomain = new URL(process.env.UMAMI_API_ENDPOINT || 'https://analytics.infra.mintel.me').origin;
|
const umamiDomain = new URL(process.env.UMAMI_API_ENDPOINT || 'https://analytics.infra.mintel.me').origin;
|
||||||
@@ -78,7 +83,7 @@ const nextConfig = {
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
key: 'Strict-Transport-Security',
|
key: 'Strict-Transport-Security',
|
||||||
value: 'max-age=63072000; includeSubDomains; preload',
|
value: isProd ? 'max-age=63072000; includeSubDomains; preload' : 'max-age=0',
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
@@ -392,6 +397,7 @@ const nextConfig = {
|
|||||||
];
|
];
|
||||||
},
|
},
|
||||||
images: {
|
images: {
|
||||||
|
qualities: [75, 100],
|
||||||
formats: ['image/webp'],
|
formats: ['image/webp'],
|
||||||
deviceSizes: [640, 750, 828, 1080, 1200, 1920, 2048, 3840],
|
deviceSizes: [640, 750, 828, 1080, 1200, 1920, 2048, 3840],
|
||||||
remotePatterns: [
|
remotePatterns: [
|
||||||
@@ -424,6 +430,22 @@ const nextConfig = {
|
|||||||
async rewrites() {
|
async rewrites() {
|
||||||
return {
|
return {
|
||||||
beforeFiles: [
|
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',
|
source: '/de/produkte',
|
||||||
destination: '/de/products',
|
destination: '/de/products',
|
||||||
|
|||||||
14
package.json
14
package.json
@@ -13,7 +13,7 @@
|
|||||||
"@payloadcms/next": "^3.77.0",
|
"@payloadcms/next": "^3.77.0",
|
||||||
"@payloadcms/richtext-lexical": "^3.77.0",
|
"@payloadcms/richtext-lexical": "^3.77.0",
|
||||||
"@payloadcms/ui": "^3.77.0",
|
"@payloadcms/ui": "^3.77.0",
|
||||||
"@react-email/components": "^1.0.7",
|
"@react-email/components": "1.0.8",
|
||||||
"@react-pdf/renderer": "^4.3.2",
|
"@react-pdf/renderer": "^4.3.2",
|
||||||
"@sentry/nextjs": "^10.39.0",
|
"@sentry/nextjs": "^10.39.0",
|
||||||
"@types/recharts": "^2.0.1",
|
"@types/recharts": "^2.0.1",
|
||||||
@@ -89,7 +89,8 @@
|
|||||||
"tsx": "^4.21.0",
|
"tsx": "^4.21.0",
|
||||||
"turbo": "^2.8.10",
|
"turbo": "^2.8.10",
|
||||||
"typescript": "^5.7.2",
|
"typescript": "^5.7.2",
|
||||||
"vitest": "^4.0.16"
|
"vitest": "^4.0.16",
|
||||||
|
"xlsx-cli": "^1.1.3"
|
||||||
},
|
},
|
||||||
"scripts": {
|
"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'",
|
"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'",
|
||||||
@@ -101,6 +102,7 @@
|
|||||||
"typecheck": "tsc --noEmit",
|
"typecheck": "tsc --noEmit",
|
||||||
"test": "vitest run --passWithNoTests",
|
"test": "vitest run --passWithNoTests",
|
||||||
"test:og": "vitest run tests/og-image.test.ts",
|
"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:og": "tsx scripts/check-og-images.ts",
|
||||||
"check:a11y": "pa11y-ci",
|
"check:a11y": "pa11y-ci",
|
||||||
"check:wcag": "tsx ./scripts/wcag-sitemap.ts",
|
"check:wcag": "tsx ./scripts/wcag-sitemap.ts",
|
||||||
@@ -113,9 +115,11 @@
|
|||||||
"check:assets": "tsx ./scripts/check-broken-assets.ts",
|
"check:assets": "tsx ./scripts/check-broken-assets.ts",
|
||||||
"check:forms": "tsx ./scripts/check-forms.ts",
|
"check:forms": "tsx ./scripts/check-forms.ts",
|
||||||
"check:apis": "tsx ./scripts/check-apis.ts",
|
"check:apis": "tsx ./scripts/check-apis.ts",
|
||||||
"pdf:datasheets": "tsx ./scripts/generate-pdf-datasheets.ts",
|
"pdf:datasheets": "tsx ./scripts/generate-pdf-datasheets.tsx",
|
||||||
"pdf:datasheets:legacy": "tsx ./scripts/generate-pdf-datasheets-pdf-lib.ts",
|
"pdf:datasheets:legacy": "tsx ./scripts/generate-pdf-datasheets-pdf-lib.ts",
|
||||||
"cms:migrate": "payload migrate",
|
"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:seed": "tsx ./scripts/seed-payload.ts",
|
"cms:seed": "tsx ./scripts/seed-payload.ts",
|
||||||
"assets:push:testing": "bash ./scripts/assets-sync.sh local testing",
|
"assets:push:testing": "bash ./scripts/assets-sync.sh local testing",
|
||||||
"assets:push:staging": "bash ./scripts/assets-sync.sh local staging",
|
"assets:push:staging": "bash ./scripts/assets-sync.sh local staging",
|
||||||
@@ -139,7 +143,7 @@
|
|||||||
"prepare": "husky",
|
"prepare": "husky",
|
||||||
"preinstall": "npx only-allow pnpm"
|
"preinstall": "npx only-allow pnpm"
|
||||||
},
|
},
|
||||||
"version": "2.0.2",
|
"version": "2.2.12",
|
||||||
"pnpm": {
|
"pnpm": {
|
||||||
"onlyBuiltDependencies": [
|
"onlyBuiltDependencies": [
|
||||||
"@parcel/watcher",
|
"@parcel/watcher",
|
||||||
|
|||||||
@@ -87,7 +87,9 @@ export interface Config {
|
|||||||
products: ProductsSelect<false> | ProductsSelect<true>;
|
products: ProductsSelect<false> | ProductsSelect<true>;
|
||||||
pages: PagesSelect<false> | PagesSelect<true>;
|
pages: PagesSelect<false> | PagesSelect<true>;
|
||||||
'payload-kv': PayloadKvSelect<false> | PayloadKvSelect<true>;
|
'payload-kv': PayloadKvSelect<false> | PayloadKvSelect<true>;
|
||||||
'payload-locked-documents': PayloadLockedDocumentsSelect<false> | PayloadLockedDocumentsSelect<true>;
|
'payload-locked-documents':
|
||||||
|
| PayloadLockedDocumentsSelect<false>
|
||||||
|
| PayloadLockedDocumentsSelect<true>;
|
||||||
'payload-preferences': PayloadPreferencesSelect<false> | PayloadPreferencesSelect<true>;
|
'payload-preferences': PayloadPreferencesSelect<false> | PayloadPreferencesSelect<true>;
|
||||||
'payload-migrations': PayloadMigrationsSelect<false> | PayloadMigrationsSelect<true>;
|
'payload-migrations': PayloadMigrationsSelect<false> | PayloadMigrationsSelect<true>;
|
||||||
};
|
};
|
||||||
@@ -98,6 +100,9 @@ export interface Config {
|
|||||||
globals: {};
|
globals: {};
|
||||||
globalsSelect: {};
|
globalsSelect: {};
|
||||||
locale: 'de' | 'en';
|
locale: 'de' | 'en';
|
||||||
|
widgets: {
|
||||||
|
collections: CollectionsWidget;
|
||||||
|
};
|
||||||
user: User;
|
user: User;
|
||||||
jobs: {
|
jobs: {
|
||||||
tasks: unknown;
|
tasks: unknown;
|
||||||
@@ -249,7 +254,7 @@ export interface FormSubmission {
|
|||||||
id: number;
|
id: number;
|
||||||
name: string;
|
name: string;
|
||||||
email: string;
|
email: string;
|
||||||
type: 'contact' | 'product_quote';
|
type: 'contact' | 'product_quote' | 'brochure_download';
|
||||||
/**
|
/**
|
||||||
* The specific KLZ product the user requested a quote for.
|
* The specific KLZ product the user requested a quote for.
|
||||||
*/
|
*/
|
||||||
@@ -619,6 +624,16 @@ export interface PayloadMigrationsSelect<T extends boolean = true> {
|
|||||||
updatedAt?: T;
|
updatedAt?: T;
|
||||||
createdAt?: T;
|
createdAt?: T;
|
||||||
}
|
}
|
||||||
|
/**
|
||||||
|
* This interface was referenced by `Config`'s JSON-Schema
|
||||||
|
* via the `definition` "collections_widget".
|
||||||
|
*/
|
||||||
|
export interface CollectionsWidget {
|
||||||
|
data?: {
|
||||||
|
[k: string]: unknown;
|
||||||
|
};
|
||||||
|
width: 'full';
|
||||||
|
}
|
||||||
/**
|
/**
|
||||||
* This interface was referenced by `Config`'s JSON-Schema
|
* This interface was referenced by `Config`'s JSON-Schema
|
||||||
* via the `definition` "StatsBlock".
|
* via the `definition` "StatsBlock".
|
||||||
@@ -957,7 +972,6 @@ export interface Auth {
|
|||||||
[k: string]: unknown;
|
[k: string]: unknown;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
declare module 'payload' {
|
declare module 'payload' {
|
||||||
export interface GeneratedTypes extends Config {}
|
export interface GeneratedTypes extends Config {}
|
||||||
}
|
}
|
||||||
|
|||||||
3176
pnpm-lock.yaml
generated
3176
pnpm-lock.yaml
generated
File diff suppressed because it is too large
Load Diff
BIN
public/brochure/klz-product-catalog-de.pdf
Normal file
BIN
public/brochure/klz-product-catalog-de.pdf
Normal file
Binary file not shown.
BIN
public/brochure/klz-product-catalog-en.pdf
Normal file
BIN
public/brochure/klz-product-catalog-en.pdf
Normal file
Binary file not shown.
BIN
public/datasheets/h1z2z2-k-de.pdf
Normal file
BIN
public/datasheets/h1z2z2-k-de.pdf
Normal file
Binary file not shown.
BIN
public/datasheets/h1z2z2-k-en.pdf
Normal file
BIN
public/datasheets/h1z2z2-k-en.pdf
Normal file
Binary file not shown.
BIN
public/datasheets/high-voltage/n2x-f-k2y-de.pdf
Normal file
BIN
public/datasheets/high-voltage/n2x-f-k2y-de.pdf
Normal file
Binary file not shown.
BIN
public/datasheets/high-voltage/n2x-f-k2y-en.pdf
Normal file
BIN
public/datasheets/high-voltage/n2x-f-k2y-en.pdf
Normal file
Binary file not shown.
BIN
public/datasheets/high-voltage/n2x-f-kld2y-de.pdf
Normal file
BIN
public/datasheets/high-voltage/n2x-f-kld2y-de.pdf
Normal file
Binary file not shown.
BIN
public/datasheets/high-voltage/n2x-f-kld2y-en.pdf
Normal file
BIN
public/datasheets/high-voltage/n2x-f-kld2y-en.pdf
Normal file
Binary file not shown.
BIN
public/datasheets/high-voltage/n2xs-fl-2y-de.pdf
Normal file
BIN
public/datasheets/high-voltage/n2xs-fl-2y-de.pdf
Normal file
Binary file not shown.
BIN
public/datasheets/high-voltage/n2xs-fl-2y-en.pdf
Normal file
BIN
public/datasheets/high-voltage/n2xs-fl-2y-en.pdf
Normal file
Binary file not shown.
BIN
public/datasheets/high-voltage/n2xsfl2y-hv-de.pdf
Normal file
BIN
public/datasheets/high-voltage/n2xsfl2y-hv-de.pdf
Normal file
Binary file not shown.
BIN
public/datasheets/high-voltage/n2xsfl2y-hv-en.pdf
Normal file
BIN
public/datasheets/high-voltage/n2xsfl2y-hv-en.pdf
Normal file
Binary file not shown.
BIN
public/datasheets/high-voltage/na2x-f-k2y-de.pdf
Normal file
BIN
public/datasheets/high-voltage/na2x-f-k2y-de.pdf
Normal file
Binary file not shown.
BIN
public/datasheets/high-voltage/na2x-f-k2y-en.pdf
Normal file
BIN
public/datasheets/high-voltage/na2x-f-k2y-en.pdf
Normal file
Binary file not shown.
BIN
public/datasheets/high-voltage/na2x-f-kld2y-de.pdf
Normal file
BIN
public/datasheets/high-voltage/na2x-f-kld2y-de.pdf
Normal file
Binary file not shown.
BIN
public/datasheets/high-voltage/na2x-f-kld2y-en.pdf
Normal file
BIN
public/datasheets/high-voltage/na2x-f-kld2y-en.pdf
Normal file
Binary file not shown.
BIN
public/datasheets/high-voltage/na2xs-fl-2y-de.pdf
Normal file
BIN
public/datasheets/high-voltage/na2xs-fl-2y-de.pdf
Normal file
Binary file not shown.
BIN
public/datasheets/high-voltage/na2xs-fl-2y-en.pdf
Normal file
BIN
public/datasheets/high-voltage/na2xs-fl-2y-en.pdf
Normal file
Binary file not shown.
BIN
public/datasheets/high-voltage/na2xsfl2y-hv-de.pdf
Normal file
BIN
public/datasheets/high-voltage/na2xsfl2y-hv-de.pdf
Normal file
Binary file not shown.
BIN
public/datasheets/high-voltage/na2xsfl2y-hv-en.pdf
Normal file
BIN
public/datasheets/high-voltage/na2xsfl2y-hv-en.pdf
Normal file
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user