Compare commits

...

26 Commits

Author SHA1 Message Date
2f8ce42409 refactor: Remove automatic CMS bootstrapping and wait commands from the dev script.
All checks were successful
Build & Deploy KLZ Cables / 🔍 Prepare Environment (push) Successful in 21s
Build & Deploy KLZ Cables / 🧪 Quality Assurance (push) Successful in 1m17s
Build & Deploy KLZ Cables / 🏗️ Build & Push (push) Successful in 3m28s
Build & Deploy KLZ Cables / 🚀 Deploy (push) Successful in 43s
Build & Deploy KLZ Cables / 🔔 Notifications (push) Successful in 1s
2026-02-01 13:04:11 +01:00
cf7af73b72 feat: Automate Directus CMS bootstrapping in the dev script and update gitignore rules.
Some checks failed
Build & Deploy KLZ Cables / 🔍 Prepare Environment (push) Successful in 20s
Build & Deploy KLZ Cables / 🧪 Quality Assurance (push) Successful in 1m15s
Build & Deploy KLZ Cables / 🏗️ Build & Push (push) Successful in 3m32s
Build & Deploy KLZ Cables / 🚀 Deploy (push) Failing after 1m0s
Build & Deploy KLZ Cables / 🔔 Notifications (push) Successful in 2s
2026-02-01 12:47:46 +01:00
d526bfe56f chore: Remove various unused uploaded files from Directus. 2026-02-01 12:47:30 +01:00
4cb7d438a0 feat: make Directus CMS URL configurable via environment variable with a default.
All checks were successful
Build & Deploy KLZ Cables / 🔍 Prepare Environment (push) Successful in 21s
Build & Deploy KLZ Cables / 🧪 Quality Assurance (push) Successful in 1m17s
Build & Deploy KLZ Cables / 🏗️ Build & Push (push) Successful in 3m42s
Build & Deploy KLZ Cables / 🚀 Deploy (push) Successful in 56s
Build & Deploy KLZ Cables / 🔔 Notifications (push) Successful in 2s
2026-02-01 11:17:24 +01:00
03e597442b feat: Centralize OG image font loading and sizing, simplify product page OG generation, and refine template styling.
Some checks failed
Build & Deploy KLZ Cables / 🔍 Prepare Environment (push) Successful in 21s
Build & Deploy KLZ Cables / 🧪 Quality Assurance (push) Successful in 1m36s
Build & Deploy KLZ Cables / 🏗️ Build & Push (push) Failing after 1m31s
Build & Deploy KLZ Cables / 🚀 Deploy (push) Has been skipped
Build & Deploy KLZ Cables / 🔔 Notifications (push) Successful in 3s
2026-02-01 11:05:37 +01:00
5f9ee7d976 chore: resolve merge conflict in deploy workflow and sync docker-compose
Some checks failed
Build & Deploy KLZ Cables / 🔍 Prepare Environment (push) Successful in 24s
Build & Deploy KLZ Cables / 🧪 Quality Assurance (push) Failing after 1m16s
Build & Deploy KLZ Cables / 🏗️ Build & Push (push) Has been skipped
Build & Deploy KLZ Cables / 🚀 Deploy (push) Has been skipped
Build & Deploy KLZ Cables / 🔔 Notifications (push) Successful in 2s
2026-02-01 02:18:25 +01:00
a4ea42a043 directus 2026-02-01 02:10:24 +01:00
ee04d2422c directus 2026-02-01 02:02:03 +01:00
26fc34299e directus 2026-02-01 00:58:54 +01:00
6d13611a16 directus 2026-02-01 00:55:33 +01:00
4a9246be5e directus 2026-01-31 23:52:51 +01:00
2ed038174d directus 2026-01-31 23:32:01 +01:00
c1304403a1 ci cd
Some checks failed
Build & Deploy KLZ Cables / 🔍 Prepare Environment (push) Successful in 22s
Build & Deploy KLZ Cables / 🧪 Quality Assurance (push) Failing after 1m34s
Build & Deploy KLZ Cables / 🏗️ Build & Push (push) Has been skipped
Build & Deploy KLZ Cables / 🚀 Deploy (push) Has been skipped
Build & Deploy KLZ Cables / 🔔 Notifications (push) Successful in 1s
2026-01-31 22:32:32 +01:00
5036c5fe28 deploy
All checks were successful
Build & Deploy KLZ Cables / build-and-deploy (push) Successful in 4m13s
2026-01-31 21:10:12 +01:00
50a524c515 gitea
Some checks failed
Build & Deploy KLZ Cables / build-and-deploy (push) Failing after 3m54s
2026-01-31 19:21:53 +01:00
57886a01d6 traefik
All checks were successful
Build & Deploy KLZ Cables / build-and-deploy (push) Successful in 3m58s
2026-01-31 18:36:34 +01:00
c89bd8e80f staging deploy
All checks were successful
Build & Deploy KLZ Cables / build-and-deploy (push) Successful in 3m54s
2026-01-31 18:07:28 +01:00
9c54322654 datasheet download
All checks were successful
Build & Deploy KLZ Cables / build-and-deploy (push) Successful in 4m1s
2026-01-31 10:40:17 +01:00
8a80eb7b9a og
All checks were successful
Build & Deploy KLZ Cables / build-and-deploy (push) Successful in 3m55s
2026-01-31 10:35:07 +01:00
c1773a7072 datasheets as downloads
All checks were successful
Build & Deploy KLZ Cables / build-and-deploy (push) Successful in 3m59s
2026-01-31 10:29:39 +01:00
33ed13d255 og
Some checks failed
Build & Deploy KLZ Cables / build-and-deploy (push) Failing after 3m28s
2026-01-31 10:25:25 +01:00
0f5811edb9 og
Some checks failed
Build & Deploy KLZ Cables / build-and-deploy (push) Failing after 1m47s
2026-01-31 10:21:24 +01:00
06bbed8c21 strapi 2026-01-31 10:11:45 +01:00
f5a879fa60 Merge branch 'main' into feature/strapi 2026-01-30 22:10:16 +01:00
e4eabd7a86 sheets
Some checks failed
Build & Deploy KLZ Cables / build-and-deploy (push) Failing after 17m33s
2026-01-30 22:10:01 +01:00
be9f9cf483 strapi 2026-01-29 19:47:55 +01:00
105 changed files with 5361 additions and 831 deletions

10
.env
View File

@@ -19,3 +19,13 @@ MAIL_USERNAME=postmaster@mg.mintel.me
MAIL_PASSWORD=4592fcb94599ee1a45b4ac2386fd0a64-102c75d8-ca2870e6
MAIL_FROM="KLZ Cables <postmaster@mg.mintel.me>"
MAIL_RECIPIENTS=marc@cablecreations.de,info@klz-cables.com
# Directus
DIRECTUS_URL=https://cms.klz-cables.com
DIRECTUS_KEY=59fb8f4c1a51b18fe28ad947f713914e
DIRECTUS_SECRET=7459038d41401dfb11254cf7f1ef2d0f
DIRECTUS_ADMIN_EMAIL=marc@mintel.me
DIRECTUS_ADMIN_PASSWORD=Tim300493.
DIRECTUS_DB_NAME=directus
DIRECTUS_DB_USER=directus
DIRECTUS_DB_PASSWORD=directus

View File

@@ -40,11 +40,32 @@ MAIL_RECIPIENTS=info@klz-cables.com
# ────────────────────────────────────────────────────────────────────────────
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_SIZE=256m
# ────────────────────────────────────────────────────────────────────────────
# Strapi CMS
# ────────────────────────────────────────────────────────────────────────────
STRAPI_DATABASE_NAME=strapi
STRAPI_DATABASE_USERNAME=strapi
STRAPI_DATABASE_PASSWORD=strapi
STRAPI_URL=http://localhost:1337
APP_KEYS=toBeModified1,toBeModified2
API_TOKEN_SALT=tobemodified
ADMIN_JWT_SECRET=tobemodified
TRANSFER_TOKEN_SALT=tobemodified
JWT_SECRET=tobemodified
# ============================================================================
# IMPORTANT NOTES
# ============================================================================
@@ -61,7 +82,11 @@ VARNISH_CACHE_SIZE=256m
# ──────────────────
# 1. Build-time: Only NEXT_PUBLIC_* vars are needed (via --build-arg)
# 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:
# ─────────

View File

@@ -26,6 +26,15 @@ MAIL_PASSWORD=
MAIL_FROM=KLZ Cables <noreply@klz-cables.com>
MAIL_RECIPIENTS=info@klz-cables.com
# Strapi
STRAPI_DATABASE_NAME=strapi
STRAPI_DATABASE_USERNAME=strapi
STRAPI_DATABASE_PASSWORD=
APP_KEYS=
API_TOKEN_SALT=
ADMIN_JWT_SECRET=
TRANSFER_TOKEN_SALT=
JWT_SECRET=
# Varnish Cache Size (optional)
VARNISH_CACHE_SIZE=256m

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

@@ -0,0 +1,32 @@
name: CI - Lint, Typecheck & Test
on:
push:
branches-ignore:
- main
pull_request:
jobs:
quality-assurance:
runs-on: docker
steps:
- name: Checkout repository
uses: actions/checkout@v4
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: 20
cache: 'npm'
- name: Install dependencies
run: npm ci
- name: 🔍 Lint
run: npm run lint
- name: 🏗️ Typecheck
run: npm run typecheck
- name: 🧪 Test
run: npm run test

View File

