Compare commits
175 Commits
v2.1.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 | |||
| eebe7972e0 | |||
| a9c7fa7c5e | |||
| 85e7ff71d5 | |||
| 2acb0c1608 | |||
| 082733c4f4 | |||
| af67ae7994 | |||
| 1fd247e358 | |||
| 44401cf546 | |||
| 7f106b1fa7 | |||
| 08425a3a42 | |||
| 62f1e9a89c | |||
| a5718c5013 | |||
| 82bb7240d5 | |||
| 9e7f6ec76f | |||
| b3057d8be0 | |||
| 3b45a967f7 | |||
| cadb104917 | |||
| 0be885428d | |||
| 009f12a3bf | |||
| 8e2a06d6f2 | |||
| 4f2bf3fa51 | |||
| 064ebf45e3 | |||
| e6dfeaffef | |||
| 7cdfe5d7f8 | |||
| 83f4b8eea8 | |||
| 97e76c7cac | |||
| 6caa850045 | |||
| 04ce0ecedd | |||
| 083859d52d | |||
| a13074902b | |||
| 4280f11772 | |||
| 3049c1b6e7 | |||
| 647f9a5f19 | |||
| a2872be02e | |||
| 9c3c7bd34b | |||
| 45602db7ff | |||
| 89405e6e18 | |||
| 57d54231eb | |||
| 5c4225d0a9 | |||
| e1101f2e60 | |||
| 0be6076512 | |||
| 62400943c2 | |||
| 4c60029e21 | |||
| b3c5b911d9 | |||
| 89f00c79a1 | |||
| 98ac3dbd10 | |||
| 0db4c819ff | |||
| 08a3b0be7b | |||
| a953820241 | |||
| fa02ac597f | |||
| 925765233e | |||
| 0487bd8ebe | |||
| 87b2624ab3 | |||
| 7cad437eb4 | |||
| f8b7d4f59d |
11
.env
11
.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
|
||||||
@@ -25,3 +26,13 @@ MAIL_RECIPIENTS=marc@cablecreations.de,info@klz-cables.com
|
|||||||
PAYLOAD_DB_NAME=payload
|
PAYLOAD_DB_NAME=payload
|
||||||
PAYLOAD_DB_USER=payload
|
PAYLOAD_DB_USER=payload
|
||||||
PAYLOAD_DB_PASSWORD=120in09oenaoinsd9iaidon
|
PAYLOAD_DB_PASSWORD=120in09oenaoinsd9iaidon
|
||||||
|
|
||||||
|
# ────────────────────────────────────────────────────────────────────────────
|
||||||
|
# Hetzner S3 Object Storage
|
||||||
|
# ────────────────────────────────────────────────────────────────────────────
|
||||||
|
S3_ENDPOINT=https://fsn1.your-objectstorage.com
|
||||||
|
S3_ACCESS_KEY=ROB3MSWMEIGRL7N94ZKS
|
||||||
|
S3_SECRET_KEY=9QJV3NE8xeLxhyufhNU7lsUB0RffJxPhGuEuFSH3
|
||||||
|
S3_BUCKET=mintel
|
||||||
|
S3_REGION=fsn1
|
||||||
|
S3_PREFIX=klz-cables
|
||||||
@@ -1,52 +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: |
|
|
||||||
REGISTRY="${{ vars.REGISTRY_HOST || 'npm.infra.mintel.me' }}"
|
|
||||||
echo "@mintel:registry=https://$REGISTRY" > .npmrc
|
|
||||||
echo "//$REGISTRY/:_authToken=${{ secrets.REGISTRY_PASS }}" >> .npmrc
|
|
||||||
|
|
||||||
- name: Install dependencies
|
|
||||||
run: pnpm install
|
|
||||||
env:
|
|
||||||
NPM_TOKEN: ${{ secrets.REGISTRY_PASS }}
|
|
||||||
|
|
||||||
- name: 🧪 QA Checks
|
|
||||||
env:
|
|
||||||
TURBO_TELEMETRY_DISABLED: "1"
|
|
||||||
run: npx turbo run check:mdx lint typecheck test --cache-dir=".turbo"
|
|
||||||
|
|
||||||
- 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,17 +85,19 @@ 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
|
# Standardize Traefik Rule (escaped backticks for Traefik v3)
|
||||||
if [[ "$TRAEFIK_HOST" == *","* ]]; then
|
if [[ "$TRAEFIK_HOST" == *","* ]]; then
|
||||||
TRAEFIK_RULE=$(echo "$TRAEFIK_HOST" | sed 's/,/ /g' | awk '{for(i=1;i<=NF;i++) printf "Host(\"%s\")%s", $i, (i==NF?"":" || ")}')
|
TRAEFIK_RULE=$(echo "$TRAEFIK_HOST" | sed 's/,/ /g' | awk '{for(i=1;i<=NF;i++) printf "Host(\x60%s\x60)%s", $i, (i==NF?"":" || ")}')
|
||||||
PRIMARY_HOST=$(echo "$TRAEFIK_HOST" | cut -d',' -f1 | sed 's/ //g')
|
PRIMARY_HOST=$(echo "$TRAEFIK_HOST" | cut -d',' -f1 | sed 's/ //g')
|
||||||
else
|
else
|
||||||
TRAEFIK_RULE="Host(\"$TRAEFIK_HOST\")"
|
TRAEFIK_RULE='Host(`'"$TRAEFIK_HOST"'`)'
|
||||||
PRIMARY_HOST="$TRAEFIK_HOST"
|
PRIMARY_HOST="$TRAEFIK_HOST"
|
||||||
fi
|
fi
|
||||||
|
|
||||||
|
GATEKEEPER_HOST="gatekeeper.$PRIMARY_HOST"
|
||||||
|
|
||||||
{
|
{
|
||||||
echo "target=$TARGET"
|
echo "target=$TARGET"
|
||||||
@@ -101,6 +105,7 @@ jobs:
|
|||||||
echo "env_file=$ENV_FILE"
|
echo "env_file=$ENV_FILE"
|
||||||
echo "traefik_host=$PRIMARY_HOST"
|
echo "traefik_host=$PRIMARY_HOST"
|
||||||
echo "traefik_rule=$TRAEFIK_RULE"
|
echo "traefik_rule=$TRAEFIK_RULE"
|
||||||
|
echo "gatekeeper_host=$GATEKEEPER_HOST"
|
||||||
echo "next_public_url=https://$PRIMARY_HOST"
|
echo "next_public_url=https://$PRIMARY_HOST"
|
||||||
if [[ "$TARGET" == "production" ]]; then
|
if [[ "$TARGET" == "production" ]]; then
|
||||||
echo "project_name=klz-cablescom"
|
echo "project_name=klz-cablescom"
|
||||||
@@ -110,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
|
||||||
@@ -153,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:
|
||||||
@@ -169,19 +177,21 @@ jobs:
|
|||||||
|
|
||||||
- name: 🔐 Registry Auth
|
- name: 🔐 Registry Auth
|
||||||
run: |
|
run: |
|
||||||
echo "@mintel:registry=https://${{ vars.REGISTRY_HOST || 'npm.infra.mintel.me' }}" > .npmrc
|
echo "@mintel:registry=https://git.infra.mintel.me/api/packages/mmintel/npm" > .npmrc
|
||||||
echo "//${{ vars.REGISTRY_HOST || 'npm.infra.mintel.me' }}/:_authToken=${{ secrets.REGISTRY_PASS }}" >> .npmrc
|
echo "//git.infra.mintel.me/api/packages/mmintel/npm/:_authToken=${{ secrets.NPM_TOKEN }}" >> .npmrc
|
||||||
- name: Install dependencies
|
- name: Install dependencies
|
||||||
run: pnpm install --frozen-lockfile
|
run: |
|
||||||
|
pnpm store prune
|
||||||
|
pnpm install --no-frozen-lockfile
|
||||||
|
|
||||||
- name: 🔒 Security Audit
|
- name: 🔒 Security Audit
|
||||||
run: pnpm audit --audit-level high
|
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 check:spell typecheck test --cache-dir=".turbo"
|
run: npx turbo run lint typecheck test --cache-dir=".turbo"
|
||||||
|
|
||||||
# ──────────────────────────────────────────────────────────────────────────────
|
# ──────────────────────────────────────────────────────────────────────────────
|
||||||
# JOB 3: Build & Push
|
# JOB 3: Build & Push
|
||||||
# ──────────────────────────────────────────────────────────────────────────────
|
# ──────────────────────────────────────────────────────────────────────────────
|
||||||
@@ -198,23 +208,24 @@ 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:
|
||||||
context: .
|
context: .
|
||||||
push: true
|
push: true
|
||||||
provenance: false
|
provenance: false
|
||||||
platforms: linux/arm64
|
platforms: linux/amd64
|
||||||
build-args: |
|
build-args: |
|
||||||
NEXT_PUBLIC_BASE_URL=${{ needs.prepare.outputs.next_public_url }}
|
NEXT_PUBLIC_BASE_URL=${{ needs.prepare.outputs.next_public_url }}
|
||||||
NEXT_PUBLIC_TARGET=${{ needs.prepare.outputs.target }}
|
NEXT_PUBLIC_TARGET=${{ needs.prepare.outputs.target }}
|
||||||
UMAMI_WEBSITE_ID=${{ secrets.UMAMI_WEBSITE_ID || vars.UMAMI_WEBSITE_ID }}
|
UMAMI_WEBSITE_ID=${{ secrets.UMAMI_WEBSITE_ID || vars.UMAMI_WEBSITE_ID }}
|
||||||
UMAMI_API_ENDPOINT=${{ secrets.UMAMI_API_ENDPOINT || vars.UMAMI_API_ENDPOINT || 'https://analytics.infra.mintel.me' }}
|
UMAMI_API_ENDPOINT=${{ secrets.UMAMI_API_ENDPOINT || vars.UMAMI_API_ENDPOINT || 'https://analytics.infra.mintel.me' }}
|
||||||
NPM_TOKEN=${{ secrets.REGISTRY_PASS }}
|
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.REGISTRY_PASS }}"
|
NPM_TOKEN=${{ secrets.NPM_TOKEN }}
|
||||||
|
|
||||||
# ──────────────────────────────────────────────────────────────────────────────
|
# ──────────────────────────────────────────────────────────────────────────────
|
||||||
# JOB 4: Deploy
|
# JOB 4: Deploy
|
||||||
@@ -231,6 +242,8 @@ jobs:
|
|||||||
PROJECT_NAME: ${{ needs.prepare.outputs.project_name }}
|
PROJECT_NAME: ${{ needs.prepare.outputs.project_name }}
|
||||||
NEXT_PUBLIC_BASE_URL: ${{ needs.prepare.outputs.next_public_url }}
|
NEXT_PUBLIC_BASE_URL: ${{ needs.prepare.outputs.next_public_url }}
|
||||||
TRAEFIK_HOST: ${{ needs.prepare.outputs.traefik_host }}
|
TRAEFIK_HOST: ${{ needs.prepare.outputs.traefik_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' }}
|
||||||
@@ -243,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 }}
|
||||||
@@ -255,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
|
||||||
@@ -282,7 +304,7 @@ jobs:
|
|||||||
AUTH_MIDDLEWARE_UNPROTECTED="$STD_MW"
|
AUTH_MIDDLEWARE_UNPROTECTED="$STD_MW"
|
||||||
|
|
||||||
# Gatekeeper Origin
|
# Gatekeeper Origin
|
||||||
GATEKEEPER_ORIGIN="$NEXT_PUBLIC_BASE_URL/gatekeeper"
|
GATEKEEPER_ORIGIN="${NEXT_PUBLIC_BASE_URL}/gatekeeper"
|
||||||
|
|
||||||
{
|
{
|
||||||
echo "# Generated by CI - $TARGET"
|
echo "# Generated by CI - $TARGET"
|
||||||
@@ -313,11 +335,18 @@ 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"
|
||||||
printf 'TRAEFIK_HOST_RULE=%s\n' "$TRAEFIK_RULE"
|
printf 'TRAEFIK_HOST_RULE=%s\n' "$TRAEFIK_RULE"
|
||||||
echo "TRAEFIK_HOST=$TRAEFIK_HOST"
|
echo "TRAEFIK_HOST=$TRAEFIK_HOST"
|
||||||
|
echo "GATEKEEPER_HOST=$GATEKEEPER_HOST"
|
||||||
echo "TRAEFIK_ENTRYPOINT=websecure"
|
echo "TRAEFIK_ENTRYPOINT=websecure"
|
||||||
echo "TRAEFIK_TLS=true"
|
echo "TRAEFIK_TLS=true"
|
||||||
echo "TRAEFIK_CERT_RESOLVER=le"
|
echo "TRAEFIK_CERT_RESOLVER=le"
|
||||||
@@ -331,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
|
||||||
@@ -341,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"
|
||||||
@@ -349,54 +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}"
|
|
||||||
ssh root@alpha.mintel.me "docker exec $DB_CONTAINER psql -U $REMOTE_DB_USER -d $REMOTE_DB_NAME -c \"
|
# Wait for DB to be healthy with a 60s timeout
|
||||||
DO \\\$\\\$ BEGIN
|
echo "⏳ Waiting for branch database to be ready..."
|
||||||
DELETE FROM payload_migrations WHERE batch = -1;
|
ssh root@alpha.mintel.me "
|
||||||
INSERT INTO payload_migrations (name, batch)
|
for i in {1..30}; do
|
||||||
SELECT name, batch FROM (VALUES
|
if docker exec $DB_CONTAINER pg_isready -U payload >/dev/null 2>&1; then
|
||||||
('20260223_195005_products_collection', 1),
|
exit 0
|
||||||
('20260223_195151_remove_sku_unique', 2),
|
fi
|
||||||
('20260225_003500_add_pages_collection', 3)
|
sleep 2
|
||||||
) AS v(name, batch)
|
done
|
||||||
WHERE NOT EXISTS (SELECT 1 FROM payload_migrations pm WHERE pm.name = v.name);
|
echo '❌ Database failed to become ready after 60 seconds'
|
||||||
EXCEPTION WHEN undefined_table THEN
|
exit 1
|
||||||
RAISE NOTICE 'payload_migrations table does not exist yet — skipping sanitization';
|
" || exit 1
|
||||||
END \\\$\\\$;
|
|
||||||
\"" || echo "⚠️ Migration sanitization skipped (table may not exist yet)"
|
# Copy Production Payload DB to Branch Payload DB & ensure media is copied
|
||||||
|
echo "📦 Syncing Production DB into Branch DB..."
|
||||||
|
ssh root@alpha.mintel.me "
|
||||||
|
set -e -o pipefail
|
||||||
|
docker exec klz-cablescom-klz-db-1 pg_dump -U payload -d payload --clean --if-exists | docker exec -i $DB_CONTAINER psql -U payload -d payload --quiet
|
||||||
|
rsync -a --delete /var/lib/docker/volumes/klz-cablescom_klz_media_data/_data/ /var/lib/docker/volumes/${{ needs.prepare.outputs.project_name }}_klz_media_data/_data/
|
||||||
|
" || exit 1
|
||||||
|
|
||||||
|
echo "✅ Branch database and media synced successfully."
|
||||||
|
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)
|
||||||
@@ -409,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
|
||||||
@@ -426,12 +489,28 @@ jobs:
|
|||||||
node-version: 20
|
node-version: 20
|
||||||
- name: 🔐 Registry Auth
|
- name: 🔐 Registry Auth
|
||||||
run: |
|
run: |
|
||||||
echo "@mintel:registry=https://${{ vars.REGISTRY_HOST || 'npm.infra.mintel.me' }}" > .npmrc
|
echo "@mintel:registry=https://git.infra.mintel.me/api/packages/mmintel/npm" > .npmrc
|
||||||
echo "//${{ vars.REGISTRY_HOST || 'npm.infra.mintel.me' }}/:_authToken=${{ secrets.REGISTRY_PASS }}" >> .npmrc
|
echo "//git.infra.mintel.me/api/packages/mmintel/npm/:_authToken=${{ secrets.NPM_TOKEN }}" >> .npmrc
|
||||||
- name: Install dependencies
|
- name: Install dependencies
|
||||||
id: deps
|
id: deps
|
||||||
run: pnpm install --frozen-lockfile
|
run: |
|
||||||
- name: 🔍 Install Chromium (for Asset Scan)
|
pnpm store prune
|
||||||
|
pnpm install --no-frozen-lockfile
|
||||||
|
- name: 📦 Cache APT Packages
|
||||||
|
uses: actions/cache@v4
|
||||||
|
with:
|
||||||
|
path: /var/cache/apt/archives
|
||||||
|
key: apt-cache-${{ runner.os }}-${{ runner.arch }}-chromium
|
||||||
|
|
||||||
|
- name: 💾 Cache Chromium
|
||||||
|
id: cache-chromium
|
||||||
|
uses: actions/cache@v4
|
||||||
|
with:
|
||||||
|
path: /usr/bin/chromium
|
||||||
|
key: ${{ runner.os }}-chromium-native-${{ hashFiles('package.json') }}
|
||||||
|
|
||||||
|
- name: 🔍 Install Chromium (Native & ARM64)
|
||||||
|
if: steps.cache-chromium.outputs.cache-hit != 'true'
|
||||||
run: |
|
run: |
|
||||||
rm -f /etc/apt/apt.conf.d/docker-clean
|
rm -f /etc/apt/apt.conf.d/docker-clean
|
||||||
apt-get update
|
apt-get update
|
||||||
@@ -453,139 +532,63 @@ jobs:
|
|||||||
[ -f /usr/bin/chromium ] && ln -sf /usr/bin/chromium /usr/bin/chromium-browser
|
[ -f /usr/bin/chromium ] && ln -sf /usr/bin/chromium /usr/bin/chromium-browser
|
||||||
|
|
||||||
# ── Critical Smoke Tests (MUST pass) ──────────────────────────────────
|
# ── Critical Smoke Tests (MUST pass) ──────────────────────────────────
|
||||||
|
- name: 🏥 CMS Deep Health Check
|
||||||
|
env:
|
||||||
|
DEPLOY_URL: ${{ needs.prepare.outputs.next_public_url }}
|
||||||
|
GK_PASS: ${{ secrets.GATEKEEPER_PASSWORD || 'klz2026' }}
|
||||||
|
run: |
|
||||||
|
echo "Waiting 10s for app to fully start..."
|
||||||
|
sleep 10
|
||||||
|
echo "Checking basic health..."
|
||||||
|
curl -sf "$DEPLOY_URL/health" || { echo "❌ Basic health check failed"; exit 1; }
|
||||||
|
echo "✅ Basic health OK"
|
||||||
|
echo "Checking CMS DB connectivity..."
|
||||||
|
RESPONSE=$(curl -sf "$DEPLOY_URL/api/health/cms?gk_bypass=$GK_PASS" 2>&1) || {
|
||||||
|
echo "❌ CMS health check failed!"
|
||||||
|
echo "$RESPONSE"
|
||||||
|
echo ""
|
||||||
|
echo "This usually means Payload CMS migrations failed or DB tables are missing."
|
||||||
|
echo "Check: docker logs \$APP_CONTAINER | grep -i error"
|
||||||
|
exit 1
|
||||||
|
}
|
||||||
|
echo "✅ CMS health: $RESPONSE"
|
||||||
- name: 🚀 OG Image Check
|
- name: 🚀 OG Image Check
|
||||||
if: always() && steps.deps.outcome == 'success'
|
if: always() && steps.deps.outcome == 'success'
|
||||||
env:
|
env:
|
||||||
TEST_URL: ${{ needs.prepare.outputs.next_public_url }}
|
TEST_URL: ${{ needs.prepare.outputs.next_public_url }}
|
||||||
run: pnpm run check:og
|
run: pnpm run check:og
|
||||||
- name: 🌐 Full Sitemap HTTP Validation
|
- name: 🌐 Core Smoke Tests (HTTP, API, Locale)
|
||||||
|
if: always() && steps.deps.outcome == 'success'
|
||||||
|
uses: https://git.infra.mintel.me/mmintel/at-mintel/.gitea/actions/core-smoke-tests@main
|
||||||
|
with:
|
||||||
|
TARGET_URL: ${{ needs.prepare.outputs.next_public_url }}
|
||||||
|
GATEKEEPER_PASSWORD: ${{ secrets.GATEKEEPER_PASSWORD || 'klz2026' }}
|
||||||
|
UMAMI_API_ENDPOINT: ${{ secrets.UMAMI_API_ENDPOINT || vars.UMAMI_API_ENDPOINT || 'https://analytics.infra.mintel.me' }}
|
||||||
|
SENTRY_DSN: ${{ secrets.SENTRY_DSN || vars.SENTRY_DSN }}
|
||||||
|
- name: 📊 Excel Datasheet Accessibility Check
|
||||||
if: always() && steps.deps.outcome == 'success'
|
if: always() && steps.deps.outcome == 'success'
|
||||||
env:
|
env:
|
||||||
NEXT_PUBLIC_BASE_URL: ${{ needs.prepare.outputs.next_public_url }}
|
TEST_URL: ${{ needs.prepare.outputs.next_public_url }}
|
||||||
GATEKEEPER_PASSWORD: ${{ secrets.GATEKEEPER_PASSWORD || 'klz2026' }}
|
run: |
|
||||||
run: pnpm run check:http
|
echo "Checking if datasheets directory is reachable..."
|
||||||
- name: 🌐 Locale & Language Switcher Validation
|
# This checks if the /datasheets/ directory returns a valid response (200, 403, or 404 is technically reachable, but we'd prefer 200/403)
|
||||||
if: always() && steps.deps.outcome == 'success'
|
# Since the files are in public/datasheets/products/, we check that path.
|
||||||
env:
|
curl -I -L -s -o /dev/null -w "%{http_code}" "$TEST_URL/datasheets/products/" | grep -E "200|301|302|403|404"
|
||||||
NEXT_PUBLIC_BASE_URL: ${{ needs.prepare.outputs.next_public_url }}
|
|
||||||
GATEKEEPER_PASSWORD: ${{ secrets.GATEKEEPER_PASSWORD || 'klz2026' }}
|
|
||||||
run: pnpm run check:locale
|
|
||||||
|
|
||||||
# ── Quality Gates (informational, don't block pipeline) ───────────────
|
- name: 📝 E2E Form Submission Test
|
||||||
- name: 🌐 HTML DOM Validation
|
|
||||||
if: always() && steps.deps.outcome == 'success'
|
if: always() && steps.deps.outcome == 'success'
|
||||||
continue-on-error: true
|
|
||||||
env:
|
|
||||||
NEXT_PUBLIC_BASE_URL: ${{ needs.prepare.outputs.next_public_url }}
|
|
||||||
GATEKEEPER_PASSWORD: ${{ secrets.GATEKEEPER_PASSWORD || 'klz2026' }}
|
|
||||||
run: pnpm check:html
|
|
||||||
- name: 🔒 Security Headers Scan
|
|
||||||
if: always() && steps.deps.outcome == 'success'
|
|
||||||
continue-on-error: true
|
|
||||||
env:
|
|
||||||
NEXT_PUBLIC_BASE_URL: ${{ needs.prepare.outputs.next_public_url }}
|
|
||||||
GATEKEEPER_PASSWORD: ${{ secrets.GATEKEEPER_PASSWORD || 'klz2026' }}
|
|
||||||
run: pnpm check:security
|
|
||||||
- name: 🔗 Lychee Deep Link Crawl
|
|
||||||
if: always() && steps.deps.outcome == 'success'
|
|
||||||
continue-on-error: true
|
|
||||||
env:
|
|
||||||
NEXT_PUBLIC_BASE_URL: ${{ needs.prepare.outputs.next_public_url }}
|
|
||||||
GATEKEEPER_PASSWORD: ${{ secrets.GATEKEEPER_PASSWORD || 'klz2026' }}
|
|
||||||
run: pnpm check:links
|
|
||||||
- name: 🖼️ Dynamic Asset & Image Integrity Scan
|
|
||||||
if: always() && steps.deps.outcome == 'success'
|
|
||||||
continue-on-error: true
|
|
||||||
env:
|
env:
|
||||||
NEXT_PUBLIC_BASE_URL: ${{ needs.prepare.outputs.next_public_url }}
|
NEXT_PUBLIC_BASE_URL: ${{ needs.prepare.outputs.next_public_url }}
|
||||||
GATEKEEPER_PASSWORD: ${{ secrets.GATEKEEPER_PASSWORD || 'klz2026' }}
|
GATEKEEPER_PASSWORD: ${{ secrets.GATEKEEPER_PASSWORD || 'klz2026' }}
|
||||||
PUPPETEER_EXECUTABLE_PATH: /usr/bin/chromium
|
PUPPETEER_EXECUTABLE_PATH: /usr/bin/chromium
|
||||||
CHROME_PATH: /usr/bin/chromium
|
run: pnpm run check:forms
|
||||||
run: pnpm check:assets
|
|
||||||
|
|
||||||
# ──────────────────────────────────────────────────────────────────────────────
|
|
||||||
# JOB 6: Performance & Accessibility (Lighthouse + WCAG)
|
|
||||||
# ──────────────────────────────────────────────────────────────────────────────
|
|
||||||
performance:
|
|
||||||
name: ⚡ Performance & Accessibility
|
|
||||||
needs: [prepare, post_deploy_checks]
|
|
||||||
continue-on-error: true
|
|
||||||
if: needs.post_deploy_checks.result == 'success' && needs.prepare.outputs.target != 'branch'
|
|
||||||
runs-on: docker
|
|
||||||
container:
|
|
||||||
image: catthehacker/ubuntu:act-latest
|
|
||||||
steps:
|
|
||||||
- name: Checkout repository
|
|
||||||
uses: actions/checkout@v4
|
|
||||||
- name: Setup pnpm
|
|
||||||
uses: pnpm/action-setup@v3
|
|
||||||
with:
|
|
||||||
version: 10
|
|
||||||
- name: Setup Node.js
|
|
||||||
uses: actions/setup-node@v4
|
|
||||||
with:
|
|
||||||
node-version: 20
|
|
||||||
- name: 🔐 Registry Auth
|
|
||||||
run: |
|
|
||||||
echo "@mintel:registry=https://${{ vars.REGISTRY_HOST || 'npm.infra.mintel.me' }}" > .npmrc
|
|
||||||
echo "//${{ vars.REGISTRY_HOST || 'npm.infra.mintel.me' }}/:_authToken=${{ secrets.REGISTRY_PASS }}" >> .npmrc
|
|
||||||
- name: Install dependencies
|
|
||||||
run: pnpm install --frozen-lockfile
|
|
||||||
- name: 🔍 Install Chromium (Native & ARM64)
|
|
||||||
run: |
|
|
||||||
rm -f /etc/apt/apt.conf.d/docker-clean
|
|
||||||
apt-get update
|
|
||||||
apt-get install -y gnupg wget ca-certificates
|
|
||||||
|
|
||||||
# Detect OS
|
|
||||||
OS_ID=$(. /etc/os-release && echo $ID)
|
|
||||||
CODENAME=$(. /etc/os-release && echo $VERSION_CODENAME)
|
|
||||||
|
|
||||||
if [ "$OS_ID" = "debian" ]; then
|
|
||||||
echo "🎯 Debian detected - installing native chromium"
|
|
||||||
apt-get install -y chromium
|
|
||||||
else
|
|
||||||
echo "🎯 Ubuntu detected - adding xtradeb PPA"
|
|
||||||
mkdir -p /etc/apt/keyrings
|
|
||||||
KEY_ID="82BB6851C64F6880"
|
|
||||||
|
|
||||||
# Fetch PPA key
|
|
||||||
wget -qO- "https://keyserver.ubuntu.com/pks/lookup?op=get&search=0x$KEY_ID" | gpg --dearmor > /etc/apt/keyrings/xtradeb.gpg
|
|
||||||
|
|
||||||
# Add PPA repository
|
|
||||||
echo "deb [signed-by=/etc/apt/keyrings/xtradeb.gpg] http://ppa.launchpad.net/xtradeb/apps/ubuntu $CODENAME main" > /etc/apt/sources.list.d/xtradeb-ppa.list
|
|
||||||
|
|
||||||
# PRIORITY PINNING: Force PPA over Snap-dummy
|
|
||||||
printf "Package: *\nPin: release o=LP-PPA-xtradeb-apps\nPin-Priority: 1001\n" > /etc/apt/preferences.d/xtradeb
|
|
||||||
|
|
||||||
apt-get update
|
|
||||||
apt-get install -y --allow-downgrades chromium
|
|
||||||
fi
|
|
||||||
|
|
||||||
# Standardize binary paths
|
|
||||||
[ -f /usr/bin/chromium ] && ln -sf /usr/bin/chromium /usr/bin/google-chrome
|
|
||||||
[ -f /usr/bin/chromium ] && ln -sf /usr/bin/chromium /usr/bin/chromium-browser
|
|
||||||
- name: ⚡ Lighthouse CI
|
|
||||||
env:
|
|
||||||
NEXT_PUBLIC_BASE_URL: ${{ needs.prepare.outputs.next_public_url }}
|
|
||||||
GATEKEEPER_PASSWORD: ${{ secrets.GATEKEEPER_PASSWORD || 'klz2026' }}
|
|
||||||
CHROME_PATH: /usr/bin/chromium
|
|
||||||
PAGESPEED_LIMIT: 8
|
|
||||||
run: pnpm run pagespeed:test
|
|
||||||
- name: ♿ WCAG Audit
|
|
||||||
env:
|
|
||||||
NEXT_PUBLIC_BASE_URL: ${{ needs.prepare.outputs.next_public_url }}
|
|
||||||
GATEKEEPER_PASSWORD: ${{ secrets.GATEKEEPER_PASSWORD || 'klz2026' }}
|
|
||||||
CHROME_PATH: /usr/bin/chromium
|
|
||||||
PAGESPEED_LIMIT: 8
|
|
||||||
run: pnpm run check:wcag
|
|
||||||
|
|
||||||
# ──────────────────────────────────────────────────────────────────────────────
|
# ──────────────────────────────────────────────────────────────────────────────
|
||||||
# JOB 7: Notifications
|
# JOB 7: Notifications
|
||||||
# ──────────────────────────────────────────────────────────────────────────────
|
# ──────────────────────────────────────────────────────────────────────────────
|
||||||
notifications:
|
notifications:
|
||||||
name: 🔔 Notify
|
name: 🔔 Notify
|
||||||
needs: [prepare, deploy, post_deploy_checks, performance]
|
needs: [prepare, deploy, post_deploy_checks]
|
||||||
if: always()
|
if: always()
|
||||||
runs-on: docker
|
runs-on: docker
|
||||||
container:
|
container:
|
||||||
@@ -596,7 +599,7 @@ jobs:
|
|||||||
run: |
|
run: |
|
||||||
DEPLOY="${{ needs.deploy.result }}"
|
DEPLOY="${{ needs.deploy.result }}"
|
||||||
SMOKE="${{ needs.post_deploy_checks.result }}"
|
SMOKE="${{ needs.post_deploy_checks.result }}"
|
||||||
PERF="${{ needs.performance.result }}"
|
PERF="${{ needs.post_deploy_checks.result }}"
|
||||||
TARGET="${{ needs.prepare.outputs.target }}"
|
TARGET="${{ needs.prepare.outputs.target }}"
|
||||||
VERSION="${{ needs.prepare.outputs.image_tag }}"
|
VERSION="${{ needs.prepare.outputs.image_tag }}"
|
||||||
URL="${{ needs.prepare.outputs.next_public_url }}"
|
URL="${{ needs.prepare.outputs.next_public_url }}"
|
||||||
@@ -624,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" \
|
||||||
|
|||||||
236
.gitea/workflows/qa.yml
Normal file
236
.gitea/workflows/qa.yml
Normal file
@@ -0,0 +1,236 @@
|
|||||||
|
name: Nightly QA
|
||||||
|
|
||||||
|
on:
|
||||||
|
schedule:
|
||||||
|
- cron: '0 3 * * *'
|
||||||
|
workflow_dispatch:
|
||||||
|
|
||||||
|
env:
|
||||||
|
TARGET_URL: 'https://testing.klz-cables.com'
|
||||||
|
PROJECT_NAME: 'klz-2026'
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
# ────────────────────────────────────────────────────
|
||||||
|
# 1. Static Checks (HTML, Assets, HTTP)
|
||||||
|
# ────────────────────────────────────────────────────
|
||||||
|
static:
|
||||||
|
name: 🔍 Static Analysis
|
||||||
|
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: 🌐 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}
|
||||||
|
|||||||
31
Dockerfile
31
Dockerfile
@@ -1,18 +1,16 @@
|
|||||||
# Stage 1: Builder
|
# Stage 1: Builder
|
||||||
FROM registry.infra.mintel.me/mintel/nextjs:v1.7.10 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
|
||||||
@@ -25,9 +23,10 @@ COPY pnpm-lock.yaml package.json .npmrc* ./
|
|||||||
RUN --mount=type=cache,id=pnpm,target=/pnpm/store \
|
RUN --mount=type=cache,id=pnpm,target=/pnpm/store \
|
||||||
--mount=type=secret,id=NPM_TOKEN \
|
--mount=type=secret,id=NPM_TOKEN \
|
||||||
export NPM_TOKEN=$(cat /run/secrets/NPM_TOKEN) && \
|
export NPM_TOKEN=$(cat /run/secrets/NPM_TOKEN) && \
|
||||||
echo "@mintel:registry=https://npm.infra.mintel.me" > .npmrc && \
|
echo "@mintel:registry=https://git.infra.mintel.me/api/packages/mmintel/npm" > .npmrc && \
|
||||||
echo "//npm.infra.mintel.me/:_authToken=\${NPM_TOKEN}" >> .npmrc && \
|
echo "//git.infra.mintel.me/api/packages/mmintel/npm/:_authToken=\${NPM_TOKEN}" >> .npmrc && \
|
||||||
pnpm install --frozen-lockfile && \
|
pnpm store prune && \
|
||||||
|
pnpm install --no-frozen-lockfile && \
|
||||||
rm .npmrc
|
rm .npmrc
|
||||||
|
|
||||||
# Copy source code
|
# Copy source code
|
||||||
@@ -41,19 +40,20 @@ CMD ["pnpm", "dev:local"]
|
|||||||
# Build application
|
# Build application
|
||||||
# Stage 3: Builder (Production)
|
# Stage 3: Builder (Production)
|
||||||
FROM base AS builder
|
FROM base AS builder
|
||||||
# Limit memory to 2GB to prevent ResourceExhausted on 4GB runner
|
# Limit memory to 1GB to prevent ResourceExhausted in combination with worker limits
|
||||||
ENV NODE_OPTIONS="--max-old-space-size=2048"
|
ENV NODE_OPTIONS="--max-old-space-size=1024"
|
||||||
|
|
||||||
|
# Force Turbopack (Rust/Rayon) and Node.js to use strictly 3 threads to avoid starving the Gitea Runner VPS CPU
|
||||||
|
ENV RAYON_NUM_THREADS=3
|
||||||
|
ENV UV_THREADPOOL_SIZE=3
|
||||||
|
|
||||||
RUN pnpm build
|
RUN pnpm build
|
||||||
|
# Excel generation moved to post-deploy
|
||||||
|
|
||||||
# Stage 3: Runner
|
# Stage 2: Runner
|
||||||
FROM registry.infra.mintel.me/mintel/runtime:v1.7.10 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
|
||||||
@@ -65,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"]
|
||||||
|
|
||||||
|
|||||||
@@ -462,3 +462,4 @@ Proprietary - KLZ Cables
|
|||||||
**Status**: ✅ **READY FOR DEPLOYMENT**
|
**Status**: ✅ **READY FOR DEPLOYMENT**
|
||||||
**Version**: 1.0.0
|
**Version**: 1.0.0
|
||||||
**Last Updated**: December 27, 2025
|
**Last Updated**: December 27, 2025
|
||||||
|
Trigger rebuilding for x86 architecture.
|
||||||
|
|||||||
@@ -1,8 +1,8 @@
|
|||||||
import { notFound } from 'next/navigation';
|
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';
|
||||||
@@ -21,15 +21,18 @@ export async function generateMetadata({ params }: PageProps): Promise<Metadata>
|
|||||||
|
|
||||||
if (!pageData) return {};
|
if (!pageData) return {};
|
||||||
|
|
||||||
const fileSlug = await mapSlugToFileSlug(slug, locale);
|
const fileSlug = await mapSlugToFileSlug(pageData.slug || slug, locale);
|
||||||
const deSlug = await mapFileSlugToTranslated(fileSlug, 'de');
|
const deSlug = await mapFileSlugToTranslated(fileSlug, 'de');
|
||||||
const enSlug = await mapFileSlugToTranslated(fileSlug, 'en');
|
const enSlug = await mapFileSlugToTranslated(fileSlug, 'en');
|
||||||
|
|
||||||
|
// Determine correct localized slug based on current locale
|
||||||
|
const currentLocaleSlug = locale === 'de' ? deSlug : enSlug;
|
||||||
|
|
||||||
return {
|
return {
|
||||||
title: pageData.frontmatter.title,
|
title: pageData.frontmatter.title,
|
||||||
description: pageData.frontmatter.excerpt || '',
|
description: pageData.frontmatter.excerpt || '',
|
||||||
alternates: {
|
alternates: {
|
||||||
canonical: `${SITE_URL}/${locale}/${slug}`,
|
canonical: `${SITE_URL}/${locale}/${currentLocaleSlug}`,
|
||||||
languages: {
|
languages: {
|
||||||
de: `${SITE_URL}/de/${deSlug}`,
|
de: `${SITE_URL}/de/${deSlug}`,
|
||||||
en: `${SITE_URL}/en/${enSlug}`,
|
en: `${SITE_URL}/en/${enSlug}`,
|
||||||
@@ -39,7 +42,7 @@ export async function generateMetadata({ params }: PageProps): Promise<Metadata>
|
|||||||
openGraph: {
|
openGraph: {
|
||||||
title: `${pageData.frontmatter.title} | KLZ Cables`,
|
title: `${pageData.frontmatter.title} | KLZ Cables`,
|
||||||
description: pageData.frontmatter.excerpt || '',
|
description: pageData.frontmatter.excerpt || '',
|
||||||
url: `${SITE_URL}/${locale}/${slug}`,
|
url: `${SITE_URL}/${locale}/${currentLocaleSlug}`,
|
||||||
},
|
},
|
||||||
twitter: {
|
twitter: {
|
||||||
card: 'summary_large_image',
|
card: 'summary_large_image',
|
||||||
@@ -59,6 +62,13 @@ export default async function StandardPage({ params }: PageProps) {
|
|||||||
notFound();
|
notFound();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Redirect if accessed via a different locale's slug
|
||||||
|
const fileSlug = await mapSlugToFileSlug(pageData.slug || slug, locale);
|
||||||
|
const correctSlug = await mapFileSlugToTranslated(fileSlug, locale);
|
||||||
|
if (correctSlug && correctSlug !== slug) {
|
||||||
|
redirect(`/${locale}/${correctSlug}`);
|
||||||
|
}
|
||||||
|
|
||||||
// Full-bleed pages render blocks edge-to-edge without the generic article wrapper
|
// Full-bleed pages render blocks edge-to-edge without the generic article wrapper
|
||||||
if (pageData.frontmatter.layout === 'fullBleed') {
|
if (pageData.frontmatter.layout === 'fullBleed') {
|
||||||
return (
|
return (
|
||||||
|
|||||||
@@ -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 } 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';
|
||||||
@@ -32,7 +38,7 @@ export async function generateMetadata({ params }: BlogPostProps): Promise<Metad
|
|||||||
title: post.frontmatter.title,
|
title: post.frontmatter.title,
|
||||||
description: description,
|
description: description,
|
||||||
alternates: {
|
alternates: {
|
||||||
canonical: `${SITE_URL}/${locale}/blog/${slug}`,
|
canonical: `${SITE_URL}/${locale}/blog/${post.slug}`,
|
||||||
},
|
},
|
||||||
openGraph: {
|
openGraph: {
|
||||||
title: `${post.frontmatter.title} | KLZ Cables`,
|
title: `${post.frontmatter.title} | KLZ Cables`,
|
||||||
@@ -40,7 +46,7 @@ export async function generateMetadata({ params }: BlogPostProps): Promise<Metad
|
|||||||
type: 'article',
|
type: 'article',
|
||||||
publishedTime: post.frontmatter.date,
|
publishedTime: post.frontmatter.date,
|
||||||
authors: ['KLZ Cables'],
|
authors: ['KLZ Cables'],
|
||||||
url: `${SITE_URL}/${locale}/blog/${slug}`,
|
url: `${SITE_URL}/${locale}/blog/${post.slug}`,
|
||||||
},
|
},
|
||||||
twitter: {
|
twitter: {
|
||||||
card: 'summary_large_image',
|
card: 'summary_large_image',
|
||||||
@@ -54,12 +60,23 @@ export default async function BlogPost({ params }: BlogPostProps) {
|
|||||||
const { locale, slug } = await params;
|
const { locale, slug } = await params;
|
||||||
setRequestLocale(locale);
|
setRequestLocale(locale);
|
||||||
const post = await getPostBySlug(slug, locale);
|
const post = await getPostBySlug(slug, locale);
|
||||||
const { prev, next, isPrevRandom, isNextRandom } = await getAdjacentPosts(slug, locale);
|
|
||||||
|
|
||||||
if (!post) {
|
if (!post) {
|
||||||
notFound();
|
notFound();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// If the user accessed this post using a slug from a different locale
|
||||||
|
// (e.g. via the generic language switcher), redirect them to the correct localized slug URL
|
||||||
|
if (post.slug && post.slug !== slug) {
|
||||||
|
redirect(`/${locale}/blog/${post.slug}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const { prev, next, isPrevRandom, isNextRandom } = await getAdjacentPosts(post.slug, locale);
|
||||||
|
|
||||||
|
// Convert Lexical content into a plain string to estimate reading time roughly
|
||||||
|
// Extract headings for TOC
|
||||||
|
const headings = extractLexicalHeadings(post.content?.root || post.content);
|
||||||
|
|
||||||
// Convert Lexical content into a plain string to estimate reading time roughly
|
// Convert Lexical content into a plain string to estimate reading time roughly
|
||||||
const rawTextContent = JSON.stringify(post.content);
|
const rawTextContent = JSON.stringify(post.content);
|
||||||
|
|
||||||
@@ -81,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={{
|
||||||
@@ -88,7 +106,7 @@ export default async function BlogPost({ params }: BlogPostProps) {
|
|||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div className="absolute inset-0 bg-gradient-to-t from-neutral-dark via-neutral-dark/40 to-transparent" />
|
<div className="absolute inset-0 bg-gradient-to-t from-neutral-dark/90 via-neutral-dark/40 to-transparent" />
|
||||||
|
|
||||||
{/* Title overlay on image */}
|
{/* Title overlay on image */}
|
||||||
<div className="absolute inset-0 flex flex-col justify-end pb-16 md:pb-24">
|
<div className="absolute inset-0 flex flex-col justify-end pb-16 md:pb-24">
|
||||||
@@ -105,8 +123,8 @@ export default async function BlogPost({ params }: BlogPostProps) {
|
|||||||
{post.frontmatter.title}
|
{post.frontmatter.title}
|
||||||
</Heading>
|
</Heading>
|
||||||
<div className="flex flex-wrap items-center gap-6 text-white/80 text-sm md:text-base font-medium">
|
<div className="flex flex-wrap items-center gap-6 text-white/80 text-sm md:text-base font-medium">
|
||||||
<time dateTime={post.frontmatter.date}>
|
<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',
|
||||||
@@ -142,8 +160,8 @@ export default async function BlogPost({ params }: BlogPostProps) {
|
|||||||
{post.frontmatter.title}
|
{post.frontmatter.title}
|
||||||
</Heading>
|
</Heading>
|
||||||
<div className="flex items-center gap-6 text-text-primary/80 font-medium">
|
<div className="flex items-center gap-6 text-text-primary/80 font-medium">
|
||||||
<time dateTime={post.frontmatter.date}>
|
<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',
|
||||||
@@ -224,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 {
|
||||||
@@ -73,7 +73,7 @@ export default async function BlogIndex({ params }: BlogIndexProps) {
|
|||||||
sizes="100vw"
|
sizes="100vw"
|
||||||
priority
|
priority
|
||||||
/>
|
/>
|
||||||
<div className="absolute inset-0 image-overlay-gradient" />
|
<div className="absolute inset-0 bg-neutral-dark/20" />
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
@@ -84,12 +84,9 @@ export default async function BlogIndex({ params }: BlogIndexProps) {
|
|||||||
{featuredPost &&
|
{featuredPost &&
|
||||||
(new Date(featuredPost.frontmatter.date) > new Date() ||
|
(new Date(featuredPost.frontmatter.date) > new Date() ||
|
||||||
featuredPost.frontmatter.public === false) && (
|
featuredPost.frontmatter.public === false) && (
|
||||||
<Badge
|
<span className="px-2 py-0.5 border border-white/40 text-white/80 rounded uppercase tracking-widest text-[10px] md:text-xs font-bold">
|
||||||
variant="neutral"
|
|
||||||
className="border border-white/30 bg-transparent text-white/80 shadow-none"
|
|
||||||
>
|
|
||||||
Draft Preview
|
Draft Preview
|
||||||
</Badge>
|
</span>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
{featuredPost && (
|
{featuredPost && (
|
||||||
@@ -156,66 +153,76 @@ export default async function BlogIndex({ params }: BlogIndexProps) {
|
|||||||
</Reveal>
|
</Reveal>
|
||||||
|
|
||||||
{/* Grid for remaining posts */}
|
{/* Grid for remaining posts */}
|
||||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6 md:gap-12">
|
<div className="grid grid-cols-1 gap-12">
|
||||||
{remainingPosts.map((post, idx) => (
|
{remainingPosts.map((post, idx) => (
|
||||||
<Reveal key={post.slug} delay={idx * 100}>
|
<Reveal key={post.slug} delay={idx * 50}>
|
||||||
<Link href={`/${locale}/blog/${post.slug}`} className="group block">
|
<Link
|
||||||
|
href={`/${locale}/blog/${post.slug}`}
|
||||||
|
className="group block focus:outline-none"
|
||||||
|
>
|
||||||
<Card
|
<Card
|
||||||
tag="article"
|
tag="article"
|
||||||
className="h-full flex flex-col border-none shadow-lg hover:shadow-2xl transition-all duration-500 rounded-2xl md:rounded-3xl overflow-hidden"
|
className="relative flex flex-col justify-end border-none shadow-lg hover:shadow-2xl transition-all duration-500 rounded-3xl overflow-hidden min-h-[450px] md:min-h-[500px]"
|
||||||
>
|
>
|
||||||
{post.frontmatter.featuredImage && (
|
{post.frontmatter.featuredImage && (
|
||||||
<div className="relative h-48 md:h-72 overflow-hidden">
|
<>
|
||||||
<Image
|
<Image
|
||||||
src={post.frontmatter.featuredImage.split('?')[0]}
|
src={post.frontmatter.featuredImage.split('?')[0]}
|
||||||
alt={post.frontmatter.title}
|
alt={post.frontmatter.title}
|
||||||
fill
|
fill
|
||||||
className="w-full h-full object-cover transition-transform duration-1000 group-hover:scale-110"
|
className="absolute inset-0 w-full h-full object-cover transition-transform duration-1000 group-hover:scale-105"
|
||||||
style={{
|
style={{
|
||||||
objectPosition: `${post.frontmatter.focalX ?? 50}% ${post.frontmatter.focalY ?? 50}%`,
|
objectPosition: `${post.frontmatter.focalX ?? 50}% ${post.frontmatter.focalY ?? 50}%`,
|
||||||
}}
|
}}
|
||||||
sizes="(max-width: 768px) 100vw, (max-width: 1024px) 50vw, 33vw"
|
sizes="(max-width: 768px) 100vw, 100vw"
|
||||||
|
loading="lazy"
|
||||||
/>
|
/>
|
||||||
<div className="absolute inset-0 image-overlay-gradient opacity-0 group-hover:opacity-100 transition-opacity duration-500" />
|
<div className="absolute inset-0 bg-neutral-dark/10 group-hover:bg-neutral-dark/5 transition-colors duration-500" />
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="relative z-10 w-full p-6 md:p-10 bg-gradient-to-t from-neutral-dark/95 via-neutral-dark/70 to-transparent flex flex-col pt-40">
|
||||||
|
<div className="flex flex-wrap items-center gap-4 mb-4">
|
||||||
{post.frontmatter.category && (
|
{post.frontmatter.category && (
|
||||||
<Badge
|
<Badge variant="accent" className="shadow-md">
|
||||||
variant="accent"
|
|
||||||
className="absolute top-3 left-3 md:top-6 md:left-6 shadow-lg"
|
|
||||||
>
|
|
||||||
{post.frontmatter.category}
|
{post.frontmatter.category}
|
||||||
</Badge>
|
</Badge>
|
||||||
)}
|
)}
|
||||||
|
{(new Date(post.frontmatter.date) > new Date() ||
|
||||||
|
post.frontmatter.public === false) && (
|
||||||
|
<span className="px-2 py-0.5 border border-white/40 text-white/90 rounded uppercase tracking-widest text-[10px] md:text-xs font-bold bg-neutral-dark/40 shadow-sm">
|
||||||
|
Draft Preview
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
)}
|
|
||||||
<div className="p-5 md:p-10 flex flex-col flex-1">
|
<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-[10px] md:text-sm font-bold text-primary/70 mb-2 md:mb-4 tracking-widest uppercase">
|
<time dateTime={post.frontmatter.date} suppressHydrationWarning>
|
||||||
<span>
|
{new Date(post.frontmatter.date).toLocaleDateString(locale || 'de', {
|
||||||
{new Date(post.frontmatter.date).toLocaleDateString(locale, {
|
|
||||||
year: 'numeric',
|
year: 'numeric',
|
||||||
month: 'long',
|
month: 'long',
|
||||||
day: 'numeric',
|
day: 'numeric',
|
||||||
})}
|
})}
|
||||||
</span>
|
</time>
|
||||||
{(new Date(post.frontmatter.date) > new Date() ||
|
|
||||||
post.frontmatter.public === false) && (
|
|
||||||
<span className="px-1.5 py-0.5 border border-current rounded-sm text-[9px] md:text-xs">
|
|
||||||
Draft
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
<h3 className="text-lg md:text-2xl font-bold text-primary mb-3 md:mb-6 group-hover:text-accent-dark transition-colors line-clamp-3 md:line-clamp-4 leading-tight">
|
|
||||||
|
<h3 className="text-xl md:text-3xl font-bold text-white mb-4 group-hover:text-accent transition-colors drop-shadow-md leading-tight max-w-4xl">
|
||||||
{post.frontmatter.title}
|
{post.frontmatter.title}
|
||||||
</h3>
|
</h3>
|
||||||
<p className="text-text-secondary text-sm md:text-lg line-clamp-3 md:line-clamp-4 mb-4 md:mb-8 leading-relaxed">
|
|
||||||
{post.frontmatter.excerpt}
|
{post.frontmatter.excerpt && (
|
||||||
</p>
|
<p className="text-white/90 text-sm md:text-lg line-clamp-3 mb-6 max-w-4xl drop-shadow-sm leading-relaxed">
|
||||||
<div className="mt-auto pt-4 md:pt-8 border-t border-neutral-medium flex items-center justify-between">
|
{post.frontmatter.excerpt}
|
||||||
<span className="text-saturated text-sm md:text-base font-extrabold group-hover:text-accent-dark transition-colors">
|
</p>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="mt-auto flex items-center justify-between border-t border-white/20 pt-6">
|
||||||
|
<span className="text-accent text-sm md:text-base font-extrabold group-hover:text-white transition-colors">
|
||||||
{t('readMore')}
|
{t('readMore')}
|
||||||
</span>
|
</span>
|
||||||
<div className="w-8 h-8 md:w-10 md:h-10 rounded-full bg-primary-light flex items-center justify-center text-saturated group-hover:bg-accent group-hover:text-primary-dark transition-all duration-300">
|
<div className="w-10 h-10 rounded-full bg-white/10 flex items-center justify-center text-accent group-hover:bg-accent group-hover:text-primary-dark transition-all duration-300 backdrop-blur-sm border border-white/20">
|
||||||
<svg
|
<svg
|
||||||
className="w-4 h-4 md:w-5 md:h-5 transition-transform group-hover:translate-x-1"
|
className="w-5 h-5 transition-transform group-hover:translate-x-1"
|
||||||
fill="none"
|
fill="none"
|
||||||
stroke="currentColor"
|
stroke="currentColor"
|
||||||
viewBox="0 0 24 24"
|
viewBox="0 0 24 24"
|
||||||
|
|||||||
@@ -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<{
|
||||||
@@ -59,6 +60,21 @@ export default async function ContactPage({ params }: ContactPageProps) {
|
|||||||
const { locale } = await params;
|
const { locale } = await params;
|
||||||
setRequestLocale(locale);
|
setRequestLocale(locale);
|
||||||
const t = await getTranslations({ locale, namespace: 'Contact' });
|
const t = await getTranslations({ locale, namespace: 'Contact' });
|
||||||
|
|
||||||
|
// Get translated slug to redirect if user used incorrect static slug
|
||||||
|
const { headers } = await import('next/headers');
|
||||||
|
const headersList = await headers();
|
||||||
|
const urlPath = headersList.get('x-invoke-path') || '';
|
||||||
|
const currentSlug = urlPath.split('/').pop();
|
||||||
|
|
||||||
|
if (currentSlug) {
|
||||||
|
const contactSlugDe = locale === 'de' ? 'kontakt' : 'contact';
|
||||||
|
if (currentSlug !== contactSlugDe && (currentSlug === 'kontakt' || currentSlug === 'contact')) {
|
||||||
|
const { redirect } = await import('next/navigation');
|
||||||
|
redirect(`/${locale}/${contactSlugDe}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-col min-h-screen bg-neutral-light">
|
<div className="flex flex-col min-h-screen bg-neutral-light">
|
||||||
<JsonLd
|
<JsonLd
|
||||||
@@ -189,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>
|
||||||
|
|||||||
@@ -1,66 +1,137 @@
|
|||||||
'use client';
|
import { getTranslations } from 'next-intl/server';
|
||||||
import { useTranslations } from 'next-intl';
|
|
||||||
import { Container, Button, Heading } from '@/components/ui';
|
import { Container, Button, Heading } from '@/components/ui';
|
||||||
import Scribble from '@/components/Scribble';
|
import Scribble from '@/components/Scribble';
|
||||||
import { useEffect } from 'react';
|
import { getPayload } from 'payload';
|
||||||
import { useAnalytics } from '@/components/analytics/useAnalytics';
|
import configPromise from '@payload-config';
|
||||||
import { AnalyticsEvents } from '@/components/analytics/analytics-events';
|
import { headers } from 'next/headers';
|
||||||
|
import ClientNotFoundTracker from '@/components/analytics/ClientNotFoundTracker';
|
||||||
|
|
||||||
export default function NotFound() {
|
export default async function NotFound() {
|
||||||
const t = useTranslations('Error.notFound');
|
const t = await getTranslations('Error.notFound');
|
||||||
const { trackEvent } = useAnalytics();
|
|
||||||
|
|
||||||
useEffect(() => {
|
// Try to determine the requested path
|
||||||
const errorUrl = typeof window !== 'undefined' ? window.location.pathname : 'unknown';
|
const headersList = await headers();
|
||||||
trackEvent(AnalyticsEvents.ERROR, {
|
const urlPath = headersList.get('x-invoke-path') || '';
|
||||||
type: '404_not_found',
|
|
||||||
path: errorUrl,
|
|
||||||
});
|
|
||||||
|
|
||||||
// Explicitly send the 404 to Sentry so we have visibility into broken links
|
let suggestedUrl = null;
|
||||||
import('@sentry/nextjs').then((Sentry) => {
|
let suggestedLang = null;
|
||||||
Sentry.withScope((scope) => {
|
|
||||||
scope.setTag('status_code', '404');
|
// If we have a path, try to see if the last segment (slug) exists in ANY locale
|
||||||
scope.setTag('path', errorUrl);
|
if (urlPath) {
|
||||||
Sentry.captureMessage(`Route Not Found: ${errorUrl}`, 'warning');
|
const slug = urlPath.split('/').filter(Boolean).pop();
|
||||||
});
|
if (slug) {
|
||||||
});
|
try {
|
||||||
}, [trackEvent]);
|
const payload = await getPayload({ config: configPromise });
|
||||||
|
|
||||||
|
// Check posts
|
||||||
|
const postRes = await payload.find({
|
||||||
|
collection: 'posts',
|
||||||
|
where: { slug: { equals: slug } },
|
||||||
|
locale: 'all',
|
||||||
|
limit: 1,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Check products
|
||||||
|
const productRes =
|
||||||
|
postRes.docs.length === 0
|
||||||
|
? await payload.find({
|
||||||
|
collection: 'products',
|
||||||
|
where: { slug: { equals: slug } },
|
||||||
|
locale: 'all',
|
||||||
|
limit: 1,
|
||||||
|
})
|
||||||
|
: { docs: [] };
|
||||||
|
|
||||||
|
// Check pages
|
||||||
|
const pageRes =
|
||||||
|
postRes.docs.length === 0 && productRes.docs.length === 0
|
||||||
|
? await payload.find({
|
||||||
|
collection: 'pages',
|
||||||
|
where: { slug: { equals: slug } },
|
||||||
|
locale: 'all',
|
||||||
|
limit: 1,
|
||||||
|
})
|
||||||
|
: { docs: [] };
|
||||||
|
|
||||||
|
const anyDoc = postRes.docs[0] || productRes.docs[0] || pageRes.docs[0];
|
||||||
|
|
||||||
|
if (anyDoc) {
|
||||||
|
// If the doc exists, we can figure out its native locale or
|
||||||
|
// offer the alternative locale (if we are in 'de', offer 'en')
|
||||||
|
const currentLocale = urlPath.startsWith('/en') ? 'en' : 'de';
|
||||||
|
const alternativeLocale = currentLocale === 'de' ? 'en' : 'de';
|
||||||
|
|
||||||
|
suggestedLang = alternativeLocale === 'de' ? 'Deutsch' : 'English';
|
||||||
|
|
||||||
|
// Reconstruct the URL for the alternative locale
|
||||||
|
const pathParts = urlPath.split('/').filter(Boolean);
|
||||||
|
if (pathParts.length > 0 && (pathParts[0] === 'en' || pathParts[0] === 'de')) {
|
||||||
|
pathParts[0] = alternativeLocale;
|
||||||
|
} else {
|
||||||
|
pathParts.unshift(alternativeLocale);
|
||||||
|
}
|
||||||
|
suggestedUrl = '/' + pathParts.join('/');
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// Ignore Payload errors in 404
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Container className="relative py-24 flex flex-col items-center justify-center text-center min-h-[70vh] overflow-hidden">
|
<>
|
||||||
{/* Industrial Background Element */}
|
<ClientNotFoundTracker path={urlPath} />
|
||||||
<div className="absolute inset-0 -z-10 opacity-[0.03] pointer-events-none flex items-center justify-center">
|
<Container className="relative py-24 flex flex-col items-center justify-center text-center min-h-[70vh] overflow-hidden">
|
||||||
<span className="text-[20rem] font-bold select-none">404</span>
|
{/* Industrial Background Element */}
|
||||||
</div>
|
<div className="absolute inset-0 -z-10 opacity-[0.03] pointer-events-none flex items-center justify-center">
|
||||||
|
<span className="text-[20rem] font-bold select-none">404</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div className="relative mb-8">
|
<div className="relative mb-8">
|
||||||
<Heading level={1} className="text-6xl md:text-8xl font-bold mb-2">
|
<Heading level={1} className="text-6xl md:text-8xl font-bold mb-2">
|
||||||
404
|
404
|
||||||
|
</Heading>
|
||||||
|
<Scribble
|
||||||
|
variant="circle"
|
||||||
|
className="w-[150%] h-[150%] -top-[25%] -left-[25%] text-accent/40"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Heading level={2} className="text-2xl md:text-3xl font-bold mb-4 text-primary">
|
||||||
|
{t('title')}
|
||||||
</Heading>
|
</Heading>
|
||||||
<Scribble
|
|
||||||
variant="circle"
|
|
||||||
className="w-[150%] h-[150%] -top-[25%] -left-[25%] text-accent/40"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<Heading level={2} className="text-2xl md:text-3xl font-bold mb-4 text-primary">
|
<p className="text-text-secondary mb-10 max-w-md text-lg">{t('description')}</p>
|
||||||
{t('title')}
|
|
||||||
</Heading>
|
|
||||||
|
|
||||||
<p className="text-white/60 mb-10 max-w-md text-lg">{t('description')}</p>
|
{suggestedUrl && (
|
||||||
|
<div className="mb-12 p-6 bg-accent/10 border border-accent/20 rounded-2xl animate-fade-in shadow-lg relative overflow-hidden group">
|
||||||
|
<div className="absolute inset-0 bg-accent/5 -skew-x-12 translate-x-full group-hover:translate-x-0 transition-transform duration-700" />
|
||||||
|
<div className="relative z-10">
|
||||||
|
<h3 className="text-primary font-bold mb-2 text-lg">
|
||||||
|
Did you mean to visit the {suggestedLang} version?
|
||||||
|
</h3>
|
||||||
|
<p className="text-text-secondary text-sm mb-4">
|
||||||
|
This page exists, but in another language.
|
||||||
|
</p>
|
||||||
|
<Button href={suggestedUrl} variant="accent" size="md" className="w-full sm:w-auto">
|
||||||
|
Go to {suggestedLang} Version
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
<div className="flex flex-col sm:flex-row gap-4">
|
<div className="flex flex-col sm:flex-row gap-4">
|
||||||
<Button href="/" variant="accent" size="lg">
|
<Button href="/" variant={suggestedUrl ? 'outline' : 'accent'} size="lg">
|
||||||
{t('cta')}
|
{t('cta')}
|
||||||
</Button>
|
</Button>
|
||||||
<Button href="/contact" variant="outline" size="lg">
|
<Button href="/contact" variant={suggestedUrl ? 'ghost' : 'outline'} size="lg">
|
||||||
Contact Support
|
Contact Support
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Decorative Industrial Line */}
|
{/* Decorative Industrial Line */}
|
||||||
<div className="absolute bottom-0 left-1/2 -translate-x-1/2 w-px h-24 bg-gradient-to-t from-accent/50 to-transparent" />
|
<div className="absolute bottom-0 left-1/2 -translate-x-1/2 w-px h-24 bg-gradient-to-t from-accent/50 to-transparent" />
|
||||||
</Container>
|
</Container>
|
||||||
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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';
|
||||||
@@ -14,7 +13,7 @@ import { getTranslations, setRequestLocale } from 'next-intl/server';
|
|||||||
import { getProductOGImageMetadata } from '@/lib/metadata';
|
import { getProductOGImageMetadata } from '@/lib/metadata';
|
||||||
import Image from 'next/image';
|
import Image from 'next/image';
|
||||||
import Link from 'next/link';
|
import Link from 'next/link';
|
||||||
import { notFound } from 'next/navigation';
|
import { notFound, redirect } from 'next/navigation';
|
||||||
import ProductEngagementTracker from '@/components/analytics/ProductEngagementTracker';
|
import ProductEngagementTracker from '@/components/analytics/ProductEngagementTracker';
|
||||||
import PayloadRichText from '@/components/PayloadRichText';
|
import PayloadRichText from '@/components/PayloadRichText';
|
||||||
|
|
||||||
@@ -53,7 +52,7 @@ export async function generateMetadata({ params }: ProductPageProps): Promise<Me
|
|||||||
title: categoryTitle,
|
title: categoryTitle,
|
||||||
description: categoryDesc,
|
description: categoryDesc,
|
||||||
alternates: {
|
alternates: {
|
||||||
canonical: `${SITE_URL}/${locale}/${await mapFileSlugToTranslated('products', locale)}/${productSlug}`,
|
canonical: `${SITE_URL}/${locale}/${await mapFileSlugToTranslated('products', locale)}/${await mapFileSlugToTranslated(fileSlug, locale)}`,
|
||||||
languages: {
|
languages: {
|
||||||
de: `${SITE_URL}/de/${await mapFileSlugToTranslated('products', 'de')}/${await mapFileSlugToTranslated(fileSlug, 'de')}`,
|
de: `${SITE_URL}/de/${await mapFileSlugToTranslated('products', 'de')}/${await mapFileSlugToTranslated(fileSlug, 'de')}`,
|
||||||
en: `${SITE_URL}/en/${await mapFileSlugToTranslated('products', 'en')}/${await mapFileSlugToTranslated(fileSlug, 'en')}`,
|
en: `${SITE_URL}/en/${await mapFileSlugToTranslated('products', 'en')}/${await mapFileSlugToTranslated(fileSlug, 'en')}`,
|
||||||
@@ -75,11 +74,13 @@ export async function generateMetadata({ params }: ProductPageProps): Promise<Me
|
|||||||
const product = await getProductBySlug(productSlug, locale);
|
const product = await getProductBySlug(productSlug, locale);
|
||||||
if (!product) return {};
|
if (!product) return {};
|
||||||
|
|
||||||
|
const currentLocalePath = await getLocalizedPath(locale);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
title: product.frontmatter.title,
|
title: product.frontmatter.title,
|
||||||
description: product.frontmatter.description,
|
description: product.frontmatter.description,
|
||||||
alternates: {
|
alternates: {
|
||||||
canonical: `${SITE_URL}/${locale}/${await mapFileSlugToTranslated('products', locale)}/${slug.join('/')}`,
|
canonical: `${SITE_URL}/${locale}/${currentLocalePath}`,
|
||||||
languages: {
|
languages: {
|
||||||
de: `${SITE_URL}/de/${await getLocalizedPath('de')}`,
|
de: `${SITE_URL}/de/${await getLocalizedPath('de')}`,
|
||||||
en: `${SITE_URL}/en/${await getLocalizedPath('en')}`,
|
en: `${SITE_URL}/en/${await getLocalizedPath('en')}`,
|
||||||
@@ -90,7 +91,7 @@ export async function generateMetadata({ params }: ProductPageProps): Promise<Me
|
|||||||
title: product.frontmatter.title,
|
title: product.frontmatter.title,
|
||||||
description: product.frontmatter.description,
|
description: product.frontmatter.description,
|
||||||
type: 'website',
|
type: 'website',
|
||||||
url: `${SITE_URL}/${locale}/products/${slug.join('/')}`,
|
url: `${SITE_URL}/${locale}/${currentLocalePath}`,
|
||||||
images: getProductOGImageMetadata(productSlug, product.frontmatter.title, locale),
|
images: getProductOGImageMetadata(productSlug, product.frontmatter.title, locale),
|
||||||
},
|
},
|
||||||
twitter: {
|
twitter: {
|
||||||
@@ -114,7 +115,19 @@ export default async function ProductPage({ params }: ProductPageProps) {
|
|||||||
'high-voltage-cables',
|
'high-voltage-cables',
|
||||||
'solar-cables',
|
'solar-cables',
|
||||||
];
|
];
|
||||||
const fileSlug = await mapSlugToFileSlug(productSlug, locale);
|
|
||||||
|
const fileSlugs = await Promise.all(slug.map((s) => mapSlugToFileSlug(s, locale)));
|
||||||
|
const translatedSlugsForLocale = await Promise.all(
|
||||||
|
fileSlugs.map((fs) => mapFileSlugToTranslated(fs, locale)),
|
||||||
|
);
|
||||||
|
|
||||||
|
// If the requested slugs don't exactly match the translated slugs for the current locale
|
||||||
|
// (i.e. if the user used the static language switcher but kept the original locale's slugs)
|
||||||
|
if (slug.join('/') !== translatedSlugsForLocale.join('/')) {
|
||||||
|
redirect(`/${locale}/${productsSlug}/${translatedSlugsForLocale.join('/')}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const fileSlug = fileSlugs[fileSlugs.length - 1];
|
||||||
|
|
||||||
if (categories.includes(fileSlug)) {
|
if (categories.includes(fileSlug)) {
|
||||||
const allProducts = await getAllProducts(locale);
|
const allProducts = await getAllProducts(locale);
|
||||||
@@ -125,11 +138,26 @@ export default async function ProductPage({ params }: ProductPageProps) {
|
|||||||
? t(`categories.${categoryKey}.title`)
|
? t(`categories.${categoryKey}.title`)
|
||||||
: fileSlug;
|
: fileSlug;
|
||||||
|
|
||||||
const filteredProducts = allProducts.filter((p) =>
|
const filteredProducts = allProducts.filter((p) => {
|
||||||
p.frontmatter.categories.some(
|
const firstCat = p.frontmatter.categories[0] || '';
|
||||||
(cat) => cat.toLowerCase().replace(/\s+/g, '-') === fileSlug || cat === categoryTitle,
|
const normalizedCat = firstCat.toLowerCase().replace(/\s+/g, '-');
|
||||||
),
|
let pFileSlug = 'low-voltage-cables';
|
||||||
);
|
if (normalizedCat === 'hochspannungskabel' || normalizedCat === 'high-voltage-cables')
|
||||||
|
pFileSlug = 'high-voltage-cables';
|
||||||
|
else if (
|
||||||
|
normalizedCat === 'mittelspannungskabel' ||
|
||||||
|
normalizedCat === 'medium-voltage-cables'
|
||||||
|
)
|
||||||
|
pFileSlug = 'medium-voltage-cables';
|
||||||
|
else if (
|
||||||
|
normalizedCat === 'solarkabel' ||
|
||||||
|
normalizedCat === 'solar-cables' ||
|
||||||
|
normalizedCat === 'solar'
|
||||||
|
)
|
||||||
|
pFileSlug = 'solar-cables';
|
||||||
|
|
||||||
|
return pFileSlug === fileSlug;
|
||||||
|
});
|
||||||
|
|
||||||
const productsWithTranslatedSlugs = await Promise.all(
|
const productsWithTranslatedSlugs = await Promise.all(
|
||||||
filteredProducts.map(async (p) => ({
|
filteredProducts.map(async (p) => ({
|
||||||
@@ -249,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);
|
||||||
@@ -293,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,
|
||||||
@@ -312,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}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -324,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">
|
||||||
@@ -357,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}
|
||||||
@@ -368,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>
|
||||||
@@ -385,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}
|
||||||
@@ -424,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 ? (
|
||||||
@@ -435,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>
|
||||||
|
|
||||||
@@ -443,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>
|
||||||
@@ -457,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>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
@@ -501,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)
|
||||||
|
|||||||
@@ -1,9 +1,49 @@
|
|||||||
import { NextResponse } from 'next/server';
|
import { NextResponse } from 'next/server';
|
||||||
|
import { getPayload } from 'payload';
|
||||||
|
import configPromise from '@payload-config';
|
||||||
|
|
||||||
export const dynamic = 'force-dynamic';
|
export const dynamic = 'force-dynamic';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Deep CMS Health Check
|
||||||
|
* Validates that Payload CMS can actually query the database.
|
||||||
|
* Used by post-deploy smoke tests to catch migration/schema issues.
|
||||||
|
*/
|
||||||
export async function GET() {
|
export async function GET() {
|
||||||
// Payload is embedded within the Next.js app, so if this route responds, the CMS is up.
|
const checks: Record<string, string> = {};
|
||||||
// Further DB health checks can be implemented via Payload Local API later.
|
|
||||||
return NextResponse.json({ status: 'ok', message: 'Payload CMS is embedded.' }, { status: 200 });
|
try {
|
||||||
|
const payload = await getPayload({ config: configPromise });
|
||||||
|
checks.init = 'ok';
|
||||||
|
|
||||||
|
// Ensure migrations are applied on startup (reliable for standalone builds)
|
||||||
|
try {
|
||||||
|
await payload.db.migrate();
|
||||||
|
} catch (e: any) {
|
||||||
|
console.error('Migration failed:', e.message);
|
||||||
|
// We continue to check the collections even if migration fails
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verify each collection can be queried (catches missing locale tables, broken migrations)
|
||||||
|
const collections = ['posts', 'products', 'pages', 'media'] as const;
|
||||||
|
for (const collection of collections) {
|
||||||
|
try {
|
||||||
|
await payload.find({ collection, limit: 1, locale: 'en' });
|
||||||
|
checks[collection] = 'ok';
|
||||||
|
} catch (e: any) {
|
||||||
|
checks[collection] = `error: ${e.message?.substring(0, 100)}`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const hasErrors = Object.values(checks).some((v) => v.startsWith('error'));
|
||||||
|
return NextResponse.json(
|
||||||
|
{ status: hasErrors ? 'degraded' : 'ok', checks },
|
||||||
|
{ status: hasErrors ? 503 : 200 },
|
||||||
|
);
|
||||||
|
} catch (e: any) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ status: 'error', message: e.message?.substring(0, 200), checks },
|
||||||
|
{ status: 503 },
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
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 });
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -58,9 +58,24 @@ export default async function sitemap(): Promise<MetadataRoute.Sitemap> {
|
|||||||
for (const product of productsMetadata) {
|
for (const product of productsMetadata) {
|
||||||
if (!product.frontmatter || !product.slug) continue;
|
if (!product.frontmatter || !product.slug) continue;
|
||||||
|
|
||||||
const category =
|
const firstCat = product.frontmatter.categories[0] || '';
|
||||||
product.frontmatter.categories[0]?.toLowerCase().replace(/\s+/g, '-') || 'other';
|
const normalizedCat = firstCat.toLowerCase().replace(/\s+/g, '-');
|
||||||
const translatedCategory = await mapFileSlugToTranslated(category, locale);
|
let categoryFileSlug = 'low-voltage-cables';
|
||||||
|
if (normalizedCat === 'hochspannungskabel' || normalizedCat === 'high-voltage-cables')
|
||||||
|
categoryFileSlug = 'high-voltage-cables';
|
||||||
|
else if (
|
||||||
|
normalizedCat === 'mittelspannungskabel' ||
|
||||||
|
normalizedCat === 'medium-voltage-cables'
|
||||||
|
)
|
||||||
|
categoryFileSlug = 'medium-voltage-cables';
|
||||||
|
else if (
|
||||||
|
normalizedCat === 'solarkabel' ||
|
||||||
|
normalizedCat === 'solar-cables' ||
|
||||||
|
normalizedCat === 'solar'
|
||||||
|
)
|
||||||
|
categoryFileSlug = 'solar-cables';
|
||||||
|
|
||||||
|
const translatedCategory = await mapFileSlugToTranslated(categoryFileSlug, locale);
|
||||||
const translatedSlug = await mapFileSlugToTranslated(product.slug, locale);
|
const translatedSlug = await mapFileSlugToTranslated(product.slug, locale);
|
||||||
|
|
||||||
sitemapEntries.push({
|
sitemapEntries.push({
|
||||||
|
|||||||
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,97 +37,248 @@ 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 (
|
|
||||||
<>
|
|
||||||
{prefix && (
|
|
||||||
<span dangerouslySetInnerHTML={prefix.includes('<') ? { __html: prefix } : undefined}>
|
|
||||||
{!prefix.includes('<') ? prefix : undefined}
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
<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>
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
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"
|
|
||||||
rel="noopener noreferrer"
|
|
||||||
className="text-primary underline decoration-primary/30 hover:decoration-primary transition-colors"
|
|
||||||
>
|
|
||||||
{match[1]}
|
|
||||||
</a>,
|
|
||||||
);
|
|
||||||
lastIndex = match.index + match[0].length;
|
|
||||||
}
|
|
||||||
if (lastIndex < remaining.length) {
|
|
||||||
parts.push(<span key={key++}>{remaining.slice(lastIndex)}</span>);
|
|
||||||
}
|
|
||||||
return <>{parts}</>;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Handle newlines in text nodes — convert to <br> for proper line breaks
|
// Obfuscate phone numbers in text content (simple pattern for +XX XXX ...)
|
||||||
if (text && text.includes('\n')) {
|
if (typeof content === 'string' && content.match(/\+\d+/)) {
|
||||||
const lines = text.split('\n');
|
const phoneRegex = /(\+\d{1,4}[\d\s-]{5,15})/g;
|
||||||
|
const parts = content.split(phoneRegex);
|
||||||
|
content = parts.map((part, i) => {
|
||||||
|
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>
|
||||||
|
);
|
||||||
|
if (node.format & 32) content = <sub>{content}</sub>;
|
||||||
|
if (node.format & 64) content = <sup>{content}</sup>;
|
||||||
|
}
|
||||||
|
return <>{content}</>;
|
||||||
|
},
|
||||||
|
// Use div instead of p for paragraphs to allow nested block elements (like the lists above)
|
||||||
|
paragraph: ({ node, nodesToJSX }: any) => {
|
||||||
|
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
|
||||||
|
heading: ({ node, nodesToJSX }: any) => {
|
||||||
|
const children = nodesToJSX({ nodes: node.children });
|
||||||
|
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')
|
||||||
return (
|
return (
|
||||||
<>
|
<h2
|
||||||
{lines.map((line: string, i: number) => (
|
id={id}
|
||||||
<span key={i}>
|
className="text-2xl md:text-3xl font-bold mt-10 mb-5 text-text-primary scroll-mt-24"
|
||||||
{line}
|
>
|
||||||
{i < lines.length - 1 && <br />}
|
{children}
|
||||||
</span>
|
</h2>
|
||||||
))}
|
);
|
||||||
</>
|
if (tag === 'h2')
|
||||||
|
return (
|
||||||
|
<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')
|
||||||
|
return (
|
||||||
|
<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')
|
||||||
|
return (
|
||||||
|
<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')
|
||||||
|
return (
|
||||||
|
<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 id={id} className="text-base font-bold mt-6 mb-4 text-text-primary scroll-mt-24">
|
||||||
|
{children}
|
||||||
|
</h6>
|
||||||
|
);
|
||||||
|
},
|
||||||
|
list: ({ node, nodesToJSX }: any) => {
|
||||||
|
const children = nodesToJSX({ nodes: node.children });
|
||||||
|
if (node?.listType === 'number') {
|
||||||
|
return (
|
||||||
|
<ol className="list-decimal pl-6 my-6 space-y-2 text-text-secondary marker:text-primary marker:font-bold">
|
||||||
|
{children}
|
||||||
|
</ol>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (node?.listType === 'check') {
|
||||||
|
return <ul className="list-none pl-0 my-6 space-y-2 text-text-secondary">{children}</ul>;
|
||||||
|
}
|
||||||
|
return (
|
||||||
|
<ul className="list-disc pl-6 my-6 space-y-2 text-text-secondary marker:text-primary">
|
||||||
|
{children}
|
||||||
|
</ul>
|
||||||
|
);
|
||||||
|
},
|
||||||
|
listitem: ({ node, nodesToJSX }: any) => {
|
||||||
|
const children = nodesToJSX({ nodes: node.children });
|
||||||
|
if (node?.checked != null) {
|
||||||
|
return (
|
||||||
|
<li className="flex items-start gap-3 mb-2 leading-relaxed">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={node.checked}
|
||||||
|
readOnly
|
||||||
|
className="mt-1.5 w-4 h-4 text-primary focus:ring-primary border-gray-300 rounded shrink-0"
|
||||||
|
/>
|
||||||
|
<div className="flex-1">{children}</div>
|
||||||
|
</li>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return <li className="mb-2 leading-relaxed block">{children}</li>;
|
||||||
|
},
|
||||||
|
quote: ({ node, nodesToJSX }: any) => {
|
||||||
|
const children = nodesToJSX({ nodes: node.children });
|
||||||
|
return (
|
||||||
|
<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}
|
||||||
|
</blockquote>
|
||||||
|
);
|
||||||
|
},
|
||||||
|
link: ({ node, nodesToJSX }: any) => {
|
||||||
|
const children = nodesToJSX({ nodes: node.children });
|
||||||
|
// Handling Payload CMS link nodes
|
||||||
|
const href = node?.fields?.url || node?.url || '#';
|
||||||
|
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"
|
||||||
|
/>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (node.format === 1) return <strong>{text}</strong>;
|
return (
|
||||||
if (node.format === 2) return <em>{text}</em>;
|
<a
|
||||||
return <span>{text}</span>;
|
href={href}
|
||||||
|
target={newTab ? '_blank' : undefined}
|
||||||
|
rel={newTab ? 'noopener noreferrer' : undefined}
|
||||||
|
className="text-primary no-underline hover:underline font-medium transition-colors"
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</a>
|
||||||
|
);
|
||||||
},
|
},
|
||||||
// Scale headings to prevent multiple H1s (H1 -> H2, etc)
|
|
||||||
h1: ({ children }: any) => <h2 className="text-3xl md:text-4xl font-bold my-6">{children}</h2>,
|
|
||||||
h2: ({ children }: any) => <h3 className="text-2xl md:text-3xl font-bold my-5">{children}</h3>,
|
|
||||||
h3: ({ children }: any) => <h4 className="text-xl md:text-2xl font-bold my-4">{children}</h4>,
|
|
||||||
|
|
||||||
blocks: {
|
blocks: {
|
||||||
// ... preserved existing blocks ...
|
// ... preserved existing blocks ...
|
||||||
@@ -170,10 +323,10 @@ const jsxConverters: JSXConverters = {
|
|||||||
/>
|
/>
|
||||||
),
|
),
|
||||||
technicalGrid: ({ node }: any) => (
|
technicalGrid: ({ node }: any) => (
|
||||||
<TechnicalGrid title={node.fields.title} items={node.fields.items} />
|
<TechnicalGrid title={node?.fields?.title} items={node?.fields?.items} />
|
||||||
),
|
),
|
||||||
'block-technicalGrid': ({ node }: any) => {
|
'block-technicalGrid': ({ node }: any) => {
|
||||||
console.log('[PayloadRichText] Rendering block-technicalGrid:', node.fields.title);
|
if (!node?.fields) return null;
|
||||||
return <TechnicalGrid title={node.fields.title} items={node.fields.items} />;
|
return <TechnicalGrid title={node.fields.title} items={node.fields.items} />;
|
||||||
},
|
},
|
||||||
highlightBox: ({ node }: any) => (
|
highlightBox: ({ node }: any) => (
|
||||||
@@ -246,20 +399,23 @@ const jsxConverters: JSXConverters = {
|
|||||||
{node.fields.title}
|
{node.fields.title}
|
||||||
</SplitHeading>
|
</SplitHeading>
|
||||||
),
|
),
|
||||||
productTabs: ({ node }: any) => (
|
productTabs: ({ node }: any) => {
|
||||||
<ProductTabs
|
if (!node?.fields) return null;
|
||||||
technicalData={
|
return (
|
||||||
<ProductTechnicalData
|
<ProductTabs
|
||||||
data={{
|
technicalData={
|
||||||
technicalItems: node.fields.technicalItems,
|
<ProductTechnicalData
|
||||||
voltageTables: node.fields.voltageTables,
|
data={{
|
||||||
}}
|
technicalItems: node.fields.technicalItems,
|
||||||
/>
|
voltageTables: node.fields.voltageTables,
|
||||||
}
|
}}
|
||||||
>
|
/>
|
||||||
<></>
|
}
|
||||||
</ProductTabs>
|
>
|
||||||
),
|
<></>
|
||||||
|
</ProductTabs>
|
||||||
|
);
|
||||||
|
},
|
||||||
'block-productTabs': ({ node }: any) => (
|
'block-productTabs': ({ node }: any) => (
|
||||||
<ProductTabs
|
<ProductTabs
|
||||||
technicalData={
|
technicalData={
|
||||||
@@ -274,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;
|
||||||
@@ -631,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 (
|
||||||
@@ -1009,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>
|
||||||
|
|||||||
@@ -41,10 +41,8 @@ export default async function RelatedProducts({
|
|||||||
];
|
];
|
||||||
const catFileSlug =
|
const catFileSlug =
|
||||||
categorySlugs.find((slug) => {
|
categorySlugs.find((slug) => {
|
||||||
const key = slug.replace(/-cables$/, '').replace(/-([a-z])/g, (g) => g[1].toUpperCase());
|
|
||||||
const title = t(`categories.${key}.title`);
|
|
||||||
return product.frontmatter.categories.some(
|
return product.frontmatter.categories.some(
|
||||||
(cat) => cat.toLowerCase().replace(/\s+/g, '-') === slug || cat === title,
|
(cat) => cat.toLowerCase().replace(/\s+/g, '-') === slug,
|
||||||
);
|
);
|
||||||
}) || 'low-voltage-cables';
|
}) || 'low-voltage-cables';
|
||||||
|
|
||||||
|
|||||||
@@ -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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
26
components/analytics/ClientNotFoundTracker.tsx
Normal file
26
components/analytics/ClientNotFoundTracker.tsx
Normal file
@@ -0,0 +1,26 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useEffect } from 'react';
|
||||||
|
import { useAnalytics } from './useAnalytics';
|
||||||
|
import { AnalyticsEvents } from './analytics-events';
|
||||||
|
|
||||||
|
export default function ClientNotFoundTracker({ path }: { path: string }) {
|
||||||
|
const { trackEvent } = useAnalytics();
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
trackEvent(AnalyticsEvents.ERROR, {
|
||||||
|
type: '404_not_found',
|
||||||
|
path,
|
||||||
|
});
|
||||||
|
|
||||||
|
import('@sentry/nextjs').then((Sentry) => {
|
||||||
|
Sentry.withScope((scope) => {
|
||||||
|
scope.setTag('status_code', '404');
|
||||||
|
scope.setTag('path', path);
|
||||||
|
Sentry.captureMessage(`Route Not Found: ${path}`, 'warning');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}, [trackEvent, path]);
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
@@ -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,23 +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 dangerouslySetInnerHTML={{ __html: data.title.replace(/<green>/g, '<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>') }} />
|
<span
|
||||||
|
dangerouslySetInnerHTML={{
|
||||||
|
__html: data.title
|
||||||
|
.replace(/<green>/g, '<span class="text-accent italic">')
|
||||||
|
.replace(/<\/green>/g, '</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" />
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ import Image from 'next/image';
|
|||||||
import Link from 'next/link';
|
import Link from 'next/link';
|
||||||
import { getAllPosts } from '@/lib/blog';
|
import { getAllPosts } from '@/lib/blog';
|
||||||
import { getTranslations } from 'next-intl/server';
|
import { getTranslations } from 'next-intl/server';
|
||||||
import { Section, Container, Heading, Card, Badge } from '../../components/ui';
|
import { Section, Container, Heading } from '../../components/ui';
|
||||||
|
|
||||||
interface RecentPostsProps {
|
interface RecentPostsProps {
|
||||||
locale: string;
|
locale: string;
|
||||||
@@ -13,7 +13,7 @@ interface RecentPostsProps {
|
|||||||
export default async function RecentPosts({ locale, data }: RecentPostsProps) {
|
export default async function RecentPosts({ locale, data }: RecentPostsProps) {
|
||||||
const t = await getTranslations('Blog');
|
const t = await getTranslations('Blog');
|
||||||
const posts = await getAllPosts(locale);
|
const posts = await getAllPosts(locale);
|
||||||
const recentPosts = posts.slice(0, 3);
|
const recentPosts = posts.slice(0, 4);
|
||||||
|
|
||||||
if (recentPosts.length === 0) return null;
|
if (recentPosts.length === 0) return null;
|
||||||
|
|
||||||
@@ -21,9 +21,9 @@ export default async function RecentPosts({ locale, data }: RecentPostsProps) {
|
|||||||
const subtitle = data?.subtitle || t('latestNews');
|
const subtitle = data?.subtitle || t('latestNews');
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Section className="bg-neutral py-16 md:py-24">
|
<Section className="bg-neutral-light py-0 md:py-0 lg:py-0">
|
||||||
<Container>
|
<Container className="py-12 md:py-16">
|
||||||
<div className="flex flex-col md:flex-row items-start md:items-end justify-between mb-12 md:mb-16 gap-6">
|
<div className="flex flex-col md:flex-row items-start md:items-end justify-between gap-6">
|
||||||
<Heading level={2} subtitle={subtitle} className="mb-0 text-primary">
|
<Heading level={2} subtitle={subtitle} className="mb-0 text-primary">
|
||||||
{title}
|
{title}
|
||||||
</Heading>
|
</Heading>
|
||||||
@@ -35,74 +35,76 @@ export default async function RecentPosts({ locale, data }: RecentPostsProps) {
|
|||||||
<span className="ml-2 transition-transform group-hover:translate-x-2">→</span>
|
<span className="ml-2 transition-transform group-hover:translate-x-2">→</span>
|
||||||
</Link>
|
</Link>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<ul className="grid grid-cols-1 md:grid-cols-3 gap-8 md:gap-10 list-none p-0 m-0">
|
|
||||||
{recentPosts.map((post, idx) => (
|
|
||||||
<li key={`${post.slug}-${idx}`}>
|
|
||||||
<Link href={`/${locale}/blog/${post.slug}`} className="group block h-full">
|
|
||||||
<Card
|
|
||||||
tag="article"
|
|
||||||
className="h-full flex flex-col border-none shadow-lg hover:shadow-2xl transition-all duration-500 rounded-3xl"
|
|
||||||
>
|
|
||||||
{post.frontmatter.featuredImage && (
|
|
||||||
<div className="relative h-64 overflow-hidden">
|
|
||||||
<Image
|
|
||||||
src={post.frontmatter.featuredImage.split('?')[0]}
|
|
||||||
alt={post.frontmatter.title}
|
|
||||||
fill
|
|
||||||
className="w-full h-full object-cover transition-transform duration-700 group-hover:scale-110"
|
|
||||||
style={{
|
|
||||||
objectPosition: `${post.frontmatter.focalX ?? 50}% ${post.frontmatter.focalY ?? 50}%`,
|
|
||||||
}}
|
|
||||||
sizes="(max-width: 768px) 100vw, 33vw"
|
|
||||||
loading="lazy"
|
|
||||||
/>
|
|
||||||
<div className="absolute inset-0 image-overlay-gradient opacity-0 group-hover:opacity-100 transition-opacity duration-500" />
|
|
||||||
{post.frontmatter.category && (
|
|
||||||
<Badge variant="accent" className="absolute top-4 left-4 shadow-md">
|
|
||||||
{post.frontmatter.category}
|
|
||||||
</Badge>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
<div className="p-6 md:p-8 flex flex-col flex-grow">
|
|
||||||
<div className="text-xs md:text-sm font-medium text-text-light mb-3 md:mb-4 flex items-center gap-2">
|
|
||||||
<span className="w-6 md:w-8 h-px bg-neutral-medium" />
|
|
||||||
<time dateTime={post.frontmatter.date}>
|
|
||||||
{new Date(post.frontmatter.date).toLocaleDateString(locale, {
|
|
||||||
year: 'numeric',
|
|
||||||
month: 'long',
|
|
||||||
day: 'numeric',
|
|
||||||
})}
|
|
||||||
</time>
|
|
||||||
</div>
|
|
||||||
<h3 className="text-lg md:text-xl font-bold text-primary group-hover:text-accent-dark transition-colors line-clamp-2 mb-4 md:mb-6 leading-tight">
|
|
||||||
{post.frontmatter.title}
|
|
||||||
</h3>
|
|
||||||
<div className="mt-auto flex items-center text-primary font-bold group-hover:underline decoration-2 underline-offset-4">
|
|
||||||
{t('readMore')}
|
|
||||||
<svg
|
|
||||||
className="ml-2 w-5 h-5 transition-transform group-hover:translate-x-2"
|
|
||||||
fill="none"
|
|
||||||
stroke="currentColor"
|
|
||||||
viewBox="0 0 24 24"
|
|
||||||
aria-hidden="true"
|
|
||||||
>
|
|
||||||
<path
|
|
||||||
strokeLinecap="round"
|
|
||||||
strokeLinejoin="round"
|
|
||||||
strokeWidth={2}
|
|
||||||
d="M17 8l4 4m0 0l-4 4m4-4H3"
|
|
||||||
/>
|
|
||||||
</svg>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</Card>
|
|
||||||
</Link>
|
|
||||||
</li>
|
|
||||||
))}
|
|
||||||
</ul>
|
|
||||||
</Container>
|
</Container>
|
||||||
|
|
||||||
|
<ul className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 m-0 p-0 list-none">
|
||||||
|
{recentPosts.map((post, idx) => (
|
||||||
|
<li key={`${post.slug}-${idx}`} className="block">
|
||||||
|
<Link
|
||||||
|
href={`/${locale}/blog/${post.slug}`}
|
||||||
|
className="group block relative h-[400px] md:h-[500px] lg:h-[650px] overflow-hidden border-b md:border-b-0 md:border-r border-white/10 last:border-0 focus:outline-none"
|
||||||
|
>
|
||||||
|
{post.frontmatter.featuredImage && (
|
||||||
|
<>
|
||||||
|
<Image
|
||||||
|
src={post.frontmatter.featuredImage.split('?')[0]}
|
||||||
|
alt={post.frontmatter.title}
|
||||||
|
fill
|
||||||
|
className="object-cover transition-transform duration-1000 group-hover:scale-110"
|
||||||
|
style={{
|
||||||
|
objectPosition: `${post.frontmatter.focalX ?? 50}% ${post.frontmatter.focalY ?? 50}%`,
|
||||||
|
}}
|
||||||
|
sizes="(max-width: 768px) 100vw, (max-width: 1024px) 50vw, 25vw"
|
||||||
|
loading="lazy"
|
||||||
|
/>
|
||||||
|
<div className="absolute inset-0 bg-primary-dark/40 group-hover:bg-primary-dark/60 transition-all duration-500" />
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="absolute inset-0 p-8 md:p-10 flex flex-col justify-end text-white">
|
||||||
|
<div className="mb-4 md:mb-6 transform transition-all duration-500 group-hover:-translate-y-4">
|
||||||
|
<div className="flex flex-wrap items-center gap-2 mb-4">
|
||||||
|
{post.frontmatter.category && (
|
||||||
|
<span className="px-3 py-1 bg-accent text-primary-dark rounded-full text-[10px] md:text-xs font-bold uppercase tracking-wider shadow-sm">
|
||||||
|
{post.frontmatter.category}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
<time
|
||||||
|
dateTime={post.frontmatter.date}
|
||||||
|
suppressHydrationWarning
|
||||||
|
className="px-3 py-1 text-white/80 text-[10px] md:text-xs font-bold uppercase tracking-widest border border-white/20 rounded-full bg-white/10 backdrop-blur-md"
|
||||||
|
>
|
||||||
|
{new Date(post.frontmatter.date).toLocaleDateString(
|
||||||
|
['en', 'de'].includes(locale) ? locale : 'de',
|
||||||
|
{
|
||||||
|
year: 'numeric',
|
||||||
|
month: 'short',
|
||||||
|
day: 'numeric',
|
||||||
|
},
|
||||||
|
)}
|
||||||
|
</time>
|
||||||
|
{(new Date(post.frontmatter.date) > new Date() ||
|
||||||
|
post.frontmatter.public === false) && (
|
||||||
|
<span className="px-2 py-0.5 border border-white/40 text-white/90 rounded uppercase tracking-widest text-[10px] md:text-xs font-bold bg-neutral-dark/40 shadow-sm">
|
||||||
|
Draft Preview
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<h3 className="text-xl md:text-2xl font-bold mb-2 md:mb-4 leading-tight drop-shadow-md">
|
||||||
|
{post.frontmatter.title}
|
||||||
|
</h3>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center text-accent font-bold tracking-wider uppercase text-xs md:text-xs opacity-100 md:opacity-0 group-hover:opacity-100 transition-all duration-500 delay-100">
|
||||||
|
{t('readMore')}{' '}
|
||||||
|
<span className="ml-2 transition-transform group-hover:translate-x-2">
|
||||||
|
→
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Link>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
</Section>
|
</Section>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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"
|
||||||
|
|||||||
@@ -10,10 +10,11 @@ services:
|
|||||||
env_file:
|
env_file:
|
||||||
- ${ENV_FILE:-.env}
|
- ${ENV_FILE:-.env}
|
||||||
environment:
|
environment:
|
||||||
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:-payload}@klz-db:5432/${PAYLOAD_DB_NAME:-payload}
|
||||||
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
|
||||||
@@ -29,7 +30,7 @@ services:
|
|||||||
- "traefik.http.routers.${PROJECT_NAME:-klz}.middlewares=${AUTH_MIDDLEWARE:-klz-ratelimit,klz-forward,klz-compress}"
|
- "traefik.http.routers.${PROJECT_NAME:-klz}.middlewares=${AUTH_MIDDLEWARE:-klz-ratelimit,klz-forward,klz-compress}"
|
||||||
|
|
||||||
# Public Router – paths that bypass Gatekeeper auth (health, SEO, static assets, OG images)
|
# Public Router – paths that bypass Gatekeeper auth (health, SEO, static assets, OG images)
|
||||||
- "traefik.http.routers.${PROJECT_NAME:-klz}-public.rule=(${TRAEFIK_HOST_RULE:-Host(`${TRAEFIK_HOST:-klz-cables.com}`)}) && PathRegexp(`^/(health|uploads|media|robots\\.txt|manifest\\.webmanifest|sitemap(-[0-9]+)?\\.xml|(.*/)?api/og(/.*)?|(.*/)?opengraph-image.*)`)"
|
- "traefik.http.routers.${PROJECT_NAME:-klz}-public.rule=(${TRAEFIK_HOST_RULE:-Host(`${TRAEFIK_HOST:-klz-cables.com}`)}) && PathRegexp(`^/([a-z]{2}/)?(health|login|gatekeeper|uploads|media|robots\\.txt|manifest\\.webmanifest|sitemap(-[0-9]+)?\\.xml|(.*/)?api/og(/.*)?|(.*/)?opengraph-image.*)`)"
|
||||||
- "traefik.http.routers.${PROJECT_NAME:-klz}-public.entrypoints=${TRAEFIK_ENTRYPOINT:-web}"
|
- "traefik.http.routers.${PROJECT_NAME:-klz}-public.entrypoints=${TRAEFIK_ENTRYPOINT:-web}"
|
||||||
- "traefik.http.routers.${PROJECT_NAME:-klz}-public.tls.certresolver=${TRAEFIK_CERT_RESOLVER:-}"
|
- "traefik.http.routers.${PROJECT_NAME:-klz}-public.tls.certresolver=${TRAEFIK_CERT_RESOLVER:-}"
|
||||||
- "traefik.http.routers.${PROJECT_NAME:-klz}-public.tls=${TRAEFIK_TLS:-false}"
|
- "traefik.http.routers.${PROJECT_NAME:-klz}-public.tls=${TRAEFIK_TLS:-false}"
|
||||||
@@ -46,9 +47,21 @@ services:
|
|||||||
- "traefik.http.middlewares.${PROJECT_NAME:-klz}-forward.headers.customrequestheaders.X-Forwarded-Proto=https"
|
- "traefik.http.middlewares.${PROJECT_NAME:-klz}-forward.headers.customrequestheaders.X-Forwarded-Proto=https"
|
||||||
- "traefik.http.middlewares.${PROJECT_NAME:-klz}-forward.headers.customrequestheaders.X-Forwarded-Ssl=on"
|
- "traefik.http.middlewares.${PROJECT_NAME:-klz}-forward.headers.customrequestheaders.X-Forwarded-Ssl=on"
|
||||||
|
|
||||||
|
# Login redirect – the app's middleware sends users to /login but login lives at /gatekeeper/login
|
||||||
|
- "traefik.http.middlewares.${PROJECT_NAME:-klz}-loginredirect.redirectregex.regex=^https?://[^/]+/([a-z]{2}/)?login(.*)"
|
||||||
|
- "traefik.http.middlewares.${PROJECT_NAME:-klz}-loginredirect.redirectregex.replacement=https://${TRAEFIK_HOST:-klz-cables.com}/gatekeeper/login$${2}"
|
||||||
|
- "traefik.http.middlewares.${PROJECT_NAME:-klz}-loginredirect.redirectregex.permanent=false"
|
||||||
|
- "traefik.http.routers.${PROJECT_NAME:-klz}-loginredir.rule=(${TRAEFIK_HOST_RULE:-Host(`${TRAEFIK_HOST:-klz-cables.com}`)}) && PathRegexp(`^/([a-z]{2}/)?login`)"
|
||||||
|
- "traefik.http.routers.${PROJECT_NAME:-klz}-loginredir.entrypoints=${TRAEFIK_ENTRYPOINT:-web}"
|
||||||
|
- "traefik.http.routers.${PROJECT_NAME:-klz}-loginredir.tls.certresolver=${TRAEFIK_CERT_RESOLVER:-}"
|
||||||
|
- "traefik.http.routers.${PROJECT_NAME:-klz}-loginredir.tls=${TRAEFIK_TLS:-false}"
|
||||||
|
- "traefik.http.routers.${PROJECT_NAME:-klz}-loginredir.middlewares=${PROJECT_NAME:-klz}-loginredirect"
|
||||||
|
- "traefik.http.routers.${PROJECT_NAME:-klz}-loginredir.service=${PROJECT_NAME:-klz}-app-svc"
|
||||||
|
- "traefik.http.routers.${PROJECT_NAME:-klz}-loginredir.priority=2002"
|
||||||
|
|
||||||
klz-gatekeeper:
|
klz-gatekeeper:
|
||||||
profiles: [ "gatekeeper" ]
|
profiles: [ "gatekeeper" ]
|
||||||
image: registry.infra.mintel.me/mintel/gatekeeper:v1.7.12
|
image: registry.infra.mintel.me/mintel/gatekeeper:testing
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
networks:
|
networks:
|
||||||
infra:
|
infra:
|
||||||
@@ -66,16 +79,8 @@ services:
|
|||||||
- "traefik.http.middlewares.${PROJECT_NAME:-klz}-auth.forwardauth.authResponseHeaders=X-Auth-User"
|
- "traefik.http.middlewares.${PROJECT_NAME:-klz}-auth.forwardauth.authResponseHeaders=X-Auth-User"
|
||||||
- "traefik.docker.network=infra"
|
- "traefik.docker.network=infra"
|
||||||
|
|
||||||
# Gatekeeper Public Router (Login/Auth UI)
|
# Gatekeeper Public Router (Login/Auth UI) — basePath mode on main domain
|
||||||
- "traefik.http.routers.${PROJECT_NAME:-klz}-gatekeeper.rule=(${TRAEFIK_HOST_RULE:-Host(`${TRAEFIK_HOST:-klz-cables.com}`)}) && PathRegexp(`^/(login|gatekeeper)(/.*)?`)"
|
- "traefik.http.routers.${PROJECT_NAME:-klz}-gatekeeper.rule=(${TRAEFIK_HOST_RULE:-Host(`${TRAEFIK_HOST:-klz-cables.com}`)}) && PathPrefix(`/gatekeeper`)"
|
||||||
- "traefik.http.routers.${PROJECT_NAME:-klz}-gatekeeper.entrypoints=${TRAEFIK_ENTRYPOINT:-web}"
|
|
||||||
- "traefik.http.routers.${PROJECT_NAME:-klz}-gatekeeper.tls.certresolver=${TRAEFIK_CERT_RESOLVER:-}"
|
|
||||||
- "traefik.http.routers.${PROJECT_NAME:-klz}-gatekeeper.tls=${TRAEFIK_TLS:-false}"
|
|
||||||
- "traefik.http.routers.${PROJECT_NAME:-klz}-gatekeeper.service=${PROJECT_NAME:-klz}-gatekeeper-svc"
|
|
||||||
- "traefik.http.routers.${PROJECT_NAME:-klz}-gatekeeper.priority=2001"
|
|
||||||
|
|
||||||
# Gatekeeper Public Router (Login/Auth UI)
|
|
||||||
- "traefik.http.routers.${PROJECT_NAME:-klz}-gatekeeper.rule=(${TRAEFIK_HOST_RULE:-Host(`${TRAEFIK_HOST:-klz-cables.com}`)}) && PathRegexp(`^/(login|gatekeeper)(/.*)?`)"
|
|
||||||
- "traefik.http.routers.${PROJECT_NAME:-klz}-gatekeeper.entrypoints=${TRAEFIK_ENTRYPOINT:-web}"
|
- "traefik.http.routers.${PROJECT_NAME:-klz}-gatekeeper.entrypoints=${TRAEFIK_ENTRYPOINT:-web}"
|
||||||
- "traefik.http.routers.${PROJECT_NAME:-klz}-gatekeeper.tls.certresolver=${TRAEFIK_CERT_RESOLVER:-}"
|
- "traefik.http.routers.${PROJECT_NAME:-klz}-gatekeeper.tls.certresolver=${TRAEFIK_CERT_RESOLVER:-}"
|
||||||
- "traefik.http.routers.${PROJECT_NAME:-klz}-gatekeeper.tls=${TRAEFIK_TLS:-false}"
|
- "traefik.http.routers.${PROJECT_NAME:-klz}-gatekeeper.tls=${TRAEFIK_TLS:-false}"
|
||||||
@@ -90,7 +95,7 @@ services:
|
|||||||
environment:
|
environment:
|
||||||
POSTGRES_DB: ${PAYLOAD_DB_NAME:-payload}
|
POSTGRES_DB: ${PAYLOAD_DB_NAME:-payload}
|
||||||
POSTGRES_USER: ${PAYLOAD_DB_USER:-payload}
|
POSTGRES_USER: ${PAYLOAD_DB_USER:-payload}
|
||||||
POSTGRES_PASSWORD: ${PAYLOAD_DB_PASSWORD:-120in09oenaoinsd9iaidon}
|
POSTGRES_PASSWORD: ${PAYLOAD_DB_PASSWORD:-payload}
|
||||||
volumes:
|
volumes:
|
||||||
- klz_db_data:/var/lib/postgresql/data
|
- klz_db_data:/var/lib/postgresql/data
|
||||||
networks:
|
networks:
|
||||||
@@ -107,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
84
lib/blog.ts
84
lib/blog.ts
@@ -59,18 +59,50 @@ export async function getPostBySlug(slug: string, locale: string): Promise<PostD
|
|||||||
try {
|
try {
|
||||||
const payload = await getPayload({ config: configPromise });
|
const payload = await getPayload({ config: configPromise });
|
||||||
|
|
||||||
const isDev = process.env.NODE_ENV === 'development';
|
// First try: Find in the requested locale
|
||||||
const { docs } = await payload.find({
|
let { docs } = await payload.find({
|
||||||
collection: 'posts',
|
collection: 'posts',
|
||||||
where: {
|
where: {
|
||||||
slug: { equals: slug },
|
slug: { equals: slug },
|
||||||
...(!isDev ? { _status: { equals: 'published' } } : {}),
|
...(!config.showDrafts ? { _status: { equals: 'published' } } : {}),
|
||||||
},
|
},
|
||||||
locale: locale as any,
|
locale: locale as any,
|
||||||
draft: isDev,
|
draft: config.showDrafts,
|
||||||
limit: 1,
|
limit: 1,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Fallback: If not found, try searching across all locales.
|
||||||
|
// This happens when a user uses the static language switcher
|
||||||
|
// e.g. switching from /en/blog/en-slug to /de/blog/en-slug.
|
||||||
|
if (!docs || docs.length === 0) {
|
||||||
|
const { docs: crossLocaleDocs } = await payload.find({
|
||||||
|
collection: 'posts',
|
||||||
|
where: {
|
||||||
|
slug: { equals: slug },
|
||||||
|
...(!config.showDrafts ? { _status: { equals: 'published' } } : {}),
|
||||||
|
},
|
||||||
|
locale: 'all',
|
||||||
|
draft: config.showDrafts,
|
||||||
|
limit: 1,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (crossLocaleDocs && crossLocaleDocs.length > 0) {
|
||||||
|
// Fetch the found document again, but strictly in the requested locale
|
||||||
|
// so we get the correctly translated fields (like the localized slug)
|
||||||
|
const { docs: correctLocaleDocs } = await payload.find({
|
||||||
|
collection: 'posts',
|
||||||
|
where: {
|
||||||
|
id: { equals: crossLocaleDocs[0].id },
|
||||||
|
},
|
||||||
|
locale: locale as any,
|
||||||
|
draft: config.showDrafts,
|
||||||
|
limit: 1,
|
||||||
|
});
|
||||||
|
|
||||||
|
docs = correctLocaleDocs;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
if (!docs || docs.length === 0) return null;
|
if (!docs || docs.length === 0) return null;
|
||||||
|
|
||||||
const doc = docs[0];
|
const doc = docs[0];
|
||||||
@@ -84,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
|
||||||
@@ -107,15 +139,14 @@ export async function getPostBySlug(slug: string, locale: string): Promise<PostD
|
|||||||
export async function getAllPosts(locale: string): Promise<PostData[]> {
|
export async function getAllPosts(locale: string): Promise<PostData[]> {
|
||||||
try {
|
try {
|
||||||
const payload = await getPayload({ config: configPromise });
|
const payload = await getPayload({ config: configPromise });
|
||||||
const isDev = process.env.NODE_ENV === 'development';
|
|
||||||
const { docs } = await payload.find({
|
const { docs } = await payload.find({
|
||||||
collection: 'posts',
|
collection: 'posts',
|
||||||
where: {
|
where: {
|
||||||
...(!isDev ? { _status: { equals: 'published' } } : {}),
|
...(!config.showDrafts ? { _status: { equals: 'published' } } : {}),
|
||||||
},
|
},
|
||||||
locale: locale as any,
|
locale: locale as any,
|
||||||
sort: '-date',
|
sort: '-date',
|
||||||
draft: isDev,
|
draft: config.showDrafts,
|
||||||
limit: 100,
|
limit: 100,
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -131,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
|
||||||
@@ -255,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 '';
|
||||||
|
}
|
||||||
|
|||||||
@@ -29,6 +29,7 @@ function createConfig() {
|
|||||||
isStaging: target === 'staging',
|
isStaging: target === 'staging',
|
||||||
isTesting: target === 'testing',
|
isTesting: target === 'testing',
|
||||||
isDevelopment: target === 'development',
|
isDevelopment: target === 'development',
|
||||||
|
showDrafts: target === 'development' || target === 'testing' || target === 'staging',
|
||||||
feedbackEnabled: env.NEXT_PUBLIC_FEEDBACK_ENABLED,
|
feedbackEnabled: env.NEXT_PUBLIC_FEEDBACK_ENABLED,
|
||||||
gatekeeperUrl: env.GATEKEEPER_URL,
|
gatekeeperUrl: env.GATEKEEPER_URL,
|
||||||
|
|
||||||
@@ -116,6 +117,9 @@ export const config = {
|
|||||||
get isDevelopment() {
|
get isDevelopment() {
|
||||||
return getConfig().isDevelopment;
|
return getConfig().isDevelopment;
|
||||||
},
|
},
|
||||||
|
get showDrafts() {
|
||||||
|
return getConfig().showDrafts;
|
||||||
|
},
|
||||||
get baseUrl() {
|
get baseUrl() {
|
||||||
return getConfig().baseUrl;
|
return getConfig().baseUrl;
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -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) {
|
||||||
|
|||||||
76
lib/pages.ts
76
lib/pages.ts
@@ -1,5 +1,7 @@
|
|||||||
import { getPayload } from 'payload';
|
import { getPayload } from 'payload';
|
||||||
import configPromise from '@payload-config';
|
import configPromise from '@payload-config';
|
||||||
|
import { mapSlugToFileSlug } from './slugs';
|
||||||
|
import { config } from '@/lib/config';
|
||||||
|
|
||||||
export interface PageFrontmatter {
|
export interface PageFrontmatter {
|
||||||
title: string;
|
title: string;
|
||||||
@@ -44,19 +46,81 @@ function mapDoc(doc: any): PageData {
|
|||||||
export async function getPageBySlug(slug: string, locale: string): Promise<PageData | null> {
|
export async function getPageBySlug(slug: string, locale: string): Promise<PageData | null> {
|
||||||
try {
|
try {
|
||||||
const payload = await getPayload({ config: configPromise });
|
const payload = await getPayload({ config: configPromise });
|
||||||
|
const fileSlug = await mapSlugToFileSlug(slug, locale);
|
||||||
|
|
||||||
const result = await payload.find({
|
// Try finding exact match first
|
||||||
collection: 'pages' as any,
|
let result = await payload.find({
|
||||||
|
collection: 'pages',
|
||||||
where: {
|
where: {
|
||||||
slug: { equals: slug },
|
and: [
|
||||||
|
{ slug: { equals: fileSlug } },
|
||||||
|
...(!config.showDrafts ? [{ _status: { equals: 'published' } }] : []),
|
||||||
|
],
|
||||||
},
|
},
|
||||||
locale: locale as any,
|
locale: locale as any,
|
||||||
|
depth: 1,
|
||||||
limit: 1,
|
limit: 1,
|
||||||
});
|
});
|
||||||
|
|
||||||
const docs = result.docs as any[];
|
// Fallback: search ALL locales
|
||||||
if (!docs || docs.length === 0) return null;
|
if (result.docs.length === 0) {
|
||||||
return mapDoc(docs[0]);
|
const crossResult = await payload.find({
|
||||||
|
collection: 'pages',
|
||||||
|
where: {
|
||||||
|
and: [
|
||||||
|
{ slug: { equals: fileSlug } },
|
||||||
|
...(!config.showDrafts ? [{ _status: { equals: 'published' } }] : []),
|
||||||
|
],
|
||||||
|
},
|
||||||
|
locale: 'all',
|
||||||
|
depth: 1,
|
||||||
|
limit: 1,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (crossResult.docs.length > 0) {
|
||||||
|
// Fetch missing exact match by internal id
|
||||||
|
result = await payload.find({
|
||||||
|
collection: 'pages',
|
||||||
|
where: {
|
||||||
|
id: { equals: crossResult.docs[0].id },
|
||||||
|
},
|
||||||
|
locale: locale as any,
|
||||||
|
depth: 1,
|
||||||
|
limit: 1,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (result.docs.length > 0) {
|
||||||
|
const doc = result.docs[0];
|
||||||
|
|
||||||
|
return {
|
||||||
|
slug: doc.slug,
|
||||||
|
frontmatter: {
|
||||||
|
title: doc.title,
|
||||||
|
excerpt: doc.excerpt || '',
|
||||||
|
featuredImage:
|
||||||
|
typeof doc.featuredImage === 'object' && doc.featuredImage !== null
|
||||||
|
? doc.featuredImage.sizes?.card?.url || doc.featuredImage.url
|
||||||
|
: null,
|
||||||
|
focalX:
|
||||||
|
typeof doc.featuredImage === 'object' && doc.featuredImage !== null
|
||||||
|
? doc.featuredImage.focalX
|
||||||
|
: 50,
|
||||||
|
focalY:
|
||||||
|
typeof doc.featuredImage === 'object' && doc.featuredImage !== null
|
||||||
|
? doc.featuredImage.focalY
|
||||||
|
: 50,
|
||||||
|
layout:
|
||||||
|
doc.layout === 'fullBleed' || doc.layout === 'default'
|
||||||
|
? doc.layout
|
||||||
|
: ('default' as const),
|
||||||
|
},
|
||||||
|
content: doc.content,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error(`[Payload] getPageBySlug failed for ${slug}:`, error);
|
console.error(`[Payload] getPageBySlug failed for ${slug}:`, error);
|
||||||
return null;
|
return null;
|
||||||
|
|||||||
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>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -1,6 +1,7 @@
|
|||||||
import { getPayload } from 'payload';
|
import { getPayload } from 'payload';
|
||||||
import configPromise from '@payload-config';
|
import configPromise from '@payload-config';
|
||||||
import { mapSlugToFileSlug } from './slugs';
|
import { mapSlugToFileSlug } from './slugs';
|
||||||
|
import { config } from '@/lib/config';
|
||||||
|
|
||||||
export interface ProductFrontmatter {
|
export interface ProductFrontmatter {
|
||||||
title: string;
|
title: string;
|
||||||
@@ -17,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(
|
||||||
@@ -26,13 +28,12 @@ export async function getProductMetadata(
|
|||||||
const payload = await getPayload({ config: configPromise });
|
const payload = await getPayload({ config: configPromise });
|
||||||
const fileSlug = await mapSlugToFileSlug(slug, locale);
|
const fileSlug = await mapSlugToFileSlug(slug, locale);
|
||||||
|
|
||||||
const isDev = process.env.NODE_ENV === 'development';
|
|
||||||
const result = await payload.find({
|
const result = await payload.find({
|
||||||
collection: 'products',
|
collection: 'products',
|
||||||
where: {
|
where: {
|
||||||
and: [
|
and: [
|
||||||
{ slug: { equals: fileSlug } },
|
{ slug: { equals: fileSlug } },
|
||||||
...(!isDev ? [{ _status: { equals: 'published' } }] : []),
|
...(!config.showDrafts ? [{ _status: { equals: 'published' } }] : []),
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
locale: locale as any,
|
locale: locale as any,
|
||||||
@@ -70,13 +71,12 @@ export async function getProductBySlug(slug: string, locale: string): Promise<Pr
|
|||||||
const payload = await getPayload({ config: configPromise });
|
const payload = await getPayload({ config: configPromise });
|
||||||
const fileSlug = await mapSlugToFileSlug(slug, locale);
|
const fileSlug = await mapSlugToFileSlug(slug, locale);
|
||||||
|
|
||||||
const isDev = process.env.NODE_ENV === 'development';
|
|
||||||
const result = await payload.find({
|
const result = await payload.find({
|
||||||
collection: 'products',
|
collection: 'products',
|
||||||
where: {
|
where: {
|
||||||
and: [
|
and: [
|
||||||
{ slug: { equals: fileSlug } },
|
{ slug: { equals: fileSlug } },
|
||||||
...(!isDev ? [{ _status: { equals: 'published' } }] : []),
|
...(!config.showDrafts ? [{ _status: { equals: 'published' } }] : []),
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
locale: locale as any,
|
locale: locale as any,
|
||||||
@@ -114,6 +114,7 @@ export async function getProductBySlug(slug: string, locale: string): Promise<Pr
|
|||||||
: 50,
|
: 50,
|
||||||
},
|
},
|
||||||
content: doc.content,
|
content: doc.content,
|
||||||
|
application: doc.application,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -127,11 +128,10 @@ export async function getProductBySlug(slug: string, locale: string): Promise<Pr
|
|||||||
export async function getAllProductSlugs(locale: string): Promise<string[]> {
|
export async function getAllProductSlugs(locale: string): Promise<string[]> {
|
||||||
try {
|
try {
|
||||||
const payload = await getPayload({ config: configPromise });
|
const payload = await getPayload({ config: configPromise });
|
||||||
const isDev = process.env.NODE_ENV === 'development';
|
|
||||||
const result = await payload.find({
|
const result = await payload.find({
|
||||||
collection: 'products',
|
collection: 'products',
|
||||||
where: {
|
where: {
|
||||||
...(!isDev ? { _status: { equals: 'published' } } : {}),
|
...(!config.showDrafts ? { _status: { equals: 'published' } } : {}),
|
||||||
},
|
},
|
||||||
locale: locale as any,
|
locale: locale as any,
|
||||||
pagination: false,
|
pagination: false,
|
||||||
@@ -157,11 +157,10 @@ export async function getAllProducts(locale: string): Promise<ProductData[]> {
|
|||||||
images: true,
|
images: true,
|
||||||
} as const;
|
} as const;
|
||||||
|
|
||||||
const isDev = process.env.NODE_ENV === 'development';
|
|
||||||
const result = await payload.find({
|
const result = await payload.find({
|
||||||
collection: 'products',
|
collection: 'products',
|
||||||
where: {
|
where: {
|
||||||
...(!isDev ? { _status: { equals: 'published' } } : {}),
|
...(!config.showDrafts ? { _status: { equals: 'published' } } : {}),
|
||||||
},
|
},
|
||||||
locale: locale as any,
|
locale: locale as any,
|
||||||
depth: 1,
|
depth: 1,
|
||||||
@@ -198,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"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -45,8 +45,10 @@ export default async function middleware(request: NextRequest) {
|
|||||||
if (internalHosts.includes(urlObj.hostname)) {
|
if (internalHosts.includes(urlObj.hostname)) {
|
||||||
const proto = headers.get('x-forwarded-proto') || 'https';
|
const proto = headers.get('x-forwarded-proto') || 'https';
|
||||||
// Prioritize x-forwarded-host (passed by Traefik) over the local Host header
|
// Prioritize x-forwarded-host (passed by Traefik) over the local Host header
|
||||||
const hostHeader =
|
const fallbackHost = process.env.NEXT_PUBLIC_BASE_URL
|
||||||
headers.get('x-forwarded-host') || headers.get('host') || 'testing.klz-cables.com';
|
? new URL(process.env.NEXT_PUBLIC_BASE_URL).host
|
||||||
|
: 'klz-cables.com';
|
||||||
|
const hostHeader = headers.get('x-forwarded-host') || headers.get('host') || fallbackHost;
|
||||||
|
|
||||||
urlObj.protocol = proto;
|
urlObj.protocol = proto;
|
||||||
|
|
||||||
@@ -100,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*',
|
||||||
],
|
],
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -13,8 +13,14 @@ const nextConfig = {
|
|||||||
},
|
},
|
||||||
experimental: {
|
experimental: {
|
||||||
optimizePackageImports: ['lucide-react', 'framer-motion', '@/components/ui'],
|
optimizePackageImports: ['lucide-react', 'framer-motion', '@/components/ui'],
|
||||||
|
cpus: 3,
|
||||||
|
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: {
|
||||||
@@ -22,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;
|
||||||
@@ -76,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',
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
@@ -390,7 +397,9 @@ const nextConfig = {
|
|||||||
];
|
];
|
||||||
},
|
},
|
||||||
images: {
|
images: {
|
||||||
|
qualities: [75, 100],
|
||||||
formats: ['image/webp'],
|
formats: ['image/webp'],
|
||||||
|
deviceSizes: [640, 750, 828, 1080, 1200, 1920, 2048, 3840],
|
||||||
remotePatterns: [
|
remotePatterns: [
|
||||||
{
|
{
|
||||||
protocol: 'https',
|
protocol: 'https',
|
||||||
@@ -400,6 +409,14 @@ const nextConfig = {
|
|||||||
protocol: 'https',
|
protocol: 'https',
|
||||||
hostname: '*.klz-cables.com',
|
hostname: '*.klz-cables.com',
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
protocol: 'http',
|
||||||
|
hostname: 'klz-cables.com',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
protocol: 'http',
|
||||||
|
hostname: '*.klz-cables.com',
|
||||||
|
},
|
||||||
{
|
{
|
||||||
protocol: 'http',
|
protocol: 'http',
|
||||||
hostname: 'klz.localhost',
|
hostname: 'klz.localhost',
|
||||||
@@ -413,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',
|
||||||
@@ -425,6 +458,31 @@ const nextConfig = {
|
|||||||
source: '/de/kontakt',
|
source: '/de/kontakt',
|
||||||
destination: '/de/contact',
|
destination: '/de/contact',
|
||||||
},
|
},
|
||||||
|
// Safety rewrites for English locale using German slugs (legacy or content errors)
|
||||||
|
{
|
||||||
|
source: '/en/produkte',
|
||||||
|
destination: '/en/products',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
source: '/en/produkte/:path*',
|
||||||
|
destination: '/en/products/:path*',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
source: '/en/kontakt',
|
||||||
|
destination: '/en/contact',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
source: '/en/impressum',
|
||||||
|
destination: '/en/legal-notice',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
source: '/en/datenschutz',
|
||||||
|
destination: '/en/privacy-policy',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
source: '/en/agbs',
|
||||||
|
destination: '/en/terms',
|
||||||
|
},
|
||||||
],
|
],
|
||||||
afterFiles: [],
|
afterFiles: [],
|
||||||
fallback: [],
|
fallback: [],
|
||||||
|
|||||||
42
package.json
42
package.json
@@ -4,16 +4,16 @@
|
|||||||
"private": true,
|
"private": true,
|
||||||
"packageManager": "pnpm@10.18.3",
|
"packageManager": "pnpm@10.18.3",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@mintel/mail": "1.8.3",
|
"@mintel/mail": "^1.8.21",
|
||||||
"@mintel/next-config": "1.8.3",
|
"@mintel/next-config": "^1.8.21",
|
||||||
"@mintel/next-feedback": "1.8.10",
|
"@mintel/next-feedback": "^1.8.21",
|
||||||
"@mintel/next-utils": "^1.7.15",
|
"@mintel/next-utils": "^1.8.21",
|
||||||
"@payloadcms/db-postgres": "^3.77.0",
|
"@payloadcms/db-postgres": "^3.77.0",
|
||||||
"@payloadcms/email-nodemailer": "^3.77.0",
|
"@payloadcms/email-nodemailer": "^3.77.0",
|
||||||
"@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",
|
||||||
@@ -53,8 +53,8 @@
|
|||||||
"@commitlint/config-conventional": "^20.4.0",
|
"@commitlint/config-conventional": "^20.4.0",
|
||||||
"@cspell/dict-de-de": "^4.1.2",
|
"@cspell/dict-de-de": "^4.1.2",
|
||||||
"@lhci/cli": "^0.15.1",
|
"@lhci/cli": "^0.15.1",
|
||||||
"@mintel/eslint-config": "1.8.3",
|
"@mintel/eslint-config": "1.8.21",
|
||||||
"@mintel/tsconfig": "1.8.3",
|
"@mintel/tsconfig": "^1.8.21",
|
||||||
"@next/bundle-analyzer": "^16.1.6",
|
"@next/bundle-analyzer": "^16.1.6",
|
||||||
"@tailwindcss/cli": "^4.1.18",
|
"@tailwindcss/cli": "^4.1.18",
|
||||||
"@tailwindcss/postcss": "^4.1.18",
|
"@tailwindcss/postcss": "^4.1.18",
|
||||||
@@ -65,6 +65,7 @@
|
|||||||
"@types/react": "^19.2.7",
|
"@types/react": "^19.2.7",
|
||||||
"@types/react-dom": "^19.2.3",
|
"@types/react-dom": "^19.2.3",
|
||||||
"@types/sharp": "^0.31.1",
|
"@types/sharp": "^0.31.1",
|
||||||
|
"@types/three": "^0.183.1",
|
||||||
"@vitejs/plugin-react": "^5.1.4",
|
"@vitejs/plugin-react": "^5.1.4",
|
||||||
"@vitest/ui": "^4.0.16",
|
"@vitest/ui": "^4.0.16",
|
||||||
"autoprefixer": "^10.4.23",
|
"autoprefixer": "^10.4.23",
|
||||||
@@ -88,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'",
|
||||||
@@ -100,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",
|
||||||
@@ -110,15 +113,22 @@
|
|||||||
"check:security": "tsx ./scripts/check-security.ts",
|
"check:security": "tsx ./scripts/check-security.ts",
|
||||||
"check:links": "bash ./scripts/check-links.sh",
|
"check:links": "bash ./scripts/check-links.sh",
|
||||||
"check:assets": "tsx ./scripts/check-broken-assets.ts",
|
"check:assets": "tsx ./scripts/check-broken-assets.ts",
|
||||||
"cms:branding:local": "DIRECTUS_URL=${DIRECTUS_URL:-http://cms.klz.localhost} npx tsx --env-file=.env scripts/setup-directus-branding.ts",
|
"check:forms": "tsx ./scripts/check-forms.ts",
|
||||||
"cms:branding:testing": "DIRECTUS_URL=https://cms.testing.klz-cables.com npx tsx --env-file=.env scripts/setup-directus-branding.ts",
|
"check:apis": "tsx ./scripts/check-apis.ts",
|
||||||
"cms:branding:staging": "DIRECTUS_URL=https://cms.staging.klz-cables.com npx tsx --env-file=.env scripts/setup-directus-branding.ts",
|
"pdf:datasheets": "tsx ./scripts/generate-pdf-datasheets.tsx",
|
||||||
"cms:branding:prod": "DIRECTUS_URL=https://cms.klz-cables.com npx tsx --env-file=.env scripts/setup-directus-branding.ts",
|
|
||||||
"cms:bootstrap": "pnpm run cms:branding:local",
|
|
||||||
"pdf:datasheets": "tsx ./scripts/generate-pdf-datasheets.ts",
|
|
||||||
"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:staging": "bash ./scripts/assets-sync.sh local staging",
|
||||||
|
"assets:push:prod": "bash ./scripts/assets-sync.sh local prod",
|
||||||
|
"assets:pull:testing": "bash ./scripts/assets-sync.sh testing local",
|
||||||
|
"assets:pull:staging": "bash ./scripts/assets-sync.sh staging local",
|
||||||
|
"assets:pull:prod": "bash ./scripts/assets-sync.sh prod local",
|
||||||
|
"assets:sync:testing-to-staging": "bash ./scripts/assets-sync.sh testing staging",
|
||||||
|
"assets:sync:staging-to-prod": "bash ./scripts/assets-sync.sh staging prod",
|
||||||
"pagespeed:test": "tsx ./scripts/pagespeed-sitemap.ts",
|
"pagespeed:test": "tsx ./scripts/pagespeed-sitemap.ts",
|
||||||
"pagespeed:audit": "./scripts/audit-local.sh",
|
"pagespeed:audit": "./scripts/audit-local.sh",
|
||||||
"pagespeed:urls": "tsx -e \"import sitemap from './app/sitemap'; sitemap().then(urls => console.log(urls.map(u => u.url).join('\\n')))\"",
|
"pagespeed:urls": "tsx -e \"import sitemap from './app/sitemap'; sitemap().then(urls => console.log(urls.map(u => u.url).join('\\n')))\"",
|
||||||
@@ -133,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 {}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -45,9 +45,7 @@ export default buildConfig({
|
|||||||
},
|
},
|
||||||
meta: {
|
meta: {
|
||||||
titleSuffix: ' – KLZ Cables',
|
titleSuffix: ' – KLZ Cables',
|
||||||
icons: [
|
icons: [{ rel: 'icon', type: 'image/x-icon', url: '/favicon.ico' }],
|
||||||
{ rel: 'icon', type: 'image/x-icon', url: '/favicon.ico' },
|
|
||||||
],
|
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
localization: {
|
localization: {
|
||||||
@@ -80,18 +78,25 @@ export default buildConfig({
|
|||||||
`postgresql://${process.env.PAYLOAD_DB_USER || 'payload'}:${process.env.PAYLOAD_DB_PASSWORD || '120in09oenaoinsd9iaidon'}@127.0.0.1:54322/${process.env.PAYLOAD_DB_NAME || 'payload'}`,
|
`postgresql://${process.env.PAYLOAD_DB_USER || 'payload'}:${process.env.PAYLOAD_DB_PASSWORD || '120in09oenaoinsd9iaidon'}@127.0.0.1:54322/${process.env.PAYLOAD_DB_NAME || 'payload'}`,
|
||||||
},
|
},
|
||||||
}),
|
}),
|
||||||
email: nodemailerAdapter({
|
email: process.env.MAIL_HOST
|
||||||
defaultFromAddress: process.env.MAIL_FROM?.replace(/.*<|>.*/g, '') || 'postmaster@mg.mintel.me',
|
? nodemailerAdapter({
|
||||||
defaultFromName: process.env.MAIL_FROM?.split('<')[0]?.trim() || 'KLZ Cables',
|
defaultFromAddress:
|
||||||
transportOptions: {
|
process.env.MAIL_FROM?.replace(/.*<|>.*/g, '') || 'postmaster@mg.mintel.me',
|
||||||
host: process.env.MAIL_HOST || 'smtp.eu.mailgun.org',
|
defaultFromName: process.env.MAIL_FROM?.split('<')[0]?.trim() || 'KLZ Cables',
|
||||||
port: Number(process.env.MAIL_PORT) || 587,
|
transportOptions: {
|
||||||
auth: {
|
host: process.env.MAIL_HOST || 'smtp.eu.mailgun.org',
|
||||||
user: process.env.MAIL_USERNAME,
|
port: Number(process.env.MAIL_PORT) || 587,
|
||||||
pass: process.env.MAIL_PASSWORD,
|
...(process.env.MAIL_USERNAME
|
||||||
},
|
? {
|
||||||
},
|
auth: {
|
||||||
}),
|
user: process.env.MAIL_USERNAME,
|
||||||
|
pass: process.env.MAIL_PASSWORD,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
: {}),
|
||||||
|
},
|
||||||
|
})
|
||||||
|
: undefined,
|
||||||
sharp,
|
sharp,
|
||||||
plugins: [],
|
plugins: [],
|
||||||
});
|
});
|
||||||
|
|||||||
3416
pnpm-lock.yaml
generated
3416
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.
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user