Compare commits
42 Commits
b18ee8d7a0
...
v1.0.0-rc.
| Author | SHA1 | Date | |
|---|---|---|---|
| 57886a01d6 | |||
| c89bd8e80f | |||
| 9c54322654 | |||
| 8a80eb7b9a | |||
| c1773a7072 | |||
| 33ed13d255 | |||
| 0f5811edb9 | |||
| e4eabd7a86 | |||
| 757df76f36 | |||
| 14b2f83971 | |||
| 51565fdf41 | |||
| 506c8682fe | |||
| a909de30f3 | |||
| a2f94f15bc | |||
| 13e56a88bc | |||
| bb7d17001b | |||
| 920efa0083 | |||
| 0b81d1a4cb | |||
| 1d5bdeba26 | |||
| a0c3fbbc7e | |||
| 8101a9f156 | |||
| 7b6f4b5ea4 | |||
| 658057cdb1 | |||
| 2aa5d5b00e | |||
| 7f2f6f5aca | |||
| 4e50482769 | |||
| 1da1f05cdd | |||
| 15cfb314b1 | |||
| a3da6192e3 | |||
| af33c6225d | |||
| 9ee09bbe4b | |||
| 3f0858a1ba | |||
| a85fe64ccb | |||
| 21b16a5e6c | |||
| 6115e0e0d4 | |||
| 859d034ed7 | |||
| 91ebc54571 | |||
| d6c1d6bae6 | |||
| 407b2227b3 | |||
| 2896556659 | |||
| 8242687b07 | |||
| dab4f3f5b5 |
@@ -6,5 +6,4 @@ node_modules
|
||||
*.md
|
||||
docs
|
||||
reference
|
||||
scripts
|
||||
public/datasheets/*.pdf
|
||||
|
||||
18
.env
18
.env
@@ -1,11 +1,10 @@
|
||||
# Application
|
||||
NODE_ENV=production
|
||||
NEXT_PUBLIC_BASE_URL=https://klz-cables.com
|
||||
UMAMI_SCRIPT_URL=https://analytics.infra.mintel.me/script.js
|
||||
NEXT_PUBLIC_UMAMI_WEBSITE_ID=59a7db94-0100-4c7e-98ef-99f45b17f9c3
|
||||
SENTRY_DSN=https://c10957d0182245b1a2a806ac2d34a197@errors.infra.mintel.me/1
|
||||
LOG_LEVEL=info
|
||||
PDF_DEBUG_EXCEL=0
|
||||
PDF_LOCALE=
|
||||
PDF_MATCH=
|
||||
PDF_LIMIT=0
|
||||
|
||||
# WooCommerce & WordPress
|
||||
WOOCOMMERCE_URL=https://klz-cables.com
|
||||
@@ -13,17 +12,6 @@ WOOCOMMERCE_CONSUMER_KEY=ck_38d97df86880e8fefbd54ab5cdf47a9c5a9e5b39
|
||||
WOOCOMMERCE_CONSUMER_SECRET=cs_d675ee2ac2ec7c22de84ae5451c07e42b1717759
|
||||
WORDPRESS_APP_PASSWORD="DlJH 49dp fC3a Itc3 Sl7Z Wz0k"
|
||||
|
||||
# Umami Analytics
|
||||
NEXT_PUBLIC_UMAMI_WEBSITE_ID=59a7db94-0100-4c7e-98ef-99f45b17f9c3
|
||||
UMAMI_SCRIPT_URL=https://analytics.infra.mintel.me/script.js
|
||||
|
||||
# GlitchTip (Sentry protocol)
|
||||
SENTRY_DSN=https://c10957d0182245b1a2a806ac2d34a197@errors.infra.mintel.me/1
|
||||
|
||||
# Redis Cache
|
||||
REDIS_URL=
|
||||
REDIS_KEY_PREFIX=klz:
|
||||
|
||||
# SMTP Configuration
|
||||
MAIL_HOST=smtp.eu.mailgun.org
|
||||
MAIL_PORT=587
|
||||
|
||||
93
.env.example
93
.env.example
@@ -1,24 +1,33 @@
|
||||
# WooCommerce API (Legacy - not currently used)
|
||||
WOOCOMMERCE_URL=https://klz-cables.com
|
||||
WOOCOMMERCE_CONSUMER_KEY=
|
||||
WOOCOMMERCE_CONSUMER_SECRET=
|
||||
WORDPRESS_APP_PASSWORD=
|
||||
# ============================================================================
|
||||
# KLZ Cables - Environment Configuration
|
||||
# ============================================================================
|
||||
# Copy this file to .env for local development
|
||||
# For production, use .env.production as a template
|
||||
# ============================================================================
|
||||
|
||||
# Umami Analytics
|
||||
NEXT_PUBLIC_UMAMI_WEBSITE_ID=
|
||||
UMAMI_SCRIPT_URL=https://analytics.infra.mintel.me/script.js
|
||||
|
||||
# GlitchTip (Sentry protocol)
|
||||
SENTRY_DSN=
|
||||
# Client-side DSN should use the proxy path: https://[key]@[domain]/errors/[id]
|
||||
NEXT_PUBLIC_SENTRY_DSN=
|
||||
|
||||
# Application
|
||||
# ────────────────────────────────────────────────────────────────────────────
|
||||
# Application Configuration
|
||||
# ────────────────────────────────────────────────────────────────────────────
|
||||
NODE_ENV=development
|
||||
NEXT_PUBLIC_BASE_URL=http://localhost:3000
|
||||
LOG_LEVEL=info
|
||||
|
||||
# SMTP Configuration
|
||||
# ────────────────────────────────────────────────────────────────────────────
|
||||
# Analytics (Umami)
|
||||
# ────────────────────────────────────────────────────────────────────────────
|
||||
# Optional: Leave empty to disable analytics
|
||||
NEXT_PUBLIC_UMAMI_WEBSITE_ID=
|
||||
NEXT_PUBLIC_UMAMI_SCRIPT_URL=https://analytics.infra.mintel.me/script.js
|
||||
|
||||
# ────────────────────────────────────────────────────────────────────────────
|
||||
# Error Tracking (GlitchTip/Sentry)
|
||||
# ────────────────────────────────────────────────────────────────────────────
|
||||
# Optional: Leave empty to disable error tracking
|
||||
SENTRY_DSN=
|
||||
|
||||
# ────────────────────────────────────────────────────────────────────────────
|
||||
# Email Configuration (SMTP)
|
||||
# ────────────────────────────────────────────────────────────────────────────
|
||||
# Required for contact form functionality
|
||||
MAIL_HOST=smtp.eu.mailgun.org
|
||||
MAIL_PORT=587
|
||||
MAIL_USERNAME=
|
||||
@@ -26,6 +35,50 @@ MAIL_PASSWORD=
|
||||
MAIL_FROM=KLZ Cables <noreply@klz-cables.com>
|
||||
MAIL_RECIPIENTS=info@klz-cables.com
|
||||
|
||||
# Redis Configuration (optional)
|
||||
REDIS_URL=redis://redis:6379/2
|
||||
REDIS_KEY_PREFIX=klz:
|
||||
# ────────────────────────────────────────────────────────────────────────────
|
||||
# Logging
|
||||
# ────────────────────────────────────────────────────────────────────────────
|
||||
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
|
||||
|
||||
# ============================================================================
|
||||
# IMPORTANT NOTES
|
||||
# ============================================================================
|
||||
#
|
||||
# BUILD-TIME vs RUNTIME Variables:
|
||||
# ─────────────────────────────────
|
||||
# • NEXT_PUBLIC_* variables are baked into the client bundle at BUILD time
|
||||
# They must be provided as --build-arg when building the Docker image
|
||||
#
|
||||
# • All other variables are used at RUNTIME only
|
||||
# They are loaded from the .env file by docker-compose
|
||||
#
|
||||
# Docker Deployment:
|
||||
# ──────────────────
|
||||
# 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. 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:
|
||||
# ─────────
|
||||
# • NEVER commit .env files with real credentials to git
|
||||
# • Use Gitea/GitHub secrets for CI/CD workflows
|
||||
# • Store production .env file securely on the server only
|
||||
#
|
||||
# ============================================================================
|
||||
|
||||
31
.env.production
Normal file
31
.env.production
Normal file
@@ -0,0 +1,31 @@
|
||||
# ============================================================================
|
||||
# KLZ Cables - Production Environment Configuration
|
||||
# ============================================================================
|
||||
# This file contains runtime environment variables for the production deployment.
|
||||
# It should be placed on the production server at: /home/deploy/sites/klz-cables.com/.env
|
||||
#
|
||||
# IMPORTANT: This file contains sensitive data and should NEVER be committed to git.
|
||||
# ============================================================================
|
||||
|
||||
# Application
|
||||
NODE_ENV=production
|
||||
NEXT_PUBLIC_BASE_URL=https://klz-cables.com
|
||||
|
||||
# Analytics (Umami)
|
||||
NEXT_PUBLIC_UMAMI_WEBSITE_ID=
|
||||
NEXT_PUBLIC_UMAMI_SCRIPT_URL=https://analytics.infra.mintel.me/script.js
|
||||
|
||||
# Error Tracking (GlitchTip/Sentry)
|
||||
SENTRY_DSN=
|
||||
|
||||
# Email Configuration (Mailgun)
|
||||
MAIL_HOST=smtp.eu.mailgun.org
|
||||
MAIL_PORT=587
|
||||
MAIL_USERNAME=
|
||||
MAIL_PASSWORD=
|
||||
MAIL_FROM=KLZ Cables <noreply@klz-cables.com>
|
||||
MAIL_RECIPIENTS=info@klz-cables.com
|
||||
|
||||
|
||||
# Varnish Cache Size (optional)
|
||||
VARNISH_CACHE_SIZE=256m
|
||||
@@ -2,12 +2,10 @@ name: Build & Deploy KLZ Cables
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [main]
|
||||
branches: [main, staging]
|
||||
|
||||
jobs:
|
||||
build-and-deploy:
|
||||
# ────────────────────────────────────────────────
|
||||
# WICHTIG: Kein "docker" mehr – sondern eines der neuen Labels
|
||||
runs-on: docker
|
||||
|
||||
steps:
|
||||
@@ -16,22 +14,10 @@ jobs:
|
||||
# ═══════════════════════════════════════════════════════════════════════════════
|
||||
- name: 📋 Log Workflow Start
|
||||
run: |
|
||||
echo "╔══════════════════════════════════════════════════════════════════════════════╗"
|
||||
echo "║ KLZ Cables Deployment Workflow Started ║"
|
||||
echo "╚══════════════════════════════════════════════════════════════════════════════╝"
|
||||
echo ""
|
||||
echo "📋 Workflow Information:"
|
||||
echo " • Repository: ${{ github.repository }}"
|
||||
echo " • Branch: ${{ github.ref }}"
|
||||
echo "🚀 Starting deployment for ${{ github.repository }} (${{ github.ref }})"
|
||||
echo " • Branch: ${{ github.ref_name }}"
|
||||
echo " • Commit: ${{ github.sha }}"
|
||||
echo " • Actor: ${{ github.actor }}"
|
||||
echo " • Run ID: ${{ github.run_id }}"
|
||||
echo " • Timestamp: $(date -u +'%Y-%m-%d %H:%M:%S UTC')"
|
||||
echo ""
|
||||
echo "🔍 Environment Details:"
|
||||
echo " • Runner OS: ${{ runner.os }}"
|
||||
echo " • Workspace: ${{ github.workspace }}"
|
||||
echo ""
|
||||
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v4
|
||||
@@ -41,147 +27,133 @@ jobs:
|
||||
# ═══════════════════════════════════════════════════════════════════════════════
|
||||
- name: 🔐 Login to private registry
|
||||
run: |
|
||||
echo "╔══════════════════════════════════════════════════════════════════════════════╗"
|
||||
echo "║ Step: Registry Login ║"
|
||||
echo "╚══════════════════════════════════════════════════════════════════════════════╝"
|
||||
echo ""
|
||||
echo "🔐 Authenticating with private registry..."
|
||||
echo " Registry: registry.infra.mintel.me"
|
||||
echo " User: ${{ secrets.REGISTRY_USER != '' && '***' || 'NOT SET' }}"
|
||||
echo ""
|
||||
|
||||
# Execute login with error handling
|
||||
if echo "${{ secrets.REGISTRY_PASS }}" | docker login registry.infra.mintel.me -u "${{ secrets.REGISTRY_USER }}" --password-stdin 2>&1; then
|
||||
echo "✅ Registry login successful"
|
||||
else
|
||||
echo "❌ Registry login failed"
|
||||
exit 1
|
||||
fi
|
||||
echo ""
|
||||
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
|
||||
env:
|
||||
NEXT_PUBLIC_BASE_URL: ${{ github.ref_name == 'main' && secrets.NEXT_PUBLIC_BASE_URL || (secrets.STAGING_NEXT_PUBLIC_BASE_URL || secrets.NEXT_PUBLIC_BASE_URL) }}
|
||||
NEXT_PUBLIC_UMAMI_WEBSITE_ID: ${{ github.ref_name == 'main' && secrets.NEXT_PUBLIC_UMAMI_WEBSITE_ID || (secrets.STAGING_NEXT_PUBLIC_UMAMI_WEBSITE_ID || secrets.NEXT_PUBLIC_UMAMI_WEBSITE_ID) }}
|
||||
NEXT_PUBLIC_UMAMI_SCRIPT_URL: ${{ github.ref_name == 'main' && secrets.NEXT_PUBLIC_UMAMI_SCRIPT_URL || (secrets.STAGING_NEXT_PUBLIC_UMAMI_SCRIPT_URL || secrets.NEXT_PUBLIC_UMAMI_SCRIPT_URL) }}
|
||||
run: |
|
||||
echo "╔══════════════════════════════════════════════════════════════════════════════╗"
|
||||
echo "║ Step: Build Docker Image ║"
|
||||
echo "╚══════════════════════════════════════════════════════════════════════════════╝"
|
||||
echo ""
|
||||
echo "🏗️ Building Docker image with buildx..."
|
||||
echo " Platform: linux/arm64"
|
||||
echo " Target: registry.infra.mintel.me/mintel/klz-cables.com:latest"
|
||||
echo ""
|
||||
echo "📦 Build Arguments:"
|
||||
echo " • NEXT_PUBLIC_UMAMI_WEBSITE_ID: ${{ secrets.NEXT_PUBLIC_UMAMI_WEBSITE_ID != '' && '***' || 'NOT SET' }}"
|
||||
echo " • SENTRY_DSN: ${{ secrets.SENTRY_DSN != '' && '***' || 'NOT SET' }}"
|
||||
echo " • NEXT_PUBLIC_SENTRY_DSN: ${{ secrets.NEXT_PUBLIC_SENTRY_DSN != '' && '***' || 'NOT SET' }}"
|
||||
echo " • NEXT_PUBLIC_BASE_URL: ${{ secrets.NEXT_PUBLIC_BASE_URL != '' && '***' || 'NOT SET' }}"
|
||||
echo ""
|
||||
echo "⏱️ Build started at: $(date -u +'%Y-%m-%d %H:%M:%S UTC')"
|
||||
echo ""
|
||||
|
||||
# Execute build with detailed logging
|
||||
set -e
|
||||
echo "🏗️ Building Docker image (linux/arm64) for branch ${{ github.ref_name }}..."
|
||||
docker buildx build \
|
||||
--pull \
|
||||
--platform linux/arm64 \
|
||||
--build-arg NEXT_PUBLIC_UMAMI_WEBSITE_ID="${{ secrets.NEXT_PUBLIC_UMAMI_WEBSITE_ID }}" \
|
||||
--build-arg SENTRY_DSN="${{ secrets.SENTRY_DSN }}" \
|
||||
--build-arg NEXT_PUBLIC_SENTRY_DSN="${{ secrets.NEXT_PUBLIC_SENTRY_DSN }}" \
|
||||
--build-arg NEXT_PUBLIC_BASE_URL="${{ secrets.NEXT_PUBLIC_BASE_URL }}" \
|
||||
--build-arg NEXT_PUBLIC_BASE_URL="$NEXT_PUBLIC_BASE_URL" \
|
||||
--build-arg NEXT_PUBLIC_UMAMI_WEBSITE_ID="$NEXT_PUBLIC_UMAMI_WEBSITE_ID" \
|
||||
--build-arg NEXT_PUBLIC_UMAMI_SCRIPT_URL="$NEXT_PUBLIC_UMAMI_SCRIPT_URL" \
|
||||
-t registry.infra.mintel.me/mintel/klz-cables.com:${{ github.sha }} \
|
||||
-t registry.infra.mintel.me/mintel/klz-cables.com:latest \
|
||||
--push .
|
||||
|
||||
BUILD_EXIT_CODE=$?
|
||||
if [ $BUILD_EXIT_CODE -eq 0 ]; then
|
||||
echo ""
|
||||
echo "✅ Build completed successfully at: $(date -u +'%Y-%m-%d %H:%M:%S UTC')"
|
||||
echo ""
|
||||
echo "📊 Image Details:"
|
||||
IMAGE_SIZE=$(docker inspect registry.infra.mintel.me/mintel/klz-cables.com:latest --format='{{.Size}}')
|
||||
IMAGE_SIZE_MB=$((IMAGE_SIZE / 1024 / 1024))
|
||||
echo " • Size: ${IMAGE_SIZE_MB}MB"
|
||||
docker inspect registry.infra.mintel.me/mintel/klz-cables.com:latest --format=' • Created: {{.Created}}'
|
||||
docker inspect registry.infra.mintel.me/mintel/klz-cables.com:latest --format=' • Architecture: {{.Architecture}}'
|
||||
else
|
||||
echo ""
|
||||
echo "❌ Build failed with exit code: $BUILD_EXIT_CODE"
|
||||
exit $BUILD_EXIT_CODE
|
||||
fi
|
||||
echo ""
|
||||
|
||||
# ═══════════════════════════════════════════════════════════════════════════════
|
||||
# LOGGING: Deployment Phase
|
||||
# ═══════════════════════════════════════════════════════════════════════════════
|
||||
- name: 🚀 Deploy to production server
|
||||
- name: 🚀 Deploy to server
|
||||
env:
|
||||
NEXT_PUBLIC_BASE_URL: ${{ github.ref_name == 'main' && secrets.NEXT_PUBLIC_BASE_URL || (secrets.STAGING_NEXT_PUBLIC_BASE_URL || secrets.NEXT_PUBLIC_BASE_URL) }}
|
||||
NEXT_PUBLIC_UMAMI_WEBSITE_ID: ${{ github.ref_name == 'main' && secrets.NEXT_PUBLIC_UMAMI_WEBSITE_ID || (secrets.STAGING_NEXT_PUBLIC_UMAMI_WEBSITE_ID || secrets.NEXT_PUBLIC_UMAMI_WEBSITE_ID) }}
|
||||
NEXT_PUBLIC_UMAMI_SCRIPT_URL: ${{ github.ref_name == 'main' && secrets.NEXT_PUBLIC_UMAMI_SCRIPT_URL || (secrets.STAGING_NEXT_PUBLIC_UMAMI_SCRIPT_URL || secrets.NEXT_PUBLIC_UMAMI_SCRIPT_URL) }}
|
||||
SENTRY_DSN: ${{ github.ref_name == 'main' && secrets.SENTRY_DSN || (secrets.STAGING_SENTRY_DSN || secrets.SENTRY_DSN) }}
|
||||
MAIL_HOST: ${{ github.ref_name == 'main' && secrets.MAIL_HOST || (secrets.STAGING_MAIL_HOST || secrets.MAIL_HOST) }}
|
||||
MAIL_PORT: ${{ github.ref_name == 'main' && secrets.MAIL_PORT || (secrets.STAGING_MAIL_PORT || secrets.MAIL_PORT) }}
|
||||
MAIL_USERNAME: ${{ github.ref_name == 'main' && secrets.MAIL_USERNAME || (secrets.STAGING_MAIL_USERNAME || secrets.MAIL_USERNAME) }}
|
||||
MAIL_PASSWORD: ${{ github.ref_name == 'main' && secrets.MAIL_PASSWORD || (secrets.STAGING_MAIL_PASSWORD || secrets.MAIL_PASSWORD) }}
|
||||
MAIL_FROM: ${{ github.ref_name == 'main' && secrets.MAIL_FROM || (secrets.STAGING_MAIL_FROM || secrets.MAIL_FROM) }}
|
||||
MAIL_RECIPIENTS: ${{ github.ref_name == 'main' && secrets.MAIL_RECIPIENTS || (secrets.STAGING_MAIL_RECIPIENTS || secrets.MAIL_RECIPIENTS) }}
|
||||
run: |
|
||||
echo "╔══════════════════════════════════════════════════════════════════════════════╗"
|
||||
echo "║ Step: Deploy to Production Server ║"
|
||||
echo "╚══════════════════════════════════════════════════════════════════════════════╝"
|
||||
echo ""
|
||||
echo "🚀 Starting deployment process..."
|
||||
echo " Target Server: alpha.mintel.me"
|
||||
echo " Deploy User: deploy (via sudo from root)"
|
||||
echo " Target Path: /home/deploy/sites/klz-cables.com"
|
||||
echo ""
|
||||
BRANCH=${{ github.ref_name }}
|
||||
|
||||
# Setup SSH with logging
|
||||
echo "🔐 Setting up SSH connection..."
|
||||
# Derive domain from NEXT_PUBLIC_BASE_URL (strip https:// and trailing slash)
|
||||
DOMAIN=$(echo "$NEXT_PUBLIC_BASE_URL" | sed -E 's|https?://||' | sed -E 's|/.*||')
|
||||
|
||||
if [ "$BRANCH" = "main" ]; then
|
||||
ENV_FILE=.env.prod
|
||||
# For production, we want both root and www
|
||||
TRAEFIK_HOST="\`$DOMAIN\`, \`www.$DOMAIN\`"
|
||||
else
|
||||
ENV_FILE=.env.staging
|
||||
TRAEFIK_HOST="\`$DOMAIN\`"
|
||||
fi
|
||||
|
||||
echo "🚀 Deploying branch $BRANCH to $ENV_FILE..."
|
||||
echo "🌐 Domain: $DOMAIN"
|
||||
|
||||
# Setup SSH
|
||||
mkdir -p ~/.ssh
|
||||
echo "${{ secrets.ALPHA_SSH_KEY }}" > ~/.ssh/id_ed25519
|
||||
chmod 600 ~/.ssh/id_ed25519
|
||||
|
||||
echo "🔑 Adding host to known_hosts..."
|
||||
ssh-keyscan -H alpha.mintel.me >> ~/.ssh/known_hosts 2>/dev/null
|
||||
if [ $? -eq 0 ]; then
|
||||
echo "✅ Host key added successfully"
|
||||
else
|
||||
echo "⚠️ Warning: Could not add host key"
|
||||
|
||||
# Create .env file content
|
||||
cat > /tmp/klz-cables.env << EOF
|
||||
# ============================================================================
|
||||
# KLZ Cables - Environment Configuration ($BRANCH)
|
||||
# ============================================================================
|
||||
# Auto-generated by CI/CD workflow
|
||||
# DO NOT EDIT MANUALLY - Changes will be overwritten on next deployment
|
||||
# ============================================================================
|
||||
|
||||
NODE_ENV=production
|
||||
NEXT_PUBLIC_BASE_URL=$NEXT_PUBLIC_BASE_URL
|
||||
NEXT_PUBLIC_UMAMI_WEBSITE_ID=$NEXT_PUBLIC_UMAMI_WEBSITE_ID
|
||||
NEXT_PUBLIC_UMAMI_SCRIPT_URL=$NEXT_PUBLIC_UMAMI_SCRIPT_URL
|
||||
SENTRY_DSN=$SENTRY_DSN
|
||||
MAIL_HOST=$MAIL_HOST
|
||||
MAIL_PORT=$MAIL_PORT
|
||||
MAIL_USERNAME=$MAIL_USERNAME
|
||||
MAIL_PASSWORD=$MAIL_PASSWORD
|
||||
MAIL_FROM=$MAIL_FROM
|
||||
MAIL_RECIPIENTS=$MAIL_RECIPIENTS
|
||||
|
||||
# Deployment variables for docker-compose
|
||||
IMAGE_TAG=${{ github.sha }}
|
||||
TRAEFIK_HOST=$TRAEFIK_HOST
|
||||
ENV_FILE=$ENV_FILE
|
||||
EOF
|
||||
|
||||
# Upload .env and docker-compose.yml
|
||||
scp -o StrictHostKeyChecking=accept-new /tmp/klz-cables.env root@alpha.mintel.me:/home/deploy/sites/klz-cables.com/$ENV_FILE
|
||||
scp -o StrictHostKeyChecking=accept-new docker-compose.yml root@alpha.mintel.me:/home/deploy/sites/klz-cables.com/docker-compose.yml
|
||||
|
||||
ssh -o StrictHostKeyChecking=accept-new root@alpha.mintel.me 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 images..."
|
||||
IMAGE_TAG=${{ github.sha }} ENV_FILE=$ENV_FILE TRAEFIK_HOST="$TRAEFIK_HOST" docker compose --env-file $ENV_FILE pull
|
||||
|
||||
echo "🚀 Starting containers..."
|
||||
IMAGE_TAG=${{ github.sha }} ENV_FILE=$ENV_FILE TRAEFIK_HOST="$TRAEFIK_HOST" docker compose --env-file $ENV_FILE up -d
|
||||
|
||||
echo "🧹 Cleaning up old images..."
|
||||
docker system prune -f
|
||||
|
||||
echo "⏳ Giving the app a few seconds to warm up..."
|
||||
sleep 10
|
||||
|
||||
echo "🔍 Checking container status..."
|
||||
docker compose --env-file $ENV_FILE ps
|
||||
|
||||
if ! docker compose --env-file $ENV_FILE ps | grep -q "Up"; then
|
||||
echo "❌ Container failed to start"
|
||||
docker compose --env-file $ENV_FILE logs --tail=100
|
||||
exit 1
|
||||
fi
|
||||
echo ""
|
||||
|
||||
echo "✅ Deployment complete!"
|
||||
EOF
|
||||
|
||||
# Execute deployment commands with detailed logging
|
||||
echo "📡 Connecting to server and executing deployment commands..."
|
||||
echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"
|
||||
echo ""
|
||||
|
||||
# SSH as root and use sudo to run deployment script as deploy user
|
||||
# This works around the broken SSH output issue with deploy user
|
||||
ssh -o StrictHostKeyChecking=accept-new \
|
||||
-o ServerAliveInterval=30 \
|
||||
-o ServerAliveCountMax=3 \
|
||||
-o ConnectTimeout=10 \
|
||||
root@alpha.mintel.me \
|
||||
"MAIL_FROM='${{ secrets.MAIL_FROM }}' \
|
||||
MAIL_HOST='${{ secrets.MAIL_HOST }}' \
|
||||
MAIL_PASSWORD='${{ secrets.MAIL_PASSWORD }}' \
|
||||
MAIL_PORT='${{ secrets.MAIL_PORT }}' \
|
||||
MAIL_RECIPIENTS='${{ secrets.MAIL_RECIPIENTS }}' \
|
||||
MAIL_USERNAME='${{ secrets.MAIL_USERNAME }}' \
|
||||
NEXT_PUBLIC_BASE_URL='${{ secrets.NEXT_PUBLIC_BASE_URL }}' \
|
||||
NEXT_PUBLIC_SENTRY_DSN='${{ secrets.NEXT_PUBLIC_SENTRY_DSN }}' \
|
||||
NEXT_PUBLIC_UMAMI_WEBSITE_ID='${{ secrets.NEXT_PUBLIC_UMAMI_WEBSITE_ID }}' \
|
||||
NODE_ENV='${{ secrets.NODE_ENV }}' \
|
||||
SENTRY_DSN='${{ secrets.SENTRY_DSN }}' \
|
||||
sudo -u deploy -H -E /home/deploy/deploy.sh"
|
||||
|
||||
DEPLOY_EXIT_CODE=$?
|
||||
echo ""
|
||||
|
||||
if [ $DEPLOY_EXIT_CODE -eq 0 ]; then
|
||||
echo "✅ Deployment completed successfully at: $(date -u +'%Y-%m-%d %H:%M:%S UTC')"
|
||||
else
|
||||
echo "❌ Deployment failed with exit code: $DEPLOY_EXIT_CODE"
|
||||
echo ""
|
||||
echo "🔍 Troubleshooting Tips:"
|
||||
echo " • Check server connectivity: ping alpha.mintel.me"
|
||||
echo " • Verify SSH key permissions on server"
|
||||
echo " • Check disk space on target server"
|
||||
echo " • Review docker compose configuration"
|
||||
exit $DEPLOY_EXIT_CODE
|
||||
fi
|
||||
echo ""
|
||||
rm -f /tmp/klz-cables.env
|
||||
|
||||
# ═══════════════════════════════════════════════════════════════════════════════
|
||||
# LOGGING: Workflow Summary
|
||||
@@ -189,31 +161,9 @@ jobs:
|
||||
- name: 📊 Workflow Summary
|
||||
if: always()
|
||||
run: |
|
||||
echo "╔══════════════════════════════════════════════════════════════════════════════╗"
|
||||
echo "║ Workflow Summary ║"
|
||||
echo "╚══════════════════════════════════════════════════════════════════════════════╝"
|
||||
echo ""
|
||||
echo "📊 Final Status:"
|
||||
echo " • Workflow: ${{ job.status }}"
|
||||
echo " • Completed: $(date -u +'%Y-%m-%d %H:%M:%S UTC')"
|
||||
echo ""
|
||||
echo "🎯 Deployment Target:"
|
||||
echo " • Image: registry.infra.mintel.me/mintel/klz-cables.com:latest"
|
||||
echo " • Server: alpha.mintel.me"
|
||||
echo " • Service: klz-cables.com"
|
||||
echo ""
|
||||
echo "🔐 Security Notes:"
|
||||
echo " • All secrets are masked (*** ) in logs"
|
||||
echo " • SSH keys are created with 600 permissions"
|
||||
echo " • Passwords are never displayed in plain text"
|
||||
echo ""
|
||||
echo "╔══════════════════════════════════════════════════════════════════════════════╗"
|
||||
if [ "${{ job.status }}" == "success" ]; then
|
||||
echo "║ ✅ DEPLOYMENT SUCCESSFUL ║"
|
||||
else
|
||||
echo "║ ❌ DEPLOYMENT FAILED ║"
|
||||
fi
|
||||
echo "╚══════════════════════════════════════════════════════════════════════════════╝"
|
||||
echo "📊 Status: ${{ job.status }}"
|
||||
echo "🎯 Target: alpha.mintel.me"
|
||||
echo "🌿 Branch: ${{ github.ref_name }}"
|
||||
|
||||
# ═══════════════════════════════════════════════════════════════════════════════
|
||||
# NOTIFICATION: Gotify
|
||||
@@ -224,7 +174,7 @@ jobs:
|
||||
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.
|
||||
-F "message=The deployment of ${{ github.repository }} (branch: ${{ github.ref_name }}) was successful.
|
||||
|
||||
Commit: ${{ github.sha }}
|
||||
Actor: ${{ github.actor }}
|
||||
@@ -248,7 +198,7 @@ jobs:
|
||||
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!
|
||||
-F "message=The deployment of ${{ github.repository }} (branch: ${{ github.ref_name }}) failed!
|
||||
|
||||
Commit: ${{ github.sha }}
|
||||
Actor: ${{ github.actor }}
|
||||
|
||||
19
Dockerfile
19
Dockerfile
@@ -3,7 +3,7 @@ FROM node:20-alpine AS base
|
||||
# Install dependencies only when needed
|
||||
FROM base AS deps
|
||||
# Check https://github.com/nodejs/docker-node/tree/b4117f9333da4138b03a546ec926ef50a31506c3#nodealpine to understand why libc6-compat might be needed.
|
||||
RUN apk add --no-cache libc6-compat
|
||||
RUN apk add --no-cache libc6-compat curl
|
||||
WORKDIR /app
|
||||
|
||||
# Install dependencies based on the preferred package manager
|
||||
@@ -22,16 +22,18 @@ COPY . .
|
||||
# Uncomment the following line in case you want to disable telemetry during the build.
|
||||
ENV NEXT_TELEMETRY_DISABLED=1
|
||||
|
||||
# Build-time environment variables for Next.js
|
||||
# These are baked into the client bundle during build
|
||||
ARG NEXT_PUBLIC_BASE_URL
|
||||
ARG NEXT_PUBLIC_UMAMI_WEBSITE_ID
|
||||
ARG NEXT_PUBLIC_UMAMI_SCRIPT_URL
|
||||
ARG SENTRY_DSN
|
||||
ARG NEXT_PUBLIC_SENTRY_DSN
|
||||
ARG NEXT_PUBLIC_BASE_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 SENTRY_DSN=$SENTRY_DSN
|
||||
ENV NEXT_PUBLIC_SENTRY_DSN=$NEXT_PUBLIC_SENTRY_DSN
|
||||
ENV NEXT_PUBLIC_BASE_URL=$NEXT_PUBLIC_BASE_URL
|
||||
|
||||
# Validate environment variables during build
|
||||
RUN npx tsx scripts/validate-env.ts
|
||||
|
||||
RUN npm run build
|
||||
|
||||
@@ -39,6 +41,9 @@ RUN npm run build
|
||||
FROM base AS runner
|
||||
WORKDIR /app
|
||||
|
||||
# Install curl for health checks
|
||||
RUN apk add --no-cache curl
|
||||
|
||||
ENV NODE_ENV=production
|
||||
# Uncomment the following line in case you want to disable telemetry during runtime.
|
||||
# ENV NEXT_TELEMETRY_DISABLED 1
|
||||
|
||||
272
ENV_CLEANUP_SUMMARY.md
Normal file
272
ENV_CLEANUP_SUMMARY.md
Normal file
@@ -0,0 +1,272 @@
|
||||
# Environment Variables Cleanup - Summary
|
||||
|
||||
## What Was Done
|
||||
|
||||
Cleaned up the fragile, overkill environment variable mess and replaced it with a simple, clean, robust **fully automated** system.
|
||||
|
||||
## Changes Made
|
||||
|
||||
### 1. Dockerfile ✅
|
||||
**Before**: 4 build args including runtime-only variables (SENTRY_DSN)
|
||||
**After**: 3 build args - only `NEXT_PUBLIC_*` variables that need to be baked into the client bundle
|
||||
|
||||
```dockerfile
|
||||
# Only these build args now:
|
||||
ARG NEXT_PUBLIC_BASE_URL
|
||||
ARG NEXT_PUBLIC_UMAMI_WEBSITE_ID
|
||||
ARG NEXT_PUBLIC_UMAMI_SCRIPT_URL
|
||||
```
|
||||
|
||||
### 2. docker-compose.yml ✅
|
||||
**Before**: 12+ individual environment variables listed
|
||||
**After**: Single `env_file: .env` directive
|
||||
|
||||
```yaml
|
||||
app:
|
||||
image: registry.infra.mintel.me/mintel/klz-cables.com:latest
|
||||
env_file:
|
||||
- .env # All runtime vars loaded from here
|
||||
```
|
||||
|
||||
### 3. .gitea/workflows/deploy.yml ✅
|
||||
**Before**: Passing 12+ environment variables individually via SSH command (fragile!)
|
||||
**After**: **Fully automated** - workflow creates `.env` file from Gitea secrets and uploads it
|
||||
|
||||
```yaml
|
||||
# Before (FRAGILE):
|
||||
ssh root@alpha.mintel.me \
|
||||
"MAIL_FROM='${{ secrets.MAIL_FROM }}' \
|
||||
MAIL_HOST='${{ secrets.MAIL_HOST }}' \
|
||||
... (12+ variables) \
|
||||
/home/deploy/deploy.sh"
|
||||
|
||||
# After (AUTOMATED):
|
||||
# 1. Create .env from secrets
|
||||
cat > /tmp/klz-cables.env << EOF
|
||||
NODE_ENV=production
|
||||
NEXT_PUBLIC_BASE_URL=${{ secrets.NEXT_PUBLIC_BASE_URL }}
|
||||
# ... all other vars from secrets
|
||||
EOF
|
||||
|
||||
# 2. Upload to server
|
||||
scp /tmp/klz-cables.env root@alpha.mintel.me:/home/deploy/sites/klz-cables.com/.env
|
||||
|
||||
# 3. Deploy
|
||||
ssh root@alpha.mintel.me "cd /home/deploy/sites/klz-cables.com && docker-compose up -d"
|
||||
```
|
||||
|
||||
### 4. New Files Created ✅
|
||||
|
||||
- **`.env.production`** - Template for reference (not used in automation)
|
||||
- **`docs/DEPLOYMENT.md`** - Complete deployment guide
|
||||
- **`docs/SERVER_SETUP.md`** - Server setup instructions
|
||||
- **`docs/ENV_MIGRATION.md`** - Migration guide from old to new system
|
||||
|
||||
### 5. Updated Files ✅
|
||||
|
||||
- **`.env.example`** - Clear documentation of all variables with build-time vs runtime notes
|
||||
|
||||
## Architecture
|
||||
|
||||
### Build Time (CI/CD)
|
||||
```
|
||||
Gitea Workflow
|
||||
↓
|
||||
Only passes NEXT_PUBLIC_* as --build-arg
|
||||
↓
|
||||
Docker Build
|
||||
↓
|
||||
Validates env vars
|
||||
↓
|
||||
Bakes NEXT_PUBLIC_* into client bundle
|
||||
↓
|
||||
Push to Registry
|
||||
```
|
||||
|
||||
### Runtime (Production Server) - FULLY AUTOMATED
|
||||
```
|
||||
Gitea Secrets
|
||||
↓
|
||||
Workflow creates .env file
|
||||
↓
|
||||
SCP uploads to server
|
||||
↓
|
||||
Secured (chmod 600, chown deploy:deploy)
|
||||
↓
|
||||
docker-compose.yml (env_file: .env)
|
||||
↓
|
||||
Loads .env into container
|
||||
↓
|
||||
Application runs with full config
|
||||
```
|
||||
|
||||
## Key Benefits
|
||||
|
||||
### 1. Simplicity
|
||||
- **Before**: 15+ Gitea secrets, variables in 3+ places
|
||||
- **After**: All secrets in Gitea, automatically deployed
|
||||
|
||||
### 2. Clarity
|
||||
- **Before**: Confusing duplication, unclear which vars go where
|
||||
- **After**: Clear separation - build args vs runtime env file
|
||||
|
||||
### 3. Robustness
|
||||
- **Before**: Fragile SSH command with 12+ inline variables
|
||||
- **After**: Robust automated file generation and upload
|
||||
|
||||
### 4. Security
|
||||
- **Before**: Secrets potentially exposed in CI logs
|
||||
- **After**: Secrets masked in logs, .env auto-secured on server
|
||||
|
||||
### 5. Maintainability
|
||||
- **Before**: Update in 3 places (Dockerfile, docker-compose.yml, deploy.yml)
|
||||
- **After**: Update Gitea secrets only - deployment is automatic
|
||||
|
||||
### 6. **Zero Manual Steps** 🎉
|
||||
- **Before**: Manual .env file creation on server (error-prone, can be forgotten)
|
||||
- **After**: **Fully automated** - .env file created and uploaded on every deployment
|
||||
|
||||
## What You Need to Do
|
||||
|
||||
### Required Gitea Secrets
|
||||
|
||||
Ensure these secrets are configured in your Gitea repository:
|
||||
|
||||
**Build-Time (NEXT_PUBLIC_*):**
|
||||
- `NEXT_PUBLIC_BASE_URL` - Production URL (e.g., `https://klz-cables.com`)
|
||||
- `NEXT_PUBLIC_UMAMI_WEBSITE_ID` - Umami analytics ID
|
||||
- `NEXT_PUBLIC_UMAMI_SCRIPT_URL` - Umami script URL
|
||||
|
||||
**Runtime:**
|
||||
- `SENTRY_DSN` - Error tracking DSN
|
||||
- `MAIL_HOST` - SMTP server
|
||||
- `MAIL_PORT` - SMTP port (e.g., `587`)
|
||||
- `MAIL_USERNAME` - SMTP username
|
||||
- `MAIL_PASSWORD` - SMTP password
|
||||
- `MAIL_FROM` - Sender email
|
||||
- `MAIL_RECIPIENTS` - Recipient emails (comma-separated)
|
||||
|
||||
**Infrastructure:**
|
||||
- `REGISTRY_USER` - Docker registry username
|
||||
- `REGISTRY_PASS` - Docker registry password
|
||||
- `ALPHA_SSH_KEY` - SSH private key for deployment server
|
||||
|
||||
**Notifications:**
|
||||
- `GOTIFY_URL` - Gotify notification server URL
|
||||
- `GOTIFY_TOKEN` - Gotify application token
|
||||
|
||||
### That's It!
|
||||
|
||||
**No manual steps required.** Just push to main branch and the workflow will:
|
||||
1. ✅ Build Docker image with NEXT_PUBLIC_* build args
|
||||
2. ✅ Create .env file from all secrets
|
||||
3. ✅ Upload .env to server
|
||||
4. ✅ Secure .env file (600 permissions, deploy:deploy ownership)
|
||||
5. ✅ Pull latest image
|
||||
6. ✅ Deploy with docker-compose
|
||||
|
||||
## Files Changed
|
||||
|
||||
```
|
||||
Modified:
|
||||
├── Dockerfile (removed redundant build args)
|
||||
├── docker-compose.yml (use env_file instead of individual vars)
|
||||
├── .gitea/workflows/deploy.yml (automated .env creation & upload)
|
||||
├── .env.example (clear documentation)
|
||||
├── lib/services/create-services.ts (removed redundant dotenv usage)
|
||||
└── scripts/migrate-*.ts (removed redundant dotenv usage)
|
||||
|
||||
Created:
|
||||
├── .env.production (reference template)
|
||||
├── docs/DEPLOYMENT.md (deployment guide)
|
||||
├── docs/SERVER_SETUP.md (server setup guide)
|
||||
├── docs/ENV_MIGRATION.md (migration guide)
|
||||
└── ENV_CLEANUP_SUMMARY.md (this file)
|
||||
```
|
||||
|
||||
## Deployment Flow
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────────────────────────────┐
|
||||
│ Developer pushes to main branch │
|
||||
└─────────────────────────────────────────────────────────────┘
|
||||
↓
|
||||
┌─────────────────────────────────────────────────────────────┐
|
||||
│ Gitea Workflow Triggered │
|
||||
│ │
|
||||
│ 1. Build Docker image (NEXT_PUBLIC_* build args) │
|
||||
│ 2. Push to registry │
|
||||
│ 3. Generate .env from secrets │
|
||||
│ 4. Upload .env to server via SCP │
|
||||
│ 5. SSH to server and deploy │
|
||||
└─────────────────────────────────────────────────────────────┘
|
||||
↓
|
||||
┌─────────────────────────────────────────────────────────────┐
|
||||
│ Production Server │
|
||||
│ │
|
||||
│ 1. .env file secured (600, deploy:deploy) │
|
||||
│ 2. Docker login to registry │
|
||||
│ 3. Pull latest image │
|
||||
│ 4. docker-compose down │
|
||||
│ 5. docker-compose up -d (loads .env) │
|
||||
│ 6. Health checks pass │
|
||||
└─────────────────────────────────────────────────────────────┘
|
||||
↓
|
||||
┌─────────────────────────────────────────────────────────────┐
|
||||
│ ✅ Deployment Complete - Gotify Notification Sent │
|
||||
└─────────────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
## Comparison: Before vs After
|
||||
|
||||
| Aspect | Before | After |
|
||||
|--------|--------|-------|
|
||||
| **Gitea Secrets** | 15+ secrets | Same secrets, better organized |
|
||||
| **Build Args** | 4 vars (including runtime-only) | 3 vars (NEXT_PUBLIC_* only) |
|
||||
| **Runtime Vars** | Passed via SSH command | Auto-generated .env file |
|
||||
| **Manual Steps** | ❌ Manual .env creation | ✅ Fully automated |
|
||||
| **Maintenance** | Update in 3 places | Update Gitea secrets only |
|
||||
| **Security** | Secrets in CI logs | Secrets masked, .env secured |
|
||||
| **Clarity** | Confusing duplication | Clear separation |
|
||||
| **Robustness** | Fragile SSH command | Robust automation |
|
||||
| **Error-Prone** | ❌ Can forget .env | ✅ Impossible to forget |
|
||||
|
||||
## Documentation
|
||||
|
||||
- **[DEPLOYMENT.md](docs/DEPLOYMENT.md)** - Complete deployment guide
|
||||
- **[SERVER_SETUP.md](docs/SERVER_SETUP.md)** - Server setup instructions (mostly automated now)
|
||||
- **[ENV_MIGRATION.md](docs/ENV_MIGRATION.md)** - Migration from old to new system
|
||||
- **[.env.example](.env.example)** - Environment variables reference
|
||||
- **[.env.production](.env.production)** - Production template (for reference)
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
### Deployment Fails
|
||||
|
||||
1. **Check Gitea secrets** - Ensure all required secrets are set
|
||||
2. **Check workflow logs** - Look for specific error messages
|
||||
3. **SSH to server** - Verify .env file exists and has correct permissions
|
||||
4. **Check container logs** - `docker-compose logs -f app`
|
||||
|
||||
### .env File Issues
|
||||
|
||||
The workflow automatically:
|
||||
- Creates .env from secrets
|
||||
- Uploads to server
|
||||
- Sets 600 permissions
|
||||
- Sets deploy:deploy ownership
|
||||
|
||||
If there are issues, check the workflow logs for the "📝 Preparing environment configuration" step.
|
||||
|
||||
### Missing Environment Variables
|
||||
|
||||
If a variable is missing:
|
||||
1. Add it to Gitea secrets
|
||||
2. Update `.gitea/workflows/deploy.yml` to include it in the .env generation
|
||||
3. Push to trigger new deployment
|
||||
|
||||
---
|
||||
|
||||
**Result**: Environment variable management is now simple, clean, robust, and **fully automated**! 🎉
|
||||
|
||||
No more manual .env file creation. No more forgotten configuration. No more fragile SSH commands. Just push and deploy!
|
||||
29
README.md
29
README.md
@@ -47,11 +47,6 @@ NEXT_PUBLIC_UMAMI_SCRIPT_URL=https://analytics.infra.mintel.me/script.js
|
||||
SENTRY_DSN=https://PUBLIC_KEY@errors.infra.mintel.me/PROJECT_ID
|
||||
NEXT_PUBLIC_SENTRY_DSN=https://PUBLIC_KEY@errors.infra.mintel.me/PROJECT_ID
|
||||
|
||||
# Redis (optional cache)
|
||||
# Platform provides a shared redis container reachable as `redis`.
|
||||
# Pick a dedicated DB index per app, e.g. redis://redis:6379/2
|
||||
REDIS_URL=redis://redis:6379/2
|
||||
REDIS_KEY_PREFIX=klz:
|
||||
```
|
||||
|
||||
## 📊 Project Overview
|
||||
@@ -108,7 +103,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
|
||||
@@ -119,7 +114,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
|
||||
@@ -227,21 +222,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
|
||||
|
||||
@@ -266,7 +270,7 @@ bash scripts/deploy-webhook.sh
|
||||
### Architecture
|
||||
|
||||
```
|
||||
Client → Traefik (TLS) → Varnish (Cache) → Next.js App
|
||||
Client → Traefik (TLS) → Next.js App
|
||||
```
|
||||
|
||||
**Domains**:
|
||||
@@ -276,7 +280,6 @@ Client → Traefik (TLS) → Varnish (Cache) → Next.js App
|
||||
|
||||
**Services**:
|
||||
- `app`: Next.js application (port 3000)
|
||||
- `varnish`: HTTP cache layer
|
||||
- `traefik`: Reverse proxy (external)
|
||||
|
||||
For detailed deployment documentation, see [`docs/DEPLOYMENT.md`](docs/DEPLOYMENT.md).
|
||||
|
||||
@@ -1,10 +1,11 @@
|
||||
import { notFound } from 'next/navigation';
|
||||
import { MDXRemote } from 'next-mdx-remote/rsc';
|
||||
import { Container, Badge } from '@/components/ui';
|
||||
import { Container, Badge, Heading } from '@/components/ui';
|
||||
import { getTranslations } from 'next-intl/server';
|
||||
import { Metadata } from 'next';
|
||||
import { getPageBySlug, getAllPages } from '@/lib/pages';
|
||||
import { mdxComponents } from '@/components/blog/MDXComponents';
|
||||
import { getOGImageMetadata } from '@/lib/metadata';
|
||||
|
||||
interface PageProps {
|
||||
params: {
|
||||
@@ -47,6 +48,7 @@ export async function generateMetadata({ params: { locale, slug } }: PageProps):
|
||||
title: `${pageData.frontmatter.title} | KLZ Cables`,
|
||||
description: pageData.frontmatter.excerpt || '',
|
||||
url: `https://klz-cables.com/${locale}/${slug}`,
|
||||
images: getOGImageMetadata(slug, pageData.frontmatter.title, locale),
|
||||
},
|
||||
twitter: {
|
||||
card: 'summary_large_image',
|
||||
@@ -74,9 +76,9 @@ export default async function StandardPage({ params: { locale, slug } }: PagePro
|
||||
<Container className="relative z-10">
|
||||
<div className="max-w-4xl animate-slide-up">
|
||||
<Badge variant="accent" className="mb-4 md:mb-6">{t('badge')}</Badge>
|
||||
<h1 className="text-4xl md:text-5xl lg:text-6xl font-bold text-white mb-0 leading-tight">
|
||||
<Heading level={1} className="text-white mb-0">
|
||||
{pageData.frontmatter.title}
|
||||
</h1>
|
||||
</Heading>
|
||||
</div>
|
||||
</Container>
|
||||
</section>
|
||||
|
||||
74
app/[locale]/api/og/product/route.tsx
Normal file
74
app/[locale]/api/og/product/route.tsx
Normal file
@@ -0,0 +1,74 @@
|
||||
import { ImageResponse } from 'next/og';
|
||||
import { getProductBySlug } from '@/lib/mdx';
|
||||
import { getTranslations } from 'next-intl/server';
|
||||
import { OGImageTemplate } from '@/components/OGImageTemplate';
|
||||
import { NextRequest } from 'next/server';
|
||||
|
||||
export const runtime = 'nodejs';
|
||||
|
||||
export async function GET(
|
||||
request: NextRequest,
|
||||
{ params }: { params: { locale: string } }
|
||||
) {
|
||||
const { searchParams } = new URL(request.url);
|
||||
const slug = searchParams.get('slug');
|
||||
const locale = params.locale || 'en';
|
||||
|
||||
if (!slug) {
|
||||
return new Response('Missing slug', { status: 400 });
|
||||
}
|
||||
|
||||
const t = await getTranslations({ locale, namespace: 'Products' });
|
||||
|
||||
// Check if it's a category page
|
||||
const categories = ['low-voltage-cables', 'medium-voltage-cables', 'high-voltage-cables', 'solar-cables'];
|
||||
if (categories.includes(slug)) {
|
||||
const categoryKey = slug.replace(/-cables$/, '').replace(/-([a-z])/g, (g) => g[1].toUpperCase());
|
||||
const categoryTitle = t.has(`categories.${categoryKey}.title`) ? t(`categories.${categoryKey}.title`) : slug;
|
||||
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(slug, locale);
|
||||
|
||||
if (!product) {
|
||||
return new ImageResponse(
|
||||
<div style={{ display: 'flex', width: '100%', height: '100%', backgroundColor: '#001a4d' }} />
|
||||
);
|
||||
}
|
||||
|
||||
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]}`)
|
||||
: undefined;
|
||||
|
||||
return new ImageResponse(
|
||||
(
|
||||
<OGImageTemplate
|
||||
title={product.frontmatter.title}
|
||||
description={product.frontmatter.description}
|
||||
label={product.frontmatter.categories?.[0] || 'Product'}
|
||||
image={featuredImage?.startsWith('http') ? featuredImage : undefined}
|
||||
/>
|
||||
),
|
||||
{
|
||||
width: 1200,
|
||||
height: 630,
|
||||
}
|
||||
);
|
||||
}
|
||||
@@ -25,7 +25,7 @@ export default async function Image({ params: { locale, slug } }: { params: { lo
|
||||
title={post.frontmatter.title}
|
||||
description={post.frontmatter.excerpt}
|
||||
label={post.frontmatter.category || 'Blog'}
|
||||
image={featuredImage}
|
||||
image={featuredImage?.startsWith('http') ? featuredImage : undefined}
|
||||
/>
|
||||
),
|
||||
{
|
||||
|
||||
@@ -10,6 +10,8 @@ import PostNavigation from '@/components/blog/PostNavigation';
|
||||
import PowerCTA from '@/components/blog/PowerCTA';
|
||||
import TableOfContents from '@/components/blog/TableOfContents';
|
||||
import { mdxComponents } from '@/components/blog/MDXComponents';
|
||||
import { Heading } from '@/components/ui';
|
||||
import { getOGImageMetadata } from '@/lib/metadata';
|
||||
|
||||
interface BlogPostProps {
|
||||
params: {
|
||||
@@ -42,6 +44,7 @@ export async function generateMetadata({ params: { locale, slug } }: BlogPostPro
|
||||
publishedTime: post.frontmatter.date,
|
||||
authors: ['KLZ Cables'],
|
||||
url: `https://klz-cables.com/${locale}/blog/${slug}`,
|
||||
images: getOGImageMetadata(`blog/${slug}`, post.frontmatter.title, locale),
|
||||
},
|
||||
twitter: {
|
||||
card: 'summary_large_image',
|
||||
@@ -84,9 +87,9 @@ export default async function BlogPost({ params: { locale, slug } }: BlogPostPro
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
<h1 className="text-4xl md:text-5xl lg:text-6xl font-bold text-white mb-8 leading-[1.1] drop-shadow-2xl animate-slight-fade-in-from-bottom [animation-delay:200ms]">
|
||||
<Heading level={1} className="text-white mb-8 drop-shadow-2xl animate-slight-fade-in-from-bottom [animation-delay:200ms]">
|
||||
{post.frontmatter.title}
|
||||
</h1>
|
||||
</Heading>
|
||||
<div className="flex flex-wrap items-center gap-6 text-white/80 text-sm md:text-base font-medium animate-slight-fade-in-from-bottom [animation-delay:400ms]">
|
||||
<time dateTime={post.frontmatter.date}>
|
||||
{new Date(post.frontmatter.date).toLocaleDateString(locale, {
|
||||
@@ -112,9 +115,9 @@ export default async function BlogPost({ params: { locale, slug } }: BlogPostPro
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
<h1 className="text-4xl md:text-5xl font-bold text-text-primary mb-8 leading-tight">
|
||||
<Heading level={1} className="mb-8">
|
||||
{post.frontmatter.title}
|
||||
</h1>
|
||||
</Heading>
|
||||
<div className="flex items-center gap-6 text-text-secondary font-medium">
|
||||
<time dateTime={post.frontmatter.date}>
|
||||
{new Date(post.frontmatter.date).toLocaleDateString(locale, {
|
||||
|
||||
25
app/[locale]/blog/opengraph-image.tsx
Normal file
25
app/[locale]/blog/opengraph-image.tsx
Normal file
@@ -0,0 +1,25 @@
|
||||
import { ImageResponse } from 'next/og';
|
||||
import { getTranslations } from 'next-intl/server';
|
||||
import { OGImageTemplate } from '@/components/OGImageTemplate';
|
||||
|
||||
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');
|
||||
|
||||
return new ImageResponse(
|
||||
(
|
||||
<OGImageTemplate
|
||||
title={title}
|
||||
description={description}
|
||||
label="Blog"
|
||||
/>
|
||||
),
|
||||
{
|
||||
width: 1200,
|
||||
height: 630,
|
||||
}
|
||||
);
|
||||
}
|
||||
@@ -3,6 +3,7 @@ import { getAllPosts } from '@/lib/blog';
|
||||
import { Section, Container, Heading, Card, Badge, Button } from '@/components/ui';
|
||||
import Reveal from '@/components/Reveal';
|
||||
import { getTranslations } from 'next-intl/server';
|
||||
import { getOGImageMetadata } from '@/lib/metadata';
|
||||
|
||||
interface BlogIndexProps {
|
||||
params: {
|
||||
@@ -27,6 +28,7 @@ export async function generateMetadata({ params: { locale } }: BlogIndexProps) {
|
||||
title: `${t('title')} | KLZ Cables`,
|
||||
description: t('description'),
|
||||
url: `https://klz-cables.com/${locale}/blog`,
|
||||
images: getOGImageMetadata('blog', t('title'), locale),
|
||||
},
|
||||
twitter: {
|
||||
card: 'summary_large_image',
|
||||
@@ -69,9 +71,9 @@ export default async function BlogIndex({ params: { locale } }: BlogIndexProps)
|
||||
<Badge variant="saturated" className="mb-4 md:mb-6">{t('featuredPost')}</Badge>
|
||||
{featuredPost && (
|
||||
<>
|
||||
<h1 className="text-3xl md:text-6xl font-extrabold text-white mb-4 md:mb-8 leading-[1.1] line-clamp-3 md:line-clamp-none">
|
||||
<Heading level={1} className="text-white mb-4 md:mb-8">
|
||||
{featuredPost.frontmatter.title}
|
||||
</h1>
|
||||
</Heading>
|
||||
<p className="text-base md:text-xl text-white/80 mb-6 md:mb-10 line-clamp-2 md:line-clamp-2 max-w-2xl">
|
||||
{featuredPost.frontmatter.excerpt}
|
||||
</p>
|
||||
|
||||
@@ -4,6 +4,8 @@ import Reveal from '@/components/Reveal';
|
||||
import { Container, Heading, Section } from '@/components/ui';
|
||||
import { Metadata } from 'next';
|
||||
import { getTranslations } from 'next-intl/server';
|
||||
import { SITE_URL } from '@/lib/schema';
|
||||
import { getOGImageMetadata } from '@/lib/metadata';
|
||||
import { Suspense } from 'react';
|
||||
import dynamic from 'next/dynamic';
|
||||
const LeafletMap = dynamic(() => import('@/components/LeafletMap'), {
|
||||
@@ -40,14 +42,7 @@ export async function generateMetadata({ params: { locale } }: ContactPageProps)
|
||||
description,
|
||||
url: `https://klz-cables.com/${locale}/contact`,
|
||||
siteName: 'KLZ Cables',
|
||||
images: [
|
||||
{
|
||||
url: 'https://klz-cables.com/logo.png',
|
||||
width: 1200,
|
||||
height: 630,
|
||||
alt: 'KLZ Cables Contact',
|
||||
},
|
||||
],
|
||||
images: getOGImageMetadata('contact', title, locale),
|
||||
locale: `${locale.toUpperCase()}_DE`,
|
||||
type: 'website',
|
||||
},
|
||||
@@ -55,7 +50,7 @@ export async function generateMetadata({ params: { locale } }: ContactPageProps)
|
||||
card: 'summary_large_image',
|
||||
title: `${title} | KLZ Cables`,
|
||||
description,
|
||||
images: ['https://klz-cables.com/logo.png'],
|
||||
images: [`${SITE_URL}/${locale}/contact/opengraph-image`],
|
||||
},
|
||||
robots: {
|
||||
index: true,
|
||||
|
||||
@@ -2,11 +2,15 @@ import Footer from '@/components/Footer';
|
||||
import Header from '@/components/Header';
|
||||
import JsonLd from '@/components/JsonLd';
|
||||
import AnalyticsProvider from '@/components/analytics/AnalyticsProvider';
|
||||
import { Viewport } from 'next';
|
||||
import { Metadata, Viewport } from 'next';
|
||||
import { NextIntlClientProvider } from 'next-intl';
|
||||
import { getMessages } from 'next-intl/server';
|
||||
import '../../styles/globals.css';
|
||||
import { SITE_URL } from '@/lib/schema';
|
||||
|
||||
export const metadata: Metadata = {
|
||||
metadataBase: new URL(SITE_URL),
|
||||
};
|
||||
|
||||
export const viewport: Viewport = {
|
||||
width: 'device-width',
|
||||
|
||||
@@ -2,7 +2,7 @@ import { ImageResponse } from 'next/og';
|
||||
import { getTranslations } from 'next-intl/server';
|
||||
import { OGImageTemplate } from '@/components/OGImageTemplate';
|
||||
|
||||
export const runtime = 'edge';
|
||||
export const runtime = 'nodejs';
|
||||
|
||||
export default async function Image({ params: { locale } }: { params: { locale: string } }) {
|
||||
const t = await getTranslations({ locale, namespace: 'Index.meta' });
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import Hero from '@/components/home/Hero';
|
||||
import JsonLd from '@/components/JsonLd';
|
||||
import { getBreadcrumbSchema } from '@/lib/schema';
|
||||
import { getBreadcrumbSchema, SITE_URL } from '@/lib/schema';
|
||||
import ProductCategories from '@/components/home/ProductCategories';
|
||||
import WhatWeDo from '@/components/home/WhatWeDo';
|
||||
import RecentPosts from '@/components/home/RecentPosts';
|
||||
@@ -13,6 +13,7 @@ import CTA from '@/components/home/CTA';
|
||||
import Reveal from '@/components/Reveal';
|
||||
import { getTranslations } from 'next-intl/server';
|
||||
import { Metadata } from 'next';
|
||||
import { getOGImageMetadata } from '@/lib/metadata';
|
||||
|
||||
export default function HomePage({ params: { locale } }: { params: { locale: string } }) {
|
||||
return (
|
||||
@@ -70,6 +71,7 @@ export async function generateMetadata({ params: { locale } }: { params: { local
|
||||
title: `${title} | KLZ Cables`,
|
||||
description,
|
||||
url: `https://klz-cables.com/${locale}`,
|
||||
images: getOGImageMetadata('', title, locale),
|
||||
},
|
||||
twitter: {
|
||||
card: 'summary_large_image',
|
||||
|
||||
@@ -5,12 +5,14 @@ import ProductSidebar from '@/components/ProductSidebar';
|
||||
import ProductTabs from '@/components/ProductTabs';
|
||||
import ProductTechnicalData from '@/components/ProductTechnicalData';
|
||||
import RelatedProducts from '@/components/RelatedProducts';
|
||||
import { Badge, Container, Section } from '@/components/ui';
|
||||
import DatasheetDownload from '@/components/DatasheetDownload';
|
||||
import { Badge, Container, Heading, Section } from '@/components/ui';
|
||||
import { getDatasheetPath } from '@/lib/datasheets';
|
||||
import { getAllProducts, getProductBySlug } from '@/lib/mdx';
|
||||
import { mapFileSlugToTranslated, mapSlugToFileSlug } from '@/lib/slugs';
|
||||
import { Metadata } from 'next';
|
||||
import { getTranslations } from 'next-intl/server';
|
||||
import { getProductOGImageMetadata } from '@/lib/metadata';
|
||||
import { MDXRemote } from 'next-mdx-remote/rsc';
|
||||
import Image from 'next/image';
|
||||
import Link from 'next/link';
|
||||
@@ -51,6 +53,7 @@ export async function generateMetadata({ params }: ProductPageProps): Promise<Me
|
||||
title: `${categoryTitle} | KLZ Cables`,
|
||||
description: categoryDesc,
|
||||
url: `https://klz-cables.com/${locale}/products/${productSlug}`,
|
||||
images: getProductOGImageMetadata(fileSlug, categoryTitle, locale),
|
||||
},
|
||||
twitter: {
|
||||
card: 'summary_large_image',
|
||||
@@ -79,6 +82,7 @@ export async function generateMetadata({ params }: ProductPageProps): Promise<Me
|
||||
description: product.frontmatter.description,
|
||||
type: 'website',
|
||||
url: `https://klz-cables.com/${locale}/products/${slug.join('/')}`,
|
||||
images: getProductOGImageMetadata(productSlug, product.frontmatter.title, locale),
|
||||
},
|
||||
twitter: {
|
||||
card: 'summary_large_image',
|
||||
@@ -164,9 +168,9 @@ export default async function ProductPage({ params }: ProductPageProps) {
|
||||
<span className="mx-3 opacity-30">/</span>
|
||||
<span className="text-white/90">{categoryTitle}</span>
|
||||
</nav>
|
||||
<h1 className="text-5xl md:text-7xl lg:text-8xl font-extrabold text-white mb-8 tracking-tight leading-[1.05]">
|
||||
<Heading level={1} className="text-white mb-8">
|
||||
{categoryTitle}
|
||||
</h1>
|
||||
</Heading>
|
||||
<div className="h-1.5 w-24 bg-accent rounded-full" />
|
||||
</div>
|
||||
</Container>
|
||||
@@ -309,9 +313,9 @@ export default async function ProductPage({ params }: ProductPageProps) {
|
||||
</Badge>
|
||||
))}
|
||||
</div>
|
||||
<h1 className="text-6xl md:text-8xl lg:text-9xl font-black text-white mb-8 tracking-tighter leading-[0.9] uppercase">
|
||||
<Heading level={1} className="text-white mb-8 uppercase">
|
||||
{product.frontmatter.title}
|
||||
</h1>
|
||||
</Heading>
|
||||
<p className="text-xl md:text-2xl text-white/60 max-w-2xl leading-relaxed font-medium">
|
||||
{product.frontmatter.description}
|
||||
</p>
|
||||
@@ -359,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}`}
|
||||
|
||||
@@ -5,10 +5,31 @@ import { OGImageTemplate } from '@/components/OGImageTemplate';
|
||||
|
||||
export const runtime = 'nodejs';
|
||||
|
||||
export default async function Image({ params: { locale, slug } }: { params: { locale: string, slug: string[] } }) {
|
||||
const productSlug = slug[slug.length - 1];
|
||||
export default async function Image({ params: { locale, slug } }: { params: { locale: string, slug?: string[] } }) {
|
||||
const t = await getTranslations('Products');
|
||||
|
||||
// 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)) {
|
||||
@@ -51,7 +72,7 @@ export default async function Image({ params: { locale, slug } }: { params: { lo
|
||||
title={product.frontmatter.title}
|
||||
description={product.frontmatter.description}
|
||||
label={product.frontmatter.categories?.[0] || 'Product'}
|
||||
image={featuredImage}
|
||||
image={featuredImage?.startsWith('http') ? featuredImage : undefined}
|
||||
/>
|
||||
),
|
||||
{
|
||||
|
||||
@@ -1,11 +1,12 @@
|
||||
import Reveal from '@/components/Reveal';
|
||||
import Scribble from '@/components/Scribble';
|
||||
import { Badge, Button, Card, Container, Section } from '@/components/ui';
|
||||
import { Badge, Button, Card, Container, Heading, Section } from '@/components/ui';
|
||||
import { getTranslations } from 'next-intl/server';
|
||||
import { Metadata } from 'next';
|
||||
import Image from 'next/image';
|
||||
import Link from 'next/link';
|
||||
import { mapFileSlugToTranslated } from '@/lib/slugs';
|
||||
import { getOGImageMetadata } from '@/lib/metadata';
|
||||
|
||||
interface ProductsPageProps {
|
||||
params: {
|
||||
@@ -32,6 +33,7 @@ export async function generateMetadata({ params: { locale } }: ProductsPageProps
|
||||
title: `${title} | KLZ Cables`,
|
||||
description,
|
||||
url: `https://klz-cables.com/${locale}/products`,
|
||||
images: getOGImageMetadata('products', title, locale),
|
||||
},
|
||||
twitter: {
|
||||
card: 'summary_large_image',
|
||||
@@ -90,7 +92,7 @@ export default async function ProductsPage({ params }: ProductsPageProps) {
|
||||
<Badge variant="saturated" className="mb-4 md:mb-8 shadow-lg px-3 py-1 md:px-4 md:py-1.5">
|
||||
{t('heroSubtitle')}
|
||||
</Badge>
|
||||
<h1 className="text-4xl md:text-6xl lg:text-7xl font-extrabold text-white mb-4 md:mb-8 tracking-tight leading-[1.05]">
|
||||
<Heading level={1} className="text-white mb-4 md:mb-8">
|
||||
{t.rich('title', {
|
||||
green: (chunks) => (
|
||||
<span className="relative inline-block">
|
||||
@@ -99,7 +101,7 @@ export default async function ProductsPage({ params }: ProductsPageProps) {
|
||||
</span>
|
||||
)
|
||||
})}
|
||||
</h1>
|
||||
</Heading>
|
||||
<p className="text-lg md:text-xl text-white/70 leading-relaxed max-w-2xl mb-8 md:mb-12 line-clamp-2 md:line-clamp-none">
|
||||
{t('subtitle')}
|
||||
</p>
|
||||
|
||||
@@ -15,7 +15,6 @@ 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"
|
||||
/>
|
||||
),
|
||||
{
|
||||
|
||||
@@ -3,6 +3,7 @@ import { Metadata } from 'next';
|
||||
import JsonLd from '@/components/JsonLd';
|
||||
import { getBreadcrumbSchema, SITE_URL } from '@/lib/schema';
|
||||
import { Section, Container, Heading, Badge, Button } from '@/components/ui';
|
||||
import { getOGImageMetadata } from '@/lib/metadata';
|
||||
import Image from 'next/image';
|
||||
import Reveal from '@/components/Reveal';
|
||||
import Gallery from '@/components/team/Gallery';
|
||||
@@ -32,6 +33,7 @@ export async function generateMetadata({ params: { locale } }: TeamPageProps): P
|
||||
title: `${title} | KLZ Cables`,
|
||||
description,
|
||||
url: `https://klz-cables.com/${locale}/team`,
|
||||
images: getOGImageMetadata('team', title, locale),
|
||||
},
|
||||
twitter: {
|
||||
card: 'summary_large_image',
|
||||
@@ -102,9 +104,9 @@ export default async function TeamPage({ params: { locale } }: TeamPageProps) {
|
||||
|
||||
<Container className="relative z-10 text-center text-white max-w-5xl">
|
||||
<Badge variant="saturated" className="mb-4 md:mb-8 shadow-lg">{t('hero.badge')}</Badge>
|
||||
<h1 className="text-3xl md:text-6xl lg:text-7xl font-extrabold tracking-tight leading-[1.1] mb-4 md:mb-8">
|
||||
<Heading level={1} className="text-white mb-4 md:mb-8">
|
||||
{t('hero.subtitle')}
|
||||
</h1>
|
||||
</Heading>
|
||||
<p className="text-lg md:text-2xl text-white/70 font-medium italic">
|
||||
{t('hero.title')}
|
||||
</p>
|
||||
|
||||
68
components/DatasheetDownload.tsx
Normal file
68
components/DatasheetDownload.tsx
Normal file
@@ -0,0 +1,68 @@
|
||||
'use client';
|
||||
|
||||
import { cn } from '@/components/ui/utils';
|
||||
import { useTranslations } from 'next-intl';
|
||||
|
||||
interface DatasheetDownloadProps {
|
||||
datasheetPath: string;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export default function DatasheetDownload({ datasheetPath, className }: DatasheetDownloadProps) {
|
||||
const t = useTranslations('Products');
|
||||
|
||||
return (
|
||||
<div className={cn("mt-8 animate-slight-fade-in-from-bottom", className)}>
|
||||
<a
|
||||
href={datasheetPath}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="group relative block w-full overflow-hidden rounded-[32px] bg-primary-dark p-1 transition-all duration-500 hover:shadow-[0_20px_50px_rgba(0,0,0,0.2)] hover:-translate-y-1"
|
||||
>
|
||||
{/* Animated Background Gradient */}
|
||||
<div className="absolute inset-0 bg-gradient-to-r from-accent via-saturated to-accent opacity-20 group-hover:opacity-40 transition-opacity duration-500 animate-gradient-x" />
|
||||
|
||||
{/* Inner Content */}
|
||||
<div className="relative flex items-center gap-6 rounded-[31px] bg-primary-dark/90 backdrop-blur-xl p-6 md:p-8 border border-white/10">
|
||||
{/* Icon Container */}
|
||||
<div className="relative flex h-16 w-16 flex-shrink-0 items-center justify-center rounded-2xl bg-white/5 border border-white/10 group-hover:bg-accent group-hover:border-white/20 transition-all duration-500">
|
||||
<div className="absolute inset-0 rounded-2xl bg-accent/20 blur-xl opacity-0 group-hover:opacity-100 transition-opacity duration-500" />
|
||||
<svg
|
||||
className="relative h-8 w-8 text-white transition-transform duration-500 group-hover:scale-110 group-hover:rotate-3"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={1.5}
|
||||
d="M12 10v6m0 0l-3-3m3 3l3-3m2 8H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z"
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
|
||||
{/* Text Content */}
|
||||
<div className="flex-1">
|
||||
<div className="flex items-center gap-2 mb-1">
|
||||
<span className="text-[10px] font-black uppercase tracking-[0.2em] text-accent">PDF Datasheet</span>
|
||||
</div>
|
||||
<h3 className="text-xl md:text-2xl font-black text-white uppercase tracking-tighter leading-none group-hover:text-accent transition-colors duration-300">
|
||||
{t('downloadDatasheet')}
|
||||
</h3>
|
||||
<p className="mt-2 text-sm font-medium text-white/60 leading-relaxed">
|
||||
{t('downloadDatasheetDesc')}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Arrow Icon */}
|
||||
<div className="flex h-10 w-10 items-center justify-center rounded-full bg-white/5 text-white/20 group-hover:bg-accent group-hover:text-white group-hover:translate-x-1 transition-all duration-500">
|
||||
<svg className="h-6 w-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2.5} d="M9 5l7 7-7 7" />
|
||||
</svg>
|
||||
</div>
|
||||
</div>
|
||||
</a>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -29,7 +29,6 @@ export function OGImageTemplate({
|
||||
backgroundColor: mode === 'light' ? '#ffffff' : primaryBlue,
|
||||
padding: '80px',
|
||||
position: 'relative',
|
||||
fontFamily: 'Inter, sans-serif',
|
||||
};
|
||||
|
||||
return (
|
||||
@@ -39,13 +38,18 @@ export function OGImageTemplate({
|
||||
<div
|
||||
style={{
|
||||
position: 'absolute',
|
||||
inset: 0,
|
||||
top: 0,
|
||||
left: 0,
|
||||
right: 0,
|
||||
bottom: 0,
|
||||
display: 'flex',
|
||||
}}
|
||||
>
|
||||
<img
|
||||
src={image}
|
||||
alt=""
|
||||
width="1200"
|
||||
height="630"
|
||||
style={{
|
||||
width: '100%',
|
||||
height: '100%',
|
||||
@@ -55,8 +59,11 @@ 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.9), rgba(0,26,77,0.4))',
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
@@ -70,8 +77,8 @@ export function OGImageTemplate({
|
||||
right: '-100px',
|
||||
width: '600px',
|
||||
height: '600px',
|
||||
borderRadius: '50%',
|
||||
background: `radial-gradient(circle, ${accentGreen}1a 0%, transparent 70%)`,
|
||||
borderRadius: '300px',
|
||||
backgroundColor: `${accentGreen}1a`,
|
||||
display: 'flex',
|
||||
}}
|
||||
/>
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -49,6 +49,7 @@ export default function AnalyticsProvider() {
|
||||
id="umami-analytics"
|
||||
src="/stats/script.js"
|
||||
data-website-id={websiteId}
|
||||
data-host-url="/stats"
|
||||
strategy="afterInteractive"
|
||||
data-domains="klz-cables.com"
|
||||
defer
|
||||
|
||||
@@ -19,7 +19,7 @@ export default function Hero() {
|
||||
variants={containerVariants}
|
||||
>
|
||||
<motion.div variants={headingVariants}>
|
||||
<Heading level={1} className="text-center md:text-left mb-6 md:mb-8 md:max-w-none text-white text-4xl sm:text-5xl md:text-7xl [text-shadow:_-2px_-2px_0_#002b49,_2px_-2px_0_#002b49,_-2px_2px_0_#002b49,_2px_2px_0_#002b49,_-2px_0_0_#002b49,_2px_0_0_#002b49,_0_-2px_0_#002b49,_0_2px_0_#002b49]">
|
||||
<Heading level={1} className="text-center md:text-left mb-6 md:mb-8 md:max-w-none text-white text-4xl sm:text-5xl md:text-7xl font-extrabold [text-shadow:_-2px_-2px_0_#002b49,_2px_-2px_0_#002b49,_-2px_2px_0_#002b49,_2px_2px_0_#002b49,_-2px_0_0_#002b49,_2px_0_0_#002b49,_0_-2px_0_#002b49,_0_2px_0_#002b49]">
|
||||
{t.rich('title', {
|
||||
green: (chunks) => (
|
||||
<span className="relative inline-block">
|
||||
@@ -65,7 +65,7 @@ export default function Hero() {
|
||||
</Container>
|
||||
|
||||
<motion.div
|
||||
className="relative md:absolute inset-0 z-0 w-full h-[40vh] md:h-full order-1 md:order-none md:mb-0 mt-[40px] md:mt-0 overflow-visible pointer-events-none"
|
||||
className="relative md:absolute inset-0 z-0 w-full h-[40vh] md:h-full order-1 md:order-none mb-[-80px] md:mb-0 mt-[80px] md:mt-0 overflow-visible pointer-events-none"
|
||||
initial={{ opacity: 0, scale: 0.95, filter: 'blur(20px)' }}
|
||||
animate={{ opacity: 1, scale: 1, filter: 'blur(0px)' }}
|
||||
transition={{ duration: 2.2, ease: 'easeOut', delay: 0.05 }}
|
||||
|
||||
@@ -17,9 +17,9 @@ export function Heading({
|
||||
const Tag = `h${level}` as any;
|
||||
|
||||
const sizes = {
|
||||
1: 'text-3xl md:text-5xl lg:text-6xl font-extrabold leading-[1.1] tracking-tight',
|
||||
2: 'text-2xl md:text-4xl lg:text-5xl font-bold leading-[1.2] tracking-tight',
|
||||
3: 'text-xl md:text-2xl lg:text-3xl font-bold leading-[1.3] tracking-tight',
|
||||
1: 'text-2xl md:text-4xl lg:text-5xl font-bold leading-[1.1] tracking-tight',
|
||||
2: 'text-xl md:text-3xl lg:text-4xl font-bold leading-[1.2] tracking-tight',
|
||||
3: 'text-lg md:text-2xl lg:text-3xl font-bold leading-[1.3] tracking-tight',
|
||||
4: 'text-lg md:text-xl lg:text-2xl font-bold leading-[1.4]',
|
||||
5: 'text-base md:text-lg font-bold leading-[1.5]',
|
||||
6: 'text-base md:text-lg font-semibold leading-[1.6]',
|
||||
|
||||
@@ -5,7 +5,7 @@ featuredImage: null
|
||||
locale: de
|
||||
---
|
||||
|
||||
*Stand November 2024*
|
||||
*Stand Januar 2026*
|
||||
|
||||
## 1. Allgemeines
|
||||
|
||||
@@ -21,13 +21,12 @@ Sofern nicht ausdrücklich als bindend bezeichnet, sind unsere Angebote freiblei
|
||||
|
||||
## 3. Preise
|
||||
|
||||
Alle von uns genannten Preise verstehen sich zzgl. der jeweiligen gesetzlichen Mehrwertsteuer vor Metallzuschlag fracht- frei innerhalb der Bundesrepublik Deutschland (Festland), jedoch ohne Abladen. Die Verkaufspreise, soweit sie als Hohlpreis deklariert sind, enthalten keinerlei Metallwerte. Diese werden zusätzlich separat berechnet.
|
||||
Die Preise gelten für den in unseren Angeboten und Auftragsbestätigungen aufgeführten Leistungs- und Lieferumfang. Mehrleistungen werden gesondert berechnet. Die Hohlpreise verstehen sich in Euro zuzüglich Metallzuschlag, gegebenenfalls Verpackung, auftragsspezifischer Schnittkosten und der gesetzlichen Mehrwertsteuer.
|
||||
|
||||
## 4. Metallnotierung
|
||||
|
||||
Basis zur Kupferabrechnung ist die Notierung "LME Copper official price cash offer", Durchschnitt des Liefervormonats zuzüglich der dann aktuellen von uns benannten Kupfer-Prämie.
|
||||
|
||||
Basis zur Aluminiumabrechnung ist die Notierung "LME Aluminium official price cash offer", Durchschnitt des Liefervormonats zuzüglich der dann von uns benannten Aluminium-Prämie. USD werden auf Basis des EUR/USD LME-FX-Rate (MTLE) in EUR umgerechnet. Die entsprechenden Notierungen können Sie der Web-Seite [www.westmetall.com](https://www.westmetall.com) entnehmen. Die Prämienzuschläge können stark variieren und KLZ behält sich das Recht vor, diese fristgerecht anzupassen, ungeachtet der Angebotslegung.
|
||||
Basis zur Kupferabrechnung ist die Notierung „LME Copper official price cash offer“, Durchschnitt des Liefervormonats zuzüglich der dann aktuellen von uns benannten Kupfer-Prämie.
|
||||
Basis zur Aluminiumabrechnung ist die Notierung „LME Aluminium official price cash offer“, Durchschnitt des Liefervormonats zuzüglich der dann von uns benannten Aluminium-Prämie. USD werden auf Basis des EUR/USD LME-FX-Rate (MTLE) in EUR umgerechnet. Die entsprechenden Notierungen können Sie der Web-Seite [www.westmetall.com](https://www.westmetall.com) entnehmen. Die Prämienzuschläge können stark variieren und KLZ behält sich das Recht vor, diese fristgerecht anzupassen, ungeachtet der Angebotslegung.
|
||||
|
||||
## 5. Metallzahl
|
||||
|
||||
@@ -43,7 +42,7 @@ Wir behalten uns an den von uns gelieferten Waren – nachfolgend: Vorbehaltswar
|
||||
|
||||
## 8. Zahlungsbedingungen | Aufrechnung | Zurückbehaltungsrechte
|
||||
|
||||
Unsere Rechnungen sind 14 Tage nach Rechnungsdatum ohne jeden Abzug zahlbar. Bei Nichteinhaltung der vereinbarten Zahlungsbedingungen sind wir berechtigt, Zinsen in Höhe von 7 %-Punkten über dem Basiszinssatz zu berechnen; das Recht zur Geltendmachung weitergehender Schäden, insbesondere nachgewiesener höherer Zinsen, bleibt hiervon unberührt.
|
||||
Unsere Rechnungen sind 10 Tage nach Rechnungsdatum ohne jeden Abzug zahlbar. Rechnungsstellung bzw. Datum ist grundsätzlich der Tag der Übergabe an den Spediteur soweit wir aus unseren deutschen Lägern liefern. Ansonsten gilt bei Direktimporten der Tag der Verzollung, der zeitnah zum Anliefertag liegt. Bei Nichteinhaltung der vereinbarten Zahlungsbedingungen sind wir berechtigt, Zinsen in Höhe von 7 %-Punkten über dem Basiszinssatz zu berechnen; das Recht zur Geltendmachung weitergehender Schäden, insbesondere nachgewiesener höherer Zinsen, bleibt hiervon unberührt.
|
||||
|
||||
## 9. Liefervorbehalt | Teillieferungen
|
||||
|
||||
@@ -69,13 +68,13 @@ Wird uns ein Abrufauftrag erteilt und werden über die Abruftermine keine gesond
|
||||
|
||||
## 12. Maß- und Gewichtsangaben
|
||||
|
||||
Alle Angaben über Durchmesser, Gewicht, technische Gestaltung, Herstellung und Umfang der von uns zu liefernden Ware stehen unter dem Vorbehalt der Abweichung innerhalb der handelsüblichen zulässigen Toleranzen. Darüber hinaus behalten wir uns Änderungen, die einer technischen Verbesserung dienen, jederzeit vor. Farbabweichungen und/oder Abweichungen in der äußeren Beschaffenheit der von uns zu liefernden Ware, die jedoch deren Qualität und technische Wirksamkeit unbeeinflusst lassen, begründen keine Mängelhaftungsansprüche des Bestellers.
|
||||
Alle Angaben über Durchmesser, Gewicht, technische Gestaltung, Herstellung und Umfang der von uns zu liefernden Ware stehen unter dem Vorbehalt der Abweichung innerhalb der handelsüblichen zulässigen Toleranzen. Darüber hinaus behalten wir uns Änderungen, die einer technischen Verbesserung dienen, jederzeit vor. Farbabweichungen und/oder Abweichungen in der äußeren Beschaffenheit der von uns zu liefernden Ware, die jedoch deren Qualität und technische Wirksamkeit unbeeinflusst lässt, begründen keine Mängelhaftungsansprüche des Bestellers.
|
||||
|
||||
## 13. Gefahrübergang und -tragung
|
||||
|
||||
Die Lieferung erfolgt DAP frei Bestimmungsort Deutschland, wo auch der Erfüllungsort für die Lieferung und eine etwaige Nacherfüllung ist.
|
||||
|
||||
Wird die bestellte Ware von uns versandbereit gestellt und/oder verzögert sich die Versendung und/oder der Abruf aus Gründen, die vom Besteller zu vertreten sind, sind wir berechtigt, Ersatz des hieraus entstehenden Schadens einschließlich Mehraufwendungen zu verlangen. Hierfür berechnen wir eine pauschale Entschädigung i.H.v 2% des Rechnungsbetrages für jeden angefangenen Monat, maximal jedoch 10 % insgesamt beginnend mit der Lieferfrist bzw. – mangels einer Lieferfrist – mit der Mitteilung der Versandbereitschaft der Ware.
|
||||
Wird die bestellte Ware von uns versandbereit gestellt und/oder verzögert sich die Versendung und/oder der Abruf aus Gründen, die vom Besteller zu vertreten sind, sind wir berechtigt, Ersatz des hieraus entstehenden Schadens einschließlich Mehraufwendungen für Einlagerungen zu verlangen. Hierfür berechnen wir eine pauschale Entschädigung i.H. von 2% des Rechnungsbetrages für jeden angefangenen Monat, maximal jedoch 10 % insgesamt beginnend mit der Lieferfrist bzw. – mangels einer Lieferfrist – mit der Mitteilung der Versandbereitschaft der Ware.
|
||||
|
||||
Der Nachweis eines höheren Schadens und unsere gesetzlichen Ansprüche (insbesondere Ersatz von Mehraufwendungen, angemessene Entschädigung, Kündigung) bleiben unberührt; die Pauschale ist aber auf weitergehende Geldansprüche anzurechnen. Dem Besteller bleibt der Nachweis gestattet, dass uns überhaupt kein oder nur ein wesentlich geringerer Schaden als vorstehende Pauschale entstanden ist. Rücksendungen an uns, die nicht vorher von uns schriftlich bestätigt worden sind, erfolgen auf alleinige Gefahr des Bestellers.
|
||||
|
||||
@@ -93,7 +92,9 @@ Weitergehende Ansprüche des Bestellers, gleich aus welchem Rechtsgrund, sind na
|
||||
|
||||
Die Verjährungsfristen für Mängelhaftungsansprüche beträgt 24 Monate ab Übergabe der Ware.
|
||||
|
||||
Sollte es bei einer Mängelrüge zu unterschiedlichen Meinungen bezüglich des Kabelschaden kommen, gilt hier im Zweifelsfall nur die Expertise des VDE-Instituts selbst. Andere, auch akkreditierte Testlabore, akzeptieren wir nicht. Wir weisen ausdrücklich daraufhin, dass beim Verlegen des Kabels in den Graben oder in Rohren, bzw. in Bauwerke eine ständige Sichtkontrolle durch den Kabelverleger vorzunehmen ist, ob Auffälligkeiten zu vermerken sind. Eine spätere Reklamation, die fahrlässiges Verhalten vermuten lässt, schränkt sich damit ein.
|
||||
Sollte es bei einer Mängelrüge zu unterschiedlichen Meinungen bezüglich des Kabelschaden kommen, gilt hier im Zweifelsfall nur die Expertise des VDE-Instituts selbst. Andere, auch akkreditierte Testlabore, akzeptieren wir nicht.
|
||||
|
||||
Wir weisen ausdrücklich daraufhin, dass beim Verlegen des Kabels in den Gräben oder in Rohren, bzw. in Bauwerke eine ständige Sichtkontrolle durch den Kabelverleger vorzunehmen ist, ob Auffälligkeiten zu vermerken sind. Eine spätere Reklamation, die fahrlässiges Verhalten vermuten lässt, schränkt sich damit ein. Dies gilt auch bei der Annahme der Ware, wo offensichtliche Beschädigungen direkt zu kommunizieren sind. Spätere Ansprüche nach Akzeptanz einer einwandfreien Belieferung sind detailliert zu beweisen.
|
||||
|
||||
## 15. Schadenersatz | Gesamthaftung
|
||||
|
||||
@@ -109,4 +110,6 @@ Es gilt ausschließlich das Recht der Bundesrepublik Deutschland unter Ausschlus
|
||||
|
||||
Mit der Veröffentlichung der vorliegenden L&Z im Internet werden alle von uns früher verwendeten Bedingungen gegenstandslos.
|
||||
|
||||
Remshalden, 28.1.2026
|
||||
|
||||
[Download als PDF](/AGB-KLZ-1-2026.pdf)
|
||||
|
||||
@@ -5,7 +5,7 @@ featuredImage: null
|
||||
locale: en
|
||||
---
|
||||
|
||||
*Status November 2024*
|
||||
*Status January 2026*
|
||||
|
||||
## 1. General
|
||||
|
||||
@@ -21,7 +21,7 @@ Unless expressly designated as binding, our offers are non-binding; the customer
|
||||
|
||||
## 3. Prices
|
||||
|
||||
All prices stated by us are understood plus the respective statutory value-added tax, before metal surcharge, freight-free within the Federal Republic of Germany (mainland), however without unloading. The sales prices, insofar as they are declared as hollow prices, contain no metal values whatsoever. These are additionally calculated separately.
|
||||
The prices apply to the scope of services and deliveries listed in our offers and order confirmations. Additional services will be charged separately. The hollow prices are in Euro plus metal surcharge, if applicable packaging, order-specific cutting costs and the statutory value-added tax.
|
||||
|
||||
## 4. Metal quotation
|
||||
|
||||
@@ -43,7 +43,7 @@ We retain title to the goods delivered by us – hereinafter: reserved goods –
|
||||
|
||||
## 8. Payment terms | Offsetting | Right of retention
|
||||
|
||||
Our invoices are payable 14 days after invoice date without any deduction. In case of non-compliance with the agreed payment terms, we are entitled to calculate interest at a rate of 7 percentage points above the base interest rate; the right to assert further damages, in particular proven higher interest, remains unaffected by this.
|
||||
Our invoices are payable 10 days after invoice date without any deduction. Invoicing or date is basically the day of handover to the forwarder as far as we deliver from our German warehouses. Otherwise, in the case of direct imports, the day of customs clearance, which is close to the delivery day, applies. In case of non-compliance with the agreed payment terms, we are entitled to calculate interest at a rate of 7 percentage points above the base interest rate; the right to assert further damages, in particular proven higher interest, remains unaffected by this.
|
||||
|
||||
## 9. Delivery reservation | Partial deliveries
|
||||
|
||||
@@ -69,13 +69,13 @@ If a call-off order is issued to us and no separate written agreements are made
|
||||
|
||||
## 12. Dimension and weight specifications
|
||||
|
||||
All information about diameter, weight, technical design, manufacture and scope of the goods to be delivered by us are subject to the reservation of deviation within the commercially usual permissible tolerances. Darüber hinaus behalten wir uns Änderungen, die einer technischen Verbesserung dienen, jederzeit vor. Color deviations and/or deviations in the external characteristics of the goods to be delivered by us, which however leave their quality and technical effectiveness unaffected, do not give rise to any claims for defects by the orderer.
|
||||
All information about diameter, weight, technical design, manufacture and scope of the goods to be delivered by us are subject to the reservation of deviation within the commercially usual permissible tolerances. Furthermore, we reserve the right to make changes that serve technical improvement at any time. Color deviations and/or deviations in the external characteristics of the goods to be delivered by us, which however leave their quality and technical effectiveness unaffected, do not give rise to any claims for defects by the orderer.
|
||||
|
||||
## 13. Transfer of risk and burden
|
||||
|
||||
Delivery is made DAP free destination Germany, where the place of performance for delivery and any subsequent performance is also located.
|
||||
|
||||
If the ordered goods are made ready for shipment by us and/or the dispatch and/or the call-off is delayed for reasons for which the orderer is responsible, we are entitled to demand compensation for the damage resulting therefrom including additional expenses. For this, we calculate a flat-rate compensation of 2% of the invoice amount for each month started, however maximum 10% in total, starting with the delivery period or – lacking a delivery period – with the notification of readiness for shipment of the goods.
|
||||
If the ordered goods are made ready for shipment by us and/or the dispatch and/or the call-off is delayed for reasons for which the orderer is responsible, we are entitled to demand compensation for the damage resulting therefrom including additional expenses for storage. For this, we calculate a flat-rate compensation of 2% of the invoice amount for each month started, however maximum 10% in total, starting with the delivery period or – lacking a delivery period – with the notification of readiness for shipment of the goods.
|
||||
|
||||
Proof of higher damage and our legal claims (in particular compensation for additional expenses, reasonable compensation, termination) remain unaffected; the flat-rate is however to be credited against further monetary claims. The orderer is permitted to prove that no damage or only a substantially lower damage than the aforementioned flat-rate has arisen for us. Returns to us, which have not been confirmed by us in writing beforehand, are at the sole risk of the orderer.
|
||||
|
||||
@@ -93,7 +93,9 @@ Further claims of the orderer, regardless of the legal basis, are excluded or li
|
||||
|
||||
The limitation periods for claims for defects are 24 months from delivery of the goods.
|
||||
|
||||
If there are different opinions regarding cable damage in the event of a defect notification, only the expertise of the VDE Institute itself applies in case of doubt. We do not accept other, even accredited test laboratories. We expressly point out that when laying the cable in the trench or in pipes, or in buildings, a constant visual inspection must be carried out by the cable layer to check for any noticeable features. A later complaint that suggests negligent behavior is thus restricted.
|
||||
If there are different opinions regarding cable damage in the event of a defect notification, only the expertise of the VDE Institute itself applies in case of doubt. We do not accept other, even accredited test laboratories.
|
||||
|
||||
We expressly point out that when laying the cable in the trench or in pipes, or in buildings, a constant visual inspection must be carried out by the cable layer to check for any noticeable features. A later complaint that suggests negligent behavior is thus restricted. This also applies to the acceptance of the goods, where obvious damage must be communicated directly. Subsequent claims after acceptance of a faultless delivery must be proven in detail.
|
||||
|
||||
## 15. Damages | Total liability
|
||||
|
||||
@@ -109,4 +111,6 @@ Only the law of the Federal Republic of Germany applies, excluding the UN Conven
|
||||
|
||||
With the publication of these DPT on the Internet, all previously used conditions of ours become void.
|
||||
|
||||
Remshalden, January 28, 2026
|
||||
|
||||
[Download as PDF](/AGB-KLZ-1-2026.pdf)
|
||||
|
||||
@@ -10,6 +10,12 @@ categories:
|
||||
- Solarkabel
|
||||
images:
|
||||
- /uploads/2025/06/H1Z2Z2-K-scaled.webp
|
||||
application: >
|
||||
Das H1Z2Z2-K entspricht der Norm DIN EN 50618 (VDE 0283-618) und ist speziell für die
|
||||
Verkabelung von Photovoltaiksystemen konzipiert. Es kann fest verlegt oder flexibel
|
||||
geführt werden – im Gebäude, im Freien, in Industrieanlagen, landwirtschaftlichen
|
||||
Betrieben oder sogar in explosionsgefährdeten Bereichen. Die Leitung ist UV-, ozon-
|
||||
und wasserbeständig (AD7) und darf direkt in der Erde verlegt werden.
|
||||
locale: de
|
||||
---
|
||||
<ProductTabs technicalData={<ProductTechnicalData data={{
|
||||
|
||||
@@ -10,6 +10,12 @@ categories:
|
||||
- Niederspannungskabel
|
||||
images:
|
||||
- /uploads/2025/01/N2X2Y-scaled.webp
|
||||
application: >
|
||||
Das N2X2Y entspricht den Normen HD 603 S1 Teil 5G und HD 627 S1 Teil 4H (gleichlautend
|
||||
mit DIN VDE 0276-603 und -627) und ist für eine Betriebsfrequenz von 50 Hz ausgelegt.
|
||||
Es eignet sich für die feste Verlegung in Innenräumen, im Erdreich, im Freien und in
|
||||
Industrieumgebungen mit hohen Temperatur- und Belastungsanforderungen. Die maximale
|
||||
Betriebstemperatur liegt bei +90 °C, im Kurzschlussfall sind +250 °C zulässig.
|
||||
locale: de
|
||||
---
|
||||
<ProductTabs technicalData={<ProductTechnicalData data={{
|
||||
|
||||
@@ -9,6 +9,12 @@ description: >
|
||||
categories:
|
||||
- Hochspannungskabel
|
||||
images: []
|
||||
application: >
|
||||
Die N2X(F)K2Y-Hochspannungskabelserie ist speziell für den Einsatz in
|
||||
Hochspannungsanwendungen konzipiert, wobei sie eine optimale Leistung durch die
|
||||
Verwendung von hochleitfähigem Kupfer und einer fortschrittlichen XLPE-Isolierung
|
||||
bietet. Diese Kombination gewährleistet eine hohe Durchschlagsfestigkeit und eine
|
||||
effiziente Thermozyklierung unter verschiedenen Betriebsbedingungen.
|
||||
locale: de
|
||||
---
|
||||
<ProductTabs technicalData={<ProductTechnicalData data={{
|
||||
|
||||
@@ -9,6 +9,13 @@ description: >
|
||||
categories:
|
||||
- Hochspannungskabel
|
||||
images: []
|
||||
application: >
|
||||
Die N2X(F)KLD2Y-Hochspannungskabel Serie 2 sind speziell für den Einsatz in
|
||||
Hochspannungsanwendungen konzipiert, wobei sie eine optimale Leistung durch die
|
||||
Verwendung von hochleitfähigen Kupferleitern und einer fortschrittlichen
|
||||
XLPE-Isolierung bieten. Diese Kabelserie ist besonders geeignet für anspruchsvolle
|
||||
industrielle Umgebungen, wo hohe Durchschlagsfestigkeit und
|
||||
Thermozyklierungsfähigkeit erforderlich sind.
|
||||
locale: de
|
||||
---
|
||||
<ProductTabs technicalData={<ProductTechnicalData data={{
|
||||
|
||||
@@ -10,6 +10,12 @@ categories:
|
||||
- Mittelspannungskabel
|
||||
images:
|
||||
- /uploads/2025/01/N2XS2Y-scaled.webp
|
||||
application: >
|
||||
Das N2XS2Y erfüllt die Normen DIN VDE 0276-620, HD 620 S2 und IEC 60502. Es eignet sich
|
||||
zur Verlegung in Innenräumen, Kabelkanälen, im Freien, im Wasser, auf Kabelpritschen
|
||||
und insbesondere im Erdreich. Aufgrund seines widerstandsfähigen Mantels wird es
|
||||
häufig in Industrieanlagen, Kraftwerken und Schaltstationen eingesetzt, wo Stabilität
|
||||
und Langlebigkeit gefordert sind.
|
||||
locale: de
|
||||
---
|
||||
<ProductTabs technicalData={<ProductTechnicalData data={{
|
||||
|
||||
@@ -10,6 +10,12 @@ categories:
|
||||
- Mittelspannungskabel
|
||||
images:
|
||||
- /uploads/2025/01/N2XSF2Y-3-scaled.webp
|
||||
application: >
|
||||
Das N2XS(F)2Y erfüllt die gängigen Normen DIN VDE 0276-620, HD 620 S2 und IEC 60502 und
|
||||
ist für die Verlegung in Innenräumen, Kabelkanälen, im Freien, in Wasser, Erde und auf
|
||||
Kabelpritschen geeignet. Besonders in EVU-Netzen, Industrieanlagen und Kraftwerken
|
||||
spielt dieses Kabel seine Stärken aus – überall dort, wo Langlebigkeit,
|
||||
Wasserdichtigkeit und Sicherheit gefragt sind.
|
||||
locale: de
|
||||
---
|
||||
<ProductTabs technicalData={<ProductTechnicalData data={{
|
||||
|
||||
@@ -10,6 +10,12 @@ categories:
|
||||
- Hochspannungskabel
|
||||
images:
|
||||
- /uploads/2025/06/N2XSFL2Y-3-scaled.webp
|
||||
application: >
|
||||
Das N2XS(FL)2Y ist konzipiert für die Verlegung im Erdreich, in Kabelkanälen, in Rohren,
|
||||
im Freien und in Innenräumen. Es entspricht der Norm IEC 60840 und lässt sich
|
||||
individuell auf projektspezifische Anforderungen anpassen. Typisch eingesetzt wird es
|
||||
in Übertragungsnetzen, Umspannwerken und großen Industrieanlagen, wo maximale
|
||||
Zuverlässigkeit gefordert ist.
|
||||
locale: de
|
||||
---
|
||||
<ProductTabs technicalData={<ProductTechnicalData data={{
|
||||
|
||||
@@ -10,6 +10,12 @@ categories:
|
||||
- Mittelspannungskabel
|
||||
images:
|
||||
- /uploads/2025/01/N2XSFL2Y-2-scaled.webp
|
||||
application: >
|
||||
Das N2XS(FL)2Y erfüllt die Standards DIN VDE 0276-620, HD 620 S2 und IEC 60502. Es
|
||||
eignet sich hervorragend für die Verlegung in Innenräumen, Kabelkanälen, im Freien, in
|
||||
Erde, im Wasser sowie auf Kabelpritschen – insbesondere in EVU-Netzen,
|
||||
Industrieanlagen und Schaltstationen, wo erhöhte Anforderungen an mechanische
|
||||
Belastbarkeit und Wasserdichtigkeit bestehen.
|
||||
locale: de
|
||||
---
|
||||
<ProductTabs technicalData={<ProductTechnicalData data={{
|
||||
|
||||
@@ -10,6 +10,12 @@ categories:
|
||||
- Mittelspannungskabel
|
||||
images:
|
||||
- /uploads/2025/01/N2XSY-scaled.webp
|
||||
application: >
|
||||
Das N2XSY erfüllt die Normen DIN VDE 0276-620, HD 620 S2 und IEC 60502. Es ist
|
||||
ausgelegt für die Verlegung in Innenräumen, Kabelkanälen, im Wasser, im Erdreich oder
|
||||
im Freien (bei geschützter Installation). Ob in Industrieanlagen, Kraftwerken oder
|
||||
Schaltanlagen – dieses Kabel sorgt für eine sichere und verlustarme
|
||||
Energieübertragung im Mittelspannungsbereich.
|
||||
locale: de
|
||||
---
|
||||
<ProductTabs technicalData={<ProductTechnicalData data={{
|
||||
|
||||
@@ -10,6 +10,13 @@ categories:
|
||||
- Niederspannungskabel
|
||||
images:
|
||||
- /uploads/2025/01/N2XY-scaled.webp
|
||||
application: >
|
||||
Das N2XY wird in Niederspannungsanlagen zur Energieverteilung eingesetzt – zum Beispiel
|
||||
in Kabeltrassen, Rohren, auf Wänden oder direkt im Erdreich. Es lässt sich sowohl im
|
||||
Innen- als auch im Außenbereich installieren und ist auch für feuchte Umgebungen
|
||||
geeignet. Dank verschiedener Aderkonfigurationen (einadrig bis vieradrig) und
|
||||
Querschnitten bis 630 mm² lässt sich das Kabel flexibel an die jeweilige Anwendung
|
||||
anpassen.
|
||||
locale: de
|
||||
---
|
||||
<ProductTabs technicalData={<ProductTechnicalData data={{
|
||||
|
||||
@@ -10,6 +10,12 @@ categories:
|
||||
- Niederspannungskabel
|
||||
images:
|
||||
- /uploads/2025/01/NA2X2Y-scaled.webp
|
||||
application: >
|
||||
Das NA2X2Y entspricht der Norm DIN VDE 0276-603 (HD 603) und ist ausgelegt für die
|
||||
feste Verlegung in Innenräumen, Kabelkanälen, im Erdreich, im Wasser oder im Freien.
|
||||
Es kommt bevorzugt in Kraftwerken, Industrieanlagen, Schaltanlagen und Ortsnetzen zum
|
||||
Einsatz – überall dort, wo robuste Kabel gefragt sind, die im Betrieb und bei der
|
||||
Verlegung hohen mechanischen Belastungen standhalten.
|
||||
locale: de
|
||||
---
|
||||
<ProductTabs technicalData={<ProductTechnicalData data={{
|
||||
|
||||
@@ -9,6 +9,12 @@ description: >
|
||||
categories:
|
||||
- Hochspannungskabel
|
||||
images: []
|
||||
application: >
|
||||
Die NA2X(F)K2Y-Hochspannungskabelserie ist speziell für den Einsatz in
|
||||
Hochspannungsanwendungen entwickelt worden, wobei sie sich durch eine hohe
|
||||
Strombelastbarkeit und exzellente Kurzschlussstromfestigkeit auszeichnet. Diese Kabel
|
||||
sind mit einer fortschrittlichen XLPE-Isolierung ausgestattet, die eine hohe
|
||||
Durchschlagsfestigkeit and Thermozyklierungsfähigkeit bietet.
|
||||
locale: de
|
||||
---
|
||||
<ProductTabs technicalData={<ProductTechnicalData data={{
|
||||
|
||||
@@ -9,6 +9,12 @@ description: >
|
||||
categories:
|
||||
- Hochspannungskabel
|
||||
images: []
|
||||
application: >
|
||||
Die NA2X(F)KLD2Y-Hochspannungskabelserie ist für den Einsatz in
|
||||
Hochspannungsanwendungen konzipiert und bietet eine optimale Lösung für
|
||||
anspruchsvolle industrielle Umgebungen. Mit ihrer fortschrittlichen
|
||||
XLPE-Isolierung und Kupferleitern erfüllt sie hohe Anforderungen an die
|
||||
Durchschlagsfestigkeit und Thermozyklierung.
|
||||
locale: de
|
||||
---
|
||||
<ProductTabs technicalData={<ProductTechnicalData data={{
|
||||
|
||||
@@ -10,6 +10,12 @@ categories:
|
||||
- Mittelspannungskabel
|
||||
images:
|
||||
- /uploads/2025/01/NA2XS2Y-scaled.webp
|
||||
application: >
|
||||
Das NA2XS2Y erfüllt die Normen DIN VDE 0276-620, HD 620 S2 und IEC 60502 und ist
|
||||
speziell für die feste Verlegung in Innenräumen, Kabelkanälen, im Freien, in Erde und
|
||||
in Wasser ausgelegt. Es findet seinen Einsatz in Industrieanlagen, Schaltstationen
|
||||
und Kraftwerken, besonders dort, wo das Kabel beim Verlegen oder im Betrieb
|
||||
mechanisch stark beansprucht wird.
|
||||
locale: de
|
||||
---
|
||||
<ProductTabs technicalData={<ProductTechnicalData data={{
|
||||
|
||||
@@ -10,6 +10,12 @@ categories:
|
||||
- Mittelspannungskabel
|
||||
images:
|
||||
- /uploads/2025/01/NA2XSF2Y-3-scaled.webp
|
||||
application: >
|
||||
Das NA2XS(F)2Y entspricht den Normen DIN VDE 0276-620, HD 620 S2 und IEC 60502. Es
|
||||
eignet sich für die Verlegung in Innenräumen, Kabelkanälen, im Erdreich, im Wasser, im
|
||||
Freien oder auf Kabelpritschen. Der Einsatzschwerpunkt liegt in EVU-Netzen,
|
||||
Industrieanlagen und Umspannwerken, wo zusätzliche Sicherheitsreserven gegen
|
||||
eindringende Feuchtigkeit und mechanische Belastung erforderlich sind.
|
||||
locale: de
|
||||
---
|
||||
<ProductTabs technicalData={<ProductTechnicalData data={{
|
||||
|
||||
@@ -10,6 +10,12 @@ categories:
|
||||
- Hochspannungskabel
|
||||
images:
|
||||
- /uploads/2025/06/NA2XSFL2Y-3-scaled.webp
|
||||
application: >
|
||||
Das NA2XS(FL)2Y erfüllt die Anforderungen der IEC 60840 und eignet sich für die
|
||||
Verlegung im Erdreich, in Kabelkanälen, in Innenräumen, in Rohren und im Freien. Es
|
||||
wird projektbezogen gefertigt und kommt insbesondere in Übertragungsnetzen,
|
||||
Versorgungs-Infrastrukturen und Umspannwerken zum Einsatz, wo Sicherheit und
|
||||
Langlebigkeit an erster Stelle stehen.
|
||||
locale: de
|
||||
---
|
||||
<ProductTabs technicalData={<ProductTechnicalData data={{
|
||||
|
||||
@@ -10,6 +10,13 @@ categories:
|
||||
- Mittelspannungskabel
|
||||
images:
|
||||
- /uploads/2025/01/NA2XSFL2Y-3-scaled.webp
|
||||
application: >
|
||||
Das NA2XS(FL)2Y erfüllt die Normen DIN VDE 0276-620, HD 620 S2 und IEC 60502. Es ist
|
||||
ideal für die Verlegung in Energieversorgungsnetzen (EVU), Innenräumen, Kabelkanälen,
|
||||
im Freien, in Erde und in Wasser geeignet. Dank seiner Konstruktion mit
|
||||
längswasserdichter Ausführung und Alu-PE-Schichtenmantel bleibt es auch bei
|
||||
Beschädigungen betriebssicher – der Wassereinfluss wird gezielt auf die Schadstelle
|
||||
begrenzt.
|
||||
locale: de
|
||||
---
|
||||
<ProductTabs technicalData={<ProductTechnicalData data={{
|
||||
|
||||
@@ -11,6 +11,12 @@ categories:
|
||||
- Mittelspannungskabel
|
||||
images:
|
||||
- /uploads/2025/01/NA2XSY-scaled.webp
|
||||
application: >
|
||||
Das NA2XSY erfüllt die Anforderungen der Normen DIN VDE 0276-620, HD 620 S2 und IEC
|
||||
60502. Es eignet sich für die Verlegung in Innenräumen, Kabelkanälen, im Erdreich, im
|
||||
Wasser oder im Freien – allerdings nur bei geschützter Verlegung. Typische
|
||||
Einsatzorte sind Industrieanlagen, Kraftwerke und Schaltanlagen, in denen
|
||||
Mittelspannung mit hoher Betriebssicherheit transportiert werden muss.
|
||||
locale: de
|
||||
---
|
||||
<ProductTabs technicalData={<ProductTechnicalData data={{
|
||||
|
||||
@@ -10,6 +10,11 @@ categories:
|
||||
- Niederspannungskabel
|
||||
images:
|
||||
- /uploads/2025/01/NA2XY-scaled.webp
|
||||
application: >
|
||||
Das NA2XY entspricht der Norm DIN VDE 0276-603 (HD 603) und ist ideal für die feste
|
||||
Verlegung in Innenräumen, Kabelkanälen, im Freien, im Wasser oder im Erdreich
|
||||
geeignet. Typische Einsatzorte sind Kraftwerke, Industrieanlagen, Schaltanlagen sowie
|
||||
Ortsnetze, bei denen mechanische Belastung im Betrieb berücksichtigt werden muss.
|
||||
locale: de
|
||||
---
|
||||
<ProductTabs technicalData={<ProductTechnicalData data={{
|
||||
|
||||
@@ -11,6 +11,12 @@ categories:
|
||||
- Niederspannungskabel
|
||||
images:
|
||||
- /uploads/2025/01/NAY2Y-scaled.webp
|
||||
application: >
|
||||
Das NAY2Y erfüllt die Anforderungen der Norm TP PRAKAB 12/03 in Anlehnung an VDE
|
||||
0276-603 und eignet sich für die feste Verlegung in Innenräumen, Kabelkanälen, im
|
||||
Erdreich, im Wasser und im Außenbereich. Es ist ideal für Anwendungen in Kraftwerken,
|
||||
Industrie- und Schaltanlagen sowie in lokalen Versorgungsnetzen – überall dort, wo
|
||||
mechanische Belastung im Betrieb oder bei der Verlegung eine Rolle spielt.
|
||||
locale: de
|
||||
---
|
||||
<ProductTabs technicalData={<ProductTechnicalData data={{
|
||||
|
||||
@@ -10,6 +10,12 @@ categories:
|
||||
- Niederspannungskabel
|
||||
images:
|
||||
- /uploads/2025/01/NAYCWY-scaled.webp
|
||||
application: >
|
||||
Das NAYCWY entspricht der Norm DIN VDE 0276-603 (HD 603) und eignet sich für den
|
||||
Einsatz in Kraftwerken, Industrieanlagen, Schaltanlagen und Ortsnetzen. Es lässt sich
|
||||
fest verlegen – in Innenräumen, Kabelkanälen, im Freien, im Erdreich oder in Wasser.
|
||||
Dank des konzentrischen Leiters bietet es zusätzlichen Schutz bei mechanischer
|
||||
Beschädigung und ermöglicht eine sichere Potenzialführung.
|
||||
locale: de
|
||||
---
|
||||
<ProductTabs technicalData={<ProductTechnicalData data={{
|
||||
|
||||
@@ -11,6 +11,12 @@ categories:
|
||||
- Niederspannungskabel
|
||||
images:
|
||||
- /uploads/2025/01/NAYY-scaled.webp
|
||||
application: >
|
||||
Das NAYY ist ein Energieverteilungskabel nach VDE 0276-603, das sich besonders für
|
||||
Anwendungen in Kraftwerken, Ortsnetzen, Industrie- und Schaltanlagen eignet. Dank
|
||||
seiner robusten Konstruktion lässt es sich fest verlegen – sei es im Innenraum, im
|
||||
Kabelkanal, im Freien oder im Erdreich. Auch bei Installation in Wasser bleibt das
|
||||
Kabel zuverlässig im Betrieb.
|
||||
locale: de
|
||||
---
|
||||
<ProductTabs technicalData={<ProductTechnicalData data={{
|
||||
|
||||
@@ -10,6 +10,12 @@ categories:
|
||||
- Niederspannungskabel
|
||||
images:
|
||||
- /uploads/2025/01/NY2Y-scaled.webp
|
||||
application: >
|
||||
Das NY2Y ist ein Niederspannungskabel für den Einsatz in Kraftwerken, Industrie- und
|
||||
Schaltanlagen sowie in Ortsnetzen. Es eignet sich für die feste Verlegung in
|
||||
Innenräumen, Kabelkanälen, im Freien, im Wasser und im Erdreich – überall dort, wo
|
||||
starke mechanische Belastungen beim Verlegen und im Betrieb zu erwarten sind. Die
|
||||
Konstruktion erfüllt die Vorgaben gemäß TP PRAKAB 16/03 in Anlehnung an VDE 0276-603.
|
||||
locale: de
|
||||
---
|
||||
<ProductTabs technicalData={<ProductTechnicalData data={{
|
||||
|
||||
@@ -10,6 +10,13 @@ categories:
|
||||
- Niederspannungskabel
|
||||
images:
|
||||
- /uploads/2025/01/NYCWY-scaled.webp
|
||||
application: >
|
||||
Das NYCWY gehört zu den klassischen Niederspannungskabeln nach VDE-Standard und ist
|
||||
für Nennspannungen bis 1 kV ausgelegt. Es kommt überall dort zum Einsatz, wo Energie
|
||||
zuverlässig verteilt werden muss – in Gebäuden, Industrieanlagen, Trafostationen oder
|
||||
direkt im Erdreich. Auch in Kabeltrassen, Betonumgebungen oder unter Wasser lässt es
|
||||
sich problemlos verlegen. Die Materialwahl sorgt dafür, dass dieses Kabel selbst
|
||||
unter rauen Bedingungen durchhält – ganz ohne zusätzliche Schutzmaßnahmen.
|
||||
locale: de
|
||||
---
|
||||
<ProductTabs technicalData={<ProductTechnicalData data={{
|
||||
|
||||
@@ -9,6 +9,9 @@ categories:
|
||||
- Niederspannungskabel
|
||||
images:
|
||||
- /uploads/2025/01/NYY-scaled.webp
|
||||
application: |
|
||||
Verwendung
|
||||
Das Kabel entspricht den Normen DIN VDE 0276-603. Es eignet sich für die Verlegung in Innenräumen, Kabelkanälen, im Erdreich, im Wasser, im Freien oder auf Kabelpritschen, sofern keine besonderen mechanischen Beanspruchungen zu erwarten sind. Der Einsatzschwerpunkt liegt in Kraftwerken, Industrieanlagen und Ortsnetzen.
|
||||
locale: de
|
||||
---
|
||||
<ProductTabs technicalData={<ProductTechnicalData data={{
|
||||
|
||||
@@ -9,6 +9,12 @@ categories:
|
||||
- Solar Cables
|
||||
images:
|
||||
- /uploads/2025/06/H1Z2Z2-K-scaled.webp
|
||||
application: >
|
||||
The H1Z2Z2-K complies with DIN EN 50618 (VDE 0283-618) and is specifically designed for
|
||||
the cabling of photovoltaic systems. It can be installed permanently or used flexibly –
|
||||
indoors, outdoors, in industrial facilities, agricultural operations, or even in
|
||||
hazardous (explosive) areas. The cable is UV-, ozone- and water-resistant (AD7) and
|
||||
can be laid directly in the ground.
|
||||
locale: en
|
||||
---
|
||||
<ProductTabs technicalData={<ProductTechnicalData data={{
|
||||
|
||||
@@ -10,6 +10,12 @@ categories:
|
||||
- Low Voltage Cables
|
||||
images:
|
||||
- /uploads/2025/01/N2X2Y-scaled.webp
|
||||
application: >
|
||||
The N2X2Y complies with HD 603 S1 Part 5G and HD 627 S1 Part 4H (equivalent to DIN VDE
|
||||
0276-603 and -627) and is designed for an operating frequency of 50 Hz. It is suitable
|
||||
for fixed installation indoors, underground, outdoors, and in industrial environments
|
||||
with high temperature and load requirements. The maximum operating temperature is
|
||||
+90 °C, and +250 °C is permissible under short-circuit conditions.
|
||||
locale: en
|
||||
---
|
||||
<ProductTabs technicalData={<ProductTechnicalData data={{
|
||||
|
||||
@@ -10,6 +10,12 @@ description: >
|
||||
categories:
|
||||
- High Voltage Cables
|
||||
images: []
|
||||
application: >
|
||||
The N2X(F)K2Y high-voltage cable series is tailored for robust performance in
|
||||
high-voltage power systems, featuring copper conductors and cross-linked
|
||||
polyethylene (XLPE) insulation. This combination ensures high dielectric strength
|
||||
and excellent thermal cycling resistance, crucial for maintaining integrity and
|
||||
functionality over long operational periods.
|
||||
locale: en
|
||||
---
|
||||
<ProductTabs technicalData={<ProductTechnicalData data={{
|
||||
|
||||
@@ -8,6 +8,11 @@ description: >
|
||||
categories:
|
||||
- High Voltage Cables
|
||||
images: []
|
||||
application: >
|
||||
The N2X(F)KLD2Y-high-voltage-cables-2 series is engineered to meet the rigorous
|
||||
demands of high-voltage power transmission with a focus on durability, efficiency,
|
||||
and safety. This series is ideal for applications requiring high dielectric
|
||||
strength and excellent thermal cycling resistance.
|
||||
locale: en
|
||||
---
|
||||
<ProductTabs technicalData={<ProductTechnicalData data={{
|
||||
|
||||
@@ -10,6 +10,12 @@ categories:
|
||||
- Medium Voltage Cables
|
||||
images:
|
||||
- /uploads/2025/01/N2XS2Y-scaled.webp
|
||||
application: >
|
||||
The N2XS2Y complies with the standards DIN VDE 0276-620, HD 620 S2 and IEC 60502. It is
|
||||
suitable for installation indoors, in cable ducts, outdoors, in water, on cable
|
||||
trays, and especially underground. Thanks to its robust sheath, it is frequently
|
||||
used in industrial plants, power stations, and switching stations, where stability
|
||||
and durability are essential.
|
||||
locale: en
|
||||
---
|
||||
<ProductTabs technicalData={<ProductTechnicalData data={{
|
||||
|
||||
@@ -10,6 +10,12 @@ categories:
|
||||
- Medium Voltage Cables
|
||||
images:
|
||||
- /uploads/2025/01/N2XSF2Y-3-scaled.webp
|
||||
application: >
|
||||
The N2XSF2Y complies with common standards DIN VDE 0276-620, HD 620 S2 and IEC 60502,
|
||||
and is suitable for installation indoors, in cable ducts, outdoors, in water,
|
||||
underground, and on cable trays. This cable proves its strengths especially in
|
||||
utility grids, industrial plants, and power stations – wherever durability,
|
||||
watertightness, and safety are essential.
|
||||
locale: en
|
||||
---
|
||||
<ProductTabs technicalData={<ProductTechnicalData data={{
|
||||
|
||||
@@ -10,6 +10,12 @@ categories:
|
||||
- High Voltage Cables
|
||||
images:
|
||||
- /uploads/2025/06/N2XSFL2Y-3-scaled.webp
|
||||
application: >
|
||||
The N2XS(FL)2Y is designed for installation in the ground, in cable ducts, pipes,
|
||||
outdoor areas, and indoor spaces. It complies with the IEC 60840 standard and can be
|
||||
tailored to specific project requirements. It is typically used in transmission
|
||||
networks, substations, and large industrial facilities where maximum reliability is
|
||||
essential.
|
||||
locale: en
|
||||
---
|
||||
<ProductTabs technicalData={<ProductTechnicalData data={{
|
||||
|
||||
@@ -10,6 +10,11 @@ categories:
|
||||
- Medium Voltage Cables
|
||||
images:
|
||||
- /uploads/2025/01/N2XSFL2Y-2-scaled.webp
|
||||
application: >
|
||||
The N2XS(FL)2Y meets the standards DIN VDE 0276-620, HD 620 S2 and IEC 60502. It is
|
||||
ideally suited for installation indoors, in cable ducts, outdoors, in soil, in water,
|
||||
and on cable trays – especially in utility grids, industrial plants, and switching
|
||||
stations, where high demands on mechanical strength and water resistance apply.
|
||||
locale: en
|
||||
---
|
||||
<ProductTabs technicalData={<ProductTechnicalData data={{
|
||||
|
||||
@@ -10,6 +10,11 @@ categories:
|
||||
- Medium Voltage Cables
|
||||
images:
|
||||
- /uploads/2025/01/N2XSY-scaled.webp
|
||||
application: >
|
||||
The N2XSY meets the standards DIN VDE 0276-620, HD 620 S2 and IEC 60502. It is designed
|
||||
for installation indoors, in cable ducts, in water, underground, or outdoors (when
|
||||
protected). Whether in industrial plants, power stations, or substations – this cable
|
||||
ensures safe and low-loss power transmission in medium-voltage networks.
|
||||
locale: en
|
||||
---
|
||||
<ProductTabs technicalData={<ProductTechnicalData data={{
|
||||
|
||||
@@ -10,6 +10,12 @@ categories:
|
||||
- Low Voltage Cables
|
||||
images:
|
||||
- /uploads/2025/01/N2XY-scaled.webp
|
||||
application: >
|
||||
The N2XY is used in low-voltage systems for power distribution – for example in cable
|
||||
trays, conduits, on walls, or directly underground. It can be installed both indoors
|
||||
and outdoors and is also suitable for humid environments. Thanks to various core
|
||||
configurations (single-core to four-core) and cross-sections up to 630 mm², the cable
|
||||
can be flexibly adapted to the respective application.
|
||||
locale: en
|
||||
---
|
||||
<ProductTabs technicalData={<ProductTechnicalData data={{
|
||||
|
||||
@@ -10,6 +10,12 @@ categories:
|
||||
- Low Voltage Cables
|
||||
images:
|
||||
- /uploads/2025/01/NA2X2Y-scaled.webp
|
||||
application: >
|
||||
The NA2X2Y complies with DIN VDE 0276-603 (HD 603) and is designed for fixed
|
||||
installation indoors, in cable ducts, underground, in water, or outdoors. It is
|
||||
primarily used in power plants, industrial facilities, and switching stations as well
|
||||
as local distribution networks – wherever robust cables are needed that can withstand
|
||||
high mechanical stress during installation and operation.
|
||||
locale: en
|
||||
---
|
||||
<ProductTabs technicalData={<ProductTechnicalData data={{
|
||||
|
||||
@@ -9,6 +9,11 @@ description: >
|
||||
categories:
|
||||
- High Voltage Cables
|
||||
images: []
|
||||
application: >
|
||||
The NA2X(F)K2Y high-voltage cable series is engineered to meet the rigorous demands
|
||||
of modern industrial and power distribution applications. It combines high-grade
|
||||
copper conductors with cross-linked polyethylene (XLPE) insulation, ensuring
|
||||
superior dielectric strength and thermal cycling resilience.
|
||||
locale: en
|
||||
---
|
||||
<ProductTabs technicalData={<ProductTechnicalData data={{
|
||||
|
||||
@@ -9,6 +9,11 @@ description: >
|
||||
categories:
|
||||
- High Voltage Cables
|
||||
images: []
|
||||
application: >
|
||||
The NA2X(F)KLD2Y high-voltage cable series is engineered to meet the rigorous
|
||||
demands of modern industrial electrical networks, offering high dielectric
|
||||
strength and excellent thermal cycling resistance. This series is ideal for
|
||||
applications that require reliable power distribution in high-voltage settings.
|
||||
locale: en
|
||||
---
|
||||
<ProductTabs technicalData={<ProductTechnicalData data={{
|
||||
|
||||
@@ -10,6 +10,12 @@ categories:
|
||||
- Medium Voltage Cables
|
||||
images:
|
||||
- /uploads/2025/01/NA2XS2Y-scaled.webp
|
||||
application: >
|
||||
The NA2XS2Y complies with DIN VDE 0276-620, HD 620 S2 and IEC 60502 standards, and is
|
||||
specifically designed for fixed installation indoors, in cable ducts, outdoors, in
|
||||
soil and water. It is commonly used in industrial facilities, switching stations, and
|
||||
power plants, especially where the cable is exposed to high mechanical stress during
|
||||
installation or operation.
|
||||
locale: en
|
||||
---
|
||||
<ProductTabs technicalData={<ProductTechnicalData data={{
|
||||
|
||||
@@ -10,6 +10,12 @@ categories:
|
||||
- Medium Voltage Cables
|
||||
images:
|
||||
- /uploads/2025/01/NA2XSF2Y-3-scaled.webp
|
||||
application: >
|
||||
The NA2XS(F)2Y complies with standards DIN VDE 0276-620, HD 620 S2 and IEC 60502. It is
|
||||
suitable for installation indoors, in cable ducts, underground, in water, outdoors,
|
||||
or on cable trays. Its main applications are in utility grids, industrial facilities,
|
||||
and substations, where additional safety reserves against moisture ingress and
|
||||
mechanical stress are required.
|
||||
locale: en
|
||||
---
|
||||
<ProductTabs technicalData={<ProductTechnicalData data={{
|
||||
|
||||
@@ -10,6 +10,12 @@ categories:
|
||||
- High Voltage Cables
|
||||
images:
|
||||
- /uploads/2025/06/NA2XSFL2Y-3-scaled.webp
|
||||
application: >
|
||||
The NA2XS(FL)2Y meets the requirements of IEC 60840 and is suitable for installation
|
||||
in the ground, in cable ducts, indoors, in pipes, and outdoors. It is manufactured
|
||||
based on project specifications and is particularly used in transmission networks,
|
||||
utility infrastructures, and substations where safety and durability are top
|
||||
priorities.
|
||||
locale: en
|
||||
---
|
||||
<ProductTabs technicalData={<ProductTechnicalData data={{
|
||||
|
||||
@@ -10,6 +10,12 @@ categories:
|
||||
- Medium Voltage Cables
|
||||
images:
|
||||
- /uploads/2025/01/NA2XSFL2Y-3-scaled.webp
|
||||
application: >
|
||||
The NA2XS(FL)2Y cable complies with standards DIN VDE 0276-620, HD 620 S2 and IEC
|
||||
60502. It is ideal for installation in power supply networks (utilities), indoors, in
|
||||
cable ducts, outdoors, in soil, and in water. Thanks to its longitudinally watertight
|
||||
design and Al/PE sheath construction, it remains operational even when damaged –
|
||||
water ingress is effectively limited to the affected area.
|
||||
locale: en
|
||||
---
|
||||
<ProductTabs technicalData={<ProductTechnicalData data={{
|
||||
|
||||
@@ -11,6 +11,12 @@ categories:
|
||||
- Medium Voltage Cables
|
||||
images:
|
||||
- /uploads/2025/01/NA2XSY-scaled.webp
|
||||
application: >
|
||||
The NA2XSY meets the requirements of DIN VDE 0276-620, HD 620 S2, and IEC 60502. It is
|
||||
suitable for installation indoors, in cable ducts, underground, in water, or outdoors
|
||||
– but only when installed with protection. Typical areas of application include
|
||||
industrial plants, power stations, and switching stations where medium voltage must
|
||||
be transported with high operational safety.
|
||||
locale: en
|
||||
---
|
||||
<ProductTabs technicalData={<ProductTechnicalData data={{
|
||||
|
||||
@@ -10,6 +10,12 @@ categories:
|
||||
- Low Voltage Cables
|
||||
images:
|
||||
- /uploads/2025/01/NA2XY-scaled.webp
|
||||
application: >
|
||||
The NA2XY complies with DIN VDE 0276-603 (HD 603) and is ideal for fixed installation
|
||||
in indoor spaces, cable ducts, outdoors, in water, or underground. Typical areas of
|
||||
application include power plants, industrial facilities, and switching stations as
|
||||
well as local distribution networks, where mechanical stress during operation must be
|
||||
considered.
|
||||
locale: en
|
||||
---
|
||||
<ProductTabs technicalData={<ProductTechnicalData data={{
|
||||
|
||||
@@ -11,6 +11,12 @@ categories:
|
||||
- Low Voltage Cables
|
||||
images:
|
||||
- /uploads/2025/01/NAY2Y-scaled.webp
|
||||
application: >
|
||||
The NAY2Y meets the requirements of the TP PRAKAB 12/03 standard based on VDE 0276-603
|
||||
and is suitable for fixed installation indoors, in cable ducts, underground, in
|
||||
water, and outdoors. It is ideal for applications in power plants, industrial and
|
||||
switching stations, as well as local supply networks – wherever mechanical stress
|
||||
during installation or operation plays a significant role.
|
||||
locale: en
|
||||
---
|
||||
<ProductTabs technicalData={<ProductTechnicalData data={{
|
||||
|
||||
@@ -10,6 +10,12 @@ categories:
|
||||
- Low Voltage Cables
|
||||
images:
|
||||
- /uploads/2025/01/NAYCWY-scaled.webp
|
||||
application: >
|
||||
The NAYCWY complies with DIN VDE 0276-603 (HD 603) and is suitable for use in power
|
||||
plants, industrial facilities, switching stations, and local networks. It can be
|
||||
permanently installed – indoors, in cable ducts, outdoors, underground, or in water.
|
||||
Thanks to its concentric conductor, it offers additional protection in case of
|
||||
mechanical damage and enables safe potential equalization.
|
||||
locale: en
|
||||
---
|
||||
<ProductTabs technicalData={<ProductTechnicalData data={{
|
||||
|
||||
@@ -11,6 +11,12 @@ categories:
|
||||
- Low Voltage Cables
|
||||
images:
|
||||
- /uploads/2025/01/NAYY-scaled.webp
|
||||
application: >
|
||||
The NAYY is a power distribution cable according to VDE 0276-603, particularly
|
||||
suitable for use in power plants, local networks, industrial facilities, and
|
||||
switching stations. Thanks to its robust design, it can be installed permanently –
|
||||
whether indoors, in cable ducts, outdoors, or underground. Even when installed in
|
||||
water, the cable remains reliable in operation.
|
||||
locale: en
|
||||
---
|
||||
<ProductTabs technicalData={<ProductTechnicalData data={{
|
||||
|
||||
@@ -10,6 +10,12 @@ categories:
|
||||
- Low Voltage Cables
|
||||
images:
|
||||
- /uploads/2025/01/NY2Y-scaled.webp
|
||||
application: >
|
||||
The NY2Y is a low-voltage cable for use in power plants, industrial facilities,
|
||||
switching stations, and local distribution networks. It is suitable for fixed
|
||||
installation indoors, in cable ducts, outdoors, in water, and underground – wherever
|
||||
strong mechanical stress is expected during installation and operation. The
|
||||
construction complies with TP PRAKAB 16/03 based on VDE 0276-603.
|
||||
locale: en
|
||||
---
|
||||
<ProductTabs technicalData={<ProductTechnicalData data={{
|
||||
|
||||
@@ -11,6 +11,14 @@ categories:
|
||||
- Low Voltage Cables
|
||||
images:
|
||||
- /uploads/2025/01/NYCWY-scaled.webp
|
||||
application: >
|
||||
The NYCWY is one of the classic low-voltage cables according to VDE standard and is
|
||||
designed for rated voltages up to 1 kV. It is used wherever reliable energy
|
||||
distribution is required – in buildings, industrial plants, transformer stations, or
|
||||
directly underground. It can also be easily installed in cable trays, concrete
|
||||
environments, or underwater. The choice of materials ensures that this cable
|
||||
withstands even harsh conditions – without the need for additional protective
|
||||
measures.
|
||||
locale: en
|
||||
---
|
||||
<ProductTabs technicalData={<ProductTechnicalData data={{
|
||||
|
||||
@@ -9,6 +9,9 @@ categories:
|
||||
- Low Voltage Cables
|
||||
images:
|
||||
- /uploads/2025/01/NYY-scaled.webp
|
||||
application: |
|
||||
Application
|
||||
The cable complies with the standards DIN VDE 0276-603. It is suitable for installation in interiors, cable ducts, in the ground, in water, outdoors or on cable trays, provided that no special mechanical stresses are to be expected. The main focus of application is in power plants, industrial plants and local networks.
|
||||
locale: en
|
||||
---
|
||||
<ProductTabs technicalData={<ProductTechnicalData data={{
|
||||
|
||||
4
docker-compose.override.yml
Normal file
4
docker-compose.override.yml
Normal file
@@ -0,0 +1,4 @@
|
||||
services:
|
||||
app:
|
||||
env_file:
|
||||
- .env
|
||||
@@ -1,81 +1,33 @@
|
||||
services:
|
||||
# Varnish sits between Traefik and the application.
|
||||
#
|
||||
# Flow:
|
||||
# Client -> Traefik -> Varnish -> app
|
||||
#
|
||||
# Traefik keeps TLS + compression; Varnish adds HTTP caching for static assets.
|
||||
varnish:
|
||||
image: varnish:7
|
||||
app:
|
||||
image: registry.infra.mintel.me/mintel/klz-cables.com:${IMAGE_TAG:-latest}
|
||||
restart: always
|
||||
networks:
|
||||
- infra
|
||||
depends_on:
|
||||
- app
|
||||
command: >-
|
||||
varnishd
|
||||
-F
|
||||
-f /etc/varnish/default.vcl
|
||||
-s malloc,${VARNISH_CACHE_SIZE:-256m}
|
||||
volumes:
|
||||
- ./varnish/default.vcl:/etc/varnish/default.vcl:ro
|
||||
healthcheck:
|
||||
test: ["CMD-SHELL", "wget --quiet --tries=1 --spider http://localhost:80/health || wget --quiet --tries=1 --spider http://localhost:80/ || true"]
|
||||
interval: 10s
|
||||
timeout: 5s
|
||||
retries: 5
|
||||
start_period: 10s
|
||||
ports:
|
||||
- "3000:3000"
|
||||
env_file:
|
||||
- ${ENV_FILE:-.env}
|
||||
labels:
|
||||
- "traefik.enable=true"
|
||||
# HTTP → HTTPS redirect (Challenge-Schutz für ALLE)
|
||||
- "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/`)"
|
||||
# HTTP ⇒ HTTPS redirect
|
||||
- "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 (für ALLE drei Domains)
|
||||
- "traefik.http.routers.klz-cables.rule=Host(`klz-cables.com`) || Host(`www.klz-cables.com`) || Host(`staging.klz-cables.com`)"
|
||||
# HTTPS router
|
||||
- "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"
|
||||
- "traefik.http.routers.klz-cables.service=klz-cables"
|
||||
- "traefik.http.services.klz-cables.loadbalancer.server.port=80"
|
||||
- "traefik.http.services.klz-cables.loadbalancer.server.port=3000"
|
||||
- "traefik.http.services.klz-cables.loadbalancer.server.scheme=http"
|
||||
# Forwarded Headers (für Apps, die HTTPS erwarten)
|
||||
# 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 anhängen
|
||||
# Middlewares
|
||||
- "traefik.http.routers.klz-cables.middlewares=klz-forward,compress"
|
||||
|
||||
app:
|
||||
image: registry.infra.mintel.me/mintel/klz-cables.com:latest
|
||||
restart: always
|
||||
networks:
|
||||
- infra
|
||||
healthcheck:
|
||||
test: ["CMD-SHELL", "wget --quiet --tries=1 --spider http://localhost:3000/health || wget --quiet --tries=1 --spider http://localhost:3000/ || true"]
|
||||
interval: 10s
|
||||
timeout: 5s
|
||||
retries: 5
|
||||
start_period: 30s
|
||||
environment:
|
||||
# Umami
|
||||
- NEXT_PUBLIC_UMAMI_WEBSITE_ID=${NEXT_PUBLIC_UMAMI_WEBSITE_ID}
|
||||
- NEXT_PUBLIC_UMAMI_SCRIPT_URL=${NEXT_PUBLIC_UMAMI_SCRIPT_URL:-https://analytics.infra.mintel.me/script.js}
|
||||
# GlitchTip (Sentry protocol)
|
||||
- SENTRY_DSN=${SENTRY_DSN}
|
||||
- NEXT_PUBLIC_SENTRY_DSN=${NEXT_PUBLIC_SENTRY_DSN}
|
||||
# Redis (app-spezifischer DB-Index)
|
||||
- REDIS_URL=${REDIS_URL:-redis://redis:6379/2}
|
||||
- REDIS_KEY_PREFIX=${REDIS_KEY_PREFIX:-klz:}
|
||||
# Mail
|
||||
- MAIL_HOST=${MAIL_HOST}
|
||||
- MAIL_PORT=${MAIL_PORT}
|
||||
- MAIL_USERNAME=${MAIL_USERNAME}
|
||||
- MAIL_PASSWORD=${MAIL_PASSWORD}
|
||||
- MAIL_FROM=${MAIL_FROM}
|
||||
- MAIL_RECIPIENTS=${MAIL_RECIPIENTS}
|
||||
# App
|
||||
- NEXT_PUBLIC_BASE_URL=${NEXT_PUBLIC_BASE_URL}
|
||||
|
||||
networks:
|
||||
infra:
|
||||
external: true
|
||||
|
||||
@@ -1,197 +1,276 @@
|
||||
# Deployment Guide
|
||||
# KLZ Cables - Deployment Guide
|
||||
|
||||
This document describes the deployment setup for KLZ Cables website.
|
||||
This document explains the deployment process and environment variable management for the KLZ Cables application.
|
||||
|
||||
## Automatic Deployment (Gitea Actions)
|
||||
## Table of Contents
|
||||
|
||||
The project uses Gitea Actions for CI/CD. On every push to the `main` branch:
|
||||
- [Overview](#overview)
|
||||
- [Environment Variables](#environment-variables)
|
||||
- [Local Development](#local-development)
|
||||
- [Production Deployment](#production-deployment)
|
||||
- [Troubleshooting](#troubleshooting)
|
||||
|
||||
1. **Build**: Docker image is built with platform `linux/arm64`
|
||||
2. **Push**: Image is pushed to `registry.infra.mintel.me/mintel/klz-cables.com:latest`
|
||||
3. **Deploy**: SSH connection to production server pulls and restarts containers
|
||||
## Overview
|
||||
|
||||
### Workflow File
|
||||
The application uses a clean, robust, **fully automated** environment variable strategy:
|
||||
|
||||
Location: `.gitea/workflows/deploy.yml`
|
||||
- **Build-time variables**: Only `NEXT_PUBLIC_*` variables are baked into the client bundle
|
||||
- **Runtime variables**: All other variables are loaded from `.env` file at runtime
|
||||
- **Fully automated**: `.env` file is automatically generated from Gitea secrets on every deployment
|
||||
- **No manual steps**: No need to manually create or update `.env` files on the server
|
||||
- **No duplication**: Single source of truth for each environment
|
||||
|
||||
### Required Secrets
|
||||
### Architecture
|
||||
|
||||
Configure these in your Gitea repository settings:
|
||||
```
|
||||
┌─────────────────────────────────────────────────────────────┐
|
||||
│ CI/CD Workflow (.gitea/workflows/deploy.yml) │
|
||||
│ │
|
||||
│ 1. Build Docker image with NEXT_PUBLIC_* build args │
|
||||
│ 2. Push to registry │
|
||||
│ 3. SSH to server and run deploy.sh │
|
||||
└─────────────────────────────────────────────────────────────┘
|
||||
↓
|
||||
┌─────────────────────────────────────────────────────────────┐
|
||||
│ Production Server (alpha.mintel.me) │
|
||||
│ │
|
||||
│ /home/deploy/sites/klz-cables.com/ │
|
||||
│ ├── .env ← Runtime environment vars │
|
||||
│ └── docker-compose.yml ← Loads .env file │
|
||||
└─────────────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
- `REGISTRY_USER` - Docker registry username
|
||||
- `REGISTRY_PASS` - Docker registry password
|
||||
- `ALPHA_SSH_KEY` - SSH private key for deployment user
|
||||
- `NEXT_PUBLIC_UMAMI_WEBSITE_ID` - Umami analytics website ID
|
||||
- `NEXT_PUBLIC_UMAMI_SCRIPT_URL` - Umami analytics script URL
|
||||
- `SENTRY_DSN` - Sentry/GlitchTip DSN for error tracking
|
||||
## Environment Variables
|
||||
|
||||
## Manual Deployment
|
||||
### Build-Time Variables (NEXT_PUBLIC_*)
|
||||
|
||||
If you need to deploy manually:
|
||||
These are embedded into the JavaScript bundle during build and are visible to the client:
|
||||
|
||||
### On the Production Server
|
||||
| Variable | Required | Description |
|
||||
|----------|----------|-------------|
|
||||
| `NEXT_PUBLIC_BASE_URL` | ✅ Yes | Base URL of the application (e.g., `https://klz-cables.com`) |
|
||||
| `NEXT_PUBLIC_UMAMI_WEBSITE_ID` | ❌ No | Umami analytics website ID |
|
||||
| `NEXT_PUBLIC_UMAMI_SCRIPT_URL` | ❌ No | Umami analytics script URL (default: `https://analytics.infra.mintel.me/script.js`) |
|
||||
|
||||
**Important**: These must be provided as `--build-arg` when building the Docker image.
|
||||
|
||||
### Runtime Variables
|
||||
|
||||
These are loaded from the `.env` file at runtime and are only available on the server:
|
||||
|
||||
| Variable | Required | Description |
|
||||
|----------|----------|-------------|
|
||||
| `NODE_ENV` | ✅ Yes | Environment mode (`production`, `development`, `test`) |
|
||||
| `SENTRY_DSN` | ❌ No | GlitchTip/Sentry error tracking DSN |
|
||||
| `MAIL_HOST` | ❌ No | SMTP server hostname |
|
||||
| `MAIL_PORT` | ❌ No | SMTP server port (default: `587`) |
|
||||
| `MAIL_USERNAME` | ❌ No | SMTP authentication username |
|
||||
| `MAIL_PASSWORD` | ❌ No | SMTP authentication password |
|
||||
| `MAIL_FROM` | ❌ No | Email sender address |
|
||||
| `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:`) |
|
||||
|
||||
## Local Development
|
||||
|
||||
### Setup
|
||||
|
||||
1. Copy the example environment file:
|
||||
```bash
|
||||
cp .env.example .env
|
||||
```
|
||||
|
||||
2. Edit `.env` and fill in your local configuration:
|
||||
```bash
|
||||
NODE_ENV=development
|
||||
NEXT_PUBLIC_BASE_URL=http://localhost:3000
|
||||
# Add other variables as needed
|
||||
```
|
||||
|
||||
3. Install dependencies:
|
||||
```bash
|
||||
npm install
|
||||
```
|
||||
|
||||
4. Run the development server:
|
||||
```bash
|
||||
npm run dev
|
||||
```
|
||||
|
||||
### Testing Docker Build Locally
|
||||
|
||||
```bash
|
||||
# SSH into the server
|
||||
ssh deploy@alpha.mintel.me
|
||||
# Build with build-time arguments
|
||||
docker build \
|
||||
--build-arg NEXT_PUBLIC_BASE_URL=http://localhost:3000 \
|
||||
--build-arg NEXT_PUBLIC_UMAMI_WEBSITE_ID=your-id \
|
||||
--build-arg NEXT_PUBLIC_UMAMI_SCRIPT_URL=https://analytics.infra.mintel.me/script.js \
|
||||
-t klz-cables:local .
|
||||
|
||||
# Run with runtime environment file
|
||||
docker run --env-file .env -p 3000:3000 klz-cables:local
|
||||
```
|
||||
|
||||
## Production Deployment
|
||||
|
||||
### Prerequisites
|
||||
|
||||
1. **Server Setup**: Ensure the production server has:
|
||||
- Docker and Docker Compose installed
|
||||
- Traefik reverse proxy running
|
||||
- Network `infra` created
|
||||
- Project directory exists: `/home/deploy/sites/klz-cables.com`
|
||||
|
||||
2. **Gitea Secrets**: Configure the following secrets in your Gitea repository:
|
||||
|
||||
**Registry Access:**
|
||||
- `REGISTRY_USER` - Docker registry username
|
||||
- `REGISTRY_PASS` - Docker registry password
|
||||
|
||||
**Build-Time Variables:**
|
||||
- `NEXT_PUBLIC_BASE_URL` - Production URL (e.g., `https://klz-cables.com`)
|
||||
- `NEXT_PUBLIC_UMAMI_WEBSITE_ID` - Umami analytics ID
|
||||
- `NEXT_PUBLIC_UMAMI_SCRIPT_URL` - Umami script URL
|
||||
|
||||
**Runtime Variables:**
|
||||
- `SENTRY_DSN` - Error tracking DSN
|
||||
- `MAIL_HOST` - SMTP server
|
||||
- `MAIL_PORT` - SMTP port
|
||||
- `MAIL_USERNAME` - SMTP username
|
||||
- `MAIL_PASSWORD` - SMTP password
|
||||
- `MAIL_FROM` - Sender email
|
||||
- `MAIL_RECIPIENTS` - Recipient emails (comma-separated)
|
||||
- `REDIS_URL` - Redis connection URL
|
||||
- `REDIS_KEY_PREFIX` - Redis key prefix
|
||||
|
||||
**Infrastructure:**
|
||||
- `REGISTRY_USER` - Docker registry username
|
||||
- `REGISTRY_PASS` - Docker registry password
|
||||
- `ALPHA_SSH_KEY` - SSH private key for deployment server
|
||||
|
||||
**Notifications:**
|
||||
- `GOTIFY_URL` - Gotify notification server URL
|
||||
- `GOTIFY_TOKEN` - Gotify application token
|
||||
|
||||
### Deployment Process
|
||||
|
||||
The deployment is **fully automated** via Gitea Actions:
|
||||
|
||||
1. **Trigger**: Push to `main` branch
|
||||
2. **Build**: Docker image is built with `NEXT_PUBLIC_*` build arguments
|
||||
3. **Push**: Image is pushed to private registry
|
||||
4. **Generate .env**: Workflow creates `.env` file from all Gitea secrets
|
||||
5. **Upload .env**: File is uploaded to server via SCP and secured (600 permissions)
|
||||
6. **Deploy**: SSH to server, pull image, and run docker-compose
|
||||
7. **Notify**: Send success/failure notification via Gotify
|
||||
|
||||
**No manual steps required!** The `.env` file is automatically created and deployed on every push.
|
||||
|
||||
### Manual Deployment
|
||||
|
||||
If you need to deploy manually (not recommended - use the automated workflow):
|
||||
|
||||
```bash
|
||||
# SSH to the server
|
||||
ssh root@alpha.mintel.me
|
||||
|
||||
# Navigate to the project directory
|
||||
cd /home/deploy/sites/klz-cables.com
|
||||
|
||||
# Ensure .env file exists (normally created by workflow)
|
||||
# If missing, you'll need to create it from Gitea secrets
|
||||
|
||||
# Pull the latest image
|
||||
docker compose pull
|
||||
docker pull registry.infra.mintel.me/mintel/klz-cables.com:latest
|
||||
|
||||
# Restart containers
|
||||
docker compose up -d --force-recreate --remove-orphans
|
||||
# Restart the services
|
||||
docker-compose down
|
||||
docker-compose up -d
|
||||
|
||||
# Clean up old images
|
||||
docker image prune -f
|
||||
# Check logs
|
||||
docker-compose logs -f app
|
||||
```
|
||||
|
||||
**Note**: Manual deployment requires the `.env` file to exist. The automated workflow creates this file from Gitea secrets. If deploying manually, you'll need to create it yourself using [`.env.production`](.env.production) as a template.
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
### Workflow Not Triggering
|
||||
|
||||
1. Check Gitea Actions is enabled in repository settings
|
||||
2. Verify the workflow file syntax
|
||||
3. Check runner availability with label `docker`
|
||||
|
||||
### Build Failures
|
||||
|
||||
1. Check build logs in Gitea Actions tab
|
||||
2. Verify all secrets are configured correctly
|
||||
3. Ensure Dockerfile is valid
|
||||
|
||||
### Deployment Failures
|
||||
|
||||
1. Verify SSH key has correct permissions (600)
|
||||
2. Check deploy user has Docker permissions
|
||||
3. Verify registry credentials are correct
|
||||
4. Check server disk space: `df -h`
|
||||
|
||||
### Container Issues
|
||||
**Problem**: Build fails with "Environment validation failed"
|
||||
|
||||
**Solution**: Ensure all required `NEXT_PUBLIC_*` variables are provided as build arguments:
|
||||
```bash
|
||||
# Check container status
|
||||
docker compose ps
|
||||
|
||||
# View logs
|
||||
docker compose logs -f app
|
||||
docker compose logs -f varnish
|
||||
|
||||
# Check health
|
||||
docker compose exec app wget -O- http://localhost:3000/health
|
||||
docker build \
|
||||
--build-arg NEXT_PUBLIC_BASE_URL=https://klz-cables.com \
|
||||
--build-arg NEXT_PUBLIC_UMAMI_WEBSITE_ID=your-id \
|
||||
--build-arg NEXT_PUBLIC_UMAMI_SCRIPT_URL=https://analytics.infra.mintel.me/script.js \
|
||||
-t klz-cables .
|
||||
```
|
||||
|
||||
## Architecture
|
||||
### Runtime Failures
|
||||
|
||||
```
|
||||
Client
|
||||
↓
|
||||
Traefik (TLS termination, routing)
|
||||
↓
|
||||
Varnish (HTTP caching)
|
||||
↓
|
||||
Next.js App (port 3000)
|
||||
```
|
||||
|
||||
### Services
|
||||
|
||||
- **app**: Next.js application
|
||||
- **varnish**: HTTP cache layer
|
||||
- **traefik**: Reverse proxy (external network)
|
||||
|
||||
### Domains
|
||||
|
||||
- `klz-cables.com` - Production
|
||||
- `www.klz-cables.com` - Production (www)
|
||||
- `staging.klz-cables.com` - Staging
|
||||
|
||||
## Environment Variables
|
||||
|
||||
### Build-time (in Dockerfile/Workflow)
|
||||
|
||||
- `NEXT_PUBLIC_UMAMI_WEBSITE_ID`
|
||||
- `NEXT_PUBLIC_UMAMI_SCRIPT_URL`
|
||||
- `NEXT_PUBLIC_SENTRY_DSN`
|
||||
|
||||
### Runtime (in docker-compose.yml)
|
||||
|
||||
- `SENTRY_DSN`
|
||||
- `REDIS_URL`
|
||||
- `REDIS_KEY_PREFIX`
|
||||
|
||||
## Monitoring
|
||||
|
||||
### Health Checks
|
||||
|
||||
- App: `https://klz-cables.com/health`
|
||||
- Varnish: Configured in docker-compose.yml
|
||||
|
||||
### Logs
|
||||
|
||||
```bash
|
||||
# Application logs
|
||||
docker compose logs -f app
|
||||
|
||||
# Varnish logs
|
||||
docker compose logs -f varnish
|
||||
|
||||
# All logs
|
||||
docker compose logs -f
|
||||
```
|
||||
|
||||
### Analytics
|
||||
|
||||
- Umami: Configured via environment variables
|
||||
- Sentry/GlitchTip: Error tracking
|
||||
|
||||
## Rollback
|
||||
|
||||
To rollback to a previous version:
|
||||
**Problem**: Container starts but application crashes
|
||||
|
||||
**Solution**: Check that the `.env` file exists and contains all required runtime variables:
|
||||
```bash
|
||||
# On the server
|
||||
cd /home/deploy/sites/klz-cables.com
|
||||
cat /home/deploy/sites/klz-cables.com/.env
|
||||
|
||||
# Pull a specific version (if tagged)
|
||||
docker pull registry.infra.mintel.me/mintel/klz-cables.com:TAG
|
||||
|
||||
# Or rebuild from a specific commit
|
||||
# (requires access to the repository on the server)
|
||||
|
||||
# Restart with the older image
|
||||
docker compose up -d --force-recreate
|
||||
# Check container logs
|
||||
docker-compose logs app
|
||||
```
|
||||
|
||||
## Performance
|
||||
### Missing Environment Variables
|
||||
|
||||
### Cache Invalidation
|
||||
**Problem**: Features not working (email, analytics, etc.)
|
||||
|
||||
Varnish caches static assets. To clear cache:
|
||||
**Solution**:
|
||||
1. Check that the secret is configured in Gitea
|
||||
2. Verify the workflow includes it in the `.env` generation (see `.gitea/workflows/deploy.yml`)
|
||||
3. Redeploy to regenerate the `.env` file:
|
||||
```bash
|
||||
git commit --allow-empty -m "Trigger redeploy"
|
||||
git push origin main
|
||||
```
|
||||
|
||||
4. Check the variable is loaded on the server:
|
||||
```bash
|
||||
ssh root@alpha.mintel.me
|
||||
cd /home/deploy/sites/klz-cables.com
|
||||
docker-compose exec app env | grep VARIABLE_NAME
|
||||
```
|
||||
|
||||
### Docker Compose Issues
|
||||
|
||||
**Problem**: `docker-compose up` fails with "env file not found"
|
||||
|
||||
**Solution**: The `.env` file should be automatically created by the workflow. If it's missing:
|
||||
1. Check the workflow logs for errors in the "📝 Preparing environment configuration" step
|
||||
2. Manually trigger a deployment by pushing to main
|
||||
3. If still missing, check server permissions and disk space
|
||||
|
||||
### Network Issues
|
||||
|
||||
**Problem**: Container can't connect to Traefik
|
||||
|
||||
**Solution**: Verify the `infra` network exists:
|
||||
```bash
|
||||
docker compose exec varnish varnishadm "ban req.url ~ ."
|
||||
docker network ls | grep infra
|
||||
docker network inspect infra
|
||||
```
|
||||
|
||||
### Cache Configuration
|
||||
## Security Best Practices
|
||||
|
||||
Edit `varnish/default.vcl` and restart:
|
||||
1. **Never commit `.env` files** with real credentials to git
|
||||
2. **Use Gitea/GitHub secrets** for CI/CD workflows
|
||||
3. **Restrict SSH key access** to deployment server
|
||||
4. **Rotate credentials regularly** (SMTP passwords, API keys, etc.)
|
||||
5. **Use HTTPS** for all production URLs
|
||||
6. **Monitor logs** for suspicious activity
|
||||
|
||||
```bash
|
||||
docker compose restart varnish
|
||||
```
|
||||
## Additional Resources
|
||||
|
||||
## Security
|
||||
|
||||
- All secrets are stored in Gitea repository settings
|
||||
- SSH key is injected at deployment time
|
||||
- Registry credentials are not stored in the repository
|
||||
- Deploy webhook requires secret token
|
||||
|
||||
## Support
|
||||
|
||||
For issues or questions:
|
||||
1. Check logs first
|
||||
2. Review this documentation
|
||||
3. Contact the development team
|
||||
- [Next.js Environment Variables](https://nextjs.org/docs/basic-features/environment-variables)
|
||||
- [Docker Compose Environment Variables](https://docs.docker.com/compose/environment-variables/)
|
||||
- [Traefik Documentation](https://doc.traefik.io/traefik/)
|
||||
|
||||
276
docs/ENV_MIGRATION.md
Normal file
276
docs/ENV_MIGRATION.md
Normal file
@@ -0,0 +1,276 @@
|
||||
# Environment Variables Migration Guide
|
||||
|
||||
This guide helps you migrate from the old fragile environment variable setup to the new clean, robust system.
|
||||
|
||||
## What Changed?
|
||||
|
||||
### Before (Fragile & Overkill)
|
||||
|
||||
❌ **Problems:**
|
||||
- Environment variables passed individually via SSH (12+ vars)
|
||||
- Duplicate definitions in Dockerfile, docker-compose.yml, and deploy.yml
|
||||
- Build args included runtime-only variables (SENTRY_DSN, MAIL_*, REDIS_*)
|
||||
- No single source of truth
|
||||
- Difficult to maintain and error-prone
|
||||
|
||||
```yaml
|
||||
# Old deploy.yml - FRAGILE!
|
||||
ssh root@alpha.mintel.me \
|
||||
"MAIL_FROM='${{ secrets.MAIL_FROM }}' \
|
||||
MAIL_HOST='${{ secrets.MAIL_HOST }}' \
|
||||
MAIL_PASSWORD='${{ secrets.MAIL_PASSWORD }}' \
|
||||
MAIL_PORT='${{ secrets.MAIL_PORT }}' \
|
||||
MAIL_RECIPIENTS='${{ secrets.MAIL_RECIPIENTS }}' \
|
||||
MAIL_USERNAME='${{ secrets.MAIL_USERNAME }}' \
|
||||
NEXT_PUBLIC_BASE_URL='${{ secrets.NEXT_PUBLIC_BASE_URL }}' \
|
||||
... (12+ variables) \
|
||||
/home/deploy/deploy.sh"
|
||||
```
|
||||
|
||||
### After (Clean & Robust)
|
||||
|
||||
✅ **Benefits:**
|
||||
- Single `.env` file on server contains all runtime variables
|
||||
- Only `NEXT_PUBLIC_*` variables passed as build args (3 vars)
|
||||
- Clear separation: build-time vs runtime
|
||||
- Easy to maintain and update
|
||||
- Single source of truth per environment
|
||||
|
||||
```yaml
|
||||
# New deploy.yml - CLEAN!
|
||||
ssh root@alpha.mintel.me "/home/deploy/deploy.sh"
|
||||
```
|
||||
|
||||
## Migration Steps
|
||||
|
||||
### Step 1: Update Gitea Secrets
|
||||
|
||||
**Remove these secrets** (no longer needed in CI/CD):
|
||||
- ❌ `MAIL_FROM`
|
||||
- ❌ `MAIL_HOST`
|
||||
- ❌ `MAIL_PASSWORD`
|
||||
- ❌ `MAIL_PORT`
|
||||
- ❌ `MAIL_RECIPIENTS`
|
||||
- ❌ `MAIL_USERNAME`
|
||||
- ❌ `NODE_ENV`
|
||||
- ❌ `REDIS_URL`
|
||||
- ❌ `REDIS_KEY_PREFIX`
|
||||
- ❌ `SENTRY_DSN` (from build args)
|
||||
|
||||
**Keep these secrets** (still needed for build):
|
||||
- ✅ `NEXT_PUBLIC_BASE_URL`
|
||||
- ✅ `NEXT_PUBLIC_UMAMI_WEBSITE_ID`
|
||||
- ✅ `NEXT_PUBLIC_UMAMI_SCRIPT_URL`
|
||||
- ✅ `REGISTRY_USER`
|
||||
- ✅ `REGISTRY_PASS`
|
||||
- ✅ `ALPHA_SSH_KEY`
|
||||
- ✅ `GOTIFY_URL`
|
||||
- ✅ `GOTIFY_TOKEN`
|
||||
|
||||
### Step 2: Create .env File on Server
|
||||
|
||||
SSH to the production server and create the `.env` file:
|
||||
|
||||
```bash
|
||||
ssh root@alpha.mintel.me
|
||||
|
||||
# Create .env file
|
||||
cat > /home/deploy/sites/klz-cables.com/.env << 'EOF'
|
||||
# Application
|
||||
NODE_ENV=production
|
||||
NEXT_PUBLIC_BASE_URL=https://klz-cables.com
|
||||
|
||||
# Analytics
|
||||
NEXT_PUBLIC_UMAMI_WEBSITE_ID=your-actual-id
|
||||
NEXT_PUBLIC_UMAMI_SCRIPT_URL=https://analytics.infra.mintel.me/script.js
|
||||
|
||||
# Error Tracking
|
||||
SENTRY_DSN=your-actual-dsn
|
||||
|
||||
# Email
|
||||
MAIL_HOST=smtp.eu.mailgun.org
|
||||
MAIL_PORT=587
|
||||
MAIL_USERNAME=your-actual-username
|
||||
MAIL_PASSWORD=your-actual-password
|
||||
MAIL_FROM=KLZ Cables <noreply@klz-cables.com>
|
||||
MAIL_RECIPIENTS=info@klz-cables.com
|
||||
|
||||
# Redis
|
||||
REDIS_URL=redis://redis:6379/2
|
||||
REDIS_KEY_PREFIX=klz:
|
||||
|
||||
# Varnish
|
||||
VARNISH_CACHE_SIZE=256m
|
||||
EOF
|
||||
|
||||
# Secure the file
|
||||
chmod 600 /home/deploy/sites/klz-cables.com/.env
|
||||
chown deploy:deploy /home/deploy/sites/klz-cables.com/.env
|
||||
```
|
||||
|
||||
**Important**: Replace all `your-actual-*` placeholders with real values from your old Gitea secrets.
|
||||
|
||||
### Step 3: Update Deployment Script
|
||||
|
||||
Update `/home/deploy/deploy.sh` to use the new approach:
|
||||
|
||||
```bash
|
||||
cat > /home/deploy/deploy.sh << 'EOF'
|
||||
#!/bin/bash
|
||||
set -e
|
||||
|
||||
PROJECT_DIR="/home/deploy/sites/klz-cables.com"
|
||||
|
||||
echo "╔══════════════════════════════════════════════════════════════════════════════╗"
|
||||
echo "║ KLZ Cables - Deployment Script ║"
|
||||
echo "╚══════════════════════════════════════════════════════════════════════════════╝"
|
||||
echo ""
|
||||
|
||||
cd "$PROJECT_DIR"
|
||||
|
||||
# Check if .env file exists
|
||||
if [ ! -f .env ]; then
|
||||
echo "❌ ERROR: .env file not found at $PROJECT_DIR/.env"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo "🔐 Logging into Docker registry..."
|
||||
echo "$REGISTRY_PASS" | docker login registry.infra.mintel.me -u "$REGISTRY_USER" --password-stdin
|
||||
|
||||
echo "🔄 Pulling latest image..."
|
||||
docker pull registry.infra.mintel.me/mintel/klz-cables.com:latest
|
||||
|
||||
echo "🔄 Stopping existing containers..."
|
||||
docker-compose down
|
||||
|
||||
echo "🚀 Starting new containers..."
|
||||
docker-compose up -d
|
||||
|
||||
echo "⏳ Waiting for services to be healthy..."
|
||||
sleep 10
|
||||
|
||||
echo "🔍 Checking service status..."
|
||||
docker-compose ps
|
||||
|
||||
echo ""
|
||||
echo "✅ Deployment complete!"
|
||||
EOF
|
||||
|
||||
chmod +x /home/deploy/deploy.sh
|
||||
```
|
||||
|
||||
### Step 4: Deploy Updated Code
|
||||
|
||||
The new code is already in the repository. Just push to trigger deployment:
|
||||
|
||||
```bash
|
||||
git push origin main
|
||||
```
|
||||
|
||||
The CI/CD workflow will:
|
||||
1. Build with only `NEXT_PUBLIC_*` build args
|
||||
2. Push to registry
|
||||
3. SSH to server and run deploy.sh
|
||||
4. Deploy.sh will use the `.env` file for runtime vars
|
||||
|
||||
### Step 5: Verify Migration
|
||||
|
||||
After deployment, verify everything works:
|
||||
|
||||
```bash
|
||||
# SSH to server
|
||||
ssh root@alpha.mintel.me
|
||||
|
||||
# Check containers are running
|
||||
cd /home/deploy/sites/klz-cables.com
|
||||
docker-compose ps
|
||||
|
||||
# Verify environment variables are loaded
|
||||
docker-compose exec app env | grep -E "NODE_ENV|NEXT_PUBLIC|MAIL|REDIS"
|
||||
|
||||
# Check application logs
|
||||
docker-compose logs -f app
|
||||
|
||||
# Test the website
|
||||
curl -I https://klz-cables.com
|
||||
```
|
||||
|
||||
## Comparison Table
|
||||
|
||||
| Aspect | Before | After |
|
||||
|--------|--------|-------|
|
||||
| **Gitea Secrets** | 15+ secrets | 8 secrets |
|
||||
| **Build Args** | 4 vars (including runtime-only) | 3 vars (NEXT_PUBLIC_* only) |
|
||||
| **Runtime Vars** | Passed via SSH command | Loaded from .env file |
|
||||
| **Maintenance** | Update in 3 places | Update in 1 place |
|
||||
| **Security** | Secrets in CI logs | Secrets only on server |
|
||||
| **Clarity** | Confusing duplication | Clear separation |
|
||||
| **Robustness** | Fragile SSH command | Robust file-based config |
|
||||
|
||||
## Rollback Plan
|
||||
|
||||
If you need to rollback to the old system:
|
||||
|
||||
1. Revert the changes in git:
|
||||
```bash
|
||||
git revert HEAD
|
||||
git push origin main
|
||||
```
|
||||
|
||||
2. Re-add the removed Gitea secrets
|
||||
|
||||
3. The old deployment will work again
|
||||
|
||||
## FAQ
|
||||
|
||||
**Q: Why keep `NEXT_PUBLIC_*` in both build args and .env file?**
|
||||
|
||||
A: `NEXT_PUBLIC_*` variables are special in Next.js - they're embedded into the JavaScript bundle at build time. They must be provided as build args. However, they're also needed at runtime for server-side rendering, so they're in the .env file too.
|
||||
|
||||
**Q: Can I update environment variables without rebuilding?**
|
||||
|
||||
A: Yes, for runtime-only variables (MAIL_*, REDIS_*, SENTRY_DSN, etc.). Just edit the `.env` file on the server and restart containers:
|
||||
```bash
|
||||
nano /home/deploy/sites/klz-cables.com/.env
|
||||
docker-compose down && docker-compose up -d
|
||||
```
|
||||
|
||||
For `NEXT_PUBLIC_*` variables, you need to rebuild the Docker image since they're baked into the client bundle.
|
||||
|
||||
**Q: Where should I store the .env file backup?**
|
||||
|
||||
A: Keep a secure backup outside the server:
|
||||
```bash
|
||||
# Download from server
|
||||
scp root@alpha.mintel.me:/home/deploy/sites/klz-cables.com/.env \
|
||||
~/secure-backups/klz-cables.env.backup
|
||||
|
||||
# Store in password manager or encrypted storage
|
||||
```
|
||||
|
||||
**Q: What if I accidentally commit .env to git?**
|
||||
|
||||
A:
|
||||
1. Remove it immediately: `git rm .env && git commit -m "Remove .env"`
|
||||
2. Rotate all credentials in the file
|
||||
3. Update the `.gitignore` to ensure it doesn't happen again (already done)
|
||||
|
||||
## Support
|
||||
|
||||
If you encounter issues during migration:
|
||||
|
||||
1. Check the logs: `docker-compose logs -f app`
|
||||
2. Verify .env file exists and has correct permissions
|
||||
3. Ensure all required variables are set
|
||||
4. Review [DEPLOYMENT.md](./DEPLOYMENT.md) for detailed troubleshooting
|
||||
|
||||
## Summary
|
||||
|
||||
The new system is:
|
||||
- ✅ **Simpler**: One .env file instead of scattered variables
|
||||
- ✅ **Cleaner**: Clear separation of build vs runtime
|
||||
- ✅ **Robust**: File-based config instead of fragile SSH commands
|
||||
- ✅ **Secure**: Secrets stay on server, not in CI logs
|
||||
- ✅ **Maintainable**: Single source of truth per environment
|
||||
|
||||
You've successfully migrated from a fragile, overkill setup to a clean, production-ready configuration! 🎉
|
||||
317
docs/SERVER_SETUP.md
Normal file
317
docs/SERVER_SETUP.md
Normal file
@@ -0,0 +1,317 @@
|
||||
# Server Setup Guide - KLZ Cables
|
||||
|
||||
This guide explains how to set up the production environment on the deployment server.
|
||||
|
||||
## Prerequisites
|
||||
|
||||
- Server: `alpha.mintel.me`
|
||||
- User: `deploy` (with sudo access)
|
||||
- Docker and Docker Compose installed
|
||||
- Traefik reverse proxy running
|
||||
- External network `infra` created
|
||||
|
||||
## Initial Server Setup
|
||||
|
||||
### 1. Create Project Directory
|
||||
|
||||
```bash
|
||||
# SSH to the server as root or deploy user
|
||||
ssh root@alpha.mintel.me
|
||||
|
||||
# Create project directory
|
||||
mkdir -p /home/deploy/sites/klz-cables.com
|
||||
cd /home/deploy/sites/klz-cables.com
|
||||
```
|
||||
|
||||
### 2. Create Environment File
|
||||
|
||||
Create the `.env` file with production configuration:
|
||||
|
||||
```bash
|
||||
# Create .env file from template
|
||||
cat > /home/deploy/sites/klz-cables.com/.env << 'EOF'
|
||||
# ============================================================================
|
||||
# KLZ Cables - Production Environment Configuration
|
||||
# ============================================================================
|
||||
|
||||
# Application
|
||||
NODE_ENV=production
|
||||
NEXT_PUBLIC_BASE_URL=https://klz-cables.com
|
||||
|
||||
# Analytics (Umami)
|
||||
NEXT_PUBLIC_UMAMI_WEBSITE_ID=your-umami-website-id
|
||||
NEXT_PUBLIC_UMAMI_SCRIPT_URL=https://analytics.infra.mintel.me/script.js
|
||||
|
||||
# Error Tracking (GlitchTip/Sentry)
|
||||
SENTRY_DSN=https://your-sentry-dsn@errors.infra.mintel.me/project-id
|
||||
|
||||
# Email Configuration (Mailgun)
|
||||
MAIL_HOST=smtp.eu.mailgun.org
|
||||
MAIL_PORT=587
|
||||
MAIL_USERNAME=your-mailgun-username
|
||||
MAIL_PASSWORD=your-mailgun-password
|
||||
MAIL_FROM=KLZ Cables <noreply@klz-cables.com>
|
||||
MAIL_RECIPIENTS=info@klz-cables.com
|
||||
|
||||
# Redis Cache
|
||||
REDIS_URL=redis://redis:6379/2
|
||||
REDIS_KEY_PREFIX=klz:
|
||||
EOF
|
||||
```
|
||||
|
||||
**Important**: Replace all placeholder values with actual production credentials.
|
||||
|
||||
### 3. Secure the Environment File
|
||||
|
||||
```bash
|
||||
# Set proper permissions (readable only by owner)
|
||||
chmod 600 /home/deploy/sites/klz-cables.com/.env
|
||||
|
||||
# Verify ownership
|
||||
chown deploy:deploy /home/deploy/sites/klz-cables.com/.env
|
||||
|
||||
# Verify permissions
|
||||
ls -la /home/deploy/sites/klz-cables.com/.env
|
||||
# Should show: -rw------- 1 deploy deploy
|
||||
```
|
||||
|
||||
### 4. Create Docker Compose File
|
||||
|
||||
```bash
|
||||
# Copy docker-compose.yml from repository
|
||||
# This should be done automatically by the deployment script
|
||||
# Or manually:
|
||||
cat > /home/deploy/sites/klz-cables.com/docker-compose.yml << 'EOF'
|
||||
services:
|
||||
app:
|
||||
image: registry.infra.mintel.me/mintel/klz-cables.com:latest
|
||||
restart: always
|
||||
networks:
|
||||
- infra
|
||||
ports:
|
||||
- "3000:3000"
|
||||
env_file:
|
||||
- .env
|
||||
healthcheck:
|
||||
test: ["CMD-SHELL", "wget --quiet --tries=1 --spider http://localhost:3000/health || wget --quiet --tries=1 --spider http://localhost:3000/ || true"]
|
||||
interval: 10s
|
||||
timeout: 5s
|
||||
retries: 5
|
||||
start_period: 30s
|
||||
labels:
|
||||
- "traefik.enable=true"
|
||||
- "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"
|
||||
- "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"
|
||||
- "traefik.http.middlewares.klz-forward.headers.customrequestheaders.X-Forwarded-Proto=https"
|
||||
- "traefik.http.middlewares.klz-forward.headers.customrequestheaders.X-Forwarded-Ssl=on"
|
||||
- "traefik.http.routers.klz-cables.middlewares=klz-forward,compress"
|
||||
|
||||
networks:
|
||||
infra:
|
||||
external: true
|
||||
EOF
|
||||
```
|
||||
|
||||
### 5. Create Deployment Script
|
||||
|
||||
```bash
|
||||
cat > /home/deploy/deploy.sh << 'EOF'
|
||||
#!/bin/bash
|
||||
set -e
|
||||
|
||||
PROJECT_DIR="/home/deploy/sites/klz-cables.com"
|
||||
|
||||
echo "╔══════════════════════════════════════════════════════════════════════════════╗"
|
||||
echo "║ KLZ Cables - Deployment Script ║"
|
||||
echo "╚══════════════════════════════════════════════════════════════════════════════╝"
|
||||
echo ""
|
||||
|
||||
cd "$PROJECT_DIR"
|
||||
|
||||
# Check if .env file exists
|
||||
if [ ! -f .env ]; then
|
||||
echo "❌ ERROR: .env file not found at $PROJECT_DIR/.env"
|
||||
echo "Please create the .env file before deploying."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo "🔐 Logging into Docker registry..."
|
||||
echo "$REGISTRY_PASS" | docker login registry.infra.mintel.me -u "$REGISTRY_USER" --password-stdin
|
||||
|
||||
echo "🔄 Pulling latest image..."
|
||||
docker pull registry.infra.mintel.me/mintel/klz-cables.com:latest
|
||||
|
||||
echo "🔄 Stopping existing containers..."
|
||||
docker-compose down
|
||||
|
||||
echo "🚀 Starting new containers..."
|
||||
docker-compose up -d
|
||||
|
||||
echo "⏳ Waiting for services to be healthy..."
|
||||
sleep 10
|
||||
|
||||
echo "🔍 Checking service status..."
|
||||
docker-compose ps
|
||||
|
||||
echo ""
|
||||
echo "✅ Deployment complete!"
|
||||
echo ""
|
||||
echo "📊 Service URLs:"
|
||||
echo " • Production: https://klz-cables.com"
|
||||
echo " • Staging: https://staging.klz-cables.com"
|
||||
echo ""
|
||||
EOF
|
||||
|
||||
# Make script executable
|
||||
chmod +x /home/deploy/deploy.sh
|
||||
```
|
||||
|
||||
### 6. Configure Docker Registry Access
|
||||
|
||||
The deployment script needs registry credentials. These are passed as environment variables from the CI/CD workflow:
|
||||
|
||||
```bash
|
||||
# The workflow passes these variables:
|
||||
# REGISTRY_USER - from Gitea secrets
|
||||
# REGISTRY_PASS - from Gitea secrets
|
||||
```
|
||||
|
||||
## Verification
|
||||
|
||||
### Check Environment File
|
||||
|
||||
```bash
|
||||
# Verify .env file exists and has correct permissions
|
||||
ls -la /home/deploy/sites/klz-cables.com/.env
|
||||
|
||||
# Check content (be careful not to expose secrets in logs)
|
||||
head -n 5 /home/deploy/sites/klz-cables.com/.env
|
||||
```
|
||||
|
||||
### Test Deployment Script
|
||||
|
||||
```bash
|
||||
# Run deployment script manually (requires registry credentials)
|
||||
REGISTRY_USER=your-user REGISTRY_PASS=your-pass /home/deploy/deploy.sh
|
||||
```
|
||||
|
||||
### Check Running Containers
|
||||
|
||||
```bash
|
||||
cd /home/deploy/sites/klz-cables.com
|
||||
docker-compose ps
|
||||
docker-compose logs -f app
|
||||
```
|
||||
|
||||
### Verify Environment Variables in Container
|
||||
|
||||
```bash
|
||||
# Check that environment variables are loaded
|
||||
docker-compose exec app env | grep -E "NODE_ENV|NEXT_PUBLIC|MAIL|REDIS"
|
||||
```
|
||||
|
||||
## Updating Environment Variables
|
||||
|
||||
When you need to update environment variables:
|
||||
|
||||
```bash
|
||||
# 1. SSH to the server
|
||||
ssh root@alpha.mintel.me
|
||||
|
||||
# 2. Edit the .env file
|
||||
nano /home/deploy/sites/klz-cables.com/.env
|
||||
|
||||
# 3. Restart the containers to pick up changes
|
||||
cd /home/deploy/sites/klz-cables.com
|
||||
docker-compose down
|
||||
docker-compose up -d
|
||||
|
||||
# 4. Verify the changes
|
||||
docker-compose logs -f app
|
||||
```
|
||||
|
||||
**Note**: Changes to `NEXT_PUBLIC_*` variables require rebuilding the Docker image, as they are baked into the client bundle at build time.
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
### .env File Not Found
|
||||
|
||||
```bash
|
||||
# Check if file exists
|
||||
ls -la /home/deploy/sites/klz-cables.com/.env
|
||||
|
||||
# If missing, create it from template
|
||||
cp .env.production /home/deploy/sites/klz-cables.com/.env
|
||||
# Then edit with actual values
|
||||
```
|
||||
|
||||
### Permission Denied
|
||||
|
||||
```bash
|
||||
# Fix ownership
|
||||
chown -R deploy:deploy /home/deploy/sites/klz-cables.com
|
||||
|
||||
# Fix .env permissions
|
||||
chmod 600 /home/deploy/sites/klz-cables.com/.env
|
||||
```
|
||||
|
||||
### Container Won't Start
|
||||
|
||||
```bash
|
||||
# Check logs
|
||||
docker-compose logs app
|
||||
|
||||
# Check if .env is loaded
|
||||
docker-compose config
|
||||
|
||||
# Verify environment variables
|
||||
docker-compose exec app env
|
||||
```
|
||||
|
||||
### Network Issues
|
||||
|
||||
```bash
|
||||
# Verify infra network exists
|
||||
docker network ls | grep infra
|
||||
|
||||
# If missing, create it
|
||||
docker network create infra
|
||||
```
|
||||
|
||||
## Security Checklist
|
||||
|
||||
- [ ] `.env` file has `600` permissions (readable only by owner)
|
||||
- [ ] `.env` file is owned by `deploy:deploy`
|
||||
- [ ] `.env` file is NOT in git repository
|
||||
- [ ] All sensitive credentials are filled in
|
||||
- [ ] SSH keys are properly secured
|
||||
- [ ] Firewall rules are configured
|
||||
- [ ] HTTPS is enforced via Traefik
|
||||
- [ ] Regular backups of `.env` file are maintained
|
||||
|
||||
## Backup
|
||||
|
||||
Create a secure backup of the environment file:
|
||||
|
||||
```bash
|
||||
# Backup .env file
|
||||
cp /home/deploy/sites/klz-cables.com/.env \
|
||||
/home/deploy/backups/klz-cables.env.$(date +%Y%m%d)
|
||||
|
||||
# Set proper permissions on backup
|
||||
chmod 600 /home/deploy/backups/klz-cables.env.*
|
||||
```
|
||||
|
||||
## Additional Resources
|
||||
|
||||
- [Deployment Guide](./DEPLOYMENT.md)
|
||||
- [Docker Compose Documentation](https://docs.docker.com/compose/)
|
||||
- [Traefik Documentation](https://doc.traefik.io/traefik/)
|
||||
@@ -13,6 +13,9 @@ export async function register() {
|
||||
|
||||
// Initialize server services on boot
|
||||
// We do this AFTER Sentry to ensure errors during service init are caught
|
||||
const { getConfig } = await import('@/lib/config');
|
||||
getConfig(); // Trigger validation
|
||||
|
||||
const { getServerAppServices } = await import('@/lib/services/create-services.server');
|
||||
getServerAppServices();
|
||||
}
|
||||
|
||||
195
lib/config.ts
195
lib/config.ts
@@ -1,129 +1,128 @@
|
||||
/**
|
||||
* Centralized configuration management for the application.
|
||||
* This file defines the schema and provides a type-safe way to access environment variables.
|
||||
* This file provides a type-safe way to access environment variables.
|
||||
*/
|
||||
import { envSchema, getRawEnv } from './env';
|
||||
|
||||
const getEnv = (key: string, defaultValue?: string): string | undefined => {
|
||||
// In the browser, we can only access NEXT_PUBLIC_ variables
|
||||
if (typeof window !== 'undefined') {
|
||||
if (!key.startsWith('NEXT_PUBLIC_')) {
|
||||
return defaultValue;
|
||||
}
|
||||
return (process.env as any)[key] || defaultValue;
|
||||
let memoizedConfig: ReturnType<typeof createConfig> | undefined;
|
||||
|
||||
/**
|
||||
* Creates and validates the configuration object.
|
||||
* Throws if validation fails.
|
||||
*/
|
||||
function createConfig() {
|
||||
const env = envSchema.parse(getRawEnv());
|
||||
|
||||
return {
|
||||
env: env.NODE_ENV,
|
||||
isProduction: env.NODE_ENV === 'production',
|
||||
isDevelopment: env.NODE_ENV === 'development',
|
||||
isTest: env.NODE_ENV === 'test',
|
||||
|
||||
baseUrl: env.NEXT_PUBLIC_BASE_URL,
|
||||
|
||||
analytics: {
|
||||
umami: {
|
||||
websiteId: env.NEXT_PUBLIC_UMAMI_WEBSITE_ID,
|
||||
scriptUrl: env.NEXT_PUBLIC_UMAMI_SCRIPT_URL,
|
||||
// The proxied path used in the frontend
|
||||
proxyPath: '/stats/script.js',
|
||||
enabled: Boolean(env.NEXT_PUBLIC_UMAMI_WEBSITE_ID),
|
||||
},
|
||||
},
|
||||
|
||||
errors: {
|
||||
glitchtip: {
|
||||
// Use SENTRY_DSN for both server and client (proxied)
|
||||
dsn: env.SENTRY_DSN,
|
||||
// The proxied origin used in the frontend
|
||||
proxyPath: '/errors',
|
||||
enabled: Boolean(env.SENTRY_DSN),
|
||||
},
|
||||
},
|
||||
|
||||
cache: {
|
||||
enabled: false,
|
||||
},
|
||||
|
||||
logging: {
|
||||
level: env.LOG_LEVEL,
|
||||
},
|
||||
|
||||
mail: {
|
||||
host: env.MAIL_HOST,
|
||||
port: env.MAIL_PORT,
|
||||
user: env.MAIL_USERNAME,
|
||||
pass: env.MAIL_PASSWORD,
|
||||
from: env.MAIL_FROM,
|
||||
recipients: env.MAIL_RECIPIENTS,
|
||||
},
|
||||
} as const;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the validated configuration.
|
||||
* Memoizes the result after the first call.
|
||||
*/
|
||||
export function getConfig() {
|
||||
if (!memoizedConfig) {
|
||||
memoizedConfig = createConfig();
|
||||
}
|
||||
return memoizedConfig;
|
||||
}
|
||||
|
||||
if (typeof process === 'undefined') return defaultValue;
|
||||
if (typeof process === 'undefined') return defaultValue;
|
||||
|
||||
// In Docker/Production, variables are in process.env
|
||||
// In local development, they might be in .env
|
||||
const value = process.env[key];
|
||||
|
||||
if (value !== undefined && value !== '') {
|
||||
return value;
|
||||
}
|
||||
|
||||
return defaultValue;
|
||||
};
|
||||
|
||||
/**
|
||||
* Exported config object for convenience.
|
||||
* Uses getters to ensure it's only initialized when accessed.
|
||||
*/
|
||||
export const config = {
|
||||
env: getEnv('NODE_ENV', 'development'),
|
||||
isProduction: getEnv('NODE_ENV') === 'production',
|
||||
isDevelopment: getEnv('NODE_ENV') === 'development',
|
||||
isTest: getEnv('NODE_ENV') === 'test',
|
||||
|
||||
baseUrl: getEnv('NEXT_PUBLIC_BASE_URL', 'http://localhost:3000'),
|
||||
|
||||
analytics: {
|
||||
umami: {
|
||||
websiteId: getEnv('NEXT_PUBLIC_UMAMI_WEBSITE_ID'),
|
||||
scriptUrl: getEnv('UMAMI_SCRIPT_URL', 'https://analytics.infra.mintel.me/script.js'),
|
||||
// The proxied path used in the frontend
|
||||
proxyPath: '/stats/script.js',
|
||||
enabled: Boolean(getEnv('NEXT_PUBLIC_UMAMI_WEBSITE_ID')),
|
||||
},
|
||||
},
|
||||
|
||||
errors: {
|
||||
glitchtip: {
|
||||
// Use SENTRY_DSN for both server and client (proxied)
|
||||
dsn: getEnv('SENTRY_DSN'),
|
||||
// The proxied origin used in the frontend
|
||||
proxyPath: '/errors',
|
||||
enabled: Boolean(getEnv('SENTRY_DSN')),
|
||||
},
|
||||
},
|
||||
|
||||
cache: {
|
||||
redis: {
|
||||
url: getEnv('REDIS_URL'),
|
||||
keyPrefix: getEnv('REDIS_KEY_PREFIX', 'klz:'),
|
||||
enabled: Boolean(getEnv('REDIS_URL')),
|
||||
},
|
||||
},
|
||||
|
||||
logging: {
|
||||
level: getEnv('LOG_LEVEL', 'info'),
|
||||
},
|
||||
|
||||
mail: {
|
||||
host: getEnv('MAIL_HOST'),
|
||||
port: parseInt(getEnv('MAIL_PORT', '587')!, 10),
|
||||
user: getEnv('MAIL_USERNAME'),
|
||||
pass: getEnv('MAIL_PASSWORD'),
|
||||
from: getEnv('MAIL_FROM'),
|
||||
recipients: getEnv('MAIL_RECIPIENTS', '')?.split(',').filter(Boolean) || [],
|
||||
},
|
||||
|
||||
woocommerce: {
|
||||
url: getEnv('WOOCOMMERCE_URL'),
|
||||
consumerKey: getEnv('WOOCOMMERCE_CONSUMER_KEY'),
|
||||
consumerSecret: getEnv('WOOCOMMERCE_CONSUMER_SECRET'),
|
||||
},
|
||||
|
||||
wordpress: {
|
||||
appPassword: getEnv('WORDPRESS_APP_PASSWORD'),
|
||||
},
|
||||
} as const;
|
||||
get env() { return getConfig().env; },
|
||||
get isProduction() { return getConfig().isProduction; },
|
||||
get isDevelopment() { return getConfig().isDevelopment; },
|
||||
get isTest() { return getConfig().isTest; },
|
||||
get baseUrl() { return getConfig().baseUrl; },
|
||||
get analytics() { return getConfig().analytics; },
|
||||
get errors() { return getConfig().errors; },
|
||||
get cache() { return getConfig().cache; },
|
||||
get logging() { return getConfig().logging; },
|
||||
get mail() { return getConfig().mail; },
|
||||
};
|
||||
|
||||
/**
|
||||
* Helper to get a masked version of the config for logging.
|
||||
*/
|
||||
export function getMaskedConfig() {
|
||||
const c = getConfig();
|
||||
const mask = (val: string | undefined) => (val ? `***${val.slice(-4)}` : 'not set');
|
||||
|
||||
return {
|
||||
env: config.env,
|
||||
baseUrl: config.baseUrl,
|
||||
env: c.env,
|
||||
baseUrl: c.baseUrl,
|
||||
analytics: {
|
||||
umami: {
|
||||
websiteId: mask(config.analytics.umami.websiteId),
|
||||
scriptUrl: config.analytics.umami.scriptUrl,
|
||||
enabled: config.analytics.umami.enabled,
|
||||
websiteId: mask(c.analytics.umami.websiteId),
|
||||
scriptUrl: c.analytics.umami.scriptUrl,
|
||||
enabled: c.analytics.umami.enabled,
|
||||
},
|
||||
},
|
||||
errors: {
|
||||
glitchtip: {
|
||||
dsn: mask(config.errors.glitchtip.dsn),
|
||||
enabled: config.errors.glitchtip.enabled,
|
||||
dsn: mask(c.errors.glitchtip.dsn),
|
||||
enabled: c.errors.glitchtip.enabled,
|
||||
},
|
||||
},
|
||||
cache: {
|
||||
redis: {
|
||||
url: mask(config.cache.redis.url),
|
||||
keyPrefix: config.cache.redis.keyPrefix,
|
||||
enabled: config.cache.redis.enabled,
|
||||
},
|
||||
enabled: c.cache.enabled,
|
||||
},
|
||||
logging: {
|
||||
level: config.logging.level,
|
||||
level: c.logging.level,
|
||||
},
|
||||
mail: {
|
||||
host: config.mail.host,
|
||||
port: config.mail.port,
|
||||
user: mask(config.mail.user),
|
||||
from: config.mail.from,
|
||||
recipients: config.mail.recipients,
|
||||
host: c.mail.host,
|
||||
port: c.mail.port,
|
||||
user: mask(c.mail.user),
|
||||
from: c.mail.from,
|
||||
recipients: c.mail.recipients,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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}`;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
58
lib/env.ts
Normal file
58
lib/env.ts
Normal file
@@ -0,0 +1,58 @@
|
||||
import { z } from 'zod';
|
||||
|
||||
/**
|
||||
* Helper to treat empty strings as undefined.
|
||||
*/
|
||||
const preprocessEmptyString = (val: unknown) => (val === '' ? undefined : val);
|
||||
|
||||
/**
|
||||
* Environment variable schema.
|
||||
*/
|
||||
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)),
|
||||
MAIL_USERNAME: z.preprocess(preprocessEmptyString, z.string().optional()),
|
||||
MAIL_PASSWORD: z.preprocess(preprocessEmptyString, z.string().optional()),
|
||||
MAIL_FROM: z.preprocess(preprocessEmptyString, z.string().optional()),
|
||||
MAIL_RECIPIENTS: z.preprocess(
|
||||
(val) => (typeof val === 'string' ? val.split(',').filter(Boolean) : val),
|
||||
z.array(z.string()).default([])
|
||||
),
|
||||
});
|
||||
|
||||
export type Env = z.infer<typeof envSchema>;
|
||||
|
||||
/**
|
||||
* Collects all environment variables from the process.
|
||||
* Explicitly references NEXT_PUBLIC_ variables for Next.js inlining.
|
||||
*/
|
||||
export function getRawEnv() {
|
||||
return {
|
||||
NODE_ENV: process.env.NODE_ENV,
|
||||
NEXT_PUBLIC_BASE_URL: process.env.NEXT_PUBLIC_BASE_URL,
|
||||
NEXT_PUBLIC_UMAMI_WEBSITE_ID: process.env.NEXT_PUBLIC_UMAMI_WEBSITE_ID,
|
||||
NEXT_PUBLIC_UMAMI_SCRIPT_URL: process.env.NEXT_PUBLIC_UMAMI_SCRIPT_URL,
|
||||
SENTRY_DSN: process.env.SENTRY_DSN,
|
||||
LOG_LEVEL: process.env.LOG_LEVEL,
|
||||
MAIL_HOST: process.env.MAIL_HOST,
|
||||
MAIL_PORT: process.env.MAIL_PORT,
|
||||
MAIL_USERNAME: process.env.MAIL_USERNAME,
|
||||
MAIL_PASSWORD: process.env.MAIL_PASSWORD,
|
||||
MAIL_FROM: process.env.MAIL_FROM,
|
||||
MAIL_RECIPIENTS: process.env.MAIL_RECIPIENTS,
|
||||
};
|
||||
}
|
||||
26
lib/mdx.ts
26
lib/mdx.ts
@@ -31,6 +31,8 @@ export async function getProductBySlug(slug: string, locale: string): Promise<Pr
|
||||
filePath = path.join(productsDir, `${fileSlug}-2.mdx`);
|
||||
}
|
||||
|
||||
let product: ProductMdx | null = null;
|
||||
|
||||
if (!fs.existsSync(filePath)) {
|
||||
// Fallback to English if locale is not 'en'
|
||||
if (locale !== 'en') {
|
||||
@@ -43,7 +45,7 @@ export async function getProductBySlug(slug: string, locale: string): Promise<Pr
|
||||
if (fs.existsSync(enFilePath)) {
|
||||
const fileContent = fs.readFileSync(enFilePath, 'utf8');
|
||||
const { data, content } = matter(fileContent);
|
||||
return {
|
||||
product = {
|
||||
slug: fileSlug,
|
||||
frontmatter: {
|
||||
...data,
|
||||
@@ -53,17 +55,23 @@ export async function getProductBySlug(slug: string, locale: string): Promise<Pr
|
||||
};
|
||||
}
|
||||
}
|
||||
} else {
|
||||
const fileContent = fs.readFileSync(filePath, 'utf8');
|
||||
const { data, content } = matter(fileContent);
|
||||
|
||||
product = {
|
||||
slug: fileSlug,
|
||||
frontmatter: data as ProductFrontmatter,
|
||||
content,
|
||||
};
|
||||
}
|
||||
|
||||
// Filter out products without images
|
||||
if (product && (!product.frontmatter.images || product.frontmatter.images.length === 0 || !product.frontmatter.images[0])) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const fileContent = fs.readFileSync(filePath, 'utf8');
|
||||
const { data, content } = matter(fileContent);
|
||||
|
||||
return {
|
||||
slug: fileSlug,
|
||||
frontmatter: data as ProductFrontmatter,
|
||||
content,
|
||||
};
|
||||
return product;
|
||||
}
|
||||
|
||||
export async function getAllProductSlugs(locale: string): Promise<string[]> {
|
||||
|
||||
25
lib/metadata.ts
Normal file
25
lib/metadata.ts
Normal file
@@ -0,0 +1,25 @@
|
||||
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}${cleanPath}/opengraph-image`,
|
||||
width: 1200,
|
||||
height: 630,
|
||||
alt: title,
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
export function getProductOGImageMetadata(slug: string, title: string, locale: string): NonNullable<Metadata['openGraph']>['images'] {
|
||||
return [
|
||||
{
|
||||
url: `${SITE_URL}/${locale}/api/og/product?slug=${slug}`,
|
||||
width: 1200,
|
||||
height: 630,
|
||||
alt: title,
|
||||
},
|
||||
];
|
||||
}
|
||||
@@ -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;
|
||||
@@ -389,19 +283,19 @@ const stripHtml = (html: string): string => {
|
||||
const getLabels = (locale: 'en' | 'de') => {
|
||||
const labels = {
|
||||
en: {
|
||||
productDatasheet: 'Product Datasheet',
|
||||
description: 'Description',
|
||||
specifications: 'Technical Specifications',
|
||||
categories: 'Categories',
|
||||
productDatasheet: 'Technical Datasheet',
|
||||
description: 'APPLICATION',
|
||||
specifications: 'TECHNICAL DATA',
|
||||
categories: 'CATEGORIES',
|
||||
sku: 'SKU',
|
||||
noImage: 'No image available',
|
||||
},
|
||||
de: {
|
||||
productDatasheet: 'Produktdatenblatt',
|
||||
description: 'Beschreibung',
|
||||
specifications: 'Technische Spezifikationen',
|
||||
categories: 'Kategorien',
|
||||
sku: 'Artikelnummer',
|
||||
productDatasheet: 'Technisches Datenblatt',
|
||||
description: 'ANWENDUNG',
|
||||
specifications: 'TECHNISCHE DATEN',
|
||||
categories: 'KATEGORIEN',
|
||||
sku: 'ARTIKELNUMMER',
|
||||
noImage: 'Kein Bild verfügbar',
|
||||
},
|
||||
};
|
||||
@@ -418,99 +312,101 @@ export const PDFDatasheet: React.FC<PDFDatasheetProps> = ({
|
||||
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>
|
||||
{/* Hero Header */}
|
||||
<View style={styles.hero}>
|
||||
<View style={styles.header}>
|
||||
<View>
|
||||
<Text style={styles.logoText}>KLZ</Text>
|
||||
</View>
|
||||
<Text style={styles.docTitle}>
|
||||
{labels.productDatasheet}
|
||||
</Text>
|
||||
</View>
|
||||
|
||||
<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>
|
||||
<Text style={styles.productName}>{product.name}</Text>
|
||||
</View>
|
||||
</View>
|
||||
<View style={styles.productImageCol}>
|
||||
{product.featuredImage ? (
|
||||
<Image src={product.featuredImage} style={styles.heroImage} />
|
||||
) : (
|
||||
<Text style={styles.noImage}>{labels.noImage}</Text>
|
||||
)}
|
||||
</View>
|
||||
</View>
|
||||
|
||||
<View style={styles.docInfo}>
|
||||
<Text style={styles.docTitle}>
|
||||
{locale === 'en' ? 'Product Datasheet' : 'Produktdatenblatt'}
|
||||
</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 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>
|
||||
|
||||
{/* Description section */}
|
||||
{(product.shortDescriptionHtml || product.descriptionHtml) && (
|
||||
<View style={styles.section}>
|
||||
<Text style={styles.sectionTitle}>{labels.description}</Text>
|
||||
<Text style={styles.description}>
|
||||
{stripHtml(product.shortDescriptionHtml || product.descriptionHtml)}
|
||||
</Text>
|
||||
</View>
|
||||
)}
|
||||
|
||||
{/* Technical specifications */}
|
||||
{product.attributes && product.attributes.length > 0 && (
|
||||
<View style={styles.section}>
|
||||
<Text style={styles.sectionTitle}>{labels.specifications}</Text>
|
||||
<View style={styles.specsTable}>
|
||||
{product.attributes.map((attr, index) => (
|
||||
<View
|
||||
key={index}
|
||||
style={[
|
||||
styles.specsTableRow,
|
||||
index === product.attributes.length - 1 &&
|
||||
styles.specsTableRowLast,
|
||||
]}
|
||||
>
|
||||
<View style={styles.specsTableLabelCell}>
|
||||
<Text style={styles.specsTableLabelText}>{attr.name}</Text>
|
||||
</View>
|
||||
<View style={styles.specsTableValueCell}>
|
||||
<Text style={styles.specsTableValueText}>
|
||||
{attr.options.join(', ')}
|
||||
</Text>
|
||||
</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.categories}>
|
||||
{product.categories.map((cat, index) => (
|
||||
<View key={index} style={styles.categoryTag}>
|
||||
<Text style={styles.categoryText}>{cat.name}</Text>
|
||||
</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',
|
||||
|
||||
55
lib/services/cache/redis-cache-service.ts
vendored
55
lib/services/cache/redis-cache-service.ts
vendored
@@ -1,55 +0,0 @@
|
||||
import { createClient, type RedisClientType } from 'redis';
|
||||
import type { CacheService, CacheSetOptions } from './cache-service';
|
||||
import { getServerAppServices } from '../create-services.server';
|
||||
|
||||
export type RedisCacheServiceOptions = {
|
||||
url: string;
|
||||
keyPrefix?: string;
|
||||
};
|
||||
|
||||
// Thin wrapper around shared Redis (platform provides host `redis`).
|
||||
// Values are JSON-serialized.
|
||||
export class RedisCacheService implements CacheService {
|
||||
private readonly client: RedisClientType;
|
||||
private readonly keyPrefix: string;
|
||||
|
||||
constructor(options: RedisCacheServiceOptions) {
|
||||
this.client = createClient({ url: options.url });
|
||||
this.keyPrefix = options.keyPrefix ?? '';
|
||||
|
||||
// Fire-and-forget connect.
|
||||
this.client.connect().catch((err) => {
|
||||
// We can't use getServerAppServices() here because it might cause a circular dependency
|
||||
// during initialization. But we can log to console as a fallback or use a global logger if we had one.
|
||||
// For now, let's just use console.error as this is a low-level service.
|
||||
console.error('Redis connection error:', err);
|
||||
});
|
||||
}
|
||||
|
||||
private k(key: string) {
|
||||
return `${this.keyPrefix}${key}`;
|
||||
}
|
||||
|
||||
async get<T>(key: string): Promise<T | undefined> {
|
||||
const raw = await this.client.get(this.k(key));
|
||||
if (raw == null) return undefined;
|
||||
return JSON.parse(raw) as T;
|
||||
}
|
||||
|
||||
async set<T>(key: string, value: T, options?: CacheSetOptions): Promise<void> {
|
||||
const ttl = options?.ttlSeconds;
|
||||
const raw = JSON.stringify(value);
|
||||
|
||||
if (ttl && ttl > 0) {
|
||||
await this.client.set(this.k(key), raw, { EX: ttl });
|
||||
return;
|
||||
}
|
||||
|
||||
await this.client.set(this.k(key), raw);
|
||||
}
|
||||
|
||||
async del(key: string): Promise<void> {
|
||||
await this.client.del(this.k(key));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,21 +2,12 @@ import { AppServices } from './app-services';
|
||||
import { NoopAnalyticsService } from './analytics/noop-analytics-service';
|
||||
import { UmamiAnalyticsService } from './analytics/umami-analytics-service';
|
||||
import { MemoryCacheService } from './cache/memory-cache-service';
|
||||
import { RedisCacheService } from './cache/redis-cache-service';
|
||||
import { GlitchtipErrorReportingService } from './errors/glitchtip-error-reporting-service';
|
||||
import { NoopErrorReportingService } from './errors/noop-error-reporting-service';
|
||||
import { PinoLoggerService } from './logging/pino-logger-service';
|
||||
import { config, getMaskedConfig } from '../config';
|
||||
import dotenv from 'dotenv';
|
||||
import path from 'path';
|
||||
|
||||
// Load .env file in development
|
||||
if (process.env.NODE_ENV !== 'production') {
|
||||
dotenv.config({ path: path.resolve(process.cwd(), '.env') });
|
||||
}
|
||||
|
||||
let singleton: AppServices | undefined;
|
||||
|
||||
export function getServerAppServices(): AppServices {
|
||||
if (singleton) return singleton;
|
||||
|
||||
@@ -31,7 +22,6 @@ export function getServerAppServices(): AppServices {
|
||||
logger.info('Service configuration', {
|
||||
umamiEnabled: config.analytics.umami.enabled,
|
||||
sentryEnabled: config.errors.glitchtip.enabled,
|
||||
redisEnabled: config.cache.redis.enabled,
|
||||
mailEnabled: Boolean(config.mail.host && config.mail.user),
|
||||
});
|
||||
|
||||
@@ -55,20 +45,8 @@ export function getServerAppServices(): AppServices {
|
||||
logger.info('Noop error reporting service initialized (error reporting disabled)');
|
||||
}
|
||||
|
||||
const cache = config.cache.redis.enabled && config.cache.redis.url
|
||||
? new RedisCacheService({
|
||||
url: config.cache.redis.url,
|
||||
keyPrefix: config.cache.redis.keyPrefix,
|
||||
})
|
||||
: new MemoryCacheService();
|
||||
|
||||
if (config.cache.redis.enabled) {
|
||||
logger.info('Redis cache service initialized', {
|
||||
keyPrefix: config.cache.redis.keyPrefix
|
||||
});
|
||||
} else {
|
||||
logger.info('Memory cache service initialized (Redis not configured)');
|
||||
}
|
||||
const cache = new MemoryCacheService();
|
||||
logger.info('Memory cache service initialized');
|
||||
|
||||
logger.info('Pino logger service initialized', {
|
||||
name: 'server',
|
||||
|
||||
@@ -6,13 +6,6 @@ import { NoopErrorReportingService } from './errors/noop-error-reporting-service
|
||||
import { NoopLoggerService } from './logging/noop-logger-service';
|
||||
import { PinoLoggerService } from './logging/pino-logger-service';
|
||||
import { config, getMaskedConfig } from '../config';
|
||||
import dotenv from 'dotenv';
|
||||
import path from 'path';
|
||||
|
||||
// Load .env file in development
|
||||
if (typeof window === 'undefined' && process.env.NODE_ENV !== 'production') {
|
||||
dotenv.config({ path: path.resolve(process.cwd(), '.env') });
|
||||
}
|
||||
|
||||
/**
|
||||
* Singleton instance of AppServices.
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import createMiddleware from 'next-intl/middleware';
|
||||
import { NextResponse } from 'next/server';
|
||||
import type { NextRequest } from 'next/server';
|
||||
import { getServerAppServices } from '@/lib/services/create-services.server';
|
||||
|
||||
// Create the internationalization middleware
|
||||
const intlMiddleware = createMiddleware({
|
||||
@@ -20,68 +19,20 @@ export default function middleware(request: NextRequest) {
|
||||
const referer = headers.get('referer') || 'none';
|
||||
const ip = headers.get('x-forwarded-for') || headers.get('x-real-ip') || 'unknown';
|
||||
|
||||
// Get logger service
|
||||
const services = getServerAppServices();
|
||||
const logger = services.logger.child({ middleware: 'request-logger' });
|
||||
|
||||
// Log incoming request
|
||||
logger.info('Incoming request', {
|
||||
method,
|
||||
url,
|
||||
userAgent,
|
||||
referer,
|
||||
ip,
|
||||
timestamp: new Date().toISOString(),
|
||||
});
|
||||
console.log(`Incoming request: method=${method} url=${url}`);
|
||||
|
||||
try {
|
||||
// Apply internationalization middleware
|
||||
const response = intlMiddleware(request);
|
||||
const duration = Date.now() - startTime;
|
||||
|
||||
// Log successful response
|
||||
const logData: any = {
|
||||
method,
|
||||
url,
|
||||
status: response.status,
|
||||
duration,
|
||||
userAgent,
|
||||
referer,
|
||||
ip,
|
||||
timestamp: new Date().toISOString(),
|
||||
};
|
||||
|
||||
// Log redirect details if it's a redirect
|
||||
if (response.status >= 300 && response.status < 400) {
|
||||
logData.redirectLocation = response.headers.get('location');
|
||||
logger.info('Redirect detected', logData);
|
||||
} else {
|
||||
logger.info('Request completed', logData);
|
||||
}
|
||||
|
||||
return response;
|
||||
} catch (error) {
|
||||
const duration = Date.now() - startTime;
|
||||
|
||||
// Log error
|
||||
logger.error('Request failed', {
|
||||
method,
|
||||
url,
|
||||
duration,
|
||||
userAgent,
|
||||
referer,
|
||||
ip,
|
||||
error: error instanceof Error ? error.message : String(error),
|
||||
stack: error instanceof Error ? error.stack : undefined,
|
||||
timestamp: new Date().toISOString(),
|
||||
});
|
||||
|
||||
// Re-throw the error to let Next.js handle it
|
||||
console.error(`Request failed: method=${method} url=${url}`, error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
export const config = {
|
||||
// Match only internationalized pathnames
|
||||
matcher: ['/((?!api|_next|_vercel|.*\\..*).*)', '/', '/(de|en)/:path*']
|
||||
matcher: ['/((?!api|_next|_vercel|health|.*\\..*).*)', '/', '/(de|en)/:path*']
|
||||
};
|
||||
|
||||
@@ -322,7 +322,7 @@ const nextConfig = {
|
||||
contentSecurityPolicy: "default-src 'self'; script-src 'none'; sandbox;",
|
||||
},
|
||||
async rewrites() {
|
||||
const umamiUrl = (process.env.UMAMI_SCRIPT_URL || 'https://analytics.infra.mintel.me').replace('/script.js', '');
|
||||
const umamiUrl = (process.env.NEXT_PUBLIC_UMAMI_SCRIPT_URL || 'https://analytics.infra.mintel.me').replace('/script.js', '');
|
||||
const glitchtipUrl = process.env.SENTRY_DSN
|
||||
? new URL(process.env.SENTRY_DSN).origin
|
||||
: 'https://errors.infra.mintel.me';
|
||||
|
||||
126
package-lock.json
generated
126
package-lock.json
generated
@@ -17,7 +17,6 @@
|
||||
"axios": "^1.13.2",
|
||||
"cheerio": "^1.1.2",
|
||||
"clsx": "^2.1.1",
|
||||
"dotenv": "^17.2.3",
|
||||
"framer-motion": "^12.27.1",
|
||||
"gray-matter": "^4.0.3",
|
||||
"i18next": "^25.7.3",
|
||||
@@ -36,13 +35,13 @@
|
||||
"react-dom": "^18.3.1",
|
||||
"react-email": "^5.2.5",
|
||||
"react-leaflet": "^4.2.1",
|
||||
"redis": "^4.7.1",
|
||||
"resend": "^3.5.0",
|
||||
"schema-dts": "^1.1.5",
|
||||
"sharp": "^0.34.5",
|
||||
"svg-to-pdfkit": "^0.1.8",
|
||||
"tailwind-merge": "^3.4.0",
|
||||
"xlsx": "^0.18.5"
|
||||
"xlsx": "^0.18.5",
|
||||
"zod": "^4.3.6"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@tailwindcss/cli": "^4.1.18",
|
||||
@@ -4459,71 +4458,6 @@
|
||||
"@react-pdf/stylesheet": "^6.1.2"
|
||||
}
|
||||
},
|
||||
"node_modules/@redis/bloom": {
|
||||
"version": "1.2.0",
|
||||
"resolved": "https://registry.npmjs.org/@redis/bloom/-/bloom-1.2.0.tgz",
|
||||
"integrity": "sha512-HG2DFjYKbpNmVXsa0keLHp/3leGJz1mjh09f2RLGGLQZzSHpkmZWuwJbAvo3QcRY8p80m5+ZdXZdYOSBLlp7Cg==",
|
||||
"license": "MIT",
|
||||
"peerDependencies": {
|
||||
"@redis/client": "^1.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@redis/client": {
|
||||
"version": "1.6.1",
|
||||
"resolved": "https://registry.npmjs.org/@redis/client/-/client-1.6.1.tgz",
|
||||
"integrity": "sha512-/KCsg3xSlR+nCK8/8ZYSknYxvXHwubJrU82F3Lm1Fp6789VQ0/3RJKfsmRXjqfaTA++23CvC3hqmqe/2GEt6Kw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"cluster-key-slot": "1.1.2",
|
||||
"generic-pool": "3.9.0",
|
||||
"yallist": "4.0.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=14"
|
||||
}
|
||||
},
|
||||
"node_modules/@redis/client/node_modules/yallist": {
|
||||
"version": "4.0.0",
|
||||
"resolved": "https://registry.npmjs.org/yallist/-/yallist-4.0.0.tgz",
|
||||
"integrity": "sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A==",
|
||||
"license": "ISC"
|
||||
},
|
||||
"node_modules/@redis/graph": {
|
||||
"version": "1.1.1",
|
||||
"resolved": "https://registry.npmjs.org/@redis/graph/-/graph-1.1.1.tgz",
|
||||
"integrity": "sha512-FEMTcTHZozZciLRl6GiiIB4zGm5z5F3F6a6FZCyrfxdKOhFlGkiAqlexWMBzCi4DcRoyiOsuLfW+cjlGWyExOw==",
|
||||
"license": "MIT",
|
||||
"peerDependencies": {
|
||||
"@redis/client": "^1.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@redis/json": {
|
||||
"version": "1.0.7",
|
||||
"resolved": "https://registry.npmjs.org/@redis/json/-/json-1.0.7.tgz",
|
||||
"integrity": "sha512-6UyXfjVaTBTJtKNG4/9Z8PSpKE6XgSyEb8iwaqDcy+uKrd/DGYHTWkUdnQDyzm727V7p21WUMhsqz5oy65kPcQ==",
|
||||
"license": "MIT",
|
||||
"peerDependencies": {
|
||||
"@redis/client": "^1.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@redis/search": {
|
||||
"version": "1.2.0",
|
||||
"resolved": "https://registry.npmjs.org/@redis/search/-/search-1.2.0.tgz",
|
||||
"integrity": "sha512-tYoDBbtqOVigEDMAcTGsRlMycIIjwMCgD8eR2t0NANeQmgK/lvxNAvYyb6bZDD4frHRhIHkJu2TBRvB0ERkOmw==",
|
||||
"license": "MIT",
|
||||
"peerDependencies": {
|
||||
"@redis/client": "^1.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@redis/time-series": {
|
||||
"version": "1.1.0",
|
||||
"resolved": "https://registry.npmjs.org/@redis/time-series/-/time-series-1.1.0.tgz",
|
||||
"integrity": "sha512-c1Q99M5ljsIuc4YdaCwfUEXsofakb9c8+Zse2qxTadu8TalLXuAESzLvFAvNVbkmSlvlzIQOLpBCmWI9wTOt+g==",
|
||||
"license": "MIT",
|
||||
"peerDependencies": {
|
||||
"@redis/client": "^1.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@rollup/plugin-commonjs": {
|
||||
"version": "28.0.1",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/plugin-commonjs/-/plugin-commonjs-28.0.1.tgz",
|
||||
@@ -8721,15 +8655,6 @@
|
||||
"node": ">=6"
|
||||
}
|
||||
},
|
||||
"node_modules/cluster-key-slot": {
|
||||
"version": "1.1.2",
|
||||
"resolved": "https://registry.npmjs.org/cluster-key-slot/-/cluster-key-slot-1.1.2.tgz",
|
||||
"integrity": "sha512-RMr0FhtfXemyinomL4hrWcYJxmX6deFdCxpJzhDttxgO1+bcCnkk+9drydLVDmAMG7NE6aN/fl4F7ucU/90gAA==",
|
||||
"license": "Apache-2.0",
|
||||
"engines": {
|
||||
"node": ">=0.10.0"
|
||||
}
|
||||
},
|
||||
"node_modules/codepage": {
|
||||
"version": "1.15.0",
|
||||
"resolved": "https://registry.npmjs.org/codepage/-/codepage-1.15.0.tgz",
|
||||
@@ -9405,18 +9330,6 @@
|
||||
"url": "https://github.com/sponsors/sindresorhus"
|
||||
}
|
||||
},
|
||||
"node_modules/dotenv": {
|
||||
"version": "17.2.3",
|
||||
"resolved": "https://registry.npmjs.org/dotenv/-/dotenv-17.2.3.tgz",
|
||||
"integrity": "sha512-JVUnt+DUIzu87TABbhPmNfVdBDt18BLOWjMUFJMSi/Qqg7NTYtabbvSNJGOJ7afbRuv9D/lngizHtP7QyLQ+9w==",
|
||||
"license": "BSD-2-Clause",
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://dotenvx.com"
|
||||
}
|
||||
},
|
||||
"node_modules/dunder-proto": {
|
||||
"version": "1.0.1",
|
||||
"resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz",
|
||||
@@ -10886,15 +10799,6 @@
|
||||
"node": ">= 0.4"
|
||||
}
|
||||
},
|
||||
"node_modules/generic-pool": {
|
||||
"version": "3.9.0",
|
||||
"resolved": "https://registry.npmjs.org/generic-pool/-/generic-pool-3.9.0.tgz",
|
||||
"integrity": "sha512-hymDOu5B53XvN4QT9dBmZxPX4CWhBPPLguTZ9MMFeFa/Kg0xWVfylOVNlJji/E7yTZWFd/q9GO5TxDLq156D7g==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">= 4"
|
||||
}
|
||||
},
|
||||
"node_modules/gensync": {
|
||||
"version": "1.0.0-beta.2",
|
||||
"resolved": "https://registry.npmjs.org/gensync/-/gensync-1.0.0-beta.2.tgz",
|
||||
@@ -16003,23 +15907,6 @@
|
||||
"url": "https://opencollective.com/unified"
|
||||
}
|
||||
},
|
||||
"node_modules/redis": {
|
||||
"version": "4.7.1",
|
||||
"resolved": "https://registry.npmjs.org/redis/-/redis-4.7.1.tgz",
|
||||
"integrity": "sha512-S1bJDnqLftzHXHP8JsT5II/CtHWQrASX5K96REjWjlmWKrviSOLWmM7QnRLstAWsu1VBBV1ffV6DzCvxNP0UJQ==",
|
||||
"license": "MIT",
|
||||
"workspaces": [
|
||||
"./packages/*"
|
||||
],
|
||||
"dependencies": {
|
||||
"@redis/bloom": "1.2.0",
|
||||
"@redis/client": "1.6.1",
|
||||
"@redis/graph": "1.1.1",
|
||||
"@redis/json": "1.0.7",
|
||||
"@redis/search": "1.2.0",
|
||||
"@redis/time-series": "1.1.0"
|
||||
}
|
||||
},
|
||||
"node_modules/reflect.getprototypeof": {
|
||||
"version": "1.0.10",
|
||||
"resolved": "https://registry.npmjs.org/reflect.getprototypeof/-/reflect.getprototypeof-1.0.10.tgz",
|
||||
@@ -19031,6 +18918,15 @@
|
||||
"integrity": "sha512-0LPOt3AxKqMdFBZA3HBAt/t/8vIKq7VaQYbuA8WxCgung+p9TVyKRYdpvCb80HcdTN2NkbIKbhNwKUfm3tQywQ==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/zod": {
|
||||
"version": "4.3.6",
|
||||
"resolved": "https://registry.npmjs.org/zod/-/zod-4.3.6.tgz",
|
||||
"integrity": "sha512-rftlrkhHZOcjDwkGlnUtZZkvaPHCsDATp4pGpuOOMDaTdDDXF91wuVDJoWoPsKX/3YPQ5fHuF3STjcYyKr+Qhg==",
|
||||
"license": "MIT",
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/colinhacks"
|
||||
}
|
||||
},
|
||||
"node_modules/zwitch": {
|
||||
"version": "2.0.4",
|
||||
"resolved": "https://registry.npmjs.org/zwitch/-/zwitch-2.0.4.tgz",
|
||||
|
||||
@@ -9,7 +9,6 @@
|
||||
"axios": "^1.13.2",
|
||||
"cheerio": "^1.1.2",
|
||||
"clsx": "^2.1.1",
|
||||
"dotenv": "^17.2.3",
|
||||
"framer-motion": "^12.27.1",
|
||||
"gray-matter": "^4.0.3",
|
||||
"i18next": "^25.7.3",
|
||||
@@ -28,13 +27,13 @@
|
||||
"react-dom": "^18.3.1",
|
||||
"react-email": "^5.2.5",
|
||||
"react-leaflet": "^4.2.1",
|
||||
"redis": "^4.7.1",
|
||||
"resend": "^3.5.0",
|
||||
"schema-dts": "^1.1.5",
|
||||
"sharp": "^0.34.5",
|
||||
"svg-to-pdfkit": "^0.1.8",
|
||||
"tailwind-merge": "^3.4.0",
|
||||
"xlsx": "^0.18.5"
|
||||
"xlsx": "^0.18.5",
|
||||
"zod": "^4.3.6"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@tailwindcss/cli": "^4.1.18",
|
||||
@@ -64,6 +63,7 @@
|
||||
"lint": "next lint",
|
||||
"typecheck": "tsc --noEmit",
|
||||
"test": "vitest run --passWithNoTests",
|
||||
"test:og": "vitest run tests/og-image.test.ts",
|
||||
"pdf:datasheets": "tsx ./scripts/generate-pdf-datasheets.ts",
|
||||
"pdf:datasheets:legacy": "tsx ./scripts/generate-pdf-datasheets-pdf-lib.ts"
|
||||
},
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user