Compare commits

...

31 Commits

Author SHA1 Message Date
506c8682fe umami
All checks were successful
Build & Deploy KLZ Cables / build-and-deploy (push) Successful in 3m52s
2026-01-29 17:43:06 +01:00
a909de30f3 filter products
All checks were successful
Build & Deploy KLZ Cables / build-and-deploy (push) Successful in 3m52s
2026-01-29 17:34:15 +01:00
a2f94f15bc og
All checks were successful
Build & Deploy KLZ Cables / build-and-deploy (push) Successful in 4m48s
2026-01-29 17:26:02 +01:00
13e56a88bc headings
All checks were successful
Build & Deploy KLZ Cables / build-and-deploy (push) Successful in 4m3s
2026-01-29 16:51:11 +01:00
bb7d17001b og
All checks were successful
Build & Deploy KLZ Cables / build-and-deploy (push) Successful in 3m57s
2026-01-29 16:33:04 +01:00
920efa0083 pdf sheets 2026-01-29 16:27:09 +01:00
0b81d1a4cb og
Some checks failed
Build & Deploy KLZ Cables / build-and-deploy (push) Failing after 14s
2026-01-29 16:18:37 +01:00
1d5bdeba26 spacing 2026-01-29 15:59:53 +01:00
a0c3fbbc7e application field to mdx 2026-01-29 15:45:35 +01:00
8101a9f156 reveal
All checks were successful
Build & Deploy KLZ Cables / build-and-deploy (push) Successful in 3m57s
2026-01-29 15:11:04 +01:00
7b6f4b5ea4 deploy
All checks were successful
Build & Deploy KLZ Cables / build-and-deploy (push) Successful in 4m2s
2026-01-29 14:47:43 +01:00
658057cdb1 deploy
Some checks failed
Build & Deploy KLZ Cables / build-and-deploy (push) Failing after 5m55s
2026-01-29 13:57:35 +01:00
2aa5d5b00e deploy
Some checks failed
Build & Deploy KLZ Cables / build-and-deploy (push) Failing after 3m53s
2026-01-29 13:52:37 +01:00
7f2f6f5aca deploy
Some checks failed
Build & Deploy KLZ Cables / build-and-deploy (push) Failing after 5m48s
2026-01-29 11:33:30 +01:00
4e50482769 remove redis
Some checks failed
Build & Deploy KLZ Cables / build-and-deploy (push) Failing after 5m50s
2026-01-29 02:23:41 +01:00
1da1f05cdd remove varnish
Some checks failed
Build & Deploy KLZ Cables / build-and-deploy (push) Failing after 4m55s
2026-01-29 02:12:39 +01:00
15cfb314b1 fixes
Some checks failed
Build & Deploy KLZ Cables / build-and-deploy (push) Failing after 3m53s
2026-01-29 01:05:57 +01:00
a3da6192e3 remove smoke test 2026-01-29 00:34:41 +01:00
af33c6225d deploy
Some checks failed
Build & Deploy KLZ Cables / build-and-deploy (push) Failing after 5m32s
2026-01-29 00:09:15 +01:00
9ee09bbe4b deploy
Some checks failed
Build & Deploy KLZ Cables / build-and-deploy (push) Failing after 4m27s
2026-01-28 23:46:51 +01:00
3f0858a1ba smoke tests
Some checks failed
Build & Deploy KLZ Cables / build-and-deploy (push) Failing after 4m17s
2026-01-28 21:39:16 +01:00
a85fe64ccb build
All checks were successful
Build & Deploy KLZ Cables / build-and-deploy (push) Successful in 4m14s
2026-01-28 19:41:01 +01:00
21b16a5e6c deploy
Some checks failed
Build & Deploy KLZ Cables / build-and-deploy (push) Failing after 1m51s
2026-01-28 19:05:20 +01:00
6115e0e0d4 deploy
Some checks failed
Build & Deploy KLZ Cables / build-and-deploy (push) Failing after 1m51s
2026-01-28 17:27:43 +01:00
859d034ed7 env
Some checks failed
Build & Deploy KLZ Cables / build-and-deploy (push) Failing after 1m17s
2026-01-28 15:26:36 +01:00
91ebc54571 env
All checks were successful
Build & Deploy KLZ Cables / build-and-deploy (push) Successful in 4m12s
2026-01-28 15:14:01 +01:00
d6c1d6bae6 env
Some checks failed
Build & Deploy KLZ Cables / build-and-deploy (push) Failing after 4m2s
2026-01-28 15:06:21 +01:00
407b2227b3 env
All checks were successful
Build & Deploy KLZ Cables / build-and-deploy (push) Successful in 3m53s
2026-01-28 01:00:30 +01:00
2896556659 env
All checks were successful
Build & Deploy KLZ Cables / build-and-deploy (push) Successful in 3m52s
2026-01-28 00:41:29 +01:00
8242687b07 env
All checks were successful
Build & Deploy KLZ Cables / build-and-deploy (push) Successful in 3m46s
2026-01-28 00:34:40 +01:00
dab4f3f5b5 logging 2026-01-28 00:13:19 +01:00
106 changed files with 2306 additions and 987 deletions