@@ -2,184 +2,281 @@ name: Build & Deploy KLZ Cables
on:
push:
branches: [main]
branches:
- main
tags:
- 'v*'
jobs:
build-and-deploy:
# ──────────────────────────────────────────────────────────────────────────────
# JOB 1: Prepare & Determine Environment
# ──────────────────────────────────────────────────────────────────────────────
prepare:
name: 🔍 Prepare Environment
runs-on: docker
outputs:
target: ${{ steps.determine.outputs.target }}
image_tag: ${{ steps.determine.outputs.image_tag }}
env_file: ${{ steps.determine.outputs.env_file }}
traefik_host: ${{ steps.determine.outputs.traefik_host }}
is_prod: ${{ steps.determine.outputs.is_prod }}
gotify_title: ${{ steps.determine.outputs.gotify_title }}
gotify_priority: ${{ steps.determine.outputs.gotify_priority }}
short_sha: ${{ steps.determine.outputs.short_sha }}
commit_msg: ${{ steps.determine.outputs.commit_msg }}
steps:
# ═══════════════════════════════════════════════════════════════════════════════
# LOGGING: Workflow Start - Full Transparency
# ═══════════════════════════════════════════════════════════════════════════════
- name: 📋 Log Workflow Start
run: |
echo "🚀 Starting deployment for ${{ github.repository }} (${{ github.ref }})"
echo " • Commit: ${{ github.sha }}"
echo " • Timestamp: $(date -u +'%Y-%m-%d %H:%M:%S UTC')"
- name: Checkout repository
uses: actions/checkout@v4
with:
fetch-depth: 0
- name: 🔍 Environment & Version ermitteln
id: determine
run: |
TAG="${{ github.ref_name }}"
SHORT_SHA="${{ github.sha }}"
SHORT_SHA="${SHORT_SHA:0:9}"
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
# ──────────────────────────────────────────────────────────────────────────────
# JOB 2: Quality Assurance (Lint & Test)
# ──────────────────────────────────────────────────────────────────────────────
qa:
name: 🧪 Quality Assurance
needs: prepare
if: needs.prepare.outputs.target != 'skip'
runs-on: docker
steps:
- name: Checkout repository
uses: actions/checkout@v4
# ═══════════════════════════════════════════════════════════════════════════════
# LOGGING: Registry Login Phase
# ═══════════════════════════════════════════════════════════════════════════════
- name: 🔐 Login to private registry
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: 20
cache: 'npm'
- name: Install dependencies
run: npm ci
- name: 🔍 Lint & Typecheck
run: |
npm run lint
npm run typecheck
- name: 🧪 Test
run: npm run test
# ──────────────────────────────────────────────────────────────────────────────
# JOB 3: Build & Push Docker Image
# ──────────────────────────────────────────────────────────────────────────────
build:
name: 🏗️ Build & Push
needs: [prepare, qa]
runs-on: docker
steps:
- name: Checkout repository
uses: actions/checkout@v4
- name: 🔐 Registry Login
run: |
echo "🔐 Authenticating with registry.infra.mintel.me..."
echo "${{ secrets.REGISTRY_PASS }}" | docker login registry.infra.mintel.me -u "${{ secrets.REGISTRY_USER }}" --password-stdin
# ═══════════════════════════════════════════════════════════════════════════════
# LOGGING: Build Phase
# ═══════════════════════════════════════════════════════════════════════════════
- name: 🏗️ Build Docker image
- name: 🏗️ Docker Image bauen & pushen
env:
IMAGE_TAG: ${{ needs.prepare.outputs.image_tag }}
TARGET: ${{ needs.prepare.outputs.target }}
NEXT_PUBLIC_BASE_URL: ${{ needs.prepare.outputs.target == 'production' && secrets.NEXT_PUBLIC_BASE_URL || (needs.prepare.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: ${{ needs.prepare.outputs.target == 'production' && secrets.NEXT_PUBLIC_UMAMI_WEBSITE_ID || (needs.prepare.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: ${{ needs.prepare.outputs.target == 'production' && secrets.NEXT_PUBLIC_UMAMI_SCRIPT_URL || (needs.prepare.outputs.target == 'staging' && secrets.STAGING_NEXT_PUBLIC_UMAMI_SCRIPT_URL || secrets.TESTING_NEXT_PUBLIC_UMAMI_SCRIPT_URL || secrets.NEXT_PUBLIC_UMAMI_SCRIPT_URL) }}
DIRECTUS_URL: ${{ needs.prepare.outputs.target == 'production' && 'https://cms.klz-cables.com' || (needs.prepare.outputs.target == 'staging' && 'https://cms-staging.klz-cables.com' || 'https://cms-testing.klz-cables.com') }}
run: |
echo "🏗️ Building Docker image (linux/arm64)..."
echo "🏗️ Building → $TARGET / $IMAGE_TAG"
docker buildx build \
--pull \
--platform linux/arm64 \
--build-arg NEXT_PUBLIC_BASE_URL="${{ secrets.NEXT_PUBLIC_BASE_URL }}" \
--build-arg NEXT_PUBLIC_UMAMI_WEBSITE_ID="${{ secrets.NEXT_PUBLIC_UMAMI_WEBSITE_ID }}" \
--build-arg NEXT_PUBLIC_UMAMI_SCRIPT_URL="${{ secrets.NEXT_PUBLIC_UMAMI_SCRIPT_URL }}" \
-t registry.infra.mintel.me/mintel/klz-cables.com:latest \
--build-arg NEXT_PUBLIC_BASE_URL="$NEXT_PUBLIC_BASE_URL" \
--build-arg NEXT_PUBLIC_UMAMI_WEBSITE_ID="$NEXT_PUBLIC_UMAMI_WEBSITE_ID" \
--build-arg NEXT_PUBLIC_UMAMI_SCRIPT_URL="$NEXT_PUBLIC_UMAMI_SCRIPT_URL" \
--build-arg DIRECTUS_URL="$DIRECTUS_URL" \
-t registry.infra.mintel.me/mintel/klz-cables.com:$IMAGE_TAG \
--push .
# ═══════════════════════════════════════════════════════════════════════════════
# LOGGING: Deployment Phase
# ═══════════════════════════════════════════════════════════════════════════════
- name: 🚀 Deploy to production server
# ──────────────────────────────────────────────────────────────────────────────
# JOB 4: Deploy via SSH
# ──────────────────────────────────────────────────────────────────────────────
deploy:
name: 🚀 Deploy
needs: [prepare, build]
runs-on: docker
env:
TARGET: ${{ needs.prepare.outputs.target }}
IMAGE_TAG: ${{ needs.prepare.outputs.image_tag }}
ENV_FILE: ${{ needs.prepare.outputs.env_file }}
TRAEFIK_HOST: ${{ needs.prepare.outputs.traefik_host }}
NEXT_PUBLIC_BASE_URL: ${{ needs.prepare.outputs.target == 'production' && secrets.NEXT_PUBLIC_BASE_URL || (needs.prepare.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: ${{ needs.prepare.outputs.target == 'production' && secrets.NEXT_PUBLIC_UMAMI_WEBSITE_ID || (needs.prepare.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: ${{ needs.prepare.outputs.target == 'production' && secrets.NEXT_PUBLIC_UMAMI_SCRIPT_URL || (needs.prepare.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: ${{ needs.prepare.outputs.target == 'production' && secrets.SENTRY_DSN || (needs.prepare.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 }}
DIRECTUS_URL: ${{ needs.prepare.outputs.target == 'production' && 'https://cms.klz-cables.com' || (needs.prepare.outputs.target == 'staging' && 'https://cms-staging.klz-cables.com' || 'https://cms-testing.klz-cables.com') }}
DIRECTUS_KEY: ${{ secrets.DIRECTUS_KEY }}
DIRECTUS_SECRET: ${{ secrets.DIRECTUS_SECRET }}
DIRECTUS_ADMIN_EMAIL: ${{ secrets.DIRECTUS_ADMIN_EMAIL }}
DIRECTUS_ADMIN_PASSWORD: ${{ secrets.DIRECTUS_ADMIN_PASSWORD }}
DIRECTUS_DB_NAME: ${{ secrets.DIRECTUS_DB_NAME || 'directus' }}
DIRECTUS_DB_USER: ${{ secrets.DIRECTUS_DB_USER || 'directus' }}
DIRECTUS_DB_PASSWORD: ${{ secrets.DIRECTUS_DB_PASSWORD }}
DIRECTUS_API_TOKEN: ${{ secrets.DIRECTUS_API_TOKEN }}
steps:
- name: Checkout repository
uses: actions/checkout@v4
- name: 🚀 Deploy to ${{ env.TARGET }}
run: |
echo "🚀 Deploying to alpha.mintel.me..."
# Setup SSH
echo "Deploying $TARGET → $IMAGE_TAG"
mkdir -p ~/.ssh
echo "${{ secrets.ALPHA_SSH_KEY }}" > ~/.ssh/id_ed25519
chmod 600 ~/.ssh/id_ed25519
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
# Generated by CI - $TARGET - $(date -u)
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 }}
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
# Directus
DIRECTUS_URL=$DIRECTUS_URL
DIRECTUS_KEY=$DIRECTUS_KEY
DIRECTUS_SECRET=$DIRECTUS_SECRET
DIRECTUS_ADMIN_EMAIL=$DIRECTUS_ADMIN_EMAIL
DIRECTUS_ADMIN_PASSWORD=$DIRECTUS_ADMIN_PASSWORD
DIRECTUS_DB_NAME=$DIRECTUS_DB_NAME
DIRECTUS_DB_USER=$DIRECTUS_DB_USER
DIRECTUS_DB_PASSWORD=$DIRECTUS_DB_PASSWORD
DIRECTUS_API_TOKEN=$DIRECTUS_API_TOKEN
IMAGE_TAG=$IMAGE_TAG
TRAEFIK_HOST=$TRAEFIK_HOST
ENV_FILE=$ENV_FILE
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!"
EOF
rm -f /tmp/klz-cables.env
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
# ═══════════════════════════════════════════════════════════════════════════════
# LOGGING: Workflow Summary
# ═══════════════════════════════════════════════════════════════════════════════
- name: 📊 Workflow Summary
if: always()
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
# ──────────────────────────────────────────────────────────────────────────────
# JOB 5: Notifications
# ──────────────────────────────────────────────────────────────────────────────
notifications:
name: 🔔 Notifications
needs: [prepare, qa, build, deploy]
if: always()
runs-on: docker
steps:
- name: 📊 Deployment Summary
run: |
echo "📊 Status: ${{ job.status }}"
echo "🎯 Target: alpha.mintel.me"
echo "┌──────────────────────────────┐"
echo "│ Deployment Summary │"
echo "├──────────────────────────────┤"
echo "│ Status: ${{ needs.deploy.result }} │"
echo "│ Umgebung: ${{ needs.prepare.outputs.target || 'skipped' }} │"
echo "│ Version: ${{ needs.prepare.outputs.image_tag }} │"
echo "│ Commit: ${{ needs.prepare.outputs.short_sha }} │"
echo "│ Message: ${{ needs.prepare.outputs.commit_msg }} │"
echo "└──────────────────────────────┘"
# ═══════════════════════════════════════════════════════════════════════════════
# NOTIFICATION: Gotify
# ═══════════════════════════════════════════════════════════════════════════════
- name: 🔔 Gotify Notification (Success)
if: success()
- name: 🔔 Gotify - Success
if: needs.deploy.result == 'success'
run: |
echo "Sending success notification to Gotify..."
RESPONSE=$(curl -k -s -w "\n%{http_code}" -X POST "${{ secrets.GOTIFY_URL }}/message?token=${{ secrets.GOTIFY_TOKEN }}" \
-F "title=✅ Deployment Success: ${{ github.repository }}" \
-F "message=The deployment of ${{ github.repository }} (branch: ${{ github.ref }}) was successful.
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
curl -s -k -X POST "${{ secrets.GOTIFY_URL }}/message?token=${{ secrets.GOTIFY_TOKEN }}" \
-F "title=${{ needs.prepare.outputs.gotify_title }}" \
-F "message=Erfolgreich deployt auf **${{ needs.prepare.outputs.target }}**\n\nVersion: **${{ needs.prepare.outputs.image_tag }}**\nCommit: ${{ needs.prepare.outputs.short_sha }} (${{ needs.prepare.outputs.commit_msg }})\nVon: ${{ github.actor }}\nRun: ${{ github.run_id }}" \
-F "priority=${{ needs.prepare.outputs.gotify_priority }}" || true
- name: 🔔 Gotify Notification (Failure)
if: failure()
- name: 🔔 Gotify - Failure
if: needs.deploy.result == 'failure' || needs.build.result == 'failure' || needs.qa.result == 'failure'
run: |
echo "Sending failure notification to Gotify..."
RESPONSE=$(curl -k -s -w "\n%{http_code}" -X POST "${{ secrets.GOTIFY_URL }}/message?token=${{ secrets.GOTIFY_TOKEN }}" \
-F "title=❌ Deployment Failed: ${{ github.repository }}" \
-F "message=The deployment of ${{ github.repository }} (branch: ${{ github.ref }}) failed!
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
curl -s -k -X POST "${{ secrets.GOTIFY_URL }}/message?token=${{ secrets.GOTIFY_TOKEN }}" \
-F "title=❌ Deployment FEHLGESCHLAGEN ${{ needs.prepare.outputs.target || 'unknown' }}" \
-F "message=**Fehler beim Deploy auf ${{ needs.prepare.outputs.target }}**\n\nVersion: ${{ needs.prepare.outputs.image_tag || '?' }}\nCommit: ${{ needs.prepare.outputs.short_sha || '?' }}\nVon: ${{ github.actor }}\nRun: ${{ github.run_id }}\n\nBitte Logs prüfen!" \
-F "priority=8" || true

7
.gitignore vendored
View File

@@ -1,2 +1,7 @@
node_modules
.next
.next
.DS_Store
# Directus
directus/uploads
!directus/extensions/

View File

@@ -27,10 +27,12 @@ ENV NEXT_TELEMETRY_DISABLED=1
ARG NEXT_PUBLIC_BASE_URL
ARG NEXT_PUBLIC_UMAMI_WEBSITE_ID
ARG NEXT_PUBLIC_UMAMI_SCRIPT_URL
ARG DIRECTUS_URL
ENV NEXT_PUBLIC_BASE_URL=$NEXT_PUBLIC_BASE_URL
ENV NEXT_PUBLIC_UMAMI_WEBSITE_ID=$NEXT_PUBLIC_UMAMI_WEBSITE_ID
ENV NEXT_PUBLIC_UMAMI_SCRIPT_URL=$NEXT_PUBLIC_UMAMI_SCRIPT_URL
ENV DIRECTUS_URL=$DIRECTUS_URL
# Validate environment variables during build
RUN npx tsx scripts/validate-env.ts

View File

@@ -29,6 +29,35 @@ npm run export
# Or run development server
npm run dev
### 🏗️ CMS (Strapi)
The CMS runs in Docker. Use the following npm scripts for local development:
```bash
# Start Strapi and its database
npm run cms:dev
# View logs
npm run cms:logs
# Stop the CMS
npm run cms:stop
```
Once running, you can access the Strapi admin panel at `http://localhost:1337/admin`.
### 🔄 Data & Migration
To sync data or migrate existing content:
```bash
# Export local data
npm run cms:export -- my-data.tar.gz
# Import data
npm run cms:import -- my-data.tar.gz
# Migrate existing MDX data to Strapi
npm run cms:migrate
```
### Environment Variables
@@ -73,7 +102,8 @@ NEXT_PUBLIC_SENTRY_DSN=https://PUBLIC_KEY@errors.infra.mintel.me/PROJECT_ID
- **Framework**: Next.js 14 (App Router)
- **Language**: TypeScript
- **Styling**: SCSS
- **Data**: Static JSON (WordPress export)
- **CMS**: Strapi (Source of Truth)
- **Data**: Static JSON (WordPress export) & Strapi API
- **Email**: Resend
- **Analytics**: Vercel (consent-based)
- **CAPTCHA**: Cloudflare Turnstile
@@ -103,7 +133,7 @@ app/
├── api/
│ └── contact/route.ts # Contact API
├── sitemap.ts # Sitemap generator
── robots.ts # Robots.txt generator
── robots.ts # Robots.txt generator
lib/
├── data.ts # Data access
@@ -114,7 +144,7 @@ components/
├── LocaleSwitcher.tsx # Language switcher
├── ContactForm.tsx # Contact form
├── CookieConsent.tsx # GDPR banner
── SEO.tsx # SEO utilities
── SEO.tsx # SEO utilities
data/
├── raw/ # WordPress export
@@ -222,21 +252,30 @@ GET /robots.txt
### 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`
2. **Push**: Image pushed to `registry.infra.mintel.me`
3. **Deploy**: SSH to production server, pull and restart containers
1. **Build**: Docker image built for `linux/arm64` with branch-specific build args
2. **Push**: Image pushed to `registry.infra.mintel.me` with commit SHA tag
3. **Deploy**: SSH to production server, pull and restart containers using branch-specific `.env` files
**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):
- `REGISTRY_USER` - Docker registry username
- `REGISTRY_PASS` - Docker registry password
- `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_SCRIPT_URL` - Analytics script URL
- `SENTRY_DSN` - Error tracking DSN
- `MAIL_HOST`, `MAIL_PORT`, `MAIL_USERNAME`, `MAIL_PASSWORD`, `MAIL_FROM`, `MAIL_RECIPIENTS` - Email configuration
### Manual Deployment

View File

@@ -1,6 +1,7 @@
import { ImageResponse } from 'next/og';
import { getPageBySlug } from '@/lib/pages';
import { OGImageTemplate } from '@/components/OGImageTemplate';
import { getOgFonts, OG_IMAGE_SIZE } from '@/lib/og-helper';
export const runtime = 'nodejs';
@@ -8,11 +9,11 @@ export default async function Image({ params: { locale, slug } }: { params: { lo
const pageData = await getPageBySlug(slug, locale);
if (!pageData) {
return new ImageResponse(
<div style={{ display: 'flex', width: '100%', height: '100%', backgroundColor: '#001a4d' }} />
);
return new Response('Page not found', { status: 404 });
}
const fonts = await getOgFonts();
return new ImageResponse(
(
<OGImageTemplate
@@ -22,8 +23,9 @@ export default async function Image({ params: { locale, slug } }: { params: { lo
/>
),
{
width: 1200,
height: 630,
...OG_IMAGE_SIZE,
fonts,
}
);
}

View File

@@ -3,6 +3,7 @@ import { getProductBySlug } from '@/lib/mdx';
import { getTranslations } from 'next-intl/server';
import { OGImageTemplate } from '@/components/OGImageTemplate';
import { NextRequest } from 'next/server';
import { getOgFonts, OG_IMAGE_SIZE } from '@/lib/og-helper';
export const runtime = 'nodejs';
@@ -10,7 +11,7 @@ export async function GET(
request: NextRequest,
{ params }: { params: { locale: string } }
) {
const { searchParams } = new URL(request.url);
const { searchParams, origin } = new URL(request.url);
const slug = searchParams.get('slug');
const locale = params.locale || 'en';
@@ -18,6 +19,7 @@ export async function GET(
return new Response('Missing slug', { status: 400 });
}
const fonts = await getOgFonts();
const t = await getTranslations({ locale, namespace: 'Products' });
// Check if it's a category page
@@ -36,8 +38,8 @@ export async function GET(
/>
),
{
width: 1200,
height: 630,
...OG_IMAGE_SIZE,
fonts,
}
);
}
@@ -45,16 +47,13 @@ export async function GET(
const product = await getProductBySlug(slug, locale);
if (!product) {
return new ImageResponse(
<div style={{ display: 'flex', width: '100%', height: '100%', backgroundColor: '#001a4d' }} />
);
return new Response('Product not found', { status: 404 });
}
const { origin } = new URL(request.url);
const featuredImage = product.frontmatter.images?.[0]
? (product.frontmatter.images[0].startsWith('http')
? product.frontmatter.images[0]
: `${origin}${product.frontmatter.images[0]}`)
? product.frontmatter.images[0]
: `${origin}${product.frontmatter.images[0]}`)
: undefined;
return new ImageResponse(
@@ -67,8 +66,9 @@ export async function GET(
/>
),
{
width: 1200,
height: 630,
...OG_IMAGE_SIZE,
fonts,
}
);
}

View File

@@ -1,6 +1,7 @@
import { ImageResponse } from 'next/og';
import { getPostBySlug } from '@/lib/blog';
import { OGImageTemplate } from '@/components/OGImageTemplate';
import { getOgFonts, OG_IMAGE_SIZE } from '@/lib/og-helper';
export const runtime = 'nodejs';
@@ -8,15 +9,19 @@ export default async function Image({ params: { locale, slug } }: { params: { lo
const post = await getPostBySlug(slug, locale);
if (!post) {
return new ImageResponse(
<div style={{ display: 'flex', width: '100%', height: '100%', backgroundColor: '#001a4d' }} />
);
return new Response('Post not found', { status: 404 });
}
const fonts = await getOgFonts();
// We don't have request.url here, but we can assume the domain from SITE_URL or config
// For local images during dev, relative paths in <img> might not work in Satori
// but if we are in nodejs runtime, we could potentially read from disk.
// For now, let's just make sure it's absolute.
const featuredImage = post.frontmatter.featuredImage
? (post.frontmatter.featuredImage.startsWith('http')
? post.frontmatter.featuredImage
: `https://klz-cables.com${post.frontmatter.featuredImage}`)
? post.frontmatter.featuredImage
: `https://klz-cables.com${post.frontmatter.featuredImage}`)
: undefined;
return new ImageResponse(
@@ -29,8 +34,9 @@ export default async function Image({ params: { locale, slug } }: { params: { lo
/>
),
{
width: 1200,
height: 630,
...OG_IMAGE_SIZE,
fonts,
}
);
}

View File

@@ -1,25 +1,25 @@
import { ImageResponse } from 'next/og';
import { getTranslations } from 'next-intl/server';
import { OGImageTemplate } from '@/components/OGImageTemplate';
import { getOgFonts, OG_IMAGE_SIZE } from '@/lib/og-helper';
export const runtime = 'nodejs';
export default async function Image({ params: { locale } }: { params: { locale: string } }) {
const t = await getTranslations({ locale, namespace: 'Blog.meta' });
const title = t('title');
const description = t('description');
const fonts = await getOgFonts();
return new ImageResponse(
(
<OGImageTemplate
title={title}
description={description}
title={t('title')}
description={t('description')}
label="Blog"
/>
),
{
width: 1200,
height: 630,
...OG_IMAGE_SIZE,
fonts,
}
);
}

View File

@@ -1,11 +1,14 @@
import { ImageResponse } from 'next/og';
import { getTranslations } from 'next-intl/server';
import { OGImageTemplate } from '@/components/OGImageTemplate';
import { getOgFonts, OG_IMAGE_SIZE } from '@/lib/og-helper';
export const runtime = 'nodejs';
export default async function Image({ params: { locale } }: { params: { locale: string } }) {
const t = await getTranslations({ locale, namespace: 'Contact' });
const fonts = await getOgFonts();
const title = t('meta.title') || t('title');
const description = t('meta.description') || t('subtitle');
@@ -18,8 +21,8 @@ export default async function Image({ params: { locale } }: { params: { locale:
/>
),
{
width: 1200,
height: 630,
...OG_IMAGE_SIZE,
fonts,
}
);
}

View File

@@ -1,11 +1,13 @@
import { ImageResponse } from 'next/og';
import { getTranslations } from 'next-intl/server';
import { OGImageTemplate } from '@/components/OGImageTemplate';
import { getOgFonts, OG_IMAGE_SIZE } from '@/lib/og-helper';
export const runtime = 'nodejs';
export default async function Image({ params: { locale } }: { params: { locale: string } }) {
const t = await getTranslations({ locale, namespace: 'Index.meta' });
const fonts = await getOgFonts();
return new ImageResponse(
(
@@ -16,8 +18,9 @@ export default async function Image({ params: { locale } }: { params: { locale:
/>
),
{
width: 1200,
height: 630,
...OG_IMAGE_SIZE,
fonts,
}
);
}

View File

@@ -5,6 +5,7 @@ import ProductSidebar from '@/components/ProductSidebar';
import ProductTabs from '@/components/ProductTabs';
import ProductTechnicalData from '@/components/ProductTechnicalData';
import RelatedProducts from '@/components/RelatedProducts';
import DatasheetDownload from '@/components/DatasheetDownload';
import { Badge, Container, Heading, Section } from '@/components/ui';
import { getDatasheetPath } from '@/lib/datasheets';
import { getAllProducts, getProductBySlug } from '@/lib/mdx';
@@ -362,6 +363,19 @@ export default async function ProductPage({ params }: ProductPageProps) {
<MDXRemote source={processedContent} components={productComponents} />
</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 */}
<JsonLd
id={`jsonld-${product.slug}`}

View File

@@ -1,83 +1,29 @@
import { ImageResponse } from 'next/og';
import { getProductBySlug } from '@/lib/mdx';
import { getTranslations } from 'next-intl/server';
import { OGImageTemplate } from '@/components/OGImageTemplate';
import { getOgFonts, OG_IMAGE_SIZE } from '@/lib/og-helper';
export const runtime = 'nodejs';
export default async function Image({ params: { locale, slug } }: { params: { locale: string, slug?: string[] } }) {
const t = await getTranslations('Products');
export default async function Image({ params: { locale } }: { params: { locale: string } }) {
const t = await getTranslations({ locale, namespace: 'Products' });
const fonts = await getOgFonts();
// If no slug, it's the main products page
if (!slug || slug.length === 0) {
const title = t('meta.title') || t('title');
const description = t('meta.description') || t('subtitle');
return new ImageResponse(
(
<OGImageTemplate
title={title}
description={description}
label="Products"
/>
),
{
width: 1200,
height: 630,
}
);
}
const productSlug = slug[slug.length - 1];
// Check if it's a category page
const categories = ['low-voltage-cables', 'medium-voltage-cables', 'high-voltage-cables', 'solar-cables'];
if (categories.includes(productSlug)) {
const categoryKey = productSlug.replace(/-cables$/, '').replace(/-([a-z])/g, (g) => g[1].toUpperCase());
const categoryTitle = t.has(`categories.${categoryKey}.title`) ? t(`categories.${categoryKey}.title`) : productSlug;
const categoryDesc = t.has(`categories.${categoryKey}.description`) ? t(`categories.${categoryKey}.description`) : '';
return new ImageResponse(
(
<OGImageTemplate
title={categoryTitle}
description={categoryDesc}
label="Product Category"
/>
),
{
width: 1200,
height: 630,
}
);
}
const product = await getProductBySlug(productSlug, locale);
if (!product) {
return new ImageResponse(
<div style={{ display: 'flex', width: '100%', height: '100%', backgroundColor: '#001a4d' }} />
);
}
const featuredImage = product.frontmatter.images?.[0]
? (product.frontmatter.images[0].startsWith('http')
? product.frontmatter.images[0]
: `https://klz-cables.com${product.frontmatter.images[0]}`)
: undefined;
const title = t('meta.title') || t('title');
const description = t('meta.description') || t('subtitle');
return new ImageResponse(
(
<OGImageTemplate
title={product.frontmatter.title}
description={product.frontmatter.description}
label={product.frontmatter.categories?.[0] || 'Product'}
image={featuredImage}
title={title}
description={description}
label="Products"
/>
),
{
width: 1200,
height: 630,
...OG_IMAGE_SIZE,
fonts,
}
);
}

View File

@@ -1,11 +1,14 @@
import { ImageResponse } from 'next/og';
import { getTranslations } from 'next-intl/server';
import { OGImageTemplate } from '@/components/OGImageTemplate';
import { getOgFonts, OG_IMAGE_SIZE } from '@/lib/og-helper';
export const runtime = 'nodejs';
export default async function Image({ params: { locale } }: { params: { locale: string } }) {
const t = await getTranslations({ locale, namespace: 'Team' });
const fonts = await getOgFonts();
const title = t('meta.title') || t('hero.subtitle');
const description = t('meta.description') || t('hero.title');
@@ -15,12 +18,12 @@ export default async function Image({ params: { locale } }: { params: { locale:
title={title}
description={description}
label="Our Team"
image="https://klz-cables.com/uploads/2024/12/DSC07655-Large.webp"
/>
),
{
width: 1200,
height: 630,
...OG_IMAGE_SIZE,
fonts,
}
);
}

View File

@@ -1,5 +1,7 @@
"use server";
import client, { ensureAuthenticated } from "@/lib/directus";
import { createItem } from "@directus/sdk";
import { sendEmail } from "@/lib/mail/mailer";
import ContactEmail from "@/components/emails/ContactEmail";
import React from "react";
@@ -18,10 +20,34 @@ export async function sendContactFormAction(formData: FormData) {
return { success: false, error: "Missing required fields" };
}
// 1. Save to Directus
try {
await ensureAuthenticated();
if (productName) {
await client.request(createItem('product_requests', {
product_name: productName,
email,
message
}));
logger.info('Product request stored in Directus');
} else {
await client.request(createItem('contact_submissions', {
name,
email,
message
}));
logger.info('Contact submission stored in Directus');
}
} catch (error) {
logger.error('Failed to store submission in Directus', { error });
// We continue anyway to try sending the email, but maybe we should report this
}
// 2. Send Email
logger.info('Sending contact form email', { email, productName });
const subject = productName
? `Product Inquiry: ${productName}`
? `Product Inquiry: ${productName}`
: "New Contact Form Submission";
const result = await sendEmail({

View File

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

View File

@@ -29,7 +29,7 @@ export function OGImageTemplate({
backgroundColor: mode === 'light' ? '#ffffff' : primaryBlue,
padding: '80px',
position: 'relative',
fontFamily: 'Inter, sans-serif',
fontFamily: 'Inter',
};
return (
@@ -39,7 +39,10 @@ export function OGImageTemplate({
<div
style={{
position: 'absolute',
inset: 0,
top: 0,
left: 0,
right: 0,
bottom: 0,
display: 'flex',
}}
>
@@ -57,23 +60,26 @@ export function OGImageTemplate({
<div
style={{
position: 'absolute',
inset: 0,
background: 'linear-gradient(to right, rgba(0,26,77,0.9) 0%, rgba(0,26,77,0.4) 100%)',
top: 0,
left: 0,
right: 0,
bottom: 0,
background: 'linear-gradient(to right, rgba(0,26,77,0.95), rgba(0,26,77,0.6))',
}}
/>
</div>
)}
{/* Decorative Scribble Circle (Simplified for Satori) */}
{/* Decorative Brand Accent (Top Right) */}
<div
style={{
position: 'absolute',
top: '-100px',
right: '-100px',
top: '-150px',
right: '-150px',
width: '600px',
height: '600px',
borderRadius: '50%',
background: `radial-gradient(circle, ${accentGreen}1a 0%, transparent 70%)`,
borderRadius: '300px',
backgroundColor: `${accentGreen}15`,
display: 'flex',
}}
/>
@@ -84,11 +90,11 @@ export function OGImageTemplate({
<div
style={{
fontSize: '24px',
fontWeight: 'bold',
fontWeight: 700,
color: accentGreen,
textTransform: 'uppercase',
letterSpacing: '0.2em',
marginBottom: '24px',
letterSpacing: '0.3em',
marginBottom: '32px',
display: 'flex',
}}
>
@@ -99,13 +105,14 @@ export function OGImageTemplate({
{/* Title */}
<div
style={{
fontSize: '72px',
fontWeight: '900',
fontSize: title.length > 40 ? '64px' : '82px',
fontWeight: 700,
color: 'white',
lineHeight: '1.1',
maxWidth: '900px',
marginBottom: '32px',
lineHeight: '1.05',
maxWidth: '950px',
marginBottom: '40px',
display: 'flex',
letterSpacing: '-0.02em',
}}
>
{title}
@@ -116,13 +123,14 @@ export function OGImageTemplate({
<div
style={{
fontSize: '32px',
color: 'rgba(255,255,255,0.8)',
maxWidth: '800px',
color: 'rgba(255,255,255,0.7)',
maxWidth: '850px',
lineHeight: '1.4',
display: 'flex',
fontWeight: 400,
}}
>
{description}
{description.length > 160 ? description.substring(0, 157) + '...' : description}
</div>
)}
</div>
@@ -139,33 +147,34 @@ export function OGImageTemplate({
>
<div
style={{
width: '120px',
height: '8px',
width: '80px',
height: '6px',
backgroundColor: accentGreen,
borderRadius: '4px',
borderRadius: '3px',
marginRight: '24px',
}}
/>
<div
style={{
fontSize: '24px',
fontWeight: 'bold',
fontWeight: 700,
color: 'white',
textTransform: 'uppercase',
letterSpacing: '0.1em',
letterSpacing: '0.15em',
display: 'flex',
}}
>
KLZ Cables
</div>
</div>
{/* Saturated Blue Accent */}
{/* Saturated Blue Brand Strip */}
<div
style={{
position: 'absolute',
top: 0,
right: 0,
width: '10px',
width: '12px',
height: '100%',
backgroundColor: saturatedBlue,
}}
@@ -173,3 +182,4 @@ export function OGImageTemplate({
</div>
);
}

View File

@@ -3,6 +3,7 @@
import Image from 'next/image';
import { useTranslations } from 'next-intl';
import RequestQuoteForm from '@/components/RequestQuoteForm';
import DatasheetDownload from '@/components/DatasheetDownload';
import Scribble from '@/components/Scribble';
import { cn } from '@/components/ui/utils';
@@ -64,33 +65,7 @@ export default function ProductSidebar({ productName, productImage, datasheetPat
{/* Datasheet Download */}
{datasheetPath && (
<a
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>
<DatasheetDownload datasheetPath={datasheetPath} className="mt-0" />
)}
</div>
);

View File

@@ -1,4 +1,27 @@
services:
app:
env_file:
- .env
image: node:20-alpine
working_dir: /app
command: sh -c "npm install && npx next dev"
volumes:
- .:/app
environment:
NODE_ENV: development
# Docker Internal Communication
DIRECTUS_URL: http://directus:8055
labels:
- "traefik.enable=true"
- "traefik.http.routers.klz-app-local.rule=Host(`klz.localhost`)"
- "traefik.http.routers.klz-app-local.entrypoints=web"
- "traefik.http.routers.klz-app-local.service=klz-cables"
directus:
labels:
- "traefik.enable=true"
- "traefik.http.routers.klz-directus-local.rule=Host(`cms.klz.localhost`)"
- "traefik.http.routers.klz-directus-local.entrypoints=web"
- "traefik.http.routers.klz-directus-local.service=klz-directus"
ports:
- "8055:8055"
environment:
PUBLIC_URL: http://cms.klz.localhost

View File

@@ -1,21 +1,19 @@
services:
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
networks:
- infra
ports:
- "3000:3000"
env_file:
- .env
- ${ENV_FILE:-.env}
labels:
- "traefik.enable=true"
# 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.middlewares=redirect-https"
# 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.tls.certresolver=le"
- "traefik.http.routers.klz-cables.tls=true"
@@ -28,6 +26,54 @@ services:
# Middlewares
- "traefik.http.routers.klz-cables.middlewares=klz-forward,compress"
directus:
image: directus/directus:11
restart: always
networks:
- infra
env_file:
- ${ENV_FILE:-.env}
environment:
KEY: ${DIRECTUS_KEY}
SECRET: ${DIRECTUS_SECRET}
ADMIN_EMAIL: ${DIRECTUS_ADMIN_EMAIL}
ADMIN_PASSWORD: ${DIRECTUS_ADMIN_PASSWORD}
DB_CLIENT: 'pg'
DB_HOST: 'directus-db'
DB_PORT: '5432'
DB_DATABASE: ${DIRECTUS_DB_NAME:-directus}
DB_USER: ${DIRECTUS_DB_USER:-directus}
DB_PASSWORD: ${DIRECTUS_DB_PASSWORD:-directus}
WEBSOCKETS_ENABLED: 'true'
PUBLIC_URL: ${DIRECTUS_URL:-https://cms.klz-cables.com}
volumes:
- ./directus/uploads:/directus/uploads
- ./directus/extensions:/directus/extensions
labels:
- "traefik.enable=true"
- "traefik.http.routers.klz-directus.rule=Host(`cms.klz-cables.com`) || Host(`cms-staging.klz-cables.com`)"
- "traefik.http.routers.klz-directus.entrypoints=websecure"
- "traefik.http.routers.klz-directus.tls.certresolver=le"
- "traefik.http.routers.klz-directus.tls=true"
- "traefik.http.services.klz-directus.loadbalancer.server.port=8055"
directus-db:
image: postgres:15-alpine
restart: always
networks:
- infra
env_file:
- ${ENV_FILE:-.env}
environment:
POSTGRES_DB: ${DIRECTUS_DB_NAME:-directus}
POSTGRES_USER: ${DIRECTUS_DB_USER:-directus}
POSTGRES_PASSWORD: ${DIRECTUS_DB_PASSWORD:-directus}
volumes:
- directus-db-data:/var/lib/postgresql/data
networks:
infra:
external: true
volumes:
directus-db-data:

81
docker-compose.yml.bak Normal file
View File

@@ -0,0 +1,81 @@
services:
app:
image: registry.infra.mintel.me/mintel/klz-cables.com:latest
restart: always
networks:
- infra
env_file:
- .env
labels:
- "traefik.enable=true"
# 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.entrypoints=web"
- "traefik.http.routers.klz-cables-web.middlewares=redirect-https"
# 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.entrypoints=websecure"
- "traefik.http.routers.klz-cables.tls.certresolver=le"
- "traefik.http.routers.klz-cables.tls=true"
- "traefik.http.routers.klz-cables.service=klz-cables"
- "traefik.http.services.klz-cables.loadbalancer.server.port=3000"
- "traefik.http.services.klz-cables.loadbalancer.server.scheme=http"
# Forwarded Headers
- "traefik.http.middlewares.klz-forward.headers.customrequestheaders.X-Forwarded-Proto=https"
- "traefik.http.middlewares.klz-forward.headers.customrequestheaders.X-Forwarded-Ssl=on"
# Middlewares
- "traefik.http.routers.klz-cables.middlewares=klz-forward,compress"
cms:
build:
context: ./cms
dockerfile: Dockerfile
restart: always
networks:
- infra
env_file:
- .env
environment:
DATABASE_CLIENT: postgres
DATABASE_HOST: cms-db
DATABASE_PORT: 5432
DATABASE_NAME: ${STRAPI_DATABASE_NAME:-strapi}
DATABASE_USERNAME: ${STRAPI_DATABASE_USERNAME:-strapi}
DATABASE_PASSWORD: ${STRAPI_DATABASE_PASSWORD:-strapi}
NODE_ENV: ${NODE_ENV:-development}
STRAPI_URL: ${STRAPI_URL:-https://cms.klz-cables.com}
volumes:
- ./cms/config:/opt/app/config
- ./cms/src:/opt/app/src
- ./cms/package.json:/opt/app/package.json
- ./cms/package-lock.json:/opt/app/package-lock.json
- ./cms/public/uploads:/opt/app/public/uploads
- ./cms/dist:/opt/app/dist
labels:
- "traefik.enable=true"
- "traefik.http.routers.klz-cms.rule=Host(`cms.klz-cables.com`) || Host(`cms-staging.klz-cables.com`)"
- "traefik.http.routers.klz-cms.entrypoints=websecure"
- "traefik.http.routers.klz-cms.tls.certresolver=le"
- "traefik.http.routers.klz-cms.tls=true"
- "traefik.http.services.klz-cms.loadbalancer.server.port=1337"
cms-db:
image: postgres:16-alpine
restart: always
networks:
- infra
env_file:
- .env
environment:
POSTGRES_DB: ${STRAPI_DATABASE_NAME:-strapi}
POSTGRES_USER: ${STRAPI_DATABASE_USERNAME:-strapi}
POSTGRES_PASSWORD: ${STRAPI_DATABASE_PASSWORD:-strapi}
volumes:
- cms-db-data:/var/lib/postgresql/data
networks:
infra:
external: true
volumes:
cms-db-data:

View File

@@ -70,6 +70,15 @@ These are loaded from the `.env` file at runtime and are only available on the s
| `MAIL_RECIPIENTS` | ❌ No | Comma-separated list of recipient emails |
| `REDIS_URL` | ❌ No | Redis connection URL (e.g., `redis://redis:6379/2`) |
| `REDIS_KEY_PREFIX` | ❌ No | Redis key prefix (default: `klz:`) |
| `STRAPI_DATABASE_NAME` | ✅ Yes | Strapi database name |
| `STRAPI_DATABASE_USERNAME` | ✅ Yes | Strapi database username |
| `STRAPI_DATABASE_PASSWORD` | ✅ Yes | Strapi database password |
| `STRAPI_URL` | ✅ Yes | URL of the Strapi CMS |
| `APP_KEYS` | ✅ Yes | Strapi application keys (comma-separated) |
| `API_TOKEN_SALT` | ✅ Yes | Strapi API token salt |
| `ADMIN_JWT_SECRET` | ✅ Yes | Strapi admin JWT secret |
| `TRANSFER_TOKEN_SALT` | ✅ Yes | Strapi transfer token salt |
| `JWT_SECRET` | ✅ Yes | Strapi JWT secret |
## Local Development

View File

@@ -57,6 +57,13 @@ function createConfig() {
from: env.MAIL_FROM,
recipients: env.MAIL_RECIPIENTS,
},
directus: {
url: env.DIRECTUS_URL,
adminEmail: env.DIRECTUS_ADMIN_EMAIL,
password: env.DIRECTUS_ADMIN_PASSWORD,
token: env.DIRECTUS_API_TOKEN,
proxyPath: '/cms',
},
} as const;
}
@@ -86,6 +93,7 @@ export const config = {
get cache() { return getConfig().cache; },
get logging() { return getConfig().logging; },
get mail() { return getConfig().mail; },
get directus() { return getConfig().directus; },
};
/**
@@ -124,5 +132,11 @@ export function getMaskedConfig() {
from: c.mail.from,
recipients: c.mail.recipients,
},
directus: {
url: c.directus.url,
adminEmail: mask(c.directus.adminEmail),
password: mask(c.directus.password),
token: mask(c.directus.token),
},
};
}

View File

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

92
lib/directus.ts Normal file
View File

@@ -0,0 +1,92 @@
import { createDirectus, rest, authentication, readItems } from '@directus/sdk';
import { config } from './config';
const { url, adminEmail, password, token, proxyPath } = config.directus;
const client = createDirectus(url)
.with(rest())
.with(authentication());
export async function ensureAuthenticated() {
if (token) {
client.setToken(token);
return;
}
if (adminEmail && password) {
try {
await client.login(adminEmail, password);
} catch (e) {
console.error("Failed to authenticate with Directus:", e);
}
}
}
/**
* Maps the new translation-based schema back to the application's Product interface
*/
function mapDirectusProduct(item: any, locale: string): any {
const langCode = locale === 'en' ? 'en-US' : 'de-DE';
const translation = item.translations?.find((t: any) => t.languages_code === langCode) || item.translations?.[0] || {};
return {
id: item.id,
sku: item.sku,
title: translation.name || '',
description: translation.description || '',
content: translation.content || '',
technicalData: {
technicalItems: translation.technical_items || [],
voltageTables: translation.voltage_tables || []
},
locale: locale,
// Use proxy URL for assets to avoid CORS and handle internal/external issues
data_sheet_url: item.data_sheet ? `${proxyPath}/assets/${item.data_sheet}` : null,
categories: (item.categories_link || []).map((c: any) => c.categories_id?.translations?.[0]?.name).filter(Boolean)
};
}
export async function getProducts(locale: string = 'de') {
await ensureAuthenticated();
try {
const items = await client.request(readItems('products', {
fields: [
'*',
'translations.*',
'categories_link.categories_id.translations.name'
]
}));
return items.map(item => mapDirectusProduct(item, locale));
} catch (error) {
console.error('Error fetching products:', error);
return [];
}
}
export async function getProductBySlug(slug: string, locale: string = 'de') {
await ensureAuthenticated();
const langCode = locale === 'en' ? 'en-US' : 'de-DE';
try {
const items = await client.request(readItems('products', {
filter: {
translations: {
slug: { _eq: slug },
languages_code: { _eq: langCode }
}
},
fields: [
'*',
'translations.*',
'categories_link.categories_id.translations.name'
],
limit: 1
}));
if (!items || items.length === 0) return null;
return mapDirectusProduct(items[0], locale);
} catch (error) {
console.error(`Error fetching product ${slug}:`, error);
return null;
}
}
export default client;

View File

@@ -11,17 +11,17 @@ const preprocessEmptyString = (val: unknown) => (val === '' ? undefined : val);
export const envSchema = z.object({
NODE_ENV: z.enum(['development', 'production', 'test']).default('development'),
NEXT_PUBLIC_BASE_URL: z.preprocess(preprocessEmptyString, z.string().url()),
// Analytics
NEXT_PUBLIC_UMAMI_WEBSITE_ID: z.preprocess(preprocessEmptyString, z.string().optional()),
NEXT_PUBLIC_UMAMI_SCRIPT_URL: z.preprocess(preprocessEmptyString, z.string().url().default('https://analytics.infra.mintel.me/script.js')),
// Error Tracking
SENTRY_DSN: z.preprocess(preprocessEmptyString, z.string().optional()),
// Logging
LOG_LEVEL: z.enum(['debug', 'info', 'warn', 'error']).default('info'),
// Mail
MAIL_HOST: z.preprocess(preprocessEmptyString, z.string().optional()),
MAIL_PORT: z.preprocess(preprocessEmptyString, z.coerce.number().default(587)),
@@ -32,6 +32,12 @@ export const envSchema = z.object({
(val) => (typeof val === 'string' ? val.split(',').filter(Boolean) : val),
z.array(z.string()).default([])
),
// Directus
DIRECTUS_URL: z.preprocess(preprocessEmptyString, z.string().url().default('http://localhost:8055')),
DIRECTUS_ADMIN_EMAIL: z.preprocess(preprocessEmptyString, z.string().optional()),
DIRECTUS_ADMIN_PASSWORD: z.preprocess(preprocessEmptyString, z.string().optional()),
DIRECTUS_API_TOKEN: z.preprocess(preprocessEmptyString, z.string().optional()),
});
export type Env = z.infer<typeof envSchema>;
@@ -54,5 +60,9 @@ export function getRawEnv() {
MAIL_PASSWORD: process.env.MAIL_PASSWORD,
MAIL_FROM: process.env.MAIL_FROM,
MAIL_RECIPIENTS: process.env.MAIL_RECIPIENTS,
DIRECTUS_URL: process.env.DIRECTUS_URL,
DIRECTUS_ADMIN_EMAIL: process.env.DIRECTUS_ADMIN_EMAIL,
DIRECTUS_ADMIN_PASSWORD: process.env.DIRECTUS_ADMIN_PASSWORD,
DIRECTUS_API_TOKEN: process.env.DIRECTUS_API_TOKEN,
};
}

View File

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

42
lib/og-helper.tsx Normal file
View File

@@ -0,0 +1,42 @@
import { readFileSync } from 'fs';
import { join } from 'path';
/**
* Loads the Inter fonts for use in Satori (Next.js OG Image generation).
* Since we are using runtime = 'nodejs', we can read them from the filesystem.
*/
export async function getOgFonts() {
const boldFontPath = join(process.cwd(), 'public/fonts/Inter-Bold.ttf');
const regularFontPath = join(process.cwd(), 'public/fonts/Inter-Regular.ttf');
try {
const boldFont = readFileSync(boldFontPath);
const regularFont = readFileSync(regularFontPath);
return [
{
name: 'Inter',
data: boldFont,
weight: 700 as const,
style: 'normal' as const,
},
{
name: 'Inter',
data: regularFont,
weight: 400 as const,
style: 'normal' as const,
},
];
} catch (error) {
console.error('Failed to load OG fonts from filesystem, falling back to system fonts:', error);
return [];
}
}
/**
* Common configuration for OG images
*/
export const OG_IMAGE_SIZE = {
width: 1200,
height: 630,
};

View File

@@ -21,234 +21,149 @@ Font.register({
// Industrial/technical/restrained design - STYLEGUIDE.md compliant
const styles = StyleSheet.create({
page: {
// Large margins for engineering documentation feel.
// Extra bottom padding reserves space for the fixed footer so content
// (esp. long descriptions) doesn't render underneath it.
paddingTop: 72,
paddingLeft: 72,
paddingRight: 72,
paddingBottom: 140,
color: '#111827', // Text Primary
lineHeight: 1.5,
backgroundColor: '#FFFFFF',
paddingTop: 0,
paddingBottom: 100,
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: {
flexDirection: 'row',
justifyContent: 'space-between',
alignItems: 'flex-start',
marginBottom: 48, // Large spacing
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',
alignItems: 'center',
marginBottom: 16,
},
logoText: {
fontSize: 20,
fontSize: 24,
fontWeight: 700,
color: '#0E2A47', // Dark navy
color: '#000d26',
letterSpacing: 1,
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: {
fontSize: 16,
fontSize: 10,
fontWeight: 700,
color: '#0E2A47', // Dark navy
marginBottom: 8,
letterSpacing: 0.5,
color: '#001a4d',
letterSpacing: 2,
textTransform: 'uppercase',
},
skuContainer: {
backgroundColor: '#E6E9ED', // Light gray background
paddingHorizontal: 16,
paddingVertical: 8,
border: '1px solid #E6E9ED',
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: '#e5e7eb',
backgroundColor: '#FFFFFF',
overflow: 'hidden',
},
skuLabel: {
fontSize: 8,
color: '#6B7280', // Medium gray
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',
// Product Hero Info
productHero: {
marginTop: 0,
},
productName: {
fontSize: 24,
fontWeight: 700,
color: '#0E2A47', // Dark navy
marginBottom: 12,
lineHeight: 1.2,
color: '#000d26',
marginBottom: 0,
textTransform: 'uppercase',
letterSpacing: 0.5,
letterSpacing: -0.5,
},
productMeta: {
fontSize: 12,
color: '#6B7280', // Medium gray
fontWeight: 500,
fontSize: 10,
color: '#4b5563',
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: {
marginBottom: 32,
backgroundColor: '#FFFFFF',
padding: 24,
border: '1px solid #E6E9ED',
marginBottom: 20,
},
sectionTitle: {
fontSize: 14,
fontWeight: 700,
color: '#0E2A47', // Dark navy
marginBottom: 16,
letterSpacing: 0.5,
color: '#000d26', // Primary Dark
marginBottom: 8,
textTransform: 'uppercase',
borderBottom: '1px solid #E6E9ED',
paddingBottom: 8,
letterSpacing: -0.2,
},
sectionAccent: {
width: 30,
height: 3,
backgroundColor: '#82ed20', // Accent Green
marginBottom: 8,
borderRadius: 1.5,
},
// Description - technical documentation style
description: {
fontSize: 10,
lineHeight: 1.6,
color: '#1F2933', // Dark gray text
marginBottom: 0,
fontSize: 11,
lineHeight: 1.7,
color: '#4b5563', // Text Secondary
},
// Cross-section table - engineering specification style
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)
// Technical data table
specsTable: {
borderWidth: 1,
borderColor: '#E6E9ED',
marginTop: 8,
border: '1px solid #e5e7eb',
borderRadius: 8,
overflow: 'hidden',
},
specsTableRow: {
flexDirection: 'row',
borderBottomWidth: 1,
borderBottomColor: '#E6E9ED',
borderBottomColor: '#e5e7eb',
},
specsTableRowLast: {
@@ -256,63 +171,35 @@ const styles = StyleSheet.create({
},
specsTableLabelCell: {
flex: 3,
paddingVertical: 8,
paddingHorizontal: 8,
backgroundColor: '#F8F9FA',
flex: 1,
paddingVertical: 4,
paddingHorizontal: 16,
backgroundColor: '#f8f9fa',
borderRightWidth: 1,
borderRightColor: '#E6E9ED',
justifyContent: 'center',
borderRightColor: '#e5e7eb',
},
specsTableValueCell: {
flex: 4,
paddingVertical: 8,
paddingHorizontal: 8,
justifyContent: 'center',
flex: 1,
paddingVertical: 4,
paddingHorizontal: 16,
},
specsTableLabelText: {
fontSize: 9,
fontWeight: 700,
color: '#0E2A47',
color: '#000d26',
textTransform: 'uppercase',
letterSpacing: 0.3,
lineHeight: 1.2,
letterSpacing: 0.5,
},
specsTableValueText: {
fontSize: 10,
color: '#1F2933',
lineHeight: 1.4,
color: '#111827',
fontWeight: 500,
},
specColumn: {
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',
flexWrap: 'wrap',
@@ -320,42 +207,48 @@ const styles = StyleSheet.create({
},
categoryTag: {
backgroundColor: '#E6E9ED',
backgroundColor: '#f8f9fa',
paddingHorizontal: 12,
paddingVertical: 6,
border: '1px solid #E6E9ED',
border: '1px solid #e5e7eb',
borderRadius: 100,
},
categoryText: {
fontSize: 9,
color: '#6B7280',
fontWeight: 500,
fontSize: 8,
color: '#4b5563',
fontWeight: 700,
textTransform: 'uppercase',
letterSpacing: 0.3,
letterSpacing: 0.5,
},
// Engineering documentation footer
// Footer
footer: {
position: 'absolute',
bottom: 48,
bottom: 40,
left: 72,
right: 72,
flexDirection: 'row',
justifyContent: 'space-between',
alignItems: 'center',
paddingTop: 24,
borderTop: '2px solid #E6E9ED',
fontSize: 9,
color: '#6B7280',
borderTop: '1px solid #e5e7eb',
},
footerLeft: {
footerText: {
fontSize: 8,
color: '#9ca3af',
fontWeight: 500,
textTransform: 'uppercase',
letterSpacing: 1,
},
footerBrand: {
fontSize: 10,
fontWeight: 700,
color: '#0E2A47',
},
footerRight: {
color: '#6B7280',
color: '#000d26',
textTransform: 'uppercase',
letterSpacing: 1,
},
});
@@ -364,6 +257,7 @@ interface ProductData {
name: string;
shortDescriptionHtml: string;
descriptionHtml: string;
applicationHtml?: string;
images: string[];
featuredImage: string | null;
sku: string;
@@ -411,106 +305,111 @@ const getLabels = (locale: 'en' | 'de') => {
export const PDFDatasheet: React.FC<PDFDatasheetProps> = ({
product,
locale,
logoUrl = '/media/logo.svg',
}) => {
const labels = getLabels(locale);
return (
<Document>
<Page size="A4" style={styles.page}>
{/* Clean, minimal header */}
<View style={styles.header}>
<View style={styles.logoArea}>
<View style={styles.logoContainer}>
{logoUrl ? (
/* 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>
)}
{/* Hero Header */}
<View style={styles.hero}>
<View style={styles.header}>
<View>
<Text style={styles.logoText}>KLZ</Text>
</View>
</View>
<View style={styles.docInfo}>
<Text style={styles.docTitle}>
{labels.productDatasheet}
</Text>
<View style={styles.skuContainer}>
<Text style={styles.skuLabel}>{labels.sku}</Text>
<Text style={styles.skuValue}>{product.sku}</Text>
</View>
</View>
</View>
{/* Product section - clean and prominent */}
<View style={styles.productSection}>
<Text style={styles.productName}>{product.name}</Text>
<Text style={styles.productMeta}>
{product.categories.map(cat => cat.name).join(' • ')}
</Text>
</View>
{/* 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(', ')}
<View style={styles.productRow}>
<View style={styles.productInfoCol}>
<View style={styles.productHero}>
<View style={styles.categories}>
{product.categories.map((cat, index) => (
<Text key={index} style={styles.productMeta}>
{cat.name}{index < product.categories.length - 1 ? ' • ' : ''}
</Text>
</View>
))}
</View>
))}
<Text style={styles.productName}>{product.name}</Text>
</View>
</View>
</View>
)}
<View style={styles.productImageCol}>
{product.featuredImage ? (
<Image
src={product.featuredImage}
style={styles.heroImage}
/>
) : (
{/* Categories as clean tags */}
{product.categories && product.categories.length > 0 && (
<View style={styles.section}>
<Text style={styles.sectionTitle}>{labels.categories}</Text>
<View style={styles.categories}>
{product.categories.map((cat, index) => (
<View key={index} style={styles.categoryTag}>
<Text style={styles.categoryText}>{cat.name}</Text>
</View>
))}
<Text style={styles.noImage}>{labels.noImage}</Text>
)}
</View>
</View>
)}
</View>
<View style={styles.content}>
{/* Description section */}
{(product.applicationHtml || product.shortDescriptionHtml || product.descriptionHtml) && (
<View style={styles.section}>
<Text style={styles.sectionTitle}>{labels.description}</Text>
<View style={styles.sectionAccent} />
<Text style={styles.description}>
{stripHtml(product.applicationHtml || 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.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 */}
<View style={styles.footer} fixed>
<Text style={styles.footerLeft}>
{labels.sku}: {product.sku}
</Text>
<Text style={styles.footerRight}>
<Text style={styles.footerBrand}>KLZ CABLES</Text>
<Text style={styles.footerText}>
{new Date().toLocaleDateString(locale === 'en' ? 'en-US' : 'de-DE', {
year: 'numeric',
month: 'long',

85
lib/strapi.ts Normal file
View File

@@ -0,0 +1,85 @@
import axios from 'axios';
const STRAPI_URL = process.env.STRAPI_URL || 'http://localhost:1337';
const STRAPI_TOKEN = process.env.STRAPI_API_TOKEN;
const strapi = axios.create({
baseURL: `${STRAPI_URL}/api`,
headers: {
Authorization: `Bearer ${STRAPI_TOKEN}`,
},
});
export interface StrapiResponse<T> {
data: {
id: number;
attributes: T;
}[];
meta: {
pagination: {
page: number;
pageSize: number;
pageCount: number;
total: number;
};
};
}
export interface Product {
title: string;
sku: string;
description: string;
application: string;
content: string;
technicalData: any;
locale: string;
images?: {
data: {
attributes: {
url: string;
alternativeText: string;
};
}[];
};
}
export async function getProducts(locale: string = 'de') {
try {
const response = await strapi.get<StrapiResponse<Product>>('/products', {
params: {
locale,
populate: '*',
},
});
return response.data.data.map(item => ({
id: item.id,
...item.attributes,
}));
} catch (error) {
console.error('Error fetching products from Strapi:', error);
return [];
}
}
export async function getProductBySku(sku: string, locale: string = 'de') {
try {
const response = await strapi.get<StrapiResponse<Product>>('/products', {
params: {
filters: { sku: { $eq: sku } },
locale,
populate: '*',
},
});
if (response.data.data.length === 0) return null;
const item = response.data.data[0];
return {
id: item.id,
...item.attributes,
};
} catch (error) {
console.error(`Error fetching product ${sku} from Strapi:`, error);
return null;
}
}
export default strapi;

View File

@@ -1,8 +1,8 @@
import createNextIntlPlugin from 'next-intl/plugin';
import { withSentryConfig } from '@sentry/nextjs';
const withNextIntl = createNextIntlPlugin();
/** @type {import('next').NextConfig} */
const nextConfig = {
output: 'standalone',
@@ -327,6 +327,8 @@ const nextConfig = {
? new URL(process.env.SENTRY_DSN).origin
: 'https://errors.infra.mintel.me';
const directusUrl = process.env.DIRECTUS_URL || 'https://cms.klz-cables.com';
return [
{
source: '/stats/:path*',
@@ -336,6 +338,10 @@ const nextConfig = {
source: '/errors/:path*',
destination: `${glitchtipUrl}/:path*`,
},
{
source: '/cms/:path*',
destination: `${directusUrl}/:path*`,
},
];
},
};

13
package-lock.json generated
View File

@@ -8,6 +8,7 @@
"name": "klz-cables-nextjs",
"version": "1.0.0",
"dependencies": {
"@directus/sdk": "^18.0.3",
"@react-email/components": "^1.0.6",
"@react-pdf/renderer": "^4.3.2",
"@sentry/nextjs": "^8.55.0",
@@ -1360,6 +1361,18 @@
"node": ">=18"
}
},
"node_modules/@directus/sdk": {
"version": "18.0.3",
"resolved": "https://registry.npmjs.org/@directus/sdk/-/sdk-18.0.3.tgz",
"integrity": "sha512-PnEDRDqr2x/DG3HZ3qxU7nFp2nW6zqJqswjii57NhriXgTz4TBUI8NmSdzQvnyHuTL9J0nedYfQGfW4v8odS1A==",
"license": "MIT",
"engines": {
"node": ">=18.0.0"
},
"funding": {
"url": "https://github.com/directus/directus?sponsor=1"
}
},
"node_modules/@emnapi/core": {
"version": "1.7.1",
"resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.7.1.tgz",

View File

@@ -1,5 +1,6 @@
{
"dependencies": {
"@directus/sdk": "^18.0.3",
"@react-email/components": "^1.0.6",
"@react-pdf/renderer": "^4.3.2",
"@sentry/nextjs": "^8.55.0",
@@ -57,15 +58,17 @@
"name": "klz-cables-nextjs",
"private": true,
"scripts": {
"dev": "next dev",
"dev": "docker network create infra 2>/dev/null || true && echo '\\n🚀 Development Environment Starting...\\n\\n📱 App: http://klz.localhost\\n🗄 CMS: http://cms.klz.localhost/admin\\n🚦 Traefik: http://localhost:8080\\n\\n(Press Ctrl+C to stop)\\n' && docker-compose down --remove-orphans && docker-compose up",
"dev:local": "next dev",
"build": "next build",
"start": "next start",
"lint": "next lint",
"typecheck": "tsc --noEmit",
"test": "vitest run --passWithNoTests",
"test:og": "vitest run tests/og-image.test.ts",
"bootstrap:cms": "DIRECTUS_URL=http://localhost:8055 npx tsx --env-file=.env scripts/setup-directus-branding.ts",
"pdf:datasheets": "tsx ./scripts/generate-pdf-datasheets.ts",
"pdf:datasheets:legacy": "tsx ./scripts/generate-pdf-datasheets-pdf-lib.ts"
},
"version": "1.0.0"
}
}

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

1447
public/fonts/Inter-Bold.ttf Normal file

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View File

@@ -0,0 +1,19 @@
import client, { ensureAuthenticated } from '../lib/directus';
import { readCollections, deleteCollection } from '@directus/sdk';
async function cleanup() {
await ensureAuthenticated();
const collections = await (client as any).request(readCollections());
for (const c of collections) {
if (!c.collection.startsWith('directus_')) {
console.log(`Deleting ${c.collection}...`);
try {
await (client as any).request(deleteCollection(c.collection));
} catch (e) {
console.error(`Failed to delete ${c.collection}`);
}
}
}
}
cleanup().catch(console.error);

99
scripts/fix-schema.ts Normal file
View File

@@ -0,0 +1,99 @@
import client, { ensureAuthenticated } from '../lib/directus';
import {
createCollection,
createField,
createItem,
readCollections,
deleteCollection
} from '@directus/sdk';
async function fixSchema() {
console.log('🚑 EXTERNAL RESCUE: Fixing Schema & Data...');
await ensureAuthenticated();
// 1. Reset Products Collection to be 100% Standard
console.log('🗑️ Clearing broken collections...');
try { await client.request(deleteCollection('products')); } catch (e) { }
try { await client.request(deleteCollection('products_translations')); } catch (e) { }
// 2. Create Products (Simple, Standard ID)
console.log('🏗️ Rebuilding Products Schema...');
await client.request(createCollection({
collection: 'products',
schema: {}, // Let Directus decide defaults
meta: {
display_template: '{{sku}}',
archive_field: 'status',
archive_value: 'archived',
unarchive_value: 'published'
},
fields: [
{
field: 'id',
type: 'integer',
schema: { is_primary_key: true, has_auto_increment: true },
meta: { hidden: true }
},
{
field: 'status',
type: 'string',
schema: { default_value: 'published' },
meta: { width: 'full', options: { choices: [{ text: 'Published', value: 'published' }] } }
},
{
field: 'sku',
type: 'string',
meta: { interface: 'input', width: 'half' }
}
]
} as any));
// 3. Create Translation Relation Safely
console.log('🌍 Rebuilding Translations...');
await client.request(createCollection({
collection: 'products_translations',
schema: {},
fields: [
{
field: 'id',
type: 'integer',
schema: { is_primary_key: true, has_auto_increment: true },
meta: { hidden: true }
},
{ field: 'products_id', type: 'integer' },
{ field: 'languages_code', type: 'string' },
{ field: 'name', type: 'string', meta: { interface: 'input', width: 'full' } },
{ field: 'description', type: 'text', meta: { interface: 'input-multiline' } },
{ field: 'technical_items', type: 'json', meta: { interface: 'input-code-json' } }
]
} as any));
// 4. Manually Insert ONE Product to Verify
console.log('📦 Injecting Test Product...');
try {
// We do this in two steps to be absolutely sure permissions don't block us
// Step A: Create User-Facing Product
const product = await client.request(createItem('products', {
sku: 'H1Z2Z2-K-TEST',
status: 'published'
}));
// Step B: Add Translation
await client.request(createItem('products_translations', {
products_id: product.id,
languages_code: 'de-DE',
name: 'H1Z2Z2-K Test Cable',
description: 'This is a verified imported product.',
technical_items: [{ label: 'Test', value: '100%' }]
}));
console.log(`✅ SUCCESS! Product Created with ID: ${product.id}`);
console.log(`verify at: ${process.env.DIRECTUS_URL}/admin/content/products/${product.id}`);
} catch (e: any) {
console.error('❌ Failed to create product:', e);
if (e.errors) console.error(JSON.stringify(e.errors, null, 2));
}
}
fixSchema().catch(console.error);

View File

@@ -39,6 +39,7 @@ type MdxProduct = {
categories: string[];
images: string[];
descriptionHtml: string;
applicationHtml: string;
};
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 descriptionHtml = extractDescriptionFromMdxFrontmatter(data);
const applicationHtml = normalizeValue(String(data?.application || ''));
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;
@@ -183,6 +185,7 @@ async function loadProductsFromExcelAndMdx(locale: 'en' | 'de'): Promise<Product
name: title,
shortDescriptionHtml: '',
descriptionHtml,
applicationHtml: mdx?.applicationHtml || '',
images: mdx?.images || [],
featuredImage: (mdx?.images && mdx.images[0]) || null,
sku: mdx?.sku || title,

175
scripts/migrate-data.ts Normal file
View File

@@ -0,0 +1,175 @@
import client, { ensureAuthenticated } from '../lib/directus';
import {
createCollection,
createField,
createRelation,
uploadFiles,
createItem,
updateSettings,
readFolders,
createFolder
} from '@directus/sdk';
import fs from 'fs';
import path from 'path';
import matter from 'gray-matter';
import { fileURLToPath } from 'url';
const __filename = fileURLToPath(import.meta.url);
const __dirname = path.dirname(__filename);
async function run() {
console.log('🚀 CLEAN SLATE MIGRATION 🚀');
await ensureAuthenticated();
// 1. Folders
console.log('📂 Creating Folders...');
const folders: any = {};
const folderNames = ['Products', 'Blog', 'Pages', 'Technical'];
for (const name of folderNames) {
try {
const res = await client.request(createFolder({ name }));
folders[name] = res.id;
} catch (e) {
const existing = await client.request(readFolders({ filter: { name: { _eq: name } } }));
folders[name] = existing[0].id;
}
}
// 2. Assets
const assetMap: Record<string, string> = {};
const uploadDir = async (dir: string, folderId: string) => {
if (!fs.existsSync(dir)) return;
const files = fs.readdirSync(dir, { withFileTypes: true });
for (const file of files) {
const fullPath = path.join(dir, file.name);
if (file.isDirectory()) {
await uploadDir(fullPath, folderId);
} else {
const relPath = '/' + path.relative(path.join(process.cwd(), 'public'), fullPath).split(path.sep).join('/');
try {
const form = new FormData();
form.append('folder', folderId);
form.append('file', new Blob([fs.readFileSync(fullPath)]), file.name);
const res = await client.request(uploadFiles(form));
assetMap[relPath] = res.id;
console.log(`✅ Asset: ${relPath}`);
} catch (e) { }
}
}
};
await uploadDir(path.join(process.cwd(), 'public/uploads'), folders.Products);
// 3. Collections (Minimalist)
const collections = [
'categories', 'products', 'posts', 'pages', 'globals',
'categories_translations', 'products_translations', 'posts_translations', 'pages_translations', 'globals_translations',
'categories_link'
];
console.log('🏗️ Creating Collections...');
for (const name of collections) {
try {
const isSingleton = name === 'globals';
await client.request(createCollection({
collection: name,
schema: {},
meta: { singleton: isSingleton }
} as any));
// Add ID field
await client.request(createField(name, {
field: 'id',
type: 'integer',
meta: { hidden: true },
schema: { is_primary_key: true, has_auto_increment: name !== 'globals' }
}));
console.log(`✅ Collection: ${name}`);
} catch (e: any) {
console.log(` Collection ${name} exists or error: ${e.message}`);
}
}
// 4. Fields & Relations
console.log('🔧 Configuring Schema...');
const safeAdd = async (col: string, f: any) => { try { await client.request(createField(col, f)); } catch (e) { } };
// Products
await safeAdd('products', { field: 'sku', type: 'string' });
await safeAdd('products', { field: 'image', type: 'uuid', meta: { interface: 'file' } });
// Translations Generic
for (const col of ['categories', 'products', 'posts', 'pages', 'globals']) {
const transTable = `${col}_translations`;
await safeAdd(transTable, { field: `${col}_id`, type: 'integer' });
await safeAdd(transTable, { field: 'languages_code', type: 'string' });
// Link to Parent
try {
await client.request(createRelation({
collection: transTable,
field: `${col}_id`,
related_collection: col,
meta: { one_field: 'translations' }
}));
} catch (e) { }
}
// Specific Fields
await safeAdd('products_translations', { field: 'name', type: 'string' });
await safeAdd('products_translations', { field: 'slug', type: 'string' });
await safeAdd('products_translations', { field: 'description', type: 'text' });
await safeAdd('products_translations', { field: 'content', type: 'text', meta: { interface: 'input-rich-text-html' } });
await safeAdd('products_translations', { field: 'technical_items', type: 'json' });
await safeAdd('products_translations', { field: 'voltage_tables', type: 'json' });
await safeAdd('categories_translations', { field: 'name', type: 'string' });
await safeAdd('posts_translations', { field: 'title', type: 'string' });
await safeAdd('posts_translations', { field: 'slug', type: 'string' });
await safeAdd('posts_translations', { field: 'content', type: 'text' });
await safeAdd('globals', { field: 'company_name', type: 'string' });
await safeAdd('globals_translations', { field: 'tagline', type: 'string' });
// M2M Link
await safeAdd('categories_link', { field: 'products_id', type: 'integer' });
await safeAdd('categories_link', { field: 'categories_id', type: 'integer' });
try {
await client.request(createRelation({ collection: 'categories_link', field: 'products_id', related_collection: 'products', meta: { one_field: 'categories_link' } }));
await client.request(createRelation({ collection: 'categories_link', field: 'categories_id', related_collection: 'categories' }));
} catch (e) { }
// 5. Data Import
console.log('📥 Importing Data...');
const deDir = path.join(process.cwd(), 'data/products/de');
const files = fs.readdirSync(deDir).filter(f => f.endsWith('.mdx'));
for (const file of files) {
const doc = matter(fs.readFileSync(path.join(deDir, file), 'utf8'));
const enPath = path.join(process.cwd(), `data/products/en/${file}`);
const enDoc = fs.existsSync(enPath) ? matter(fs.readFileSync(enPath, 'utf8')) : doc;
const clean = (c: string) => c.replace(/<ProductTabs.*?>|<\/ProductTabs>|<ProductTechnicalData.*?\/>/gs, '').trim();
const extract = (c: string) => {
const m = c.match(/technicalData=\{<ProductTechnicalData data=\{(.*?)\}\s*\/>\}/s);
try { return m ? JSON.parse(m[1]) : {}; } catch (e) { return {}; }
};
try {
await client.request(createItem('products', {
sku: doc.data.sku,
image: assetMap[doc.data.images?.[0]] || null,
translations: [
{ languages_code: 'de-DE', name: doc.data.title, slug: file.replace('.mdx', ''), description: doc.data.description, content: clean(doc.content), technical_items: extract(doc.content).technicalItems, voltage_tables: extract(doc.content).voltageTables },
{ languages_code: 'en-US', name: enDoc.data.title, slug: file.replace('.mdx', ''), description: enDoc.data.description, content: clean(enDoc.content), technical_items: extract(enDoc.content).technicalItems, voltage_tables: extract(enDoc.content).voltageTables }
]
}));
console.log(`✅ Product: ${doc.data.sku}`);
} catch (e: any) {
console.error(`❌ Product ${file}: ${e.message}`);
}
}
console.log('✨ DONE!');
}
run().catch(console.error);

View File

@@ -0,0 +1,64 @@
import * as fs from 'fs';
import * as path from 'path';
import matter from 'gray-matter';
import axios from 'axios';
const STRAPI_URL = process.env.STRAPI_URL || 'http://localhost:1337';
const STRAPI_TOKEN = process.env.STRAPI_ADMIN_TOKEN; // You'll need to generate this
async function migrateProducts() {
const productsDir = path.join(process.cwd(), 'data/products');
const locales = ['de', 'en'];
for (const locale of locales) {
const localeDir = path.join(productsDir, locale);
if (!fs.existsSync(localeDir)) continue;
const files = fs.readdirSync(localeDir).filter(f => f.endsWith('.mdx'));
for (const file of files) {
const filePath = path.join(localeDir, file);
const fileContent = fs.readFileSync(filePath, 'utf8');
const { data, content } = matter(fileContent);
console.log(`Migrating ${data.title} (${locale})...`);
try {
// 1. Check if product exists (by SKU)
const existing = await axios.get(`${STRAPI_URL}/api/products?filters[sku][$eq]=${data.sku}&locale=${locale}`, {
headers: { Authorization: `Bearer ${STRAPI_TOKEN}` }
});
const productData = {
title: data.title,
sku: data.sku,
description: data.description,
application: data.application,
content: content,
technicalData: data.technicalData || {}, // This might need adjustment based on how it's stored in MDX
locale: locale,
};
if (existing.data.data.length > 0) {
// Update
const id = existing.data.data[0].id;
await axios.put(`${STRAPI_URL}/api/products/${id}`, { data: productData }, {
headers: { Authorization: `Bearer ${STRAPI_TOKEN}` }
});
console.log(`Updated ${data.title}`);
} else {
// Create
await axios.post(`${STRAPI_URL}/api/products`, { data: productData }, {
headers: { Authorization: `Bearer ${STRAPI_TOKEN}` }
});
console.log(`Created ${data.title}`);
}
} catch (error) {
console.error(`Error migrating ${data.title}:`, error.response?.data || error.message);
}
}
}
}
// Note: This script requires a running Strapi instance and an admin token.
// migrateProducts();

View File

@@ -0,0 +1,63 @@
import client, { ensureAuthenticated } from '../lib/directus';
import {
updateSettings,
updateCollection,
createItem,
updateItem
} from '@directus/sdk';
import fs from 'fs';
import path from 'path';
async function optimize() {
await ensureAuthenticated();
console.log('🎨 Fixing Branding...');
await client.request(updateSettings({
project_name: 'KLZ Cables',
public_note: '<div style="text-align: center;"><h1>Sustainable Energy.</h1><p>Industrial Reliability.</p></div>',
custom_css: 'body { font-family: Inter, sans-serif !important; } .public-view .v-card { border-radius: 20px !important; }'
}));
console.log('🔧 Fixing List Displays...');
const collections = ['products', 'categories', 'posts', 'pages'];
for (const collection of collections) {
try {
await (client as any).request(updateCollection(collection, {
meta: { display_template: '{{translations.name || translations.title}}' }
}));
} catch (e) {
console.error(`Failed to update ${collection}:`, e);
}
}
console.log('🏛️ Force-Syncing Globals...');
const de = JSON.parse(fs.readFileSync(path.join(process.cwd(), 'messages/de.json'), 'utf8'));
const en = JSON.parse(fs.readFileSync(path.join(process.cwd(), 'messages/en.json'), 'utf8'));
const payload = {
id: 1,
company_name: 'KLZ Cables GmbH',
email: 'info@klz-cables.com',
phone: '+49 711 1234567',
address: de.Contact.info.address,
opening_hours: `${de.Contact.hours.weekdays}: ${de.Contact.hours.weekdaysTime}`,
translations: [
{ languages_code: 'en-US', tagline: en.Footer.tagline },
{ languages_code: 'de-DE', tagline: de.Footer.tagline }
]
};
try {
await client.request(createItem('globals', payload));
} catch (e) {
try {
await client.request(updateItem('globals', 1, payload));
} catch (err) {
console.error('Globals still failing:', (err as any).message);
}
}
console.log('✅ Optimization complete.');
}
optimize().catch(console.error);

View File

@@ -1129,7 +1129,7 @@ function buildMediumVoltageCrossSectionTableFromNewExcel(args: {
export function buildDatasheetModel(args: { product: ProductData; locale: 'en' | 'de' }): DatasheetModel {
const labels = getLabels(args.locale);
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 productUrl = getProductUrl(args.product);
@@ -1173,22 +1173,71 @@ export function buildDatasheetModel(args: { product: ProductData; locale: 'en' |
productUrl,
},
labels,
technicalItems: [
...(excelModel.ok ? excelModel.technicalItems : []),
...(isMediumVoltageProduct(args.product)
? args.locale === 'de'
? [
{ label: 'Prüfspannung 6/10 kV', value: '21 kV' },
{ label: 'Prüfspannung 12/20 kV', value: '42 kV' },
{ label: 'Prüfspannung 18/30 kV', value: '63 kV' },
]
: [
{ label: 'Test voltage 6/10 kV', value: '21 kV' },
{ label: 'Test voltage 12/20 kV', value: '42 kV' },
{ label: 'Test voltage 18/30 kV', value: '63 kV' },
]
: []),
],
technicalItems: (() => {
if (!isMediumVoltageProduct(args.product)) {
return excelModel.ok ? excelModel.technicalItems : [];
}
const pn = normalizeDesignation(args.product.name || '');
const isAl = /^NA/.test(pn);
const isFL = pn.includes('FL');
const isF = !isFL && pn.includes('F');
const findExcelVal = (labelPart: string) => {
const found = excelModel.technicalItems.find(it => it.label.toLowerCase().includes(labelPart.toLowerCase()));
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,
legendItems: crossSectionModel.legendItems || [],
};

View File

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

View File

@@ -26,56 +26,62 @@ export function DatasheetDocument(props: { model: DatasheetModel; assets: Assets
return (
<Document>
<Page size="A4" style={styles.page}>
<Header title={headerTitle} logoDataUrl={assets.logoDataUrl} qrDataUrl={assets.qrDataUrl} />
<Footer locale={model.locale} siteUrl={CONFIG.siteUrl} />
<Text style={styles.h1}>{model.product.name}</Text>
{model.product.categoriesLine ? <Text style={styles.subhead}>{model.product.categoriesLine}</Text> : null}
<View style={styles.heroBox}>
{assets.heroDataUrl ? (
<Image src={assets.heroDataUrl} style={styles.heroImage} />
) : (
<Text style={styles.noImage}>{model.labels.noImage}</Text>
)}
<View style={styles.hero}>
<Header title={headerTitle} logoDataUrl={assets.logoDataUrl} qrDataUrl={assets.qrDataUrl} isHero={true} />
<View style={styles.productRow}>
<View style={styles.productInfoCol}>
<View style={styles.productHero}>
{model.product.categoriesLine ? <Text style={styles.productMeta}>{model.product.categoriesLine}</Text> : null}
<Text style={styles.productName}>{model.product.name}</Text>
</View>
</View>
<View style={styles.productImageCol}>
{assets.heroDataUrl ? (
<Image src={assets.heroDataUrl} style={styles.heroImage} />
) : (
<Text style={styles.noImage}>{model.labels.noImage}</Text>
)}
</View>
</View>
</View>
{model.product.descriptionText ? (
<Section title={model.labels.description} minPresenceAhead={24}>
<Text style={styles.body}>{model.product.descriptionText}</Text>
</Section>
) : null}
<Footer locale={model.locale} siteUrl={CONFIG.siteUrl} />
{model.technicalItems.length ? (
<Section title={model.labels.technicalData} minPresenceAhead={24}>
<KeyValueGrid items={model.technicalItems} />
</Section>
) : null}
<View style={styles.content}>
{model.product.descriptionText ? (
<Section title={model.labels.description} minPresenceAhead={24}>
<Text style={styles.body}>{model.product.descriptionText}</Text>
</Section>
) : null}
{model.technicalItems.length ? (
<Section title={model.labels.technicalData} minPresenceAhead={24}>
<KeyValueGrid items={model.technicalItems} />
</Section>
) : null}
</View>
</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}>
<Header title={headerTitle} logoDataUrl={assets.logoDataUrl} qrDataUrl={assets.qrDataUrl} />
<Footer locale={model.locale} siteUrl={CONFIG.siteUrl} />
{model.voltageTables.map((t: DatasheetVoltageTable) => (
<View key={t.voltageLabel} style={{ marginBottom: 14 }} break={false} minPresenceAhead={24}>
<Text style={styles.sectionTitle}>{`${model.labels.crossSection}${t.voltageLabel}`}</Text>
<View style={styles.content}>
{model.voltageTables.map((t: DatasheetVoltageTable) => (
<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} />
</View>
))}
{model.legendItems.length ? (
<Section title={model.locale === 'de' ? 'ABKÜRZUNGEN' : 'ABBREVIATIONS'} minPresenceAhead={24}>
<KeyValueGrid items={model.legendItems} />
</Section>
) : null}
{model.legendItems.length ? (
<Section title={model.locale === 'de' ? 'ABKÜRZUNGEN' : 'ABBREVIATIONS'} minPresenceAhead={24}>
<KeyValueGrid items={model.legendItems} />
</Section>
) : null}
</View>
</Page>
</Document>
);

View File

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

View File

@@ -3,17 +3,16 @@ import { Image, Text, View } from '@react-pdf/renderer';
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 (
<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}>
{props.logoDataUrl ? (
<Image src={props.logoDataUrl} style={styles.logo} />
) : (
<View style={styles.brandFallback}>
<Text style={styles.brandFallbackKlz}>KLZ</Text>
<Text style={styles.brandFallbackCables}>Cables</Text>
</View>
<Text style={styles.brandFallback}>KLZ</Text>
)}
</View>
<View style={styles.headerRight}>

View File

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

View File

@@ -11,8 +11,9 @@ export function Section(props: {
}): React.ReactElement {
const boxed = props.boxed ?? true;
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>
<View style={styles.sectionAccent} />
{props.children}
</View>
);

View File

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

208
scripts/revert-and-clean.ts Normal file
View File

@@ -0,0 +1,208 @@
import client, { ensureAuthenticated } from '../lib/directus';
import {
deleteCollection,
deleteFile,
readFiles,
updateSettings,
uploadFiles
} from '@directus/sdk';
import fs from 'fs';
import path from 'path';
import { fileURLToPath } from 'url';
// Helper for ESM __dirname
const __filename = fileURLToPath(import.meta.url);
const __dirname = path.dirname(__filename);
async function revertAndRestoreBranding() {
console.log('🚨 REVERTING EVERYTHING - RESTORING BRANDING ONLY 🚨');
await ensureAuthenticated();
// 1. DELETE ALL COLLECTIONS
const collectionsToDelete = [
'categories_link',
'categories_translations', 'categories',
'products_translations', 'products',
'posts_translations', 'posts',
'pages_translations', 'pages',
'globals_translations', 'globals'
];
console.log('🗑️ Deleting custom collections...');
for (const col of collectionsToDelete) {
try {
await client.request(deleteCollection(col));
console.log(`✅ Deleted collection: ${col}`);
} catch (e: any) {
console.log(` Collection ${col} not found or already deleted.`);
}
}
// 2. DELETE ALL FILES
console.log('🗑️ Deleting ALL files...');
try {
const files = await client.request(readFiles({ limit: -1 }));
if (files && files.length > 0) {
const ids = files.map(f => f.id);
await client.request(deleteFile(ids)); // Batch delete if supported by SDK version, else loop
console.log(`✅ Deleted ${ids.length} files.`);
} else {
console.log(' No files to delete.');
}
} catch (e: any) {
// Fallback to loop if batch fails
try {
const files = await client.request(readFiles({ limit: -1 }));
for (const f of files) {
await client.request(deleteFile(f.id));
}
console.log(`✅ Deleted files individually.`);
} catch (err) { }
}
// 3. RESTORE BRANDING (Exact copy of setup-directus-branding.ts logic)
console.log('🎨 Restoring Premium Branding...');
try {
const getMimeType = (filePath: string) => {
const ext = path.extname(filePath).toLowerCase();
switch (ext) {
case '.svg': return 'image/svg+xml';
case '.png': return 'image/png';
case '.jpg':
case '.jpeg': return 'image/jpeg';
case '.ico': return 'image/x-icon';
default: return 'application/octet-stream';
}
};
const uploadAsset = async (filePath: string, title: string) => {
if (!fs.existsSync(filePath)) {
console.warn(`⚠️ File not found: ${filePath}`);
return null;
}
const mimeType = getMimeType(filePath);
const form = new FormData();
const fileBuffer = fs.readFileSync(filePath);
const blob = new Blob([fileBuffer], { type: mimeType });
form.append('file', blob, path.basename(filePath));
form.append('title', title);
const res = await client.request(uploadFiles(form));
return res.id;
};
const logoWhiteId = await uploadAsset(path.resolve(__dirname, '../public/logo-white.svg'), 'Logo White');
const logoBlueId = await uploadAsset(path.resolve(__dirname, '../public/logo-blue.svg'), 'Logo Blue');
const faviconId = await uploadAsset(path.resolve(__dirname, '../public/favicon.ico'), 'Favicon');
// Smoother Background SVG
const bgSvgPath = path.resolve(__dirname, '../public/login-bg.svg');
fs.writeFileSync(bgSvgPath, `<svg width="1920" height="1080" viewBox="0 0 1920 1080" fill="none" xmlns="http://www.w3.org/2000/svg">
<rect width="1920" height="1080" fill="#001a4d"/>
<ellipse cx="960" cy="540" rx="800" ry="600" fill="url(#paint0_radial_premium)"/>
<defs>
<radialGradient id="paint0_radial_premium" cx="0" cy="0" r="1" gradientUnits="userSpaceOnUse" gradientTransform="translate(960 540) rotate(90) scale(600 800)">
<stop stop-color="#003d82" stop-opacity="0.8"/>
<stop offset="1" stop-color="#001a4d" stop-opacity="0"/>
</radialGradient>
</defs>
</svg>`);
const backgroundId = await uploadAsset(bgSvgPath, 'Login Bg');
if (fs.existsSync(bgSvgPath)) fs.unlinkSync(bgSvgPath);
// Update Settings
const COLOR_PRIMARY = '#001a4d';
const COLOR_ACCENT = '#82ed20';
const COLOR_SECONDARY = '#003d82';
const cssInjection = `
<style>
@import url('https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&display=swap');
body, .v-app {
font-family: 'Inter', sans-serif !important;
-webkit-font-smoothing: antialiased;
}
.public-view .v-card {
background: rgba(255, 255, 255, 0.95) !important;
backdrop-filter: blur(20px);
border: 1px solid rgba(255, 255, 255, 0.3) !important;
border-radius: 32px !important;
box-shadow: 0 50px 100px -20px rgba(0, 0, 0, 0.4) !important;
padding: 40px !important;
}
.public-view .v-button {
border-radius: 9999px !important;
height: 56px !important;
font-weight: 600 !important;
letter-spacing: -0.01em !important;
transition: all 0.4s cubic-bezier(0.16, 1, 0.3, 1) !important;
}
.public-view .v-button:hover {
transform: translateY(-2px);
box-shadow: 0 15px 30px rgba(130, 237, 32, 0.2) !important;
}
.public-view .v-input {
--v-input-border-radius: 12px !important;
--v-input-background-color: #f8f9fa !important;
}
</style>
<div style="font-family: 'Inter', sans-serif; text-align: center; margin-top: 24px;">
<p style="color: rgba(255,255,255,0.7); font-size: 14px; margin-bottom: 4px; font-weight: 500;">KLZ INFRASTRUCTURE ENGINE</p>
<h1 style="color: #ffffff; font-size: 18px; font-weight: 700; margin: 0;">Sustainable Energy. <span style="color: #82ed20;">Industrial Reliability.</span></h1>
</div>
`;
await client.request(updateSettings({
project_name: 'KLZ Cables',
project_url: 'https://klz-cables.com',
project_color: COLOR_ACCENT,
project_descriptor: 'Sustainable Energy Infrastructure',
project_owner: 'KLZ Cables',
project_logo: logoWhiteId as any,
public_foreground: logoWhiteId as any,
public_background: backgroundId as any,
public_note: cssInjection,
public_favicon: faviconId as any,
theme_light_overrides: {
"primary": COLOR_ACCENT,
"secondary": COLOR_SECONDARY,
"background": "#f1f3f7",
"backgroundNormal": "#ffffff",
"backgroundAccent": "#eef2ff",
"navigationBackground": COLOR_PRIMARY,
"navigationForeground": "#ffffff",
"navigationBackgroundHover": "rgba(255,255,255,0.05)",
"navigationForegroundHover": "#ffffff",
"navigationBackgroundActive": "rgba(130, 237, 32, 0.15)",
"navigationForegroundActive": COLOR_ACCENT,
"moduleBarBackground": "#000d26",
"moduleBarForeground": "#ffffff",
"moduleBarForegroundActive": COLOR_ACCENT,
"borderRadius": "16px",
"borderWidth": "1px",
"borderColor": "#e2e8f0",
"formFieldHeight": "48px"
} as any,
theme_dark_overrides: {
"primary": COLOR_ACCENT,
"background": "#0a0a0a",
"navigationBackground": "#000000",
"moduleBarBackground": COLOR_PRIMARY,
"borderRadius": "16px",
"formFieldHeight": "48px"
} as any
}));
console.log('✨ System Cleaned & Branding Restored Successfully');
} catch (error: any) {
console.error('❌ Error restoring branding:', JSON.stringify(error, null, 2));
}
}
revertAndRestoreBranding().catch(console.error);

View File

@@ -0,0 +1,181 @@
import client, { ensureAuthenticated } from '../lib/directus';
import { updateSettings, uploadFiles } from '@directus/sdk';
import fs from 'fs';
import path from 'path';
import { fileURLToPath } from 'url';
// Helper for ESM __dirname
const __filename = fileURLToPath(import.meta.url);
const __dirname = path.dirname(__filename);
async function setupBranding() {
console.log('🎨 Refining Directus Branding for Premium Website Look...');
// 1. Authenticate
await ensureAuthenticated();
try {
// 2. Upload Assets (MIME FIXED)
console.log('📤 Re-uploading assets for clean IDs...');
const getMimeType = (filePath: string) => {
const ext = path.extname(filePath).toLowerCase();
switch (ext) {
case '.svg': return 'image/svg+xml';
case '.png': return 'image/png';
case '.jpg':
case '.jpeg': return 'image/jpeg';
case '.ico': return 'image/x-icon';
default: return 'application/octet-stream';
}
};
const uploadAsset = async (filePath: string, title: string) => {
if (!fs.existsSync(filePath)) {
console.warn(`⚠️ File not found: ${filePath}`);
return null;
}
const mimeType = getMimeType(filePath);
const form = new FormData();
const fileBuffer = fs.readFileSync(filePath);
const blob = new Blob([fileBuffer], { type: mimeType });
form.append('file', blob, path.basename(filePath));
form.append('title', title);
const res = await client.request(uploadFiles(form));
return res.id;
};
const logoWhiteId = await uploadAsset(path.resolve(__dirname, '../public/logo-white.svg'), 'Logo White');
const logoBlueId = await uploadAsset(path.resolve(__dirname, '../public/logo-blue.svg'), 'Logo Blue');
const faviconId = await uploadAsset(path.resolve(__dirname, '../public/favicon.ico'), 'Favicon');
// Smoother Background SVG
const bgSvgPath = path.resolve(__dirname, '../public/login-bg.svg');
fs.writeFileSync(bgSvgPath, `<svg width="1920" height="1080" viewBox="0 0 1920 1080" fill="none" xmlns="http://www.w3.org/2000/svg">
<rect width="1920" height="1080" fill="#001a4d"/>
<ellipse cx="960" cy="540" rx="800" ry="600" fill="url(#paint0_radial_premium)"/>
<defs>
<radialGradient id="paint0_radial_premium" cx="0" cy="0" r="1" gradientUnits="userSpaceOnUse" gradientTransform="translate(960 540) rotate(90) scale(600 800)">
<stop stop-color="#003d82" stop-opacity="0.8"/>
<stop offset="1" stop-color="#001a4d" stop-opacity="0"/>
</radialGradient>
</defs>
</svg>`);
const backgroundId = await uploadAsset(bgSvgPath, 'Login Bg');
if (fs.existsSync(bgSvgPath)) fs.unlinkSync(bgSvgPath);
// 3. Update Settings with "Premium Web" Theme
console.log('⚙️ Updating Directus settings...');
const COLOR_PRIMARY = '#001a4d'; // Deep Blue
const COLOR_ACCENT = '#82ed20'; // Sustainability Green
const COLOR_SECONDARY = '#003d82';
const cssInjection = `
<style>
@import url('https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&display=swap');
/* Global Login Styles */
body, .v-app {
font-family: 'Inter', sans-serif !important;
-webkit-font-smoothing: antialiased;
}
/* Glassmorphism Effect for Login Card */
.public-view .v-card {
background: rgba(255, 255, 255, 0.95) !important;
backdrop-filter: blur(20px);
border: 1px solid rgba(255, 255, 255, 0.3) !important;
border-radius: 32px !important;
box-shadow: 0 50px 100px -20px rgba(0, 0, 0, 0.4) !important;
padding: 40px !important;
}
.public-view .v-button {
border-radius: full !important;
height: 56px !important;
font-weight: 600 !important;
letter-spacing: -0.01em !important;
transition: all 0.4s cubic-bezier(0.16, 1, 0.3, 1) !important;
}
.public-view .v-button:hover {
transform: translateY(-2px);
box-shadow: 0 15px 30px rgba(130, 237, 32, 0.2) !important;
}
.public-view .v-input {
--v-input-border-radius: 12px !important;
--v-input-background-color: #f8f9fa !important;
}
</style>
<div style="font-family: 'Inter', sans-serif; text-align: center; margin-top: 24px;">
<p style="color: rgba(255,255,255,0.7); font-size: 14px; margin-bottom: 4px; font-weight: 500;">KLZ INFRASTRUCTURE ENGINE</p>
<h1 style="color: #ffffff; font-size: 18px; font-weight: 700; margin: 0;">Sustainable Energy. <span style="color: #82ed20;">Industrial Reliability.</span></h1>
</div>
`;
await client.request(updateSettings({
project_name: 'KLZ Cables',
project_url: 'https://klz-cables.com',
project_color: COLOR_ACCENT,
project_descriptor: 'Sustainable Energy Infrastructure',
project_owner: 'KLZ Cables',
// FIXED: Use WHITE logo for the Blue Sidebar
project_logo: logoWhiteId as any,
public_foreground: logoWhiteId as any,
public_background: backgroundId as any,
public_note: cssInjection,
public_favicon: faviconId as any,
// DEEP PREMIUM THEME
theme_light_overrides: {
// Brands
"primary": COLOR_ACCENT, // Buttons/Actions are GREEN like the website
"secondary": COLOR_SECONDARY,
// Content Area
"background": "#f1f3f7",
"backgroundNormal": "#ffffff",
"backgroundAccent": "#eef2ff",
// Sidebar Branding
"navigationBackground": COLOR_PRIMARY,
"navigationForeground": "#ffffff",
"navigationBackgroundHover": "rgba(255,255,255,0.05)",
"navigationForegroundHover": "#ffffff",
"navigationBackgroundActive": "rgba(130, 237, 32, 0.15)", // Subtle Green highlight
"navigationForegroundActive": COLOR_ACCENT, // Active item is GREEN
// Module Bar (Thin far left)
"moduleBarBackground": "#000d26",
"moduleBarForeground": "#ffffff",
"moduleBarForegroundActive": COLOR_ACCENT,
// UI Standards
"borderRadius": "16px", // Larger radius for modern feel
"borderWidth": "1px",
"borderColor": "#e2e8f0",
"formFieldHeight": "48px" // Touch-target height
} as any,
theme_dark_overrides: {
"primary": COLOR_ACCENT,
"background": "#0a0a0a",
"navigationBackground": "#000000",
"moduleBarBackground": COLOR_PRIMARY,
"borderRadius": "16px",
"formFieldHeight": "48px"
} as any
}));
console.log('✨ Premium Theme applied successfully!');
} catch (error: any) {
console.error('❌ Error:', JSON.stringify(error, null, 2));
}
}
setupBranding();

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