Compare commits

...

10 Commits

Author SHA1 Message Date
5036c5fe28 deploy
All checks were successful
Build & Deploy KLZ Cables / build-and-deploy (push) Successful in 4m13s
2026-01-31 21:10:12 +01:00
50a524c515 gitea
Some checks failed
Build & Deploy KLZ Cables / build-and-deploy (push) Failing after 3m54s
2026-01-31 19:21:53 +01:00
57886a01d6 traefik
All checks were successful
Build & Deploy KLZ Cables / build-and-deploy (push) Successful in 3m58s
2026-01-31 18:36:34 +01:00
c89bd8e80f staging deploy
All checks were successful
Build & Deploy KLZ Cables / build-and-deploy (push) Successful in 3m54s
2026-01-31 18:07:28 +01:00
9c54322654 datasheet download
All checks were successful
Build & Deploy KLZ Cables / build-and-deploy (push) Successful in 4m1s
2026-01-31 10:40:17 +01:00
8a80eb7b9a og
All checks were successful
Build & Deploy KLZ Cables / build-and-deploy (push) Successful in 3m55s
2026-01-31 10:35:07 +01:00
c1773a7072 datasheets as downloads
All checks were successful
Build & Deploy KLZ Cables / build-and-deploy (push) Successful in 3m59s
2026-01-31 10:29:39 +01:00
33ed13d255 og
Some checks failed
Build & Deploy KLZ Cables / build-and-deploy (push) Failing after 3m28s
2026-01-31 10:25:25 +01:00
0f5811edb9 og
Some checks failed
Build & Deploy KLZ Cables / build-and-deploy (push) Failing after 1m47s
2026-01-31 10:21:24 +01:00
e4eabd7a86 sheets
Some checks failed
Build & Deploy KLZ Cables / build-and-deploy (push) Failing after 17m33s
2026-01-30 22:10:01 +01:00
72 changed files with 885 additions and 676 deletions

View File

@@ -40,6 +40,14 @@ MAIL_RECIPIENTS=info@klz-cables.com
# ──────────────────────────────────────────────────────────────────────────── # ────────────────────────────────────────────────────────────────────────────
LOG_LEVEL=info LOG_LEVEL=info
# ────────────────────────────────────────────────────────────────────────────
# Deployment Configuration (CI/CD only)
# ────────────────────────────────────────────────────────────────────────────
# These are typically set by the CI/CD workflow
IMAGE_TAG=latest
TRAEFIK_HOST=klz-cables.com
ENV_FILE=.env
# ──────────────────────────────────────────────────────────────────────────── # ────────────────────────────────────────────────────────────────────────────
# Varnish Cache (Docker only) # Varnish Cache (Docker only)
# ──────────────────────────────────────────────────────────────────────────── # ────────────────────────────────────────────────────────────────────────────
@@ -61,7 +69,11 @@ VARNISH_CACHE_SIZE=256m
# ────────────────── # ──────────────────
# 1. Build-time: Only NEXT_PUBLIC_* vars are needed (via --build-arg) # 1. Build-time: Only NEXT_PUBLIC_* vars are needed (via --build-arg)
# 2. Runtime: All vars are loaded from .env file on the server # 2. Runtime: All vars are loaded from .env file on the server
# 3. The .env file should exist at: /home/deploy/sites/klz-cables.com/.env # 3. Branch Deployments:
# - main branch uses .env.prod
# - staging branch uses .env.staging
# - CI/CD supports STAGING_ prefix for all secrets to override defaults
# - TRAEFIK_HOST is automatically derived from NEXT_PUBLIC_BASE_URL
# #
# Security: # Security:
# ───────── # ─────────

View File

