Compare commits
10 Commits
757df76f36
...
v1.0.0-rc.
| Author | SHA1 | Date | |
|---|---|---|---|
| 5036c5fe28 | |||
| 50a524c515 | |||
| 57886a01d6 | |||
| c89bd8e80f | |||
| 9c54322654 | |||
| 8a80eb7b9a | |||
| c1773a7072 | |||
| 33ed13d255 | |||
| 0f5811edb9 | |||
| e4eabd7a86 |
14
.env.example
14
.env.example
@@ -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:
|
||||||
# ─────────
|
# ─────────
|
||||||
|
|||||||
@@ -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
|
|
||||||
|
|||||||
21
README.md
21
README.md
@@ -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
|
||||||
|
|
||||||
|
|||||||
@@ -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}
|
||||||
/>
|
/>
|
||||||
),
|
),
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -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}
|
||||||
/>
|
/>
|
||||||
),
|
),
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -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}`}
|
||||||
|
|||||||
@@ -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}
|
||||||
/>
|
/>
|
||||||
),
|
),
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -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"
|
|
||||||
/>
|
/>
|
||||||
),
|
),
|
||||||
{
|
{
|
||||||
|
|||||||
68
components/DatasheetDownload.tsx
Normal file
68
components/DatasheetDownload.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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',
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -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>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -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"
|
||||||
|
|||||||
@@ -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}`;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
BIN
public/datasheets/medium-voltage/n2xsfl2y-mv-de.pdf
Normal file
BIN
public/datasheets/medium-voltage/n2xsfl2y-mv-de.pdf
Normal file
Binary file not shown.
BIN
public/datasheets/medium-voltage/n2xsfl2y-mv-en.pdf
Normal file
BIN
public/datasheets/medium-voltage/n2xsfl2y-mv-en.pdf
Normal file
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
BIN
public/datasheets/medium-voltage/na2xsfl2y-mv-de.pdf
Normal file
BIN
public/datasheets/medium-voltage/na2xsfl2y-mv-de.pdf
Normal file
Binary file not shown.
BIN
public/datasheets/medium-voltage/na2xsfl2y-mv-en.pdf
Normal file
BIN
public/datasheets/medium-voltage/na2xsfl2y-mv-en.pdf
Normal file
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -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,
|
||||||
|
|||||||
@@ -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 || [],
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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}>
|
||||||
|
|||||||
@@ -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>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -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>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -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
46
scripts/test-og-images.ts
Normal 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();
|
||||||
@@ -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; }
|
||||||
|
|||||||
Reference in New Issue
Block a user