View File

@@ -6,5 +6,4 @@ node_modules
*.md
docs
reference
scripts
public/datasheets/*.pdf

18
.env
View File

@@ -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

View File

@@ -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,38 @@ 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
# ────────────────────────────────────────────────────────────────────────────
# 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. The .env file should exist at: /home/deploy/sites/klz-cables.com/.env
#
# 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
View 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

View File

@@ -6,8 +6,6 @@ on:
jobs:
build-and-deploy:
# ────────────────────────────────────────────────
# WICHTIG: Kein "docker" mehr sondern eines der neuen Labels
runs-on: docker
steps:
@@ -16,22 +14,9 @@ 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 " • 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 +26,101 @@ 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
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)..."
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_UMAMI_WEBSITE_ID="${{ secrets.NEXT_PUBLIC_UMAMI_WEBSITE_ID }}" \
--build-arg NEXT_PUBLIC_UMAMI_SCRIPT_URL="${{ secrets.NEXT_PUBLIC_UMAMI_SCRIPT_URL }}" \
-t registry.infra.mintel.me/mintel/klz-cables.com:latest \
--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
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 ""
echo "🚀 Deploying to alpha.mintel.me..."
# Setup SSH with logging
echo "🔐 Setting up SSH connection..."
# 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 - Production Environment Configuration
# ============================================================================
# Auto-generated by CI/CD workflow
# DO NOT EDIT MANUALLY - Changes will be overwritten on next deployment
# ============================================================================
# Application
NODE_ENV=production
NEXT_PUBLIC_BASE_URL=${{ secrets.NEXT_PUBLIC_BASE_URL }}
# Analytics (Umami)
NEXT_PUBLIC_UMAMI_WEBSITE_ID=${{ secrets.NEXT_PUBLIC_UMAMI_WEBSITE_ID }}
NEXT_PUBLIC_UMAMI_SCRIPT_URL=${{ secrets.NEXT_PUBLIC_UMAMI_SCRIPT_URL }}
# Error Tracking (GlitchTip/Sentry)
SENTRY_DSN=${{ secrets.SENTRY_DSN }}
# Email Configuration (Mailgun)
MAIL_HOST=${{ secrets.MAIL_HOST }}
MAIL_PORT=${{ secrets.MAIL_PORT }}
MAIL_USERNAME=${{ secrets.MAIL_USERNAME }}
MAIL_PASSWORD=${{ secrets.MAIL_PASSWORD }}
MAIL_FROM=${{ secrets.MAIL_FROM }}
MAIL_RECIPIENTS=${{ secrets.MAIL_RECIPIENTS }}
EOF
# Upload .env and deploy
scp -o StrictHostKeyChecking=accept-new /tmp/klz-cables.env root@alpha.mintel.me:/home/deploy/sites/klz-cables.com/.env
ssh -o StrictHostKeyChecking=accept-new root@alpha.mintel.me bash << EOF
set -e
cd /home/deploy/sites/klz-cables.com
chmod 600 .env
chown deploy:deploy .env
echo "${{ secrets.REGISTRY_PASS }}" | docker login registry.infra.mintel.me -u "${{ secrets.REGISTRY_USER }}" --password-stdin
docker pull registry.infra.mintel.me/mintel/klz-cables.com:latest
docker-compose down
echo "🚀 Starting containers..."
docker-compose up -d
echo "⏳ Giving the app a few seconds to warm up..."
sleep 10
echo "🔍 Checking container status..."
docker-compose ps
if ! docker-compose ps | grep -q "Up"; then
echo "❌ Container failed to start"
docker-compose logs --tail=100
exit 1
fi
echo ""
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 +128,8 @@ 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"
# ═══════════════════════════════════════════════════════════════════════════════
# NOTIFICATION: Gotify

View File

@@ -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
View 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!

View File

@@ -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
@@ -266,7 +261,7 @@ bash scripts/deploy-webhook.sh
### Architecture
```
Client → Traefik (TLS) → Varnish (Cache) → Next.js App
Client → Traefik (TLS) → Next.js App
```
**Domains**:
@@ -276,7 +271,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).

View File

@@ -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>

View 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}
/>
),
{
width: 1200,
height: 630,
}
);
}

View File

@@ -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, {

View 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,
}
);
}

View File

@@ -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>

View File

@@ -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,

View File

@@ -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',

View File

@@ -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' });

View File

@@ -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',

View File

@@ -5,12 +5,13 @@ 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 { 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 +52,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 +81,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 +167,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 +312,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>

View File

@@ -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)) {

View File

@@ -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>

View File

@@ -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>

View File

@@ -46,6 +46,8 @@ export function OGImageTemplate({
<img
src={image}
alt=""
width="1200"
height="630"
style={{
width: '100%',
height: '100%',

View File

@@ -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

View File

@@ -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 }}

View File

@@ -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]',

View File

@@ -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={{

View File

@@ -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 50Hz 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={{

View File

@@ -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={{

View File

@@ -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={{

View File

@@ -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={{

View File

@@ -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={{

View File

@@ -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={{

View File

@@ -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={{

View File

@@ -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={{

View File

@@ -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 630mm² lässt sich das Kabel flexibel an die jeweilige Anwendung
anpassen.
locale: de
---
<ProductTabs technicalData={<ProductTechnicalData data={{

View File

@@ -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={{

View File

@@ -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={{

View File

@@ -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={{

View File

@@ -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={{

View File

@@ -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={{

View File

@@ -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={{

View File

@@ -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={{

View File

@@ -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={{

View File

@@ -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={{

View File

@@ -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={{

View File

@@ -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={{

View File

@@ -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={{

View File

@@ -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={{

View File

@@ -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 1kV 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={{

View File

@@ -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={{

View File

@@ -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={{

View File

@@ -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={{

View File

@@ -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={{

View File

@@ -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={{

View File

@@ -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={{

View File

@@ -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={{

View File

@@ -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={{

View File

@@ -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={{

View File

@@ -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={{

View File

@@ -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={{

View File

@@ -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={{

View File

@@ -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={{

View File

@@ -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={{

View File

@@ -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={{

View File

@@ -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={{

View File

@@ -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={{

View File

@@ -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={{

View File

@@ -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={{

View File

@@ -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={{

View File

@@ -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={{

View File

@@ -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={{

View File

@@ -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={{

View File

@@ -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={{

View File

@@ -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={{

View File

@@ -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={{

View File

@@ -0,0 +1,4 @@
services:
app:
env_file:
- .env

View File

@@ -1,80 +1,32 @@
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
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
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/`)"
- "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`)"
- "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.scheme=http"
# Forwarded Headers (für Apps, die HTTPS erwarten)
- "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
- "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}
ports:
- "3000:3000"
env_file:
- .env
labels:
- "traefik.enable=true"
# HTTP ⇒ HTTPS redirect
- "traefik.http.routers.klz-cables-web.rule=(Host(`klz-cables.com`) || Host(`www.klz-cables.com`) || Host(`staging.klz-cables.com`)) && !PathPrefix(`/.well-known/acme-challenge/`)"
- "traefik.http.routers.klz-cables-web.entrypoints=web"
- "traefik.http.routers.klz-cables-web.middlewares=redirect-https"
# HTTPS router
- "traefik.http.routers.klz-cables.rule=Host(`klz-cables.com`) || Host(`www.klz-cables.com`) || Host(`staging.klz-cables.com`)"
- "traefik.http.routers.klz-cables.entrypoints=websecure"
- "traefik.http.routers.klz-cables.tls.certresolver=le"
- "traefik.http.routers.klz-cables.tls=true"
- "traefik.http.routers.klz-cables.service=klz-cables"
- "traefik.http.services.klz-cables.loadbalancer.server.port=3000"
- "traefik.http.services.klz-cables.loadbalancer.server.scheme=http"
# Forwarded Headers
- "traefik.http.middlewares.klz-forward.headers.customrequestheaders.X-Forwarded-Proto=https"
- "traefik.http.middlewares.klz-forward.headers.customrequestheaders.X-Forwarded-Ssl=on"
# Middlewares
- "traefik.http.routers.klz-cables.middlewares=klz-forward,compress"
networks:
infra:

View File

@@ -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
View 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
View 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/)

View File

@@ -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();
}

View File

@@ -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,
},
};
}

58
lib/env.ts Normal file
View 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,
};
}

View File

@@ -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[]> {

24
lib/metadata.ts Normal file
View File

@@ -0,0 +1,24 @@
import { Metadata } from 'next';
import { SITE_URL } from './schema';
export function getOGImageMetadata(path: string, title: string, locale: string): NonNullable<Metadata['openGraph']>['images'] {
return [
{
url: `${SITE_URL}/${locale}/${path}/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,
},
];
}

View File

@@ -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));
}
}

View File

@@ -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',

View File

@@ -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.

View File

@@ -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*']
};

View File

@@ -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
View File

@@ -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",

View File

@@ -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"
},

View File

@@ -1,4 +1,3 @@
import 'dotenv/config';
import axios from 'axios';
import * as fs from 'fs';
import * as path from 'path';

View File

@@ -1,4 +1,3 @@
import 'dotenv/config';
import axios from 'axios';
import * as fs from 'fs';
import * as path from 'path';

View File

@@ -1,4 +1,3 @@
import 'dotenv/config';
import axios from 'axios';
import * as fs from 'fs';
import * as path from 'path';

View File

@@ -159,26 +159,112 @@ function guessColumnKey(row: ExcelRow, patterns: RegExp[]): string | null {
}
function technicalFullLabel(args: { key: string; excelKey: string; locale: 'en' | 'de' }): string {
if (args.locale === 'en') return normalizeValue(args.excelKey);
const raw = normalizeValue(args.excelKey);
if (!raw) return '';
if (args.locale === 'de') {
return raw
.replace(/\(approx\.?\)/gi, '(ca.)')
.replace(/\bconductor material\b/gi, 'Leitermaterial')
.replace(/\bconductor class\b/gi, 'Leiterklasse')
.replace(/\bcore insulation\b/gi, 'Aderisolation')
.replace(/\binsulation\b/gi, 'Aderisolation')
.replace(/\bfield control\b/gi, 'Feldsteuerung')
.replace(/\bscreen\b/gi, 'Schirm')
.replace(/\blongitudinal water tightness\b/gi, 'Längswasserdichtigkeit')
.replace(/\btransverse water tightness\b/gi, 'Querwasserdichtigkeit')
.replace(/\bsheath material\b/gi, 'Mantelmaterial')
.replace(/\bsheath color\b/gi, 'Mantelfarbe')
.replace(/\bflame retardancy\b/gi, 'Flammwidrigkeit')
.replace(/\buv resistant\b/gi, 'UV-bestandig')
.replace(/\bmax\.? permissible conductor temperature\b/gi, 'Max. zulässige Leitertemperatur')
.replace(/\bpermissible cable outer temperature, fixed\b/gi, 'Zul. Kabelaußentemperatur, fest verlegt')
.replace(/\bpermissible cable outer temperature, in motion\b/gi, 'Zul. Kabelaußentemperatur, in Bewegung')
.replace(/\bmaximum short-circuit temperature\b/gi, 'Maximale Kurzschlußtemperatur')
.replace(/\bmin\.? bending radius, fixed\b/gi, 'Min. Biegeradius, fest verlegt')
.replace(/\bminimum laying temperature\b/gi, 'Mindesttemperatur Verlegung')
.replace(/\bmeter marking\b/gi, 'Metermarkierung')
.replace(/\bpartial discharge\b/gi, 'Teilentladung')
.replace(/\bcapacitance\b/gi, 'Kapazität')
.replace(/\binductance\b/gi, 'Induktivität')
.replace(/\breactance\b/gi, 'Reaktanz')
.replace(/\btest voltage\b/gi, 'Prüfspannung')
.replace(/\brated voltage\b/gi, 'Nennspannung')
.replace(/\boperating temperature range\b/gi, 'Temperaturbereich')
.replace(/\bminimum sheath thickness\b/gi, 'Manteldicke (min.)')
.replace(/\bsheath thickness\b/gi, 'Manteldicke')
.replace(/\bnominal insulation thickness\b/gi, 'Isolationsdicke (nom.)')
.replace(/\binsulation thickness\b/gi, 'Isolationsdicke')
.replace(/\bdc resistance at 20\s*°?c\b/gi, 'DC-Leiterwiderstand (20 °C)')
.replace(/\bouter diameter(?: of cable)?\b/gi, 'Außen-Ø')
.replace(/\bbending radius\b/gi, 'Biegeradius')
.replace(/\bpackaging\b/gi, 'Verpackung')
.replace(/\bce\s*-?conformity\b/gi, 'CE-Konformität');
}
return raw
.replace(/\(approx\.?\)/gi, '(ca.)')
.replace(/\bcapacitance\b/gi, 'Kapazität')
.replace(/\binductance\b/gi, 'Induktivität')
.replace(/\breactance\b/gi, 'Reaktanz')
.replace(/\btest voltage\b/gi, 'Prüfspannung')
.replace(/\brated voltage\b/gi, 'Nennspannung')
.replace(/\boperating temperature range\b/gi, 'Temperaturbereich')
.replace(/\bminimum sheath thickness\b/gi, 'Manteldicke (min.)')
.replace(/\bsheath thickness\b/gi, 'Manteldicke')
.replace(/\bnominal insulation thickness\b/gi, 'Isolationsdicke (nom.)')
.replace(/\binsulation thickness\b/gi, 'Isolationsdicke')
.replace(/\bdc resistance at 20\s*°?c\b/gi, 'DC-Leiterwiderstand (20 °C)')
.replace(/\bouter diameter(?: of cable)?\b/gi, 'Außen-Ø')
.replace(/\bbending radius\b/gi, 'Biegeradius')
.replace(/\bpackaging\b/gi, 'Verpackung')
.replace(/\bce\s*-?conformity\b/gi, 'CE-Konformität');
.replace(/\bconductor material\b/gi, 'Conductor material')
.replace(/\bconductor class\b/gi, 'Conductor class')
.replace(/\bcore insulation\b/gi, 'Core insulation')
.replace(/\binsulation\b/gi, 'Core insulation')
.replace(/\bfield control\b/gi, 'Field control')
.replace(/\bscreen\b/gi, 'Screen')
.replace(/\blongitudinal water tightness\b/gi, 'Longitudinal water tightness')
.replace(/\btransverse water tightness\b/gi, 'Transverse water tightness')
.replace(/\bsheath material\b/gi, 'Sheath material')
.replace(/\bsheath color\b/gi, 'Sheath color')
.replace(/\bflame retardancy\b/gi, 'Flame retardancy')
.replace(/\buv resistant\b/gi, 'UV resistant')
.replace(/\bmax\.? permissible conductor temperature\b/gi, 'Max. permissible conductor temperature')
.replace(/\bpermissible cable outer temperature, fixed\b/gi, 'Permissible cable outer temperature, fixed')
.replace(/\bpermissible cable outer temperature, in motion\b/gi, 'Permissible cable outer temperature, in motion')
.replace(/\bmaximum short-circuit temperature\b/gi, 'Maximum short-circuit temperature')
.replace(/\bmin\.? bending radius, fixed\b/gi, 'Min. bending radius, fixed')
.replace(/\bminimum laying temperature\b/gi, 'Minimum laying temperature')
.replace(/\bmeter marking\b/gi, 'Meter marking')
.replace(/\bpartial discharge\b/gi, 'Partial discharge')
.replace(/\bcapacitance\b/gi, 'Capacitance')
.replace(/\binductance\b/gi, 'Inductance')
.replace(/\breactance\b/gi, 'Reactance')
.replace(/\btest voltage\b/gi, 'Test voltage')
.replace(/\brated voltage\b/gi, 'Rated voltage')
.replace(/\boperating temperature range\b/gi, 'Operating temperature range')
.replace(/\bminimum sheath thickness\b/gi, 'Sheath thickness (min.)')
.replace(/\bsheath thickness\b/gi, 'Sheath thickness')
.replace(/\bnominal insulation thickness\b/gi, 'Insulation thickness (nom.)')
.replace(/\binsulation thickness\b/gi, 'Insulation thickness')
.replace(/\bdc resistance at 20\s*°?c\b/gi, 'DC resistance (20 °C)')
.replace(/\bouter diameter(?: of cable)?\b/gi, 'Outer diameter')
.replace(/\bbending radius\b/gi, 'Bending radius')
.replace(/\bpackaging\b/gi, 'Packaging')
.replace(/\bce\s*-?conformity\b/gi, 'CE conformity');
}
function technicalValueTranslation(args: { label: string; value: string; locale: 'en' | 'de' }): string {
const v = normalizeValue(args.value);
if (!v) return '';
if (args.locale === 'de') {
if (/^yes$/i.test(v)) return 'ja';
if (/^no$/i.test(v)) return 'nein';
if (/^copper$/i.test(v)) return 'Kupfer';
if (/^aluminum$/i.test(v)) return 'Aluminium';
if (/^black$/i.test(v)) return 'schwarz';
if (/^stranded$/i.test(v)) return 'mehrdrähtig';
if (/^(\d+)xD$/i.test(v)) return v.replace(/^(\d+)xD$/i, '$1 facher Durchmesser');
if (/^XLPE/i.test(v)) return v.replace(/^XLPE/i, 'VPE');
return v;
}
if (/^ja$/i.test(v)) return 'yes';
if (/^nein$/i.test(v)) return 'no';
if (/^kupfer$/i.test(v)) return 'Copper';
if (/^aluminium$/i.test(v)) return 'Aluminum';
if (/^schwarz$/i.test(v)) return 'black';
if (/^mehrdrähtig$/i.test(v)) return 'stranded';
if (/^(\d+)xD$/i.test(v)) return v.replace(/^(\d+)xD$/i, '$1 times diameter');
if (/^VPE/i.test(v)) return v.replace(/^VPE/i, 'XLPE');
return v;
}
function metaFullLabel(args: { key: string; excelKey: string; locale: 'en' | 'de' }): string {
@@ -507,6 +593,26 @@ function buildExcelModel(args: { product: ProductData; locale: 'en' | 'de' }): B
'flame retardant': { header: 'Flame retardant', unit: '', key: 'flame' },
'self-extinguishing of single cable': { header: 'Flame retardant', unit: '', key: 'flame' },
'conductor material': { header: 'Conductor material', unit: '', key: 'cond_mat' },
'conductor class': { header: 'Conductor class', unit: '', key: 'cond_class' },
'core insulation': { header: 'Core insulation', unit: '', key: 'core_ins' },
'field control': { header: 'Field control', unit: '', key: 'field_ctrl' },
'screen': { header: 'Screen', unit: '', key: 'screen' },
'longitudinal water tightness': { header: 'Longitudinal water tightness', unit: '', key: 'long_water' },
'transverse water tightness': { header: 'Transverse water tightness', unit: '', key: 'trans_water' },
'sheath material': { header: 'Sheath material', unit: '', key: 'sheath_mat' },
'sheath color': { header: 'Sheath color', unit: '', key: 'sheath_color' },
'flame retardancy': { header: 'Flame retardancy', unit: '', key: 'flame_ret' },
'uv resistant': { header: 'UV resistant', unit: '', key: 'uv_res' },
'max. permissible conductor temperature': { header: 'Max. permissible conductor temperature', unit: '°C', key: 'max_cond_temp' },
'permissible cable outer temperature, fixed': { header: 'Permissible cable outer temperature, fixed', unit: '°C', key: 'out_temp_fixed' },
'permissible cable outer temperature, in motion': { header: 'Permissible cable outer temperature, in motion', unit: '°C', key: 'out_temp_motion' },
'maximum short-circuit temperature': { header: 'Maximum short-circuit temperature', unit: '°C', key: 'max_sc_temp_val' },
'min. bending radius, fixed': { header: 'Min. bending radius, fixed', unit: '', key: 'min_bend_fixed' },
'minimum laying temperature': { header: 'Minimum laying temperature', unit: '°C', key: 'min_lay_temp_val' },
'meter marking': { header: 'Meter marking', unit: '', key: 'meter_mark' },
'partial discharge': { header: 'Partial discharge', unit: 'pC', key: 'partial_dis' },
// High-value electrical/screen columns
'capacitance (approx.)': { header: 'Capacitance', unit: 'uF/km', key: 'cap' },
'capacitance': { header: 'Capacitance', unit: 'uF/km', key: 'cap' },
@@ -579,11 +685,66 @@ function buildExcelModel(args: { product: ProductData; locale: 'en' | 'de' }): B
const unit = normalizeUnit(units[excelKey] || mapping.unit || '');
const labelBase = technicalFullLabel({ key: mapping.key, excelKey, locale: args.locale });
const label = formatExcelHeaderLabel(labelBase, unit);
const value = compactCellForDenseTable(values[0], unit, args.locale);
const rawValue = compactCellForDenseTable(values[0], unit, args.locale);
const value = technicalValueTranslation({ label: labelBase, value: rawValue, locale: args.locale });
if (!technicalItems.find(t => t.label === label)) technicalItems.push({ label, value, unit });
}
}
technicalItems.sort((a, b) => a.label.localeCompare(b.label));
const TECHNICAL_DATA_ORDER_DE = [
'Leitermaterial',
'Leiterklasse',
'Aderisolation',
'Feldsteuerung',
'Schirm',
'Längswasserdichtigkeit',
'Querwasserdichtigkeit',
'Mantelmaterial',
'Mantelfarbe',
'Flammwidrigkeit',
'UV-bestandig',
'Max. zulässige Leitertemperatur',
'Zul. Kabelaußentemperatur, fest verlegt',
'Zul. Kabelaußentemperatur, in Bewegung',
'Maximale Kurzschlußtemperatur',
'Min. Biegeradius, fest verlegt',
'Mindesttemperatur Verlegung',
'Metermarkierung',
'Teilentladung',
];
const TECHNICAL_DATA_ORDER_EN = [
'Conductor material',
'Conductor class',
'Core insulation',
'Field control',
'Screen',
'Longitudinal water tightness',
'Transverse water tightness',
'Sheath material',
'Sheath color',
'Flame retardancy',
'UV resistant',
'Max. permissible conductor temperature',
'Permissible cable outer temperature, fixed',
'Permissible cable outer temperature, in motion',
'Maximum short-circuit temperature',
'Min. bending radius, fixed',
'Minimum laying temperature',
'Meter marking',
'Partial discharge',
];
const order = args.locale === 'de' ? TECHNICAL_DATA_ORDER_DE : TECHNICAL_DATA_ORDER_EN;
technicalItems.sort((a, b) => {
const indexA = order.findIndex(label => a.label.startsWith(label));
const indexB = order.findIndex(label => b.label.startsWith(label));
if (indexA !== -1 && indexB !== -1) return indexA - indexB;
if (indexA !== -1) return -1;
if (indexB !== -1) return 1;
return a.label.localeCompare(b.label);
});
const voltageTables: VoltageTableModel[] = [];
for (const vKey of voltageKeysSorted) {
@@ -892,7 +1053,22 @@ export function buildDatasheetModel(args: { product: ProductData; locale: 'en' |
productUrl,
},
labels,
technicalItems: excelModel.ok ? excelModel.technicalItems : [],
technicalItems: [
...(excelModel.ok ? excelModel.technicalItems : []),
...(isMediumVoltageProduct(args.product)
? args.locale === 'de'
? [
{ label: 'Prüfspannung 6/10 kV', value: '21 kV' },
{ label: 'Prüfspannung 12/20 kV', value: '42 kV' },
{ label: 'Prüfspannung 18/30 kV', value: '63 kV' },
]
: [
{ label: 'Test voltage 6/10 kV', value: '21 kV' },
{ label: 'Test voltage 12/20 kV', value: '42 kV' },
{ label: 'Test voltage 18/30 kV', value: '63 kV' },
]
: []),
],
voltageTables,
legendItems: crossSectionModel.legendItems || [],
};

View File

@@ -53,18 +53,18 @@ export function generateFileName(product: ProductData, locale: 'en' | 'de'): str
export function getLabels(locale: 'en' | 'de') {
return {
en: {
datasheet: 'PRODUCT DATASHEET',
datasheet: 'Technical Datasheet',
description: 'DESCRIPTION',
technicalData: 'TECHNICAL DATA',
crossSection: 'CROSS-SECTION DATA',
crossSection: 'Cross-sections/Voltage',
sku: 'SKU',
noImage: 'No image available',
},
de: {
datasheet: 'PRODUKTDATENBLATT',
datasheet: 'Technisches Datenblatt',
description: 'BESCHREIBUNG',
technicalData: 'TECHNISCHE DATEN',
crossSection: 'QUERSCHNITTSDATEN',
crossSection: 'Querschnitte/Spannung',
sku: 'ARTIKELNUMMER',
noImage: 'Kein Bild verfügbar',
},

36
scripts/smoke-test.ts Normal file
View File

@@ -0,0 +1,36 @@
import * as http from 'http';
/**
* Simple smoke test to verify the application is running and healthy.
* This script is intended to be run against a local or remote instance.
*/
const url = process.argv[2] || 'http://localhost:3000/health';
const timeout = 10000; // 10 seconds
console.log(`🔍 Running smoke test against: ${url}`);
const request = http.get(url, (res) => {
const { statusCode } = res;
if (statusCode === 200) {
console.log('✅ Smoke test passed: Application is healthy.');
process.exit(0);
} else {
console.error(`❌ Smoke test failed: Received status code ${statusCode}`);
process.exit(1);
}
});
request.on('error', (err) => {
console.error(`❌ Smoke test failed: ${err.message}`);
process.exit(1);
});
request.on('timeout', () => {
console.error('❌ Smoke test failed: Request timed out');
request.destroy();
process.exit(1);
});
request.setTimeout(timeout);

17
scripts/validate-env.ts Normal file
View File

@@ -0,0 +1,17 @@
import { envSchema, getRawEnv } from '../lib/env';
/**
* Simple script to validate environment variables.
* If validation fails, this script will exit with code 1.
*/
try {
const env = envSchema.parse(getRawEnv());
console.log('✅ Environment variables validated successfully.');
console.log('Base URL:', env.NEXT_PUBLIC_BASE_URL);
} catch (error) {
console.error('❌ Environment validation failed.');
if (error instanceof Error) {
console.error(error.message);
}
process.exit(1);
}

View File

@@ -6,8 +6,6 @@ Sentry.init({
dsn,
enabled: Boolean(dsn),
tracesSampleRate: 0,
// Ensure 500 errors are always captured
debug: process.env.NODE_ENV === 'development',
// AdjustingreplaysOnErrorSampleRate to 1.0 to capture 100% of errors with replays if enabled
replaysOnErrorSampleRate: 1.0,
replaysSessionSampleRate: 0.1,

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