@@ -2,184 +2,241 @@ name: Build & Deploy KLZ Cables
on: on:
push: push:
branches: [main] branches:
- main
tags:
- 'v*'
jobs: jobs:
build-and-deploy: build-and-deploy:
runs-on: docker runs-on: docker
steps: steps:
# ═══════════════════════════════════════════════════════════════════════════════ # ──────────────────────────────────────────────────────────────────────────────
# LOGGING: Workflow Start - Full Transparency # Workflow Start & Basic Info
# ═══════════════════════════════════════════════════════════════════════════════ # ──────────────────────────────────────────────────────────────────────────────
- name: 📋 Log Workflow Start - name: 📢 Workflow Start
run: | run: |
echo "🚀 Starting deployment for ${{ github.repository }} (${{ github.ref }})" echo "┌──────────────────────────────────────────────────────────────┐"
echo " • Commit: ${{ github.sha }}" echo "│ 🚀 KLZ Cables Deployment Workflow gestartet │"
echo " • Timestamp: $(date -u +'%Y-%m-%d %H:%M:%S UTC')" echo "├──────────────────────────────────────────────────────────────┤"
echo "│ Repository: ${{ github.repository }} │"
echo "│ Ref: ${{ github.ref }} │"
echo "│ Ref-Name: ${{ github.ref_name }} │"
echo "│ Commit: ${{ github.sha }} │"
echo "│ Actor: ${{ github.actor }} │"
echo "│ Datum: $(date -u +'%Y-%m-%d %H:%M:%S UTC') │"
echo "└──────────────────────────────────────────────────────────────┘"
- name: Checkout repository - name: Checkout repository
uses: actions/checkout@v4 uses: actions/checkout@v4
with:
fetch-depth: 0
# ═══════════════════════════════════════════════════════════════════════════════ # ──────────────────────────────────────────────────────────────────────────────
# LOGGING: Registry Login Phase # Environment bestimmen + Commit-Message holen
# ═══════════════════════════════════════════════════════════════════════════════ # ──────────────────────────────────────────────────────────────────────────────
- name: 🔐 Login to private registry - name: 🔍 Environment & Version ermitteln
id: determine
run: | run: |
echo "🔐 Authenticating with registry.infra.mintel.me..." TAG="${{ github.ref_name }}"
SHORT_SHA="${{ github.sha }}"
SHORT_SHA="${SHORT_SHA:0:9}"
# Commit-Message holen (erste Zeile)
COMMIT_MSG=$(git log -1 --pretty=%s || echo "No commit message available")
if [[ "${{ github.ref_type }}" == "branch" && "$TAG" == "main" ]]; then
TARGET="testing"
IMAGE_TAG="main-${SHORT_SHA}"
ENV_FILE=".env.testing"
TRAEFIK_HOST="\`testing.klz-cables.com\`"
IS_PROD="false"
GOTIFY_TITLE="🧪 Testing-Deploy"
GOTIFY_PRIORITY=4
elif [[ "${{ github.ref_type }}" == "tag" ]]; then
if [[ "$TAG" =~ ^v[0-9]+\.[0-9]+\.[0-9]+$ ]]; then
TARGET="production"
IMAGE_TAG="$TAG"
ENV_FILE=".env.prod"
TRAEFIK_HOST="\`klz-cables.com\`, \`www.klz-cables.com\`"
IS_PROD="true"
GOTIFY_TITLE="🚀 Production-Release"
GOTIFY_PRIORITY=6
elif [[ "$TAG" =~ -rc\. || "$TAG" =~ -beta\. || "$TAG" =~ -alpha\. ]]; then
TARGET="staging"
IMAGE_TAG="$TAG"
ENV_FILE=".env.staging"
TRAEFIK_HOST="\`staging.klz-cables.com\`"
IS_PROD="false"
GOTIFY_TITLE="🧪 Staging-Deploy (Pre-Release)"
GOTIFY_PRIORITY=5
else
TARGET="skip"
GOTIFY_TITLE="❓ Unbekannter Tag"
GOTIFY_PRIORITY=3
fi
else
TARGET="skip"
fi
echo "target=$TARGET" >> $GITHUB_OUTPUT
echo "image_tag=$IMAGE_TAG" >> $GITHUB_OUTPUT
echo "env_file=$ENV_FILE" >> $GITHUB_OUTPUT
echo "traefik_host=$TRAEFIK_HOST" >> $GITHUB_OUTPUT
echo "is_prod=$IS_PROD" >> $GITHUB_OUTPUT
echo "gotify_title=$GOTIFY_TITLE" >> $GITHUB_OUTPUT
echo "gotify_priority=$GOTIFY_PRIORITY" >> $GITHUB_OUTPUT
echo "short_sha=$SHORT_SHA" >> $GITHUB_OUTPUT
echo "commit_msg=$COMMIT_MSG" >> $GITHUB_OUTPUT
- name: ⏭️ Skip Deployment
if: steps.determine.outputs.target == 'skip'
run: |
echo "Deployment übersprungen kein passender Trigger (main oder v*-Tag)"
exit 0
# ──────────────────────────────────────────────────────────────────────────────
# Registry Login
# ──────────────────────────────────────────────────────────────────────────────
- name: 🔐 Registry Login
run: |
echo "🔐 Login zu registry.infra.mintel.me ..."
echo "${{ secrets.REGISTRY_PASS }}" | docker login registry.infra.mintel.me -u "${{ secrets.REGISTRY_USER }}" --password-stdin echo "${{ secrets.REGISTRY_PASS }}" | docker login registry.infra.mintel.me -u "${{ secrets.REGISTRY_USER }}" --password-stdin
# ═══════════════════════════════════════════════════════════════════════════════ # ──────────────────────────────────────────────────────────────────────────────
# LOGGING: Build Phase # Build & Push
# ═══════════════════════════════════════════════════════════════════════════════ # ──────────────────────────────────────────────────────────────────────────────
- name: 🏗️ Build Docker image - name: 🏗️ Docker Image bauen & pushen
env:
IMAGE_TAG: ${{ steps.determine.outputs.image_tag }}
NEXT_PUBLIC_BASE_URL: ${{ steps.determine.outputs.target == 'production' && secrets.NEXT_PUBLIC_BASE_URL || (steps.determine.outputs.target == 'staging' && secrets.STAGING_NEXT_PUBLIC_BASE_URL || secrets.TESTING_NEXT_PUBLIC_BASE_URL || secrets.NEXT_PUBLIC_BASE_URL) }}
NEXT_PUBLIC_UMAMI_WEBSITE_ID: ${{ steps.determine.outputs.target == 'production' && secrets.NEXT_PUBLIC_UMAMI_WEBSITE_ID || (steps.determine.outputs.target == 'staging' && secrets.STAGING_NEXT_PUBLIC_UMAMI_WEBSITE_ID || secrets.TESTING_NEXT_PUBLIC_UMAMI_WEBSITE_ID || secrets.NEXT_PUBLIC_UMAMI_WEBSITE_ID) }}
NEXT_PUBLIC_UMAMI_SCRIPT_URL: ${{ steps.determine.outputs.target == 'production' && secrets.NEXT_PUBLIC_UMAMI_SCRIPT_URL || (steps.determine.outputs.target == 'staging' && secrets.STAGING_NEXT_PUBLIC_UMAMI_SCRIPT_URL || secrets.TESTING_NEXT_PUBLIC_UMAMI_SCRIPT_URL || secrets.NEXT_PUBLIC_UMAMI_SCRIPT_URL) }}
run: | run: |
echo "🏗️ Building Docker image (linux/arm64)..." echo "🏗️ Building → ${{ steps.determine.outputs.target }} / $IMAGE_TAG"
docker buildx build \ docker buildx build \
--pull \ --pull \
--platform linux/arm64 \ --platform linux/arm64 \
--build-arg NEXT_PUBLIC_BASE_URL="${{ secrets.NEXT_PUBLIC_BASE_URL }}" \ --build-arg NEXT_PUBLIC_BASE_URL="$NEXT_PUBLIC_BASE_URL" \
--build-arg NEXT_PUBLIC_UMAMI_WEBSITE_ID="${{ secrets.NEXT_PUBLIC_UMAMI_WEBSITE_ID }}" \ --build-arg NEXT_PUBLIC_UMAMI_WEBSITE_ID="$NEXT_PUBLIC_UMAMI_WEBSITE_ID" \
--build-arg NEXT_PUBLIC_UMAMI_SCRIPT_URL="${{ secrets.NEXT_PUBLIC_UMAMI_SCRIPT_URL }}" \ --build-arg NEXT_PUBLIC_UMAMI_SCRIPT_URL="$NEXT_PUBLIC_UMAMI_SCRIPT_URL" \
-t registry.infra.mintel.me/mintel/klz-cables.com:latest \ -t registry.infra.mintel.me/mintel/klz-cables.com:$IMAGE_TAG \
--push . --push .
# ═══════════════════════════════════════════════════════════════════════════════ # ──────────────────────────────────────────────────────────────────────────────
# LOGGING: Deployment Phase # Deploy via SSH
# ═══════════════════════════════════════════════════════════════════════════════ # ──────────────────────────────────────────────────────────────────────────────
- name: 🚀 Deploy to production server - name: 🚀 Deploy to ${{ steps.determine.outputs.target }}
env:
IMAGE_TAG: ${{ steps.determine.outputs.image_tag }}
ENV_FILE: ${{ steps.determine.outputs.env_file }}
TRAEFIK_HOST: ${{ steps.determine.outputs.traefik_host }}
# Secrets wie vorher mit Fallback-Logik pro Umgebung
NEXT_PUBLIC_BASE_URL: ${{ steps.determine.outputs.target == 'production' && secrets.NEXT_PUBLIC_BASE_URL || (steps.determine.outputs.target == 'staging' && secrets.STAGING_NEXT_PUBLIC_BASE_URL || secrets.TESTING_NEXT_PUBLIC_BASE_URL || secrets.NEXT_PUBLIC_BASE_URL) }}
NEXT_PUBLIC_UMAMI_WEBSITE_ID: ${{ steps.determine.outputs.target == 'production' && secrets.NEXT_PUBLIC_UMAMI_WEBSITE_ID || (steps.determine.outputs.target == 'staging' && secrets.STAGING_NEXT_PUBLIC_UMAMI_WEBSITE_ID || secrets.TESTING_NEXT_PUBLIC_UMAMI_WEBSITE_ID || secrets.NEXT_PUBLIC_UMAMI_WEBSITE_ID) }}
NEXT_PUBLIC_UMAMI_SCRIPT_URL: ${{ steps.determine.outputs.target == 'production' && secrets.NEXT_PUBLIC_UMAMI_SCRIPT_URL || (steps.determine.outputs.target == 'staging' && secrets.STAGING_NEXT_PUBLIC_UMAMI_SCRIPT_URL || secrets.TESTING_NEXT_PUBLIC_UMAMI_SCRIPT_URL || secrets.NEXT_PUBLIC_UMAMI_SCRIPT_URL) }}
SENTRY_DSN: ${{ steps.determine.outputs.target == 'production' && secrets.SENTRY_DSN || (steps.determine.outputs.target == 'staging' && secrets.STAGING_SENTRY_DSN || secrets.TESTING_SENTRY_DSN || secrets.SENTRY_DSN) }}
MAIL_HOST: ${{ secrets.MAIL_HOST }}
MAIL_PORT: ${{ secrets.MAIL_PORT }}
MAIL_USERNAME: ${{ secrets.MAIL_USERNAME }}
MAIL_PASSWORD: ${{ secrets.MAIL_PASSWORD }}
MAIL_FROM: ${{ secrets.MAIL_FROM }}
MAIL_RECIPIENTS: ${{ secrets.MAIL_RECIPIENTS }}
run: | run: |
echo "🚀 Deploying to alpha.mintel.me..." echo "Deploying ${{ steps.determine.outputs.target }} → $IMAGE_TAG"
# Setup SSH # SSH vorbereiten
mkdir -p ~/.ssh mkdir -p ~/.ssh
echo "${{ secrets.ALPHA_SSH_KEY }}" > ~/.ssh/id_ed25519 echo "${{ secrets.ALPHA_SSH_KEY }}" > ~/.ssh/id_ed25519
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
# Create .env file content
cat > /tmp/klz-cables.env << EOF
# ============================================================================
# KLZ Cables - Production Environment Configuration
# ============================================================================
# Auto-generated by CI/CD workflow
# DO NOT EDIT MANUALLY - Changes will be overwritten on next deployment
# ============================================================================
# Application
NODE_ENV=production
NEXT_PUBLIC_BASE_URL=${{ secrets.NEXT_PUBLIC_BASE_URL }}
# Analytics (Umami)
NEXT_PUBLIC_UMAMI_WEBSITE_ID=${{ secrets.NEXT_PUBLIC_UMAMI_WEBSITE_ID }}
NEXT_PUBLIC_UMAMI_SCRIPT_URL=${{ secrets.NEXT_PUBLIC_UMAMI_SCRIPT_URL }}
# Error Tracking (GlitchTip/Sentry)
SENTRY_DSN=${{ secrets.SENTRY_DSN }}
# Email Configuration (Mailgun)
MAIL_HOST=${{ secrets.MAIL_HOST }}
MAIL_PORT=${{ secrets.MAIL_PORT }}
MAIL_USERNAME=${{ secrets.MAIL_USERNAME }}
MAIL_PASSWORD=${{ secrets.MAIL_PASSWORD }}
MAIL_FROM=${{ secrets.MAIL_FROM }}
MAIL_RECIPIENTS=${{ secrets.MAIL_RECIPIENTS }}
EOF
# Upload .env and deploy
scp -o StrictHostKeyChecking=accept-new /tmp/klz-cables.env root@alpha.mintel.me:/home/deploy/sites/klz-cables.com/.env
ssh -o StrictHostKeyChecking=accept-new root@alpha.mintel.me bash << EOF
set -e
cd /home/deploy/sites/klz-cables.com
chmod 600 .env
chown deploy:deploy .env
echo "${{ secrets.REGISTRY_PASS }}" | docker login registry.infra.mintel.me -u "${{ secrets.REGISTRY_USER }}" --password-stdin
docker pull registry.infra.mintel.me/mintel/klz-cables.com:latest
docker-compose down
echo "🚀 Starting containers..."
docker-compose up -d
echo "⏳ Giving the app a few seconds to warm up..."
sleep 10
echo "🔍 Checking container status..."
docker-compose ps
if ! docker-compose ps | grep -q "Up"; then
echo "❌ Container failed to start"
docker-compose logs --tail=100
exit 1
fi
echo "✅ Deployment complete!" # .env-Datei erstellen
cat > /tmp/klz-cables.env << EOF
# Generated by CI - ${{ steps.determine.outputs.target }} - $(date -u)
NODE_ENV=production
NEXT_PUBLIC_BASE_URL=$NEXT_PUBLIC_BASE_URL
NEXT_PUBLIC_UMAMI_WEBSITE_ID=$NEXT_PUBLIC_UMAMI_WEBSITE_ID
NEXT_PUBLIC_UMAMI_SCRIPT_URL=$NEXT_PUBLIC_UMAMI_SCRIPT_URL
SENTRY_DSN=$SENTRY_DSN
MAIL_HOST=$MAIL_HOST
MAIL_PORT=$MAIL_PORT
MAIL_USERNAME=$MAIL_USERNAME
MAIL_PASSWORD=$MAIL_PASSWORD
MAIL_FROM=$MAIL_FROM
MAIL_RECIPIENTS=$MAIL_RECIPIENTS
IMAGE_TAG=$IMAGE_TAG
TRAEFIK_HOST=$TRAEFIK_HOST
ENV_FILE=$ENV_FILE
EOF EOF
scp -o StrictHostKeyChecking=accept-new /tmp/klz-cables.env root@alpha.mintel.me:/home/deploy/sites/klz-cables.com/$ENV_FILE
scp -o StrictHostKeyChecking=accept-new docker-compose.yml root@alpha.mintel.me:/home/deploy/sites/klz-cables.com/docker-compose.yml
ssh -o StrictHostKeyChecking=accept-new root@alpha.mintel.me IMAGE_TAG="$IMAGE_TAG" ENV_FILE="$ENV_FILE" TRAEFIK_HOST="$TRAEFIK_HOST" bash << 'EOF'
set -e
cd /home/deploy/sites/klz-cables.com
chmod 600 "$ENV_FILE"
chown deploy:deploy "$ENV_FILE"
echo "${{ secrets.REGISTRY_PASS }}" | docker login registry.infra.mintel.me -u "${{ secrets.REGISTRY_USER }}" --password-stdin
echo "→ Pulling image: $IMAGE_TAG"
docker compose --env-file "$ENV_FILE" pull
echo "→ Starting containers..."
docker compose --env-file "$ENV_FILE" up -d
docker system prune -f --filter "until=168h"
echo "→ Waiting 15s for warmup..."
sleep 15
echo "→ Container status:"
docker compose --env-file "$ENV_FILE" ps
if ! docker compose --env-file "$ENV_FILE" ps | grep -q "Up"; then
echo "❌ Fehler: Container nicht Up!"
docker compose --env-file "$ENV_FILE" logs --tail=150
exit 1
fi
echo "✅ Deployment erfolgreich!"
EOF
rm -f /tmp/klz-cables.env rm -f /tmp/klz-cables.env
# ═══════════════════════════════════════════════════════════════════════════════ # ──────────────────────────────────────────────────────────────────────────────
# LOGGING: Workflow Summary # Summary & Gotify
# ═══════════════════════════════════════════════════════════════════════════════ # ──────────────────────────────────────────────────────────────────────────────
- name: 📊 Workflow Summary - name: 📊 Deployment Summary
if: always() if: always()
run: | run: |
echo "📊 Status: ${{ job.status }}" echo "┌──────────────────────────────┐"
echo "🎯 Target: alpha.mintel.me" echo "│ Deployment Summary │"
echo "├──────────────────────────────┤"
echo "│ Status: ${{ job.status }} │"
echo "│ Umgebung: ${{ steps.determine.outputs.target || 'skipped' }} │"
echo "│ Version: ${{ steps.determine.outputs.image_tag }} │"
echo "│ Commit: ${{ steps.determine.outputs.short_sha }} │"
echo "│ Message: ${{ steps.determine.outputs.commit_msg }} │"
echo "└──────────────────────────────┘"
# ═══════════════════════════════════════════════════════════════════════════════ - name: 🔔 Gotify - Success
# NOTIFICATION: Gotify
# ═══════════════════════════════════════════════════════════════════════════════
- name: 🔔 Gotify Notification (Success)
if: success() if: success()
run: | run: |
echo "Sending success notification to Gotify..." curl -s -k -X POST "${{ secrets.GOTIFY_URL }}/message?token=${{ secrets.GOTIFY_TOKEN }}" \
RESPONSE=$(curl -k -s -w "\n%{http_code}" -X POST "${{ secrets.GOTIFY_URL }}/message?token=${{ secrets.GOTIFY_TOKEN }}" \ -F "title=${{ steps.determine.outputs.gotify_title }}" \
-F "title=✅ Deployment Success: ${{ github.repository }}" \ -F "message=Erfolgreich deployt auf **${{ steps.determine.outputs.target }}**\n\nVersion: **${{ steps.determine.outputs.image_tag }}**\nCommit: ${{ steps.determine.outputs.short_sha }} (${{ steps.determine.outputs.commit_msg }})\nVon: ${{ github.actor }}\nRun: ${{ github.run_id }}" \
-F "message=The deployment of ${{ github.repository }} (branch: ${{ github.ref }}) was successful. -F "priority=${{ steps.determine.outputs.gotify_priority }}" || true
Commit: ${{ github.sha }}
Actor: ${{ github.actor }}
Run ID: ${{ github.run_id }}" \
-F "priority=5")
HTTP_CODE=$(echo "$RESPONSE" | tail -n1)
BODY=$(echo "$RESPONSE" | sed '$d')
echo "HTTP Status: $HTTP_CODE"
echo "Response Body: $BODY"
if [ "$HTTP_CODE" -lt 200 ] || [ "$HTTP_CODE" -ge 300 ]; then
echo "Failed to send Gotify notification"
exit 0 # Don't fail the workflow because of notification failure
fi
- name: 🔔 Gotify Notification (Failure) - name: 🔔 Gotify - Failure
if: failure() if: failure()
run: | run: |
echo "Sending failure notification to Gotify..." curl -s -k -X POST "${{ secrets.GOTIFY_URL }}/message?token=${{ secrets.GOTIFY_TOKEN }}" \
RESPONSE=$(curl -k -s -w "\n%{http_code}" -X POST "${{ secrets.GOTIFY_URL }}/message?token=${{ secrets.GOTIFY_TOKEN }}" \ -F "title=❌ Deployment FEHLGESCHLAGEN ${{ steps.determine.outputs.target || 'unknown' }}" \
-F "title=❌ Deployment Failed: ${{ github.repository }}" \ -F "message=**Fehler beim Deploy auf ${{ steps.determine.outputs.target }}**\n\nVersion: ${{ steps.determine.outputs.image_tag || '?' }}\nCommit: ${{ steps.determine.outputs.short_sha || '?' }}\nVon: ${{ github.actor }}\nRun: ${{ github.run_id }}\n\nBitte Logs prüfen!" \
-F "message=The deployment of ${{ github.repository }} (branch: ${{ github.ref }}) failed! -F "priority=8" || true
Commit: ${{ github.sha }}
Actor: ${{ github.actor }}
Run ID: ${{ github.run_id }}
Please check the logs for details." \
-F "priority=8")
HTTP_CODE=$(echo "$RESPONSE" | tail -n1)
BODY=$(echo "$RESPONSE" | sed '$d')
echo "HTTP Status: $HTTP_CODE"
echo "Response Body: $BODY"
if [ "$HTTP_CODE" -lt 200 ] || [ "$HTTP_CODE" -ge 300 ]; then
echo "Failed to send Gotify notification"
exit 0 # Don't fail the workflow because of notification failure
fi

View File

@@ -103,7 +103,7 @@ app/
├── api/ ├── api/
│ └── contact/route.ts # Contact API │ └── contact/route.ts # Contact API
├── sitemap.ts # Sitemap generator ├── sitemap.ts # Sitemap generator
── robots.ts # Robots.txt generator ── robots.ts # Robots.txt generator
lib/ lib/
├── data.ts # Data access ├── data.ts # Data access
@@ -114,7 +114,7 @@ components/
├── LocaleSwitcher.tsx # Language switcher ├── LocaleSwitcher.tsx # Language switcher
├── ContactForm.tsx # Contact form ├── ContactForm.tsx # Contact form
├── CookieConsent.tsx # GDPR banner ├── CookieConsent.tsx # GDPR banner
── SEO.tsx # SEO utilities ── SEO.tsx # SEO utilities
data/ data/
├── raw/ # WordPress export ├── raw/ # WordPress export
@@ -222,21 +222,30 @@ GET /robots.txt
### Automatic Deployment (Current Setup) ### Automatic Deployment (Current Setup)
The project uses **Gitea Actions** for CI/CD. Every push to `main` triggers: The project uses **Gitea Actions** for CI/CD. Every push to `main` or `staging` triggers:
1. **Build**: Docker image built for `linux/arm64` 1. **Build**: Docker image built for `linux/arm64` with branch-specific build args
2. **Push**: Image pushed to `registry.infra.mintel.me` 2. **Push**: Image pushed to `registry.infra.mintel.me` with commit SHA tag
3. **Deploy**: SSH to production server, pull and restart containers 3. **Deploy**: SSH to production server, pull and restart containers using branch-specific `.env` files
**Workflow**: `.gitea/workflows/deploy.yml` **Workflow**: `.gitea/workflows/deploy.yml`
**Branch Deployments**:
- `main` branch: Deploys to production using `.env.prod`
- `staging` branch: Deploys to staging using `.env.staging`
**Environment Overrides**:
The CI/CD workflow supports `STAGING_`-prefixed secrets (e.g., `STAGING_NEXT_PUBLIC_BASE_URL`) to override default secrets when deploying the `staging` branch.
**Required Secrets** (configure in Gitea repository settings): **Required Secrets** (configure in Gitea repository settings):
- `REGISTRY_USER` - Docker registry username - `REGISTRY_USER` - Docker registry username
- `REGISTRY_PASS` - Docker registry password - `REGISTRY_PASS` - Docker registry password
- `ALPHA_SSH_KEY` - SSH private key for deployment - `ALPHA_SSH_KEY` - SSH private key for deployment
- `NEXT_PUBLIC_BASE_URL` - Application base URL (e.g., `https://klz-cables.com`)
- `NEXT_PUBLIC_UMAMI_WEBSITE_ID` - Analytics ID - `NEXT_PUBLIC_UMAMI_WEBSITE_ID` - Analytics ID
- `NEXT_PUBLIC_UMAMI_SCRIPT_URL` - Analytics script URL - `NEXT_PUBLIC_UMAMI_SCRIPT_URL` - Analytics script URL
- `SENTRY_DSN` - Error tracking DSN - `SENTRY_DSN` - Error tracking DSN
- `MAIL_HOST`, `MAIL_PORT`, `MAIL_USERNAME`, `MAIL_PASSWORD`, `MAIL_FROM`, `MAIL_RECIPIENTS` - Email configuration
### Manual Deployment ### Manual Deployment

View File

@@ -63,7 +63,7 @@ export async function GET(
title={product.frontmatter.title} title={product.frontmatter.title}
description={product.frontmatter.description} description={product.frontmatter.description}
label={product.frontmatter.categories?.[0] || 'Product'} label={product.frontmatter.categories?.[0] || 'Product'}
image={featuredImage} image={featuredImage?.startsWith('http') ? featuredImage : undefined}
/> />
), ),
{ {

View File

@@ -25,7 +25,7 @@ export default async function Image({ params: { locale, slug } }: { params: { lo
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={featuredImage?.startsWith('http') ? featuredImage : undefined}
/> />
), ),
{ {

View File

@@ -5,6 +5,7 @@ import ProductSidebar from '@/components/ProductSidebar';
import ProductTabs from '@/components/ProductTabs'; import ProductTabs from '@/components/ProductTabs';
import ProductTechnicalData from '@/components/ProductTechnicalData'; import ProductTechnicalData from '@/components/ProductTechnicalData';
import RelatedProducts from '@/components/RelatedProducts'; import RelatedProducts from '@/components/RelatedProducts';
import DatasheetDownload from '@/components/DatasheetDownload';
import { Badge, Container, Heading, Section } from '@/components/ui'; import { Badge, Container, Heading, Section } from '@/components/ui';
import { getDatasheetPath } from '@/lib/datasheets'; import { getDatasheetPath } from '@/lib/datasheets';
import { getAllProducts, getProductBySlug } from '@/lib/mdx'; import { getAllProducts, getProductBySlug } from '@/lib/mdx';
@@ -362,6 +363,19 @@ export default async function ProductPage({ params }: ProductPageProps) {
<MDXRemote source={processedContent} components={productComponents} /> <MDXRemote source={processedContent} components={productComponents} />
</div> </div>
{/* Datasheet Download Section - Only for Medium Voltage for now */}
{categoryFileSlug === 'medium-voltage-cables' && datasheetPath && (
<div className="mt-24 pt-24 border-t-2 border-neutral-dark/5">
<div className="mb-12">
<h2 className="text-3xl md:text-4xl font-black text-primary tracking-tighter uppercase mb-4">
{t('downloadDatasheet')}
</h2>
<div className="h-1.5 w-24 bg-accent rounded-full" />
</div>
<DatasheetDownload datasheetPath={datasheetPath} />
</div>
)}
{/* Structured Data */} {/* Structured Data */}
<JsonLd <JsonLd
id={`jsonld-${product.slug}`} id={`jsonld-${product.slug}`}

View File

@@ -72,7 +72,7 @@ export default async function Image({ params: { locale, slug } }: { params: { lo
title={product.frontmatter.title} title={product.frontmatter.title}
description={product.frontmatter.description} description={product.frontmatter.description}
label={product.frontmatter.categories?.[0] || 'Product'} label={product.frontmatter.categories?.[0] || 'Product'}
image={featuredImage} image={featuredImage?.startsWith('http') ? featuredImage : undefined}
/> />
), ),
{ {

View File

@@ -15,7 +15,6 @@ export default async function Image({ params: { locale } }: { params: { locale:
title={title} title={title}
description={description} description={description}
label="Our Team" label="Our Team"
image="https://klz-cables.com/uploads/2024/12/DSC07655-Large.webp"
/> />
), ),
{ {

View File

@@ -0,0 +1,68 @@
'use client';
import { cn } from '@/components/ui/utils';
import { useTranslations } from 'next-intl';
interface DatasheetDownloadProps {
datasheetPath: string;
className?: string;
}
export default function DatasheetDownload({ datasheetPath, className }: DatasheetDownloadProps) {
const t = useTranslations('Products');
return (
<div className={cn("mt-8 animate-slight-fade-in-from-bottom", className)}>
<a
href={datasheetPath}
target="_blank"
rel="noopener noreferrer"
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-accent via-saturated to-accent opacity-20 group-hover:opacity-40 transition-opacity duration-500 animate-gradient-x" />
{/* Inner Content */}
<div className="relative flex items-center gap-6 rounded-[31px] bg-primary-dark/90 backdrop-blur-xl p-6 md:p-8 border border-white/10">
{/* Icon Container */}
<div className="relative flex h-16 w-16 flex-shrink-0 items-center justify-center rounded-2xl bg-white/5 border border-white/10 group-hover:bg-accent group-hover:border-white/20 transition-all duration-500">
<div className="absolute inset-0 rounded-2xl bg-accent/20 blur-xl opacity-0 group-hover:opacity-100 transition-opacity duration-500" />
<svg
className="relative h-8 w-8 text-white transition-transform duration-500 group-hover:scale-110 group-hover:rotate-3"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={1.5}
d="M12 10v6m0 0l-3-3m3 3l3-3m2 8H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z"
/>
</svg>
</div>
{/* Text Content */}
<div className="flex-1">
<div className="flex items-center gap-2 mb-1">
<span className="text-[10px] font-black uppercase tracking-[0.2em] text-accent">PDF Datasheet</span>
</div>
<h3 className="text-xl md:text-2xl font-black text-white uppercase tracking-tighter leading-none group-hover:text-accent transition-colors duration-300">
{t('downloadDatasheet')}
</h3>
<p className="mt-2 text-sm font-medium text-white/60 leading-relaxed">
{t('downloadDatasheetDesc')}
</p>
</div>
{/* 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">
<svg className="h-6 w-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2.5} d="M9 5l7 7-7 7" />
</svg>
</div>
</div>
</a>
</div>
);
}

View File

@@ -29,7 +29,6 @@ export function OGImageTemplate({
backgroundColor: mode === 'light' ? '#ffffff' : primaryBlue, backgroundColor: mode === 'light' ? '#ffffff' : primaryBlue,
padding: '80px', padding: '80px',
position: 'relative', position: 'relative',
fontFamily: 'Inter, sans-serif',
}; };
return ( return (
@@ -39,7 +38,10 @@ export function OGImageTemplate({
<div <div
style={{ style={{
position: 'absolute', position: 'absolute',
inset: 0, top: 0,
left: 0,
right: 0,
bottom: 0,
display: 'flex', display: 'flex',
}} }}
> >
@@ -57,8 +59,11 @@ export function OGImageTemplate({
<div <div
style={{ style={{
position: 'absolute', position: 'absolute',
inset: 0, top: 0,
background: 'linear-gradient(to right, rgba(0,26,77,0.9) 0%, rgba(0,26,77,0.4) 100%)', left: 0,
right: 0,
bottom: 0,
background: 'linear-gradient(to right, rgba(0,26,77,0.9), rgba(0,26,77,0.4))',
}} }}
/> />
</div> </div>
@@ -72,8 +77,8 @@ export function OGImageTemplate({
right: '-100px', right: '-100px',
width: '600px', width: '600px',
height: '600px', height: '600px',
borderRadius: '50%', borderRadius: '300px',
background: `radial-gradient(circle, ${accentGreen}1a 0%, transparent 70%)`, backgroundColor: `${accentGreen}1a`,
display: 'flex', display: 'flex',
}} }}
/> />

View File

@@ -3,6 +3,7 @@
import Image from 'next/image'; 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 Scribble from '@/components/Scribble'; import Scribble from '@/components/Scribble';
import { cn } from '@/components/ui/utils'; import { cn } from '@/components/ui/utils';
@@ -64,33 +65,7 @@ export default function ProductSidebar({ productName, productImage, datasheetPat
{/* Datasheet Download */} {/* Datasheet Download */}
{datasheetPath && ( {datasheetPath && (
<a <DatasheetDownload datasheetPath={datasheetPath} className="mt-0" />
href={datasheetPath}
target="_blank"
rel="noopener noreferrer"
className="block bg-white rounded-2xl border border-neutral-medium overflow-hidden group transition-all duration-500 hover:shadow-xl hover:border-saturated/30 hover:-translate-y-0.5"
>
<div className="p-4 flex items-center gap-4">
<div className="w-12 h-12 rounded-xl bg-neutral-medium/20 flex items-center justify-center flex-shrink-0 group-hover:bg-saturated group-hover:text-white transition-all duration-500 text-saturated border border-transparent group-hover:border-white/20">
<svg className="w-6 h-6 transition-transform duration-500 group-hover:scale-110" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.5} d="M12 10v6m0 0l-3-3m3 3l3-3m2 8H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z" />
</svg>
</div>
<div className="flex-1 min-w-0">
<h3 className="text-sm md:text-base font-heading font-black text-neutral-dark m-0 uppercase tracking-tighter leading-tight group-hover:text-saturated transition-colors duration-300">
{t('downloadDatasheet')}
</h3>
<p className="text-text-secondary text-[10px] md:text-xs m-0 mt-0.5 font-semibold leading-tight truncate uppercase tracking-widest opacity-60">
{t('downloadDatasheetDesc')}
</p>
</div>
<div className="text-neutral-dark/20 group-hover:text-saturated transition-all duration-500 transform group-hover:translate-x-1">
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2.5} d="M9 5l7 7-7 7" />
</svg>
</div>
</div>
</a>
)} )}
</div> </div>
); );

View File

@@ -1,21 +1,21 @@
services: services:
app: app:
image: registry.infra.mintel.me/mintel/klz-cables.com:latest image: registry.infra.mintel.me/mintel/klz-cables.com:${IMAGE_TAG:-latest}
restart: always restart: always
networks: networks:
- infra - infra
ports: ports:
- "3000:3000" - "3000:3000"
env_file: env_file:
- .env - ${ENV_FILE:-.env}
labels: labels:
- "traefik.enable=true" - "traefik.enable=true"
# HTTP ⇒ HTTPS redirect # HTTP ⇒ HTTPS redirect
- "traefik.http.routers.klz-cables-web.rule=(Host(`klz-cables.com`) || Host(`www.klz-cables.com`) || Host(`staging.klz-cables.com`)) && !PathPrefix(`/.well-known/acme-challenge/`)" - "traefik.http.routers.klz-cables-web.rule=Host(${TRAEFIK_HOST}) && !PathPrefix(`/.well-known/acme-challenge/`)"
- "traefik.http.routers.klz-cables-web.entrypoints=web" - "traefik.http.routers.klz-cables-web.entrypoints=web"
- "traefik.http.routers.klz-cables-web.middlewares=redirect-https" - "traefik.http.routers.klz-cables-web.middlewares=redirect-https"
# HTTPS router # HTTPS router
- "traefik.http.routers.klz-cables.rule=Host(`klz-cables.com`) || Host(`www.klz-cables.com`) || Host(`staging.klz-cables.com`)" - "traefik.http.routers.klz-cables.rule=Host(${TRAEFIK_HOST})"
- "traefik.http.routers.klz-cables.entrypoints=websecure" - "traefik.http.routers.klz-cables.entrypoints=websecure"
- "traefik.http.routers.klz-cables.tls.certresolver=le" - "traefik.http.routers.klz-cables.tls.certresolver=le"
- "traefik.http.routers.klz-cables.tls=true" - "traefik.http.routers.klz-cables.tls=true"

View File

@@ -15,6 +15,9 @@ export function getDatasheetPath(slug: string, locale: string): string | null {
// Normalize slug: remove common suffixes that might not be in the PDF filename // Normalize slug: remove common suffixes that might not be in the PDF filename
const normalizedSlug = slug.replace(/-hv$|-mv$/, ''); const normalizedSlug = slug.replace(/-hv$|-mv$/, '');
// Subdirectories to search in
const subdirs = ['', '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
const patterns = [ const patterns = [
`${slug}-${locale}.pdf`, `${slug}-${locale}.pdf`,
@@ -25,10 +28,13 @@ export function getDatasheetPath(slug: string, locale: string): string | null {
`${normalizedSlug}-3-${locale}.pdf`, `${normalizedSlug}-3-${locale}.pdf`,
]; ];
for (const pattern of patterns) { for (const subdir of subdirs) {
const filePath = path.join(datasheetsDir, pattern); for (const pattern of patterns) {
if (fs.existsSync(filePath)) { const relativePath = path.join(subdir, pattern);
return `/datasheets/${pattern}`; const filePath = path.join(datasheetsDir, relativePath);
if (fs.existsSync(filePath)) {
return `/datasheets/${relativePath}`;
}
} }
} }
@@ -42,10 +48,13 @@ export function getDatasheetPath(slug: string, locale: string): string | null {
`${normalizedSlug}-2-en.pdf`, `${normalizedSlug}-2-en.pdf`,
`${normalizedSlug}-3-en.pdf`, `${normalizedSlug}-3-en.pdf`,
]; ];
for (const pattern of enPatterns) { for (const subdir of subdirs) {
const filePath = path.join(datasheetsDir, pattern); for (const pattern of enPatterns) {
if (fs.existsSync(filePath)) { const relativePath = path.join(subdir, pattern);
return `/datasheets/${pattern}`; const filePath = path.join(datasheetsDir, relativePath);
if (fs.existsSync(filePath)) {
return `/datasheets/${relativePath}`;
}
} }
} }
} }

View File

@@ -2,9 +2,10 @@ import { Metadata } from 'next';
import { SITE_URL } from './schema'; import { SITE_URL } from './schema';
export function getOGImageMetadata(path: string, title: string, locale: string): NonNullable<Metadata['openGraph']>['images'] { export function getOGImageMetadata(path: string, title: string, locale: string): NonNullable<Metadata['openGraph']>['images'] {
const cleanPath = path ? (path.startsWith('/') ? path : `/${path}`) : '';
return [ return [
{ {
url: `${SITE_URL}/${locale}/${path}/opengraph-image`, url: `${SITE_URL}/${locale}${cleanPath}/opengraph-image`,
width: 1200, width: 1200,
height: 630, height: 630,
alt: title, alt: title,

View File

@@ -21,234 +21,149 @@ Font.register({
// Industrial/technical/restrained design - STYLEGUIDE.md compliant // Industrial/technical/restrained design - STYLEGUIDE.md compliant
const styles = StyleSheet.create({ const styles = StyleSheet.create({
page: { page: {
// Large margins for engineering documentation feel. color: '#111827', // Text Primary
// Extra bottom padding reserves space for the fixed footer so content lineHeight: 1.5,
// (esp. long descriptions) doesn't render underneath it. backgroundColor: '#FFFFFF',
paddingTop: 72, paddingTop: 0,
paddingLeft: 72, paddingBottom: 100,
paddingRight: 72,
paddingBottom: 140,
fontFamily: 'Helvetica', fontFamily: 'Helvetica',
fontSize: 10,
color: '#1F2933', // Dark gray text
lineHeight: 1.5, // Generous line height
backgroundColor: '#F8F9FA', // Almost white background
}, },
// Engineering documentation header // 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: 'flex-start', alignItems: 'center',
marginBottom: 48, // Large spacing marginBottom: 16,
paddingBottom: 24,
borderBottom: '2px solid #E6E9ED', // Light gray separator
},
// Logo area - industrial style
logoArea: {
flexDirection: 'column',
alignItems: 'flex-start',
},
// Optional image logo container (keeps header height stable)
logoContainer: {
width: 120,
height: 32,
justifyContent: 'center',
},
// Image logo (preferred when available)
logo: {
width: 110,
height: 28,
objectFit: 'contain',
}, },
logoText: { logoText: {
fontSize: 20, fontSize: 24,
fontWeight: 700, fontWeight: 700,
color: '#0E2A47', // Dark navy color: '#000d26',
letterSpacing: 1, letterSpacing: 1,
textTransform: 'uppercase', textTransform: 'uppercase',
}, },
logoSubtext: {
fontSize: 10,
fontWeight: 400,
color: '#6B7280', // Medium gray
letterSpacing: 0.5,
marginTop: 2,
},
// Document info - technical style
docInfo: {
textAlign: 'right',
alignItems: 'flex-end',
},
docTitle: { docTitle: {
fontSize: 16, fontSize: 10,
fontWeight: 700, fontWeight: 700,
color: '#0E2A47', // Dark navy color: '#001a4d',
marginBottom: 8, letterSpacing: 2,
letterSpacing: 0.5,
textTransform: 'uppercase', textTransform: 'uppercase',
}, },
skuContainer: { productRow: {
backgroundColor: '#E6E9ED', // Light gray background flexDirection: 'row',
paddingHorizontal: 16, alignItems: 'center',
paddingVertical: 8, gap: 20,
border: '1px solid #E6E9ED', },
productInfoCol: {
flex: 1,
justifyContent: 'center',
},
productImageCol: {
flex: 1,
height: 120,
justifyContent: 'center',
alignItems: 'center',
borderRadius: 8,
borderWidth: 1,
borderColor: '#e5e7eb',
backgroundColor: '#FFFFFF',
overflow: 'hidden',
}, },
skuLabel: { // Product Hero Info
fontSize: 8, productHero: {
color: '#6B7280', // Medium gray marginTop: 0,
textTransform: 'uppercase',
letterSpacing: 0.5,
marginBottom: 4,
},
skuValue: {
fontSize: 14,
fontWeight: 700,
color: '#0E2A47', // Dark navy
},
// Product section - technical specification style
productSection: {
marginBottom: 40,
backgroundColor: '#FFFFFF', // White background for content blocks
padding: 24,
border: '1px solid #E6E9ED',
}, },
productName: { productName: {
fontSize: 24, fontSize: 24,
fontWeight: 700, fontWeight: 700,
color: '#0E2A47', // Dark navy color: '#000d26',
marginBottom: 12, marginBottom: 0,
lineHeight: 1.2,
textTransform: 'uppercase', textTransform: 'uppercase',
letterSpacing: 0.5, letterSpacing: -0.5,
}, },
productMeta: { productMeta: {
fontSize: 12, fontSize: 10,
color: '#6B7280', // Medium gray color: '#4b5563',
fontWeight: 500, fontWeight: 700,
textTransform: 'uppercase',
letterSpacing: 1,
}, },
// Content sections - rectangular blocks heroImage: {
width: '100%',
height: '100%',
objectFit: 'contain',
},
noImage: {
fontSize: 8,
color: '#9ca3af',
textAlign: 'center',
},
// Content Area
content: {
paddingHorizontal: 72,
},
// Content sections
section: { section: {
marginBottom: 32, marginBottom: 20,
backgroundColor: '#FFFFFF',
padding: 24,
border: '1px solid #E6E9ED',
}, },
sectionTitle: { sectionTitle: {
fontSize: 14, fontSize: 14,
fontWeight: 700, fontWeight: 700,
color: '#0E2A47', // Dark navy color: '#000d26', // Primary Dark
marginBottom: 16, marginBottom: 8,
letterSpacing: 0.5,
textTransform: 'uppercase', textTransform: 'uppercase',
borderBottom: '1px solid #E6E9ED', letterSpacing: -0.2,
paddingBottom: 8, },
sectionAccent: {
width: 30,
height: 3,
backgroundColor: '#82ed20', // Accent Green
marginBottom: 8,
borderRadius: 1.5,
}, },
// Description - technical documentation style
description: { description: {
fontSize: 10, fontSize: 11,
lineHeight: 1.6, lineHeight: 1.7,
color: '#1F2933', // Dark gray text color: '#4b5563', // Text Secondary
marginBottom: 0,
}, },
// Cross-section table - engineering specification style // Technical data table
table: {
marginTop: 16,
borderWidth: 1,
borderColor: '#E6E9ED',
},
tableHeader: {
flexDirection: 'row',
backgroundColor: '#E6E9ED',
borderBottomWidth: 1,
borderBottomColor: '#E6E9ED',
},
tableHeaderCell: {
flex: 1,
padding: 8,
fontSize: 10,
fontWeight: 700,
color: '#0E2A47',
textTransform: 'uppercase',
letterSpacing: 0.3,
},
tableHeaderCellLast: {
borderRightWidth: 0,
},
tableHeaderCellWithDivider: {
borderRightWidth: 1,
borderRightColor: '#E6E9ED',
},
tableRow: {
flexDirection: 'row',
borderBottomWidth: 1,
borderBottomColor: '#E6E9ED',
},
tableCell: {
flex: 1,
padding: 8,
fontSize: 10,
color: '#1F2933',
},
tableCellLast: {
borderRightWidth: 0,
},
tableCellWithDivider: {
borderRightWidth: 1,
borderRightColor: '#E6E9ED',
},
tableRowAlt: {
backgroundColor: '#F8F9FA',
},
// Specifications - technical data style
specsContainer: {
flexDirection: 'row',
flexWrap: 'wrap',
},
// Backwards-compatible alias used by the component markup
specsGrid: {
flexDirection: 'row',
flexWrap: 'wrap',
},
// Technical data table (used for the metagrid)
specsTable: { specsTable: {
borderWidth: 1, marginTop: 8,
borderColor: '#E6E9ED', border: '1px solid #e5e7eb',
borderRadius: 8,
overflow: 'hidden',
}, },
specsTableRow: { specsTableRow: {
flexDirection: 'row', flexDirection: 'row',
borderBottomWidth: 1, borderBottomWidth: 1,
borderBottomColor: '#E6E9ED', borderBottomColor: '#e5e7eb',
}, },
specsTableRowLast: { specsTableRowLast: {
@@ -256,63 +171,35 @@ const styles = StyleSheet.create({
}, },
specsTableLabelCell: { specsTableLabelCell: {
flex: 3, flex: 1,
paddingVertical: 8, paddingVertical: 4,
paddingHorizontal: 8, paddingHorizontal: 16,
backgroundColor: '#F8F9FA', backgroundColor: '#f8f9fa',
borderRightWidth: 1, borderRightWidth: 1,
borderRightColor: '#E6E9ED', borderRightColor: '#e5e7eb',
justifyContent: 'center',
}, },
specsTableValueCell: { specsTableValueCell: {
flex: 4, flex: 1,
paddingVertical: 8, paddingVertical: 4,
paddingHorizontal: 8, paddingHorizontal: 16,
justifyContent: 'center',
}, },
specsTableLabelText: { specsTableLabelText: {
fontSize: 9, fontSize: 9,
fontWeight: 700, fontWeight: 700,
color: '#0E2A47', color: '#000d26',
textTransform: 'uppercase', textTransform: 'uppercase',
letterSpacing: 0.3, letterSpacing: 0.5,
lineHeight: 1.2,
}, },
specsTableValueText: { specsTableValueText: {
fontSize: 10, fontSize: 10,
color: '#1F2933', color: '#111827',
lineHeight: 1.4, fontWeight: 500,
}, },
specColumn: { // Categories
width: '48%',
marginRight: '4%',
marginBottom: 16,
},
specItem: {
marginBottom: 12,
},
specLabel: {
fontSize: 9,
fontWeight: 700,
color: '#0E2A47',
marginBottom: 4,
textTransform: 'uppercase',
letterSpacing: 0.3,
},
specValue: {
fontSize: 10,
color: '#1F2933',
lineHeight: 1.4,
},
// Categories - technical classification
categories: { categories: {
flexDirection: 'row', flexDirection: 'row',
flexWrap: 'wrap', flexWrap: 'wrap',
@@ -320,42 +207,48 @@ const styles = StyleSheet.create({
}, },
categoryTag: { categoryTag: {
backgroundColor: '#E6E9ED', backgroundColor: '#f8f9fa',
paddingHorizontal: 12, paddingHorizontal: 12,
paddingVertical: 6, paddingVertical: 6,
border: '1px solid #E6E9ED', border: '1px solid #e5e7eb',
borderRadius: 100,
}, },
categoryText: { categoryText: {
fontSize: 9, fontSize: 8,
color: '#6B7280', color: '#4b5563',
fontWeight: 500, fontWeight: 700,
textTransform: 'uppercase', textTransform: 'uppercase',
letterSpacing: 0.3, letterSpacing: 0.5,
}, },
// Engineering documentation footer // Footer
footer: { footer: {
position: 'absolute', position: 'absolute',
bottom: 48, bottom: 40,
left: 72, left: 72,
right: 72, right: 72,
flexDirection: 'row', flexDirection: 'row',
justifyContent: 'space-between', justifyContent: 'space-between',
alignItems: 'center', alignItems: 'center',
paddingTop: 24, paddingTop: 24,
borderTop: '2px solid #E6E9ED', borderTop: '1px solid #e5e7eb',
fontSize: 9,
color: '#6B7280',
}, },
footerLeft: { footerText: {
fontSize: 8,
color: '#9ca3af',
fontWeight: 500,
textTransform: 'uppercase',
letterSpacing: 1,
},
footerBrand: {
fontSize: 10,
fontWeight: 700, fontWeight: 700,
color: '#0E2A47', color: '#000d26',
}, textTransform: 'uppercase',
letterSpacing: 1,
footerRight: {
color: '#6B7280',
}, },
}); });
@@ -364,6 +257,7 @@ interface ProductData {
name: string; name: string;
shortDescriptionHtml: string; shortDescriptionHtml: string;
descriptionHtml: string; descriptionHtml: string;
applicationHtml?: string;
images: string[]; images: string[];
featuredImage: string | null; featuredImage: string | null;
sku: string; sku: string;
@@ -418,99 +312,101 @@ export const PDFDatasheet: React.FC<PDFDatasheetProps> = ({
return ( return (
<Document> <Document>
<Page size="A4" style={styles.page}> <Page size="A4" style={styles.page}>
{/* Clean, minimal header */} {/* Hero Header */}
<View style={styles.header}> <View style={styles.hero}>
<View style={styles.logoArea}> <View style={styles.header}>
<View style={styles.logoContainer}> <View>
{logoUrl ? ( <Text style={styles.logoText}>KLZ</Text>
/* eslint-disable-next-line jsx-a11y/alt-text */
<Image src={logoUrl} style={styles.logo} />
) : (
<View>
<Text style={styles.logoText}>KLZ</Text>
<Text style={styles.logoSubtext}>Cables</Text>
</View>
)}
</View> </View>
</View>
<View style={styles.docInfo}>
<Text style={styles.docTitle}> <Text style={styles.docTitle}>
{labels.productDatasheet} {labels.productDatasheet}
</Text> </Text>
<View style={styles.skuContainer}>
<Text style={styles.skuLabel}>{labels.sku}</Text>
<Text style={styles.skuValue}>{product.sku}</Text>
</View>
</View> </View>
</View>
{/* Product section - clean and prominent */} <View style={styles.productRow}>
<View style={styles.productSection}> <View style={styles.productInfoCol}>
<Text style={styles.productName}>{product.name}</Text> <View style={styles.productHero}>
<Text style={styles.productMeta}> <View style={styles.categories}>
{product.categories.map(cat => cat.name).join(' • ')} {product.categories.map((cat, index) => (
</Text> <Text key={index} style={styles.productMeta}>
</View> {cat.name}{index < product.categories.length - 1 ? ' • ' : ''}
{/* Description section */}
{(product.shortDescriptionHtml || product.descriptionHtml) && (
<View style={styles.section}>
<Text style={styles.sectionTitle}>{labels.description}</Text>
<Text style={styles.description}>
{stripHtml(product.shortDescriptionHtml || product.descriptionHtml)}
</Text>
</View>
)}
{/* Technical specifications */}
{product.attributes && product.attributes.length > 0 && (
<View style={styles.section}>
<Text style={styles.sectionTitle}>{labels.specifications}</Text>
<View style={styles.specsTable}>
{product.attributes.map((attr, index) => (
<View
key={index}
style={[
styles.specsTableRow,
index === product.attributes.length - 1 &&
styles.specsTableRowLast,
]}
>
<View style={styles.specsTableLabelCell}>
<Text style={styles.specsTableLabelText}>{attr.name}</Text>
</View>
<View style={styles.specsTableValueCell}>
<Text style={styles.specsTableValueText}>
{attr.options.join(', ')}
</Text> </Text>
</View> ))}
</View> </View>
))} <Text style={styles.productName}>{product.name}</Text>
</View>
</View>
<View style={styles.productImageCol}>
{product.featuredImage ? (
<Image src={product.featuredImage} style={styles.heroImage} />
) : (
<Text style={styles.noImage}>{labels.noImage}</Text>
)}
</View> </View>
</View> </View>
)} </View>
{/* Categories as clean tags */} <View style={styles.content}>
{product.categories && product.categories.length > 0 && ( {/* Description section */}
<View style={styles.section}> {(product.applicationHtml || product.shortDescriptionHtml || product.descriptionHtml) && (
<Text style={styles.sectionTitle}>{labels.categories}</Text> <View style={styles.section}>
<View style={styles.categories}> <Text style={styles.sectionTitle}>{labels.description}</Text>
{product.categories.map((cat, index) => ( <View style={styles.sectionAccent} />
<View key={index} style={styles.categoryTag}> <Text style={styles.description}>
<Text style={styles.categoryText}>{cat.name}</Text> {stripHtml(product.applicationHtml || product.shortDescriptionHtml || product.descriptionHtml)}
</View> </Text>
))}
</View> </View>
</View> )}
)}
{/* Technical specifications */}
{product.attributes && product.attributes.length > 0 && (
<View style={styles.section}>
<Text style={styles.sectionTitle}>{labels.specifications}</Text>
<View style={styles.sectionAccent} />
<View style={styles.specsTable}>
{product.attributes.map((attr, index) => (
<View
key={index}
style={[
styles.specsTableRow,
index === product.attributes.length - 1 &&
styles.specsTableRowLast,
]}
>
<View style={styles.specsTableLabelCell}>
<Text style={styles.specsTableLabelText}>{attr.name}</Text>
</View>
<View style={styles.specsTableValueCell}>
<Text style={styles.specsTableValueText}>
{attr.options.join(', ')}
</Text>
</View>
</View>
))}
</View>
</View>
)}
{/* Categories as clean tags */}
{product.categories && product.categories.length > 0 && (
<View style={styles.section}>
<Text style={styles.sectionTitle}>{labels.categories}</Text>
<View style={styles.sectionAccent} />
<View style={styles.categories}>
{product.categories.map((cat, index) => (
<View key={index} style={styles.categoryTag}>
<Text style={styles.categoryText}>{cat.name}</Text>
</View>
))}
</View>
</View>
)}
</View>
{/* Minimal footer */} {/* Minimal footer */}
<View style={styles.footer} fixed> <View style={styles.footer} fixed>
<Text style={styles.footerLeft}> <Text style={styles.footerBrand}>KLZ CABLES</Text>
{labels.sku}: {product.sku} <Text style={styles.footerText}>
</Text>
<Text style={styles.footerRight}>
{new Date().toLocaleDateString(locale === 'en' ? 'en-US' : 'de-DE', { {new Date().toLocaleDateString(locale === 'en' ? 'en-US' : 'de-DE', {
year: 'numeric', year: 'numeric',
month: 'long', month: 'long',

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

View File

@@ -39,6 +39,7 @@ type MdxProduct = {
categories: string[]; categories: string[];
images: string[]; images: string[];
descriptionHtml: string; descriptionHtml: string;
applicationHtml: string;
}; };
type MdxIndex = Map<string, MdxProduct>; // key: normalized designation/title type MdxIndex = Map<string, MdxProduct>; // key: normalized designation/title
@@ -85,9 +86,10 @@ function buildMdxIndex(locale: 'en' | 'de'): MdxIndex {
const images = Array.isArray(data.images) ? data.images.map((i: any) => normalizeValue(String(i))).filter(Boolean) : []; const images = Array.isArray(data.images) ? data.images.map((i: any) => normalizeValue(String(i))).filter(Boolean) : [];
const descriptionHtml = extractDescriptionFromMdxFrontmatter(data); const descriptionHtml = extractDescriptionFromMdxFrontmatter(data);
const applicationHtml = normalizeValue(String(data?.application || ''));
const slug = path.basename(file, '.mdx'); const slug = path.basename(file, '.mdx');
idx.set(normalizeExcelKey(title), { slug, title, sku, categories, images, descriptionHtml }); idx.set(normalizeExcelKey(title), { slug, title, sku, categories, images, descriptionHtml, applicationHtml });
} }
return idx; return idx;
@@ -183,6 +185,7 @@ async function loadProductsFromExcelAndMdx(locale: 'en' | 'de'): Promise<Product
name: title, name: title,
shortDescriptionHtml: '', shortDescriptionHtml: '',
descriptionHtml, descriptionHtml,
applicationHtml: mdx?.applicationHtml || '',
images: mdx?.images || [], images: mdx?.images || [],
featuredImage: (mdx?.images && mdx.images[0]) || null, featuredImage: (mdx?.images && mdx.images[0]) || null,
sku: mdx?.sku || title, sku: mdx?.sku || title,

View File

@@ -1129,7 +1129,7 @@ function buildMediumVoltageCrossSectionTableFromNewExcel(args: {
export function buildDatasheetModel(args: { product: ProductData; locale: 'en' | 'de' }): DatasheetModel { export function buildDatasheetModel(args: { product: ProductData; locale: 'en' | 'de' }): DatasheetModel {
const labels = getLabels(args.locale); const labels = getLabels(args.locale);
const categoriesLine = (args.product.categories || []).map(c => stripHtml(c.name)).join(' • '); const categoriesLine = (args.product.categories || []).map(c => stripHtml(c.name)).join(' • ');
const descriptionText = stripHtml(args.product.shortDescriptionHtml || args.product.descriptionHtml || ''); const descriptionText = stripHtml(args.product.applicationHtml || '');
const heroSrc = resolveMediaToLocalPath(args.product.featuredImage || args.product.images?.[0] || null); const heroSrc = resolveMediaToLocalPath(args.product.featuredImage || args.product.images?.[0] || null);
const productUrl = getProductUrl(args.product); const productUrl = getProductUrl(args.product);
@@ -1173,22 +1173,71 @@ export function buildDatasheetModel(args: { product: ProductData; locale: 'en' |
productUrl, productUrl,
}, },
labels, labels,
technicalItems: [ technicalItems: (() => {
...(excelModel.ok ? excelModel.technicalItems : []), if (!isMediumVoltageProduct(args.product)) {
...(isMediumVoltageProduct(args.product) return excelModel.ok ? excelModel.technicalItems : [];
? args.locale === 'de' }
? [
{ label: 'Prüfspannung 6/10 kV', value: '21 kV' }, const pn = normalizeDesignation(args.product.name || '');
{ label: 'Prüfspannung 12/20 kV', value: '42 kV' }, const isAl = /^NA/.test(pn);
{ label: 'Prüfspannung 18/30 kV', value: '63 kV' }, const isFL = pn.includes('FL');
] const isF = !isFL && pn.includes('F');
: [
{ label: 'Test voltage 6/10 kV', value: '21 kV' }, const findExcelVal = (labelPart: string) => {
{ label: 'Test voltage 12/20 kV', value: '42 kV' }, const found = excelModel.technicalItems.find(it => it.label.toLowerCase().includes(labelPart.toLowerCase()));
{ label: 'Test voltage 18/30 kV', value: '63 kV' }, return found ? found.value : null;
] };
: []),
], const items: KeyValueItem[] = [];
if (args.locale === 'de') {
items.push({ label: 'Leitermaterial', value: isAl ? 'Aluminium' : 'Kupfer' });
items.push({ label: 'Leiterklasse', value: isAl ? 'Klasse 1' : 'Klasse 2 mehrdrähtig' });
items.push({ label: 'Aderisolation', value: 'VPE DIX8' });
items.push({ label: 'Feldsteuerung', value: 'innere und äußere Leitschicht aus halbleitendem Kunststoff - 3-fach-extrudiert' });
items.push({ label: 'Schirm', value: 'Kupferdrähte + Querleitwendel' });
items.push({ label: 'Längswasserdichtigkeit', value: (isF || isFL) ? 'ja, mit Quellvliess' : 'nein' });
items.push({ label: 'Querwasserdichtigkeit', value: isFL ? 'ja, Al-Band' : 'nein' });
items.push({ label: 'Mantelmaterial', value: 'Polyethylen DMP2' });
items.push({ label: 'Mantelfarbe', value: 'schwarz' });
items.push({ label: 'Flammwidrigkeit', value: 'nein' });
items.push({ label: 'UV-beständig', value: 'ja' });
items.push({ label: 'Max. zulässige Leitertemperatur', value: findExcelVal('Leitertemperatur') || '90°C' });
items.push({ label: 'Zul. Kabelaußentemperatur, fest verlegt', value: findExcelVal('fest verlegt') || '70°C' });
items.push({ label: 'Zul. Kabelaußentemperatur, in Bewegung', value: findExcelVal('in Bewegung') || '-20 °C bis +70 °C' });
items.push({ label: 'Maximale Kurzschlußtemperatur', value: findExcelVal('Kurzschlußtemperatur') || '+250 °C' });
items.push({ label: 'Min. Biegeradius, fest verlegt', value: findExcelVal('Biegeradius') || '15 facher Durchmesser' });
items.push({ label: 'Mindesttemperatur Verlegung', value: findExcelVal('Verlegung') || '-5 °C' });
items.push({ label: 'Metermarkierung', value: 'ja' });
items.push({ label: 'Teilentladung', value: findExcelVal('Teilentladung') || '2 pC' });
items.push({ label: 'Prüfspannung 6/10 kV', value: '21 kV' });
items.push({ label: 'Prüfspannung 12/20 kV', value: '42 kV' });
items.push({ label: 'Prüfspannung 18/30 kV', value: '63 kV' });
} else {
items.push({ label: 'Conductor material', value: isAl ? 'Aluminum' : 'Copper' });
items.push({ label: 'Conductor class', value: isAl ? 'Class 1' : 'Class 2 stranded' });
items.push({ label: 'Core insulation', value: 'XLPE DIX8' });
items.push({ label: 'Field control', value: 'inner and outer semiconducting layer made of semiconducting plastic - 3-fold extruded' });
items.push({ label: 'Screen', value: 'copper wires + transverse conductive helix' });
items.push({ label: 'Longitudinal water tightness', value: (isF || isFL) ? 'yes, with swelling tape' : 'no' });
items.push({ label: 'Transverse water tightness', value: isFL ? 'yes, Al-tape' : 'no' });
items.push({ label: 'Sheath material', value: 'Polyethylene DMP2' });
items.push({ label: 'Sheath color', value: 'black' });
items.push({ label: 'Flame retardancy', value: 'no' });
items.push({ label: 'UV resistant', value: 'yes' });
items.push({ label: 'Max. permissible conductor temperature', value: findExcelVal('conductor temperature') || '90°C' });
items.push({ label: 'Permissible cable outer temperature, fixed', value: findExcelVal('fixed') || '70°C' });
items.push({ label: 'Permissible cable outer temperature, in motion', value: findExcelVal('in motion') || '-20 °C to +70 °C' });
items.push({ label: 'Maximum short-circuit temperature', value: findExcelVal('short-circuit temperature') || '+250 °C' });
items.push({ label: 'Min. bending radius, fixed', value: findExcelVal('bending radius') || '15 times diameter' });
items.push({ label: 'Minimum laying temperature', value: findExcelVal('laying temperature') || '-5 °C' });
items.push({ label: 'Meter marking', value: 'yes' });
items.push({ label: 'Partial discharge', value: findExcelVal('Partial discharge') || '2 pC' });
items.push({ label: 'Test voltage 6/10 kV', value: '21 kV' });
items.push({ label: 'Test voltage 12/20 kV', value: '42 kV' });
items.push({ label: 'Test voltage 18/30 kV', value: '63 kV' });
}
return items;
})(),
voltageTables, voltageTables,
legendItems: crossSectionModel.legendItems || [], legendItems: crossSectionModel.legendItems || [],
}; };

View File

@@ -3,6 +3,7 @@ export interface ProductData {
name: string; name: string;
shortDescriptionHtml: string; shortDescriptionHtml: string;
descriptionHtml: string; descriptionHtml: string;
applicationHtml: string;
images: string[]; images: string[];
featuredImage: string | null; featuredImage: string | null;
sku: string; sku: string;

View File

@@ -26,56 +26,62 @@ export function DatasheetDocument(props: { model: DatasheetModel; assets: Assets
return ( return (
<Document> <Document>
<Page size="A4" style={styles.page}> <Page size="A4" style={styles.page}>
<Header title={headerTitle} logoDataUrl={assets.logoDataUrl} qrDataUrl={assets.qrDataUrl} /> <View style={styles.hero}>
<Footer locale={model.locale} siteUrl={CONFIG.siteUrl} /> <Header title={headerTitle} logoDataUrl={assets.logoDataUrl} qrDataUrl={assets.qrDataUrl} isHero={true} />
<Text style={styles.h1}>{model.product.name}</Text> <View style={styles.productRow}>
{model.product.categoriesLine ? <Text style={styles.subhead}>{model.product.categoriesLine}</Text> : null} <View style={styles.productInfoCol}>
<View style={styles.productHero}>
<View style={styles.heroBox}> {model.product.categoriesLine ? <Text style={styles.productMeta}>{model.product.categoriesLine}</Text> : null}
{assets.heroDataUrl ? ( <Text style={styles.productName}>{model.product.name}</Text>
<Image src={assets.heroDataUrl} style={styles.heroImage} /> </View>
) : ( </View>
<Text style={styles.noImage}>{model.labels.noImage}</Text> <View style={styles.productImageCol}>
)} {assets.heroDataUrl ? (
<Image src={assets.heroDataUrl} style={styles.heroImage} />
) : (
<Text style={styles.noImage}>{model.labels.noImage}</Text>
)}
</View>
</View>
</View> </View>
{model.product.descriptionText ? ( <Footer locale={model.locale} siteUrl={CONFIG.siteUrl} />
<Section title={model.labels.description} minPresenceAhead={24}>
<Text style={styles.body}>{model.product.descriptionText}</Text>
</Section>
) : null}
{model.technicalItems.length ? ( <View style={styles.content}>
<Section title={model.labels.technicalData} minPresenceAhead={24}> {model.product.descriptionText ? (
<KeyValueGrid items={model.technicalItems} /> <Section title={model.labels.description} minPresenceAhead={24}>
</Section> <Text style={styles.body}>{model.product.descriptionText}</Text>
) : null} </Section>
) : null}
{model.technicalItems.length ? (
<Section title={model.labels.technicalData} minPresenceAhead={24}>
<KeyValueGrid items={model.technicalItems} />
</Section>
) : null}
</View>
</Page> </Page>
{/*
Render all voltage sections in a single flow so React-PDF can paginate naturally.
This avoids hard page breaks that waste remaining whitespace at the bottom of a page.
Each table section has break={false} to prevent breaking within individual tables,
but the overall flow allows tables to move to the next page if needed.
*/}
<Page size="A4" style={styles.page}> <Page size="A4" style={styles.page}>
<Header title={headerTitle} logoDataUrl={assets.logoDataUrl} qrDataUrl={assets.qrDataUrl} /> <Header title={headerTitle} logoDataUrl={assets.logoDataUrl} qrDataUrl={assets.qrDataUrl} />
<Footer locale={model.locale} siteUrl={CONFIG.siteUrl} /> <Footer locale={model.locale} siteUrl={CONFIG.siteUrl} />
{model.voltageTables.map((t: DatasheetVoltageTable) => ( <View style={styles.content}>
<View key={t.voltageLabel} style={{ marginBottom: 14 }} break={false} minPresenceAhead={24}> {model.voltageTables.map((t: DatasheetVoltageTable) => (
<Text style={styles.sectionTitle}>{`${model.labels.crossSection}${t.voltageLabel}`}</Text> <View key={t.voltageLabel} style={{ marginBottom: 14 }} break={false} minPresenceAhead={24}>
<Text style={styles.sectionTitle}>{`${model.labels.crossSection}${t.voltageLabel}`}</Text>
<View style={styles.sectionAccent} />
<DenseTable table={{ columns: t.columns, rows: t.rows }} firstColLabel={firstColLabel} />
</View>
))}
<DenseTable table={{ columns: t.columns, rows: t.rows }} firstColLabel={firstColLabel} /> {model.legendItems.length ? (
</View> <Section title={model.locale === 'de' ? 'ABKÜRZUNGEN' : 'ABBREVIATIONS'} minPresenceAhead={24}>
))} <KeyValueGrid items={model.legendItems} />
</Section>
{model.legendItems.length ? ( ) : null}
<Section title={model.locale === 'de' ? 'ABKÜRZUNGEN' : 'ABBREVIATIONS'} minPresenceAhead={24}> </View>
<KeyValueGrid items={model.legendItems} />
</Section>
) : null}
</Page> </Page>
</Document> </Document>
); );

View File

@@ -14,9 +14,9 @@ export function Footer(props: { locale: 'en' | 'de'; siteUrl?: string }): React.
return ( return (
<View style={styles.footer} fixed> <View style={styles.footer} fixed>
<Text>{siteUrl}</Text> <Text style={styles.footerBrand}>KLZ CABLES</Text>
<Text>{date}</Text> <Text style={styles.footerText}>{date}</Text>
<Text render={({ pageNumber, totalPages }) => `${pageNumber}/${totalPages}`} /> <Text style={styles.footerText} render={({ pageNumber, totalPages }) => `${pageNumber} / ${totalPages}`} />
</View> </View>
); );
} }

View File

@@ -3,17 +3,16 @@ import { Image, Text, View } from '@react-pdf/renderer';
import { styles } from '../styles'; import { styles } from '../styles';
export function Header(props: { title: string; logoDataUrl?: string | null; qrDataUrl?: string | null }): React.ReactElement { export function Header(props: { title: string; logoDataUrl?: string | null; qrDataUrl?: string | null; isHero?: boolean }): React.ReactElement {
const { isHero = false } = props;
return ( return (
<View style={styles.header} fixed> <View style={isHero ? styles.header : [styles.header, { paddingHorizontal: 0, backgroundColor: 'transparent', borderBottomWidth: 0, marginBottom: 24, paddingTop: 40 }]}>
<View style={styles.headerLeft}> <View style={styles.headerLeft}>
{props.logoDataUrl ? ( {props.logoDataUrl ? (
<Image src={props.logoDataUrl} style={styles.logo} /> <Image src={props.logoDataUrl} style={styles.logo} />
) : ( ) : (
<View style={styles.brandFallback}> <Text style={styles.brandFallback}>KLZ</Text>
<Text style={styles.brandFallbackKlz}>KLZ</Text>
<Text style={styles.brandFallbackCables}>Cables</Text>
</View>
)} )}
</View> </View>
<View style={styles.headerRight}> <View style={styles.headerRight}>

View File

@@ -8,37 +8,25 @@ export function KeyValueGrid(props: { items: KeyValueItem[] }): React.ReactEleme
const items = (props.items || []).filter(i => i.label && i.value); const items = (props.items || []).filter(i => i.label && i.value);
if (!items.length) return null; if (!items.length) return null;
// 4-column layout: (label, value, label, value) // 2-column layout: (label, value)
const rows: Array<[KeyValueItem, KeyValueItem | null]> = [];
for (let i = 0; i < items.length; i += 2) {
rows.push([items[i], items[i + 1] || null]);
}
return ( return (
<View style={styles.kvGrid}> <View style={styles.kvGrid}>
{rows.map(([left, right], rowIndex) => { {items.map((item, rowIndex) => {
const isLast = rowIndex === rows.length - 1; const isLast = rowIndex === items.length - 1;
const leftValue = left.unit ? `${left.value} ${left.unit}` : left.value; const value = item.unit ? `${item.value} ${item.unit}` : item.value;
const rightValue = right ? (right.unit ? `${right.value} ${right.unit}` : right.value) : '';
return ( return (
<View <View
key={`${left.label}-${rowIndex}`} key={`${item.label}-${rowIndex}`}
style={[styles.kvRow, rowIndex % 2 === 0 ? styles.kvRowAlt : null, isLast ? styles.kvRowLast : null]} style={[styles.kvRow, rowIndex % 2 === 0 ? styles.kvRowAlt : null, isLast ? styles.kvRowLast : null]}
wrap={false} wrap={false}
minPresenceAhead={12} minPresenceAhead={12}
> >
<View style={[styles.kvCell, { width: '18%' }]}> <View style={[styles.kvCell, { width: '50%' }]}>
<Text style={styles.kvLabelText}>{left.label}</Text> <Text style={styles.kvLabelText}>{item.label}</Text>
</View> </View>
<View style={[styles.kvCell, styles.kvMidDivider, { width: '32%' }]}> <View style={[styles.kvCell, { width: '50%' }]}>
<Text style={styles.kvValueText}>{leftValue}</Text> <Text style={styles.kvValueText}>{value}</Text>
</View>
<View style={[styles.kvCell, { width: '18%' }]}>
<Text style={styles.kvLabelText}>{right?.label || ''}</Text>
</View>
<View style={[styles.kvCell, { width: '32%' }]}>
<Text style={styles.kvValueText}>{rightValue}</Text>
</View> </View>
</View> </View>
); );

View File

@@ -11,8 +11,9 @@ export function Section(props: {
}): React.ReactElement { }): React.ReactElement {
const boxed = props.boxed ?? true; const boxed = props.boxed ?? true;
return ( return (
<View style={boxed ? styles.section : styles.sectionPlain} minPresenceAhead={props.minPresenceAhead}> <View style={styles.section} minPresenceAhead={props.minPresenceAhead}>
<Text style={styles.sectionTitle}>{props.title}</Text> <Text style={styles.sectionTitle}>{props.title}</Text>
<View style={styles.sectionAccent} />
{props.children} {props.children}
</View> </View>
); );

View File

@@ -5,146 +5,212 @@ import { Font, StyleSheet } from '@react-pdf/renderer';
Font.registerHyphenationCallback(word => [word]); Font.registerHyphenationCallback(word => [word]);
export const COLORS = { export const COLORS = {
navy: '#0E2A47', primary: '#001a4d',
mediumGray: '#6B7280', primaryDark: '#000d26',
darkGray: '#1F2933', accent: '#82ed20',
lightGray: '#E6E9ED', textPrimary: '#111827',
almostWhite: '#F8F9FA', textSecondary: '#4b5563',
headerBg: '#F6F8FB', textLight: '#9ca3af',
neutral: '#f8f9fa',
border: '#e5e7eb',
} as const; } as const;
export const styles = StyleSheet.create({ export const styles = StyleSheet.create({
page: { page: {
paddingTop: 54, paddingTop: 0,
paddingLeft: 54, paddingLeft: 30,
paddingRight: 54, paddingRight: 30,
paddingBottom: 72, paddingBottom: 60,
fontFamily: 'Helvetica', fontFamily: 'Helvetica',
fontSize: 10, fontSize: 10,
color: COLORS.darkGray, color: COLORS.textPrimary,
backgroundColor: '#FFFFFF', backgroundColor: '#FFFFFF',
}, },
// Hero-style header
hero: {
backgroundColor: '#FFFFFF',
paddingTop: 30,
paddingBottom: 0,
paddingHorizontal: 0,
marginBottom: 20,
position: 'relative',
borderBottomWidth: 0,
borderBottomColor: COLORS.border,
},
header: { header: {
flexDirection: 'row', flexDirection: 'row',
justifyContent: 'space-between', justifyContent: 'space-between',
alignItems: 'center', alignItems: 'center',
paddingVertical: 12, marginBottom: 24,
paddingHorizontal: 16, paddingHorizontal: 0,
backgroundColor: COLORS.headerBg,
borderBottomWidth: 1,
borderBottomColor: COLORS.lightGray,
marginBottom: 16,
}, },
headerLeft: { flexDirection: 'row', alignItems: 'center', gap: 10 }, headerLeft: { flexDirection: 'row', alignItems: 'center', gap: 10 },
logo: { width: 110, height: 24, objectFit: 'contain' }, logo: { width: 100, height: 22, objectFit: 'contain' },
brandFallback: { flexDirection: 'row', alignItems: 'baseline', gap: 6 }, brandFallback: { fontSize: 20, fontWeight: 700, color: COLORS.primaryDark, letterSpacing: 1, textTransform: 'uppercase' },
brandFallbackKlz: { fontSize: 18, fontWeight: 700, color: COLORS.navy },
brandFallbackCables: { fontSize: 10, color: COLORS.mediumGray },
headerRight: { flexDirection: 'row', alignItems: 'center', gap: 10 }, headerRight: { flexDirection: 'row', alignItems: 'center', gap: 10 },
headerTitle: { fontSize: 9, fontWeight: 700, color: COLORS.navy, letterSpacing: 0.2 }, headerTitle: { fontSize: 9, fontWeight: 700, color: COLORS.primary, letterSpacing: 1.5, textTransform: 'uppercase' },
qr: { width: 34, height: 34, objectFit: 'contain' }, qr: { width: 30, height: 30, objectFit: 'contain' },
productRow: {
flexDirection: 'row',
alignItems: 'center',
gap: 20,
},
productInfoCol: {
flex: 1,
justifyContent: 'center',
},
productImageCol: {
flex: 1,
height: 120,
justifyContent: 'center',
alignItems: 'center',
borderRadius: 8,
borderWidth: 1,
borderColor: COLORS.border,
backgroundColor: '#FFFFFF',
overflow: 'hidden',
},
productHero: {
marginTop: 0,
paddingHorizontal: 0,
},
productName: {
fontSize: 24,
fontWeight: 700,
color: COLORS.primaryDark,
marginBottom: 0,
textTransform: 'uppercase',
letterSpacing: -0.5,
},
productMeta: {
fontSize: 9,
color: COLORS.textSecondary,
fontWeight: 700,
textTransform: 'uppercase',
letterSpacing: 1,
marginBottom: 4,
},
content: {
paddingHorizontal: 0,
},
footer: { footer: {
position: 'absolute', position: 'absolute',
left: 54, left: 30,
right: 54, right: 30,
bottom: 36, bottom: 30,
paddingTop: 10, paddingTop: 16,
borderTopWidth: 1, borderTopWidth: 1,
borderTopColor: COLORS.lightGray, borderTopColor: COLORS.border,
flexDirection: 'row', flexDirection: 'row',
justifyContent: 'space-between', justifyContent: 'space-between',
fontSize: 8, alignItems: 'center',
color: COLORS.mediumGray,
}, },
footerBrand: { fontSize: 9, fontWeight: 700, color: COLORS.primaryDark, textTransform: 'uppercase', letterSpacing: 1 },
footerText: { fontSize: 8, color: COLORS.textLight, fontWeight: 500, textTransform: 'uppercase', letterSpacing: 0.5 },
h1: { fontSize: 18, fontWeight: 700, color: COLORS.navy, marginBottom: 6 }, h1: { fontSize: 22, fontWeight: 700, color: COLORS.primaryDark, marginBottom: 8, textTransform: 'uppercase' },
subhead: { fontSize: 10.5, color: COLORS.mediumGray, marginBottom: 14 }, subhead: { fontSize: 10, fontWeight: 700, color: COLORS.textSecondary, marginBottom: 16, textTransform: 'uppercase', letterSpacing: 0.5 },
heroBox: { heroBox: {
height: 110, height: 180,
borderRadius: 12,
borderWidth: 1, borderWidth: 1,
borderColor: COLORS.lightGray, borderColor: COLORS.border,
backgroundColor: COLORS.almostWhite, backgroundColor: '#FFFFFF',
marginBottom: 16, marginBottom: 24,
justifyContent: 'center', justifyContent: 'center',
overflow: 'hidden', overflow: 'hidden',
padding: 0,
}, },
heroImage: { width: '100%', height: '100%', objectFit: 'contain' }, heroImage: { width: '100%', height: '100%', objectFit: 'contain' },
noImage: { fontSize: 8, color: COLORS.mediumGray, paddingHorizontal: 12 }, noImage: { fontSize: 8, color: COLORS.textLight, textAlign: 'center' },
section: { section: {
borderWidth: 1, marginBottom: 10,
borderColor: COLORS.lightGray,
padding: 14,
marginBottom: 14,
},
sectionPlain: {
paddingVertical: 2,
marginBottom: 12,
}, },
sectionTitle: { sectionTitle: {
fontSize: 10, fontSize: 14,
fontWeight: 700, fontWeight: 700,
color: COLORS.navy, color: COLORS.primaryDark,
marginBottom: 8, marginBottom: 8,
letterSpacing: 0.2, textTransform: 'uppercase',
letterSpacing: -0.2,
}, },
body: { fontSize: 10, lineHeight: 1.5, color: COLORS.darkGray }, sectionAccent: {
width: 30,
height: 3,
backgroundColor: COLORS.accent,
marginBottom: 8,
borderRadius: 1.5,
},
body: { fontSize: 10, lineHeight: 1.6, color: COLORS.textSecondary },
kvGrid: { kvGrid: {
width: '100%', width: '100%',
borderWidth: 1, borderWidth: 1,
borderColor: COLORS.lightGray, borderColor: COLORS.border,
borderRadius: 8,
overflow: 'hidden',
}, },
kvRow: { kvRow: {
flexDirection: 'row', flexDirection: 'row',
borderBottomWidth: 1, borderBottomWidth: 1,
borderBottomColor: COLORS.lightGray, borderBottomColor: COLORS.border,
}, },
kvRowAlt: { backgroundColor: COLORS.almostWhite }, kvRowAlt: { backgroundColor: COLORS.neutral },
kvRowLast: { borderBottomWidth: 0 }, kvRowLast: { borderBottomWidth: 0 },
kvCell: { paddingVertical: 6, paddingHorizontal: 8 }, kvCell: { paddingVertical: 3, paddingHorizontal: 12 },
// Visual separator between (label,value) pairs in the 4-col KV grid.
// Matches the engineering-table look and improves scanability.
kvMidDivider: { kvMidDivider: {
borderRightWidth: 1, borderRightWidth: 1,
borderRightColor: COLORS.lightGray, borderRightColor: COLORS.border,
}, },
kvLabelText: { fontSize: 8.5, fontWeight: 700, color: COLORS.mediumGray }, kvLabelText: { fontSize: 8, fontWeight: 700, color: COLORS.primaryDark, textTransform: 'uppercase', letterSpacing: 0.3 },
kvValueText: { fontSize: 9.5, color: COLORS.darkGray }, kvValueText: { fontSize: 9, color: COLORS.textPrimary, fontWeight: 500 },
tableWrap: { width: '100%', borderWidth: 1, borderColor: COLORS.lightGray, marginBottom: 14 }, tableWrap: {
width: '100%',
borderWidth: 1,
borderColor: COLORS.border,
borderRadius: 8,
overflow: 'hidden',
marginBottom: 16,
},
tableHeader: { tableHeader: {
width: '100%', width: '100%',
flexDirection: 'row', flexDirection: 'row',
backgroundColor: '#FFFFFF', backgroundColor: COLORS.neutral,
borderBottomWidth: 1, borderBottomWidth: 1,
borderBottomColor: COLORS.lightGray, borderBottomColor: COLORS.border,
}, },
tableHeaderCell: { tableHeaderCell: {
paddingVertical: 5, paddingVertical: 8,
paddingHorizontal: 4, paddingHorizontal: 6,
fontSize: 6.6, fontSize: 7,
fontWeight: 700, fontWeight: 700,
color: COLORS.navy, color: COLORS.primaryDark,
textTransform: 'uppercase',
letterSpacing: 0.2,
}, },
tableHeaderCellCfg: { tableHeaderCellCfg: {
paddingHorizontal: 6, paddingHorizontal: 8,
}, },
tableHeaderCellDivider: { tableHeaderCellDivider: {
borderRightWidth: 1, borderRightWidth: 1,
borderRightColor: COLORS.lightGray, borderRightColor: COLORS.border,
}, },
tableRow: { width: '100%', flexDirection: 'row', borderBottomWidth: 1, borderBottomColor: COLORS.lightGray }, tableRow: { width: '100%', flexDirection: 'row', borderBottomWidth: 1, borderBottomColor: COLORS.border },
tableRowAlt: { backgroundColor: COLORS.almostWhite }, tableRowAlt: { backgroundColor: '#FFFFFF' },
tableCell: { paddingVertical: 4, paddingHorizontal: 4, fontSize: 6.6, color: COLORS.darkGray }, tableCell: { paddingVertical: 6, paddingHorizontal: 6, fontSize: 7, color: COLORS.textSecondary, fontWeight: 500 },
tableCellCfg: { tableCellCfg: {
paddingHorizontal: 6, paddingHorizontal: 8,
}, },
tableCellDivider: { tableCellDivider: {
borderRightWidth: 1, borderRightWidth: 1,
borderRightColor: COLORS.lightGray, borderRightColor: COLORS.border,
}, },
}); });

46
scripts/test-og-images.ts Normal file
View File

@@ -0,0 +1,46 @@
import * as http from 'http';
const baseUrl = 'http://localhost:3010';
const paths = [
'/en/opengraph-image',
'/de/opengraph-image',
'/en/blog/opengraph-image',
'/en/contact/opengraph-image',
'/en/products/opengraph-image',
'/en/team/opengraph-image',
];
async function testUrl(path: string) {
return new Promise((resolve) => {
const url = `${baseUrl}${path}`;
console.log(`Testing ${url}...`);
const req = http.get(url, (res) => {
console.log(` Status: ${res.statusCode}`);
console.log(` Content-Type: ${res.headers['content-type']}`);
resolve(res.statusCode === 200);
});
req.on('error', (e) => {
console.error(` Error: ${e.message}`);
resolve(false);
});
req.end();
});
}
async function run() {
let allPassed = true;
for (const path of paths) {
const passed = await testUrl(path);
if (!passed) allPassed = false;
}
if (allPassed) {
console.log('\n✅ All OG images are working!');
process.exit(0);
} else {
console.log('\n❌ Some OG images failed.');
process.exit(1);
}
}
run();

View File

@@ -37,7 +37,12 @@
--animate-slow-zoom: slow-zoom 20s linear infinite; --animate-slow-zoom: slow-zoom 20s linear infinite;
--animate-reveal: reveal 0.8s cubic-bezier(0.16, 1, 0.3, 1) forwards; --animate-reveal: reveal 0.8s cubic-bezier(0.16, 1, 0.3, 1) forwards;
--animate-slight-fade-in-from-bottom: slight-fade-in-from-bottom 0.8s cubic-bezier(0.16, 1, 0.3, 1) forwards; --animate-slight-fade-in-from-bottom: slight-fade-in-from-bottom 0.8s cubic-bezier(0.16, 1, 0.3, 1) forwards;
--animate-gradient-x: gradient-x 15s ease infinite;
@keyframes gradient-x {
0%, 100% { background-position: 0% 50%; }
50% { background-position: 100% 50%; }
}
@keyframes fade-in { @keyframes fade-in {
from { opacity: 0; } from { opacity: 0; }
to { opacity: 1; } to { opacity: 1; }