Compare commits
124 Commits
af33c6225d
...
v1.0.0-rc.
| Author | SHA1 | Date | |
|---|---|---|---|
| e179e8162c | |||
| 259d712105 | |||
| 0178e828d6 | |||
| e3f7344daf | |||
| 21a7b0ade2 | |||
| d027fbeac2 | |||
| 8a751998eb | |||
| 48c3e1d013 | |||
| 3df4b44b8d | |||
| 07e0f237b9 | |||
| 57a3944301 | |||
| 5fe0a8d83e | |||
| 8062d33f35 | |||
| ebe67afd73 | |||
| b74f6b6f9e | |||
| 24eea9a2fe | |||
| c70288bba7 | |||
| d438dbdc9d | |||
| e0c4aaf298 | |||
| f44487eeac | |||
| a82b95a28f | |||
| ab688a3dab | |||
| a0ce37708e | |||
| 0379d1f05d | |||
| 50347d049d | |||
| 9678181927 | |||
| 3ffaafefe5 | |||
| e5bf8c861c | |||
| 651e14d665 | |||
| 580cd6789c | |||
| db4cf354ff | |||
| e8957e0672 | |||
| 7ef0bca9f6 | |||
| 198944649a | |||
| 6aa741ab0a | |||
| f69952a5da | |||
| 81af9bf3dd | |||
| f1b617e967 | |||
| d6be9beebf | |||
| 0a797260e3 | |||
| 2a4cc76292 | |||
| f87eb27f41 | |||
| acd86099e5 | |||
| 5ab9791c72 | |||
| 8152ccd5df | |||
| 8eeb571c2d | |||
| b1854d5255 | |||
| 7f4f970a38 | |||
| e5908c757c | |||
| 70efb0c593 | |||
| 479a36f1d0 | |||
| 372a0c5cfa | |||
| 42b06e1ef8 | |||
| b25fdd877a | |||
| dd23310ac4 | |||
| f6f28a4529 | |||
| fc3635db86 | |||
| fc000353a9 | |||
| 73c32c6d31 | |||
| 381e0b121f | |||
| 829c074c7f | |||
| 9189e813b2 | |||
| 249313cc37 | |||
| 8232971419 | |||
| f41260e1db | |||
| 950ef9d463 | |||
| fcb3169d04 | |||
| 9e87720494 | |||
| 6a403f47a0 | |||
| 2d8df53e36 | |||
| 6f49dbc56c | |||
| ad2a477636 | |||
| 77a1067820 | |||
| ea3076b4ec | |||
| 17fe0d7107 | |||
| 38b512973b | |||
| 4f73838c21 | |||
| 2f8ce42409 | |||
| cf7af73b72 | |||
| d526bfe56f | |||
| 4cb7d438a0 | |||
| 03e597442b | |||
| 5f9ee7d976 | |||
| a4ea42a043 | |||
| ee04d2422c | |||
| 26fc34299e | |||
| 6d13611a16 | |||
| 4a9246be5e | |||
| 2ed038174d | |||
| c1304403a1 | |||
| 5036c5fe28 | |||
| 50a524c515 | |||
| 57886a01d6 | |||
| c89bd8e80f | |||
| 9c54322654 | |||
| 8a80eb7b9a | |||
| c1773a7072 | |||
| 33ed13d255 | |||
| 0f5811edb9 | |||
| 06bbed8c21 | |||
| f5a879fa60 | |||
| e4eabd7a86 | |||
| 757df76f36 | |||
| 14b2f83971 | |||
| 51565fdf41 | |||
| be9f9cf483 | |||
| 506c8682fe | |||
| a909de30f3 | |||
| a2f94f15bc | |||
| 13e56a88bc | |||
| bb7d17001b | |||
| 920efa0083 | |||
| 0b81d1a4cb | |||
| 1d5bdeba26 | |||
| a0c3fbbc7e | |||
| 8101a9f156 | |||
| 7b6f4b5ea4 | |||
| 658057cdb1 | |||
| 2aa5d5b00e | |||
| 7f2f6f5aca | |||
| 4e50482769 | |||
| 1da1f05cdd | |||
| 15cfb314b1 | |||
| a3da6192e3 |
@@ -1,5 +1,6 @@
|
||||
node_modules
|
||||
.next
|
||||
!.next/cache
|
||||
.git
|
||||
.DS_Store
|
||||
.env
|
||||
|
||||
23
.env
23
.env
@@ -1,8 +1,8 @@
|
||||
# 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
|
||||
UMAMI_API_ENDPOINT=https://analytics.infra.mintel.me
|
||||
UMAMI_WEBSITE_ID=59a7db94-0100-4c7e-98ef-99f45b17f9c3
|
||||
SENTRY_DSN=https://c10957d0182245b1a2a806ac2d34a197@errors.infra.mintel.me/1
|
||||
LOG_LEVEL=info
|
||||
|
||||
@@ -12,10 +12,6 @@ WOOCOMMERCE_CONSUMER_KEY=ck_38d97df86880e8fefbd54ab5cdf47a9c5a9e5b39
|
||||
WOOCOMMERCE_CONSUMER_SECRET=cs_d675ee2ac2ec7c22de84ae5451c07e42b1717759
|
||||
WORDPRESS_APP_PASSWORD="DlJH 49dp fC3a Itc3 Sl7Z Wz0k"
|
||||
|
||||
# Redis Cache
|
||||
REDIS_URL=redis://redis:6379/2
|
||||
REDIS_KEY_PREFIX=klz:
|
||||
|
||||
# SMTP Configuration
|
||||
MAIL_HOST=smtp.eu.mailgun.org
|
||||
MAIL_PORT=587
|
||||
@@ -23,3 +19,18 @@ MAIL_USERNAME=postmaster@mg.mintel.me
|
||||
MAIL_PASSWORD=4592fcb94599ee1a45b4ac2386fd0a64-102c75d8-ca2870e6
|
||||
MAIL_FROM="KLZ Cables <postmaster@mg.mintel.me>"
|
||||
MAIL_RECIPIENTS=marc@cablecreations.de,info@klz-cables.com
|
||||
|
||||
# Directus
|
||||
DIRECTUS_URL=https://cms.klz-cables.com
|
||||
DIRECTUS_KEY=59fb8f4c1a51b18fe28ad947f713914e
|
||||
DIRECTUS_SECRET=7459038d41401dfb11254cf7f1ef2d0f
|
||||
DIRECTUS_ADMIN_EMAIL=marc@mintel.me
|
||||
DIRECTUS_ADMIN_PASSWORD=Tim300493.
|
||||
DIRECTUS_DB_NAME=directus
|
||||
DIRECTUS_DB_USER=directus
|
||||
# Local Development
|
||||
PROJECT_NAME=klz-cables
|
||||
TRAEFIK_HOST=klz.localhost
|
||||
DIRECTUS_HOST=cms.klz.localhost
|
||||
GATEKEEPER_PASSWORD=klz2026
|
||||
COOKIE_DOMAIN=localhost
|
||||
|
||||
38
.env.example
38
.env.example
@@ -10,13 +10,18 @@
|
||||
# ────────────────────────────────────────────────────────────────────────────
|
||||
NODE_ENV=development
|
||||
NEXT_PUBLIC_BASE_URL=http://localhost:3000
|
||||
# TARGET is used to differentiate between environments (testing, staging, production)
|
||||
# NEXT_PUBLIC_TARGET makes this information available to the frontend
|
||||
NEXT_PUBLIC_TARGET=development
|
||||
# TARGET is used server-side
|
||||
TARGET=development
|
||||
|
||||
# ────────────────────────────────────────────────────────────────────────────
|
||||
# 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
|
||||
UMAMI_WEBSITE_ID=
|
||||
UMAMI_API_ENDPOINT=https://analytics.infra.mintel.me
|
||||
|
||||
# ────────────────────────────────────────────────────────────────────────────
|
||||
# Error Tracking (GlitchTip/Sentry)
|
||||
@@ -35,22 +40,27 @@ MAIL_PASSWORD=
|
||||
MAIL_FROM=KLZ Cables <noreply@klz-cables.com>
|
||||
MAIL_RECIPIENTS=info@klz-cables.com
|
||||
|
||||
# ────────────────────────────────────────────────────────────────────────────
|
||||
# Redis Cache Configuration
|
||||
# ────────────────────────────────────────────────────────────────────────────
|
||||
# Optional: Leave empty to disable Redis caching
|
||||
REDIS_URL=redis://localhost:6379/2
|
||||
REDIS_KEY_PREFIX=klz:
|
||||
|
||||
# ────────────────────────────────────────────────────────────────────────────
|
||||
# Logging
|
||||
# ────────────────────────────────────────────────────────────────────────────
|
||||
LOG_LEVEL=info
|
||||
GATEKEEPER_PASSWORD=klz2026
|
||||
SENTRY_DSN=
|
||||
# For Directus Error Tracking
|
||||
# SENTRY_ENVIRONMENT is set automatically by CI
|
||||
|
||||
# ────────────────────────────────────────────────────────────────────────────
|
||||
# Varnish Cache (Docker only)
|
||||
# Deployment Configuration (CI/CD only)
|
||||
# ────────────────────────────────────────────────────────────────────────────
|
||||
VARNISH_CACHE_SIZE=256m
|
||||
# These are typically set by the CI/CD workflow
|
||||
IMAGE_TAG=latest
|
||||
TRAEFIK_HOST=klz-cables.com
|
||||
ENV_FILE=.env
|
||||
|
||||
# ────────────────────────────────────────────────────────────────────────────
|
||||
# Varnish Configuration
|
||||
# ────────────────────────────────────────────────────────────────────────────
|
||||
VARNISH_CACHE_SIZE=256M
|
||||
|
||||
# ============================================================================
|
||||
# IMPORTANT NOTES
|
||||
@@ -68,7 +78,11 @@ VARNISH_CACHE_SIZE=256m
|
||||
# ──────────────────
|
||||
# 1. Build-time: Only NEXT_PUBLIC_* vars are needed (via --build-arg)
|
||||
# 2. Runtime: All vars are loaded from .env file on the server
|
||||
# 3. The .env file should exist at: /home/deploy/sites/klz-cables.com/.env
|
||||
# 3. Branch Deployments:
|
||||
# - main branch uses .env.prod
|
||||
# - staging branch uses .env.staging
|
||||
# - CI/CD supports STAGING_ prefix for all secrets to override defaults
|
||||
# - TRAEFIK_HOST is automatically derived from NEXT_PUBLIC_BASE_URL
|
||||
#
|
||||
# Security:
|
||||
# ─────────
|
||||
|
||||
@@ -12,8 +12,8 @@ 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
|
||||
UMAMI_WEBSITE_ID=
|
||||
UMAMI_API_ENDPOINT=https://analytics.infra.mintel.me
|
||||
|
||||
# Error Tracking (GlitchTip/Sentry)
|
||||
SENTRY_DSN=
|
||||
@@ -26,9 +26,15 @@ MAIL_PASSWORD=
|
||||
MAIL_FROM=KLZ Cables <noreply@klz-cables.com>
|
||||
MAIL_RECIPIENTS=info@klz-cables.com
|
||||
|
||||
# Redis Cache (optional)
|
||||
REDIS_URL=redis://redis:6379/2
|
||||
REDIS_KEY_PREFIX=klz:
|
||||
# Strapi
|
||||
STRAPI_DATABASE_NAME=strapi
|
||||
STRAPI_DATABASE_USERNAME=strapi
|
||||
STRAPI_DATABASE_PASSWORD=
|
||||
APP_KEYS=
|
||||
API_TOKEN_SALT=
|
||||
ADMIN_JWT_SECRET=
|
||||
TRANSFER_TOKEN_SALT=
|
||||
JWT_SECRET=
|
||||
|
||||
# Varnish Cache Size (optional)
|
||||
VARNISH_CACHE_SIZE=256m
|
||||
|
||||
@@ -1,12 +1,16 @@
|
||||
{
|
||||
"extends": ["next/core-web-vitals", "next/typescript"],
|
||||
"extends": ["next/core-web-vitals", "next/typescript", "prettier"],
|
||||
"rules": {
|
||||
"@typescript-eslint/no-explicit-any": "off",
|
||||
"@typescript-eslint/no-unused-vars": ["warn", { "argsIgnorePattern": "^_" }],
|
||||
"@typescript-eslint/no-unused-vars": [
|
||||
"warn",
|
||||
{
|
||||
"argsIgnorePattern": "^_"
|
||||
}
|
||||
],
|
||||
"@typescript-eslint/no-require-imports": "off",
|
||||
"prefer-const": "warn",
|
||||
"react/no-unescaped-entities": "off",
|
||||
"@next/next/no-img-element": "warn"
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
32
.gitea/workflows/ci.yml
Normal file
32
.gitea/workflows/ci.yml
Normal file
@@ -0,0 +1,32 @@
|
||||
name: CI - Lint, Typecheck & Test
|
||||
|
||||
on:
|
||||
push:
|
||||
branches-ignore:
|
||||
- main
|
||||
pull_request:
|
||||
|
||||
jobs:
|
||||
quality-assurance:
|
||||
runs-on: docker
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: 20
|
||||
cache: 'npm'
|
||||
|
||||
- name: Install dependencies
|
||||
run: npm ci
|
||||
|
||||
- name: 🔍 Lint
|
||||
run: npm run lint
|
||||
|
||||
- name: 🏗️ Typecheck
|
||||
run: npm run typecheck
|
||||
|
||||
- name: 🧪 Test
|
||||
run: npm run test
|
||||
@@ -2,435 +2,499 @@ name: Build & Deploy KLZ Cables
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [main]
|
||||
branches:
|
||||
- main
|
||||
tags:
|
||||
- 'v*'
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
skip_long_checks:
|
||||
description: 'Skip tests? (true/false)'
|
||||
required: false
|
||||
default: 'false'
|
||||
|
||||
concurrency:
|
||||
group: ${{ github.workflow }}-${{ (github.ref_type == 'tag' && !contains(github.ref_name, '-')) && 'prod' || (github.ref_type == 'tag' && 'staging' || 'testing') }}
|
||||
cancel-in-progress: true
|
||||
|
||||
jobs:
|
||||
build-and-deploy:
|
||||
# ──────────────────────────────────────────────────────────────────────────────
|
||||
# JOB 1: Prepare & Determine Environment
|
||||
# ──────────────────────────────────────────────────────────────────────────────
|
||||
prepare:
|
||||
name: 🔍 Prepare Environment
|
||||
runs-on: docker
|
||||
|
||||
outputs:
|
||||
target: ${{ steps.determine.outputs.target }}
|
||||
image_tag: ${{ steps.determine.outputs.image_tag }}
|
||||
env_file: ${{ steps.determine.outputs.env_file }}
|
||||
traefik_host: ${{ steps.determine.outputs.traefik_host }}
|
||||
next_public_base_url: ${{ steps.determine.outputs.next_public_base_url }}
|
||||
directus_url: ${{ steps.determine.outputs.directus_url }}
|
||||
directus_host: ${{ steps.determine.outputs.directus_host }}
|
||||
project_name: ${{ steps.determine.outputs.project_name }}
|
||||
is_prod: ${{ steps.determine.outputs.is_prod }}
|
||||
gotify_title: ${{ steps.determine.outputs.gotify_title }}
|
||||
gotify_priority: ${{ steps.determine.outputs.gotify_priority }}
|
||||
short_sha: ${{ steps.determine.outputs.short_sha }}
|
||||
commit_msg: ${{ steps.determine.outputs.commit_msg }}
|
||||
container:
|
||||
image: catthehacker/ubuntu:act-latest
|
||||
steps:
|
||||
# ═══════════════════════════════════════════════════════════════════════════════
|
||||
# LOGGING: Workflow Start - Full Transparency
|
||||
# ═══════════════════════════════════════════════════════════════════════════════
|
||||
- name: 📋 Log Workflow Start
|
||||
- name: 🧹 Maintenance (High Density Cleanup)
|
||||
shell: bash
|
||||
run: |
|
||||
echo "╔══════════════════════════════════════════════════════════════════════════════╗"
|
||||
echo "║ KLZ Cables Deployment Workflow Started ║"
|
||||
echo "╚══════════════════════════════════════════════════════════════════════════════╝"
|
||||
echo ""
|
||||
echo "📋 Workflow Information:"
|
||||
echo " • Repository: ${{ github.repository }}"
|
||||
echo " • Branch: ${{ 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 ""
|
||||
echo "Purging old build layers and dangling images..."
|
||||
docker image prune -f
|
||||
docker builder prune -f --filter "until=6h"
|
||||
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
fetch-depth: 2
|
||||
|
||||
- name: 🟢 Setup Node.js
|
||||
|
||||
- name: 🔍 Environment & Version ermitteln
|
||||
id: determine
|
||||
shell: bash
|
||||
run: |
|
||||
TAG="${{ github.ref_name }}"
|
||||
SHORT_SHA=$(echo "${{ github.sha }}" | cut -c1-9)
|
||||
IMAGE_TAG="sha-${SHORT_SHA}"
|
||||
COMMIT_MSG=$(git log -1 --pretty=%s || echo "No commit message available")
|
||||
|
||||
if [[ "${{ github.ref_type }}" == "branch" && "$TAG" == "main" ]]; then
|
||||
if [[ "$COMMIT_MSG" =~ ^chore: ]]; then
|
||||
TARGET="skip"
|
||||
GOTIFY_TITLE="ℹ️ Skip Deploy (Chore)"
|
||||
GOTIFY_PRIORITY=2
|
||||
else
|
||||
TARGET="testing"
|
||||
IMAGE_TAG="main-${SHORT_SHA}"
|
||||
ENV_FILE=".env.testing"
|
||||
TRAEFIK_HOST="testing.klz-cables.com"
|
||||
NEXT_PUBLIC_BASE_URL="https://testing.klz-cables.com"
|
||||
DIRECTUS_URL="https://cms.testing.klz-cables.com"
|
||||
DIRECTUS_HOST="cms.testing.klz-cables.com"
|
||||
PROJECT_NAME="klz-cables-testing"
|
||||
IS_PROD="false"
|
||||
GOTIFY_TITLE="🧪 Testing-Deploy"
|
||||
GOTIFY_PRIORITY=4
|
||||
fi
|
||||
elif [[ "${{ github.ref_type }}" == "tag" ]]; then
|
||||
if [[ "$TAG" =~ ^v[0-9]+\.[0-9]+\.[0-9]+$ ]]; then
|
||||
TARGET="production"
|
||||
IMAGE_TAG="$TAG"
|
||||
ENV_FILE=".env.prod"
|
||||
TRAEFIK_HOST="klz-cables.com, www.klz-cables.com"
|
||||
NEXT_PUBLIC_BASE_URL="https://klz-cables.com"
|
||||
DIRECTUS_URL="https://cms.klz-cables.com"
|
||||
DIRECTUS_HOST="cms.klz-cables.com"
|
||||
PROJECT_NAME="klz-cables-prod"
|
||||
IS_PROD="true"
|
||||
GOTIFY_TITLE="🚀 Production-Release"
|
||||
GOTIFY_PRIORITY=6
|
||||
elif [[ "$TAG" =~ -rc || "$TAG" =~ -beta || "$TAG" =~ -alpha ]]; then
|
||||
TARGET="staging"
|
||||
IMAGE_TAG="$TAG"
|
||||
ENV_FILE=".env.staging"
|
||||
TRAEFIK_HOST="staging.klz-cables.com"
|
||||
NEXT_PUBLIC_BASE_URL="https://staging.klz-cables.com"
|
||||
DIRECTUS_URL="https://cms.staging.klz-cables.com"
|
||||
DIRECTUS_HOST="cms.staging.klz-cables.com"
|
||||
PROJECT_NAME="klz-cables-staging"
|
||||
IS_PROD="false"
|
||||
GOTIFY_TITLE="🧪 Staging-Deploy (Pre-Release)"
|
||||
GOTIFY_PRIORITY=5
|
||||
else
|
||||
TARGET="skip"
|
||||
GOTIFY_TITLE="❓ Unbekannter Tag"
|
||||
GOTIFY_PRIORITY=3
|
||||
fi
|
||||
else
|
||||
TARGET="skip"
|
||||
fi
|
||||
|
||||
{
|
||||
echo "target=$TARGET"
|
||||
echo "image_tag=$IMAGE_TAG"
|
||||
echo "env_file=$ENV_FILE"
|
||||
echo "traefik_host=$TRAEFIK_HOST"
|
||||
echo "next_public_base_url=$NEXT_PUBLIC_BASE_URL"
|
||||
echo "directus_url=$DIRECTUS_URL"
|
||||
echo "directus_host=$DIRECTUS_HOST"
|
||||
echo "project_name=$PROJECT_NAME"
|
||||
echo "is_prod=$IS_PROD"
|
||||
echo "gotify_title=$GOTIFY_TITLE"
|
||||
echo "gotify_priority=$GOTIFY_PRIORITY"
|
||||
echo "short_sha=$SHORT_SHA"
|
||||
echo "commit_msg=$COMMIT_MSG"
|
||||
} >> "$GITHUB_OUTPUT"
|
||||
|
||||
# ──────────────────────────────────────────────────────────────────────────────
|
||||
# JOB 2: Quality Assurance (Lint & Test)
|
||||
# ──────────────────────────────────────────────────────────────────────────────
|
||||
qa:
|
||||
name: 🧪 Quality Assurance
|
||||
needs: prepare
|
||||
if: needs.prepare.outputs.target != 'skip'
|
||||
runs-on: docker
|
||||
container:
|
||||
image: catthehacker/ubuntu:act-latest
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
fetch-depth: 1
|
||||
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: 20
|
||||
cache: 'npm'
|
||||
|
||||
# ═══════════════════════════════════════════════════════════════════════════════
|
||||
# LOGGING: Registry Login Phase
|
||||
# ═══════════════════════════════════════════════════════════════════════════════
|
||||
- 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 ""
|
||||
- name: Install dependencies
|
||||
run: npm ci --legacy-peer-deps
|
||||
|
||||
# ═══════════════════════════════════════════════════════════════════════════════
|
||||
# LOGGING: Build Phase
|
||||
# ═══════════════════════════════════════════════════════════════════════════════
|
||||
- name: 🏗️ Build application on runner
|
||||
- name: 🧪 Run Checks in Parallel
|
||||
if: github.event.inputs.skip_long_checks != 'true'
|
||||
run: |
|
||||
echo "╔══════════════════════════════════════════════════════════════════════════════╗"
|
||||
echo "║ Step: Build Application on Runner ║"
|
||||
echo "╚══════════════════════════════════════════════════════════════════════════════╝"
|
||||
echo ""
|
||||
echo "📦 Installing dependencies..."
|
||||
npm ci
|
||||
|
||||
echo "🏗️ Building Next.js application..."
|
||||
npm run build
|
||||
npm run lint &
|
||||
LINT_PID=$!
|
||||
npm run typecheck &
|
||||
TYPE_PID=$!
|
||||
npm run test &
|
||||
TEST_PID=$!
|
||||
|
||||
# Wait for all and fail if any fail
|
||||
wait $LINT_PID || exit 1
|
||||
wait $TYPE_PID || exit 1
|
||||
wait $TEST_PID || exit 1
|
||||
|
||||
# ──────────────────────────────────────────────────────────────────────────────
|
||||
# JOB 3: Build & Push Docker Image
|
||||
# ──────────────────────────────────────────────────────────────────────────────
|
||||
build-app:
|
||||
name: 🏗️ Build App
|
||||
needs: prepare
|
||||
if: ${{ needs.prepare.outputs.target != 'skip' }}
|
||||
runs-on: docker
|
||||
container:
|
||||
image: catthehacker/ubuntu:act-latest
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
fetch-depth: 1
|
||||
|
||||
- name: 🐳 Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@v3
|
||||
|
||||
- name: 🔐 Registry Login
|
||||
run: |
|
||||
echo "${{ secrets.REGISTRY_PASS }}" | docker login registry.infra.mintel.me -u "${{ secrets.REGISTRY_USER }}" --password-stdin
|
||||
|
||||
- name: 🏗️ App bauen & pushen
|
||||
env:
|
||||
NEXT_PUBLIC_BASE_URL: ${{ secrets.NEXT_PUBLIC_BASE_URL }}
|
||||
NEXT_PUBLIC_UMAMI_WEBSITE_ID: ${{ secrets.NEXT_PUBLIC_UMAMI_WEBSITE_ID }}
|
||||
NEXT_PUBLIC_UMAMI_SCRIPT_URL: ${{ secrets.NEXT_PUBLIC_UMAMI_SCRIPT_URL }}
|
||||
|
||||
# ═══════════════════════════════════════════════════════════════════════════════
|
||||
# LOGGING: Smoke Test Phase
|
||||
# ═══════════════════════════════════════════════════════════════════════════════
|
||||
- name: 🧪 Run Smoke Test
|
||||
IMAGE_TAG: ${{ needs.prepare.outputs.image_tag }}
|
||||
TARGET: ${{ needs.prepare.outputs.target }}
|
||||
NEXT_PUBLIC_BASE_URL: ${{ needs.prepare.outputs.next_public_base_url }}
|
||||
UMAMI_WEBSITE_ID: ${{ needs.prepare.outputs.target == 'production' && secrets.NEXT_PUBLIC_UMAMI_WEBSITE_ID || (needs.prepare.outputs.target == 'staging' && secrets.STAGING_NEXT_PUBLIC_UMAMI_WEBSITE_ID || secrets.TESTING_NEXT_PUBLIC_UMAMI_WEBSITE_ID || secrets.NEXT_PUBLIC_UMAMI_WEBSITE_ID) }}
|
||||
UMAMI_API_ENDPOINT: ${{ needs.prepare.outputs.target == 'production' && secrets.NEXT_PUBLIC_UMAMI_SCRIPT_URL || (needs.prepare.outputs.target == 'staging' && secrets.STAGING_NEXT_PUBLIC_UMAMI_SCRIPT_URL || secrets.TESTING_NEXT_PUBLIC_UMAMI_SCRIPT_URL || secrets.NEXT_PUBLIC_UMAMI_SCRIPT_URL) }}
|
||||
DIRECTUS_URL: ${{ needs.prepare.outputs.directus_url }}
|
||||
run: |
|
||||
echo "╔══════════════════════════════════════════════════════════════════════════════╗"
|
||||
echo "║ Step: Smoke Test ║"
|
||||
echo "╚══════════════════════════════════════════════════════════════════════════════╝"
|
||||
echo ""
|
||||
echo "🧪 Starting local smoke test to verify build integrity..."
|
||||
|
||||
# Start the application in the background
|
||||
# We use the standalone output which is what runs in production
|
||||
export PORT=3001
|
||||
export NODE_ENV=production
|
||||
export NEXT_PUBLIC_BASE_URL="http://localhost:3001"
|
||||
|
||||
node .next/standalone/server.js &
|
||||
APP_PID=$!
|
||||
|
||||
echo "⏳ Waiting for application to start on port 3001 (PID: $APP_PID)..."
|
||||
|
||||
# Wait for health check to pass
|
||||
MAX_RETRIES=10
|
||||
RETRY_COUNT=0
|
||||
SUCCESS=false
|
||||
|
||||
while [ $RETRY_COUNT -lt $MAX_RETRIES ]; do
|
||||
if curl -s http://localhost:3001/health > /dev/null; then
|
||||
SUCCESS=true
|
||||
break
|
||||
fi
|
||||
echo " • Waiting... ($((RETRY_COUNT + 1))/$MAX_RETRIES)"
|
||||
sleep 3
|
||||
RETRY_COUNT=$((RETRY_COUNT + 1))
|
||||
done
|
||||
|
||||
if [ "$SUCCESS" = true ]; then
|
||||
echo "✅ Smoke test passed: Application started successfully"
|
||||
kill $APP_PID
|
||||
else
|
||||
echo "❌ Smoke test failed: Application failed to start or health check timed out"
|
||||
echo "🔍 Application Logs:"
|
||||
# Try to get some logs before killing
|
||||
kill -0 $APP_PID 2>/dev/null && sleep 2
|
||||
kill $APP_PID
|
||||
exit 1
|
||||
fi
|
||||
echo ""
|
||||
|
||||
# ═══════════════════════════════════════════════════════════════════════════════
|
||||
# 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 (NEXT_PUBLIC_* only - baked into client bundle):"
|
||||
echo " • NEXT_PUBLIC_BASE_URL: ${{ secrets.NEXT_PUBLIC_BASE_URL != '' && '***' || 'NOT SET' }}"
|
||||
echo " • NEXT_PUBLIC_UMAMI_WEBSITE_ID: ${{ secrets.NEXT_PUBLIC_UMAMI_WEBSITE_ID != '' && '***' || 'NOT SET' }}"
|
||||
echo " • NEXT_PUBLIC_UMAMI_SCRIPT_URL: ${{ secrets.NEXT_PUBLIC_UMAMI_SCRIPT_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
|
||||
docker buildx build \
|
||||
--pull \
|
||||
--platform linux/arm64 \
|
||||
--build-arg NEXT_PUBLIC_BASE_URL="${{ secrets.NEXT_PUBLIC_BASE_URL }}" \
|
||||
--build-arg NEXT_PUBLIC_UMAMI_WEBSITE_ID="${{ secrets.NEXT_PUBLIC_UMAMI_WEBSITE_ID }}" \
|
||||
--build-arg NEXT_PUBLIC_UMAMI_SCRIPT_URL="${{ secrets.NEXT_PUBLIC_UMAMI_SCRIPT_URL }}" \
|
||||
-t registry.infra.mintel.me/mintel/klz-cables.com:latest \
|
||||
--build-arg NEXT_PUBLIC_BASE_URL="$NEXT_PUBLIC_BASE_URL" \
|
||||
--build-arg UMAMI_WEBSITE_ID="$UMAMI_WEBSITE_ID" \
|
||||
--build-arg UMAMI_API_ENDPOINT="$UMAMI_API_ENDPOINT" \
|
||||
--build-arg NEXT_PUBLIC_TARGET="$TARGET" \
|
||||
--build-arg DIRECTUS_URL="$DIRECTUS_URL" \
|
||||
-t registry.infra.mintel.me/mintel/klz-cables.com:$IMAGE_TAG \
|
||||
--cache-from type=registry,ref=registry.infra.mintel.me/mintel/klz-cables.com:buildcache \
|
||||
--cache-to type=registry,ref=registry.infra.mintel.me/mintel/klz-cables.com:buildcache,mode=max \
|
||||
--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
|
||||
# ──────────────────────────────────────────────────────────────────────────────
|
||||
# JOB 4: Deploy via SSH
|
||||
# ──────────────────────────────────────────────────────────────────────────────
|
||||
deploy:
|
||||
name: 🚀 Deploy
|
||||
needs: [prepare, build-app, qa]
|
||||
if: ${{ needs.prepare.outputs.target != 'skip' }}
|
||||
runs-on: docker
|
||||
container:
|
||||
image: catthehacker/ubuntu:act-latest
|
||||
env:
|
||||
TARGET: ${{ needs.prepare.outputs.target }}
|
||||
IMAGE_TAG: ${{ needs.prepare.outputs.image_tag }}
|
||||
ENV_FILE: ${{ needs.prepare.outputs.env_file }}
|
||||
TRAEFIK_HOST: ${{ needs.prepare.outputs.traefik_host }}
|
||||
NEXT_PUBLIC_BASE_URL: ${{ needs.prepare.outputs.next_public_base_url }}
|
||||
UMAMI_WEBSITE_ID: ${{ needs.prepare.outputs.target == 'production' && secrets.NEXT_PUBLIC_UMAMI_WEBSITE_ID || (needs.prepare.outputs.target == 'staging' && secrets.STAGING_NEXT_PUBLIC_UMAMI_WEBSITE_ID || secrets.TESTING_NEXT_PUBLIC_UMAMI_WEBSITE_ID || secrets.NEXT_PUBLIC_UMAMI_WEBSITE_ID) }}
|
||||
UMAMI_API_ENDPOINT: ${{ needs.prepare.outputs.target == 'production' && secrets.NEXT_PUBLIC_UMAMI_SCRIPT_URL || (needs.prepare.outputs.target == 'staging' && secrets.STAGING_NEXT_PUBLIC_UMAMI_SCRIPT_URL || secrets.TESTING_NEXT_PUBLIC_UMAMI_SCRIPT_URL || secrets.NEXT_PUBLIC_UMAMI_SCRIPT_URL) }}
|
||||
SENTRY_DSN: ${{ secrets.SENTRY_DSN || vars.SENTRY_DSN || (needs.prepare.outputs.target == 'production' && secrets.SENTRY_DSN || (needs.prepare.outputs.target == 'staging' && secrets.STAGING_SENTRY_DSN || secrets.TESTING_SENTRY_DSN || secrets.SENTRY_DSN)) }}
|
||||
MAIL_HOST: ${{ secrets.MAIL_HOST || vars.MAIL_HOST || (needs.prepare.outputs.target == 'production' && (secrets.MAIL_HOST || vars.MAIL_HOST) || (needs.prepare.outputs.target == 'staging' && (secrets.STAGING_MAIL_HOST || vars.STAGING_MAIL_HOST) || (secrets.TESTING_MAIL_HOST || vars.TESTING_MAIL_HOST) || (secrets.MAIL_HOST || vars.MAIL_HOST))) }}
|
||||
MAIL_PORT: ${{ secrets.MAIL_PORT || vars.MAIL_PORT || (needs.prepare.outputs.target == 'production' && (secrets.MAIL_PORT || vars.MAIL_PORT) || (needs.prepare.outputs.target == 'staging' && (secrets.STAGING_MAIL_PORT || vars.STAGING_MAIL_PORT) || (secrets.TESTING_MAIL_PORT || vars.TESTING_MAIL_PORT) || (secrets.MAIL_PORT || vars.MAIL_PORT))) }}
|
||||
MAIL_USERNAME: ${{ secrets.MAIL_USERNAME || vars.MAIL_USERNAME || (needs.prepare.outputs.target == 'production' && (secrets.MAIL_USERNAME || vars.MAIL_USERNAME) || (needs.prepare.outputs.target == 'staging' && (secrets.STAGING_MAIL_USERNAME || vars.STAGING_MAIL_USERNAME) || (secrets.TESTING_MAIL_USERNAME || vars.TESTING_MAIL_USERNAME) || (secrets.MAIL_USERNAME || vars.MAIL_USERNAME))) }}
|
||||
MAIL_PASSWORD: ${{ secrets.MAIL_PASSWORD || (needs.prepare.outputs.target == 'production' && secrets.MAIL_PASSWORD || (needs.prepare.outputs.target == 'staging' && secrets.STAGING_MAIL_PASSWORD || secrets.TESTING_MAIL_PASSWORD || secrets.MAIL_PASSWORD)) }}
|
||||
MAIL_FROM: ${{ secrets.MAIL_FROM || vars.MAIL_FROM || (needs.prepare.outputs.target == 'production' && (secrets.MAIL_FROM || vars.MAIL_FROM) || (needs.prepare.outputs.target == 'staging' && (secrets.STAGING_MAIL_FROM || vars.STAGING_MAIL_FROM) || (secrets.TESTING_MAIL_FROM || vars.TESTING_MAIL_FROM) || (secrets.MAIL_FROM || vars.MAIL_FROM))) }}
|
||||
MAIL_RECIPIENTS: ${{ secrets.MAIL_RECIPIENTS || vars.MAIL_RECIPIENTS || (needs.prepare.outputs.target == 'production' && (secrets.MAIL_RECIPIENTS || vars.MAIL_RECIPIENTS) || (needs.prepare.outputs.target == 'staging' && (secrets.STAGING_MAIL_RECIPIENTS || vars.STAGING_MAIL_RECIPIENTS) || (secrets.TESTING_MAIL_RECIPIENTS || vars.TESTING_MAIL_RECIPIENTS) || (secrets.MAIL_RECIPIENTS || vars.MAIL_RECIPIENTS))) }}
|
||||
DIRECTUS_URL: ${{ needs.prepare.outputs.directus_url }}
|
||||
DIRECTUS_HOST: ${{ needs.prepare.outputs.directus_host }}
|
||||
PROJECT_NAME: ${{ needs.prepare.outputs.project_name }}
|
||||
DIRECTUS_KEY: ${{ secrets.DIRECTUS_KEY || (needs.prepare.outputs.target == 'production' && secrets.DIRECTUS_KEY || (needs.prepare.outputs.target == 'staging' && secrets.STAGING_DIRECTUS_KEY || secrets.TESTING_DIRECTUS_KEY || secrets.DIRECTUS_KEY)) }}
|
||||
DIRECTUS_SECRET: ${{ secrets.DIRECTUS_SECRET || (needs.prepare.outputs.target == 'production' && secrets.DIRECTUS_SECRET || (needs.prepare.outputs.target == 'staging' && secrets.STAGING_DIRECTUS_SECRET || secrets.TESTING_DIRECTUS_SECRET || secrets.DIRECTUS_SECRET)) }}
|
||||
DIRECTUS_ADMIN_EMAIL: ${{ secrets.DIRECTUS_ADMIN_EMAIL || (needs.prepare.outputs.target == 'production' && secrets.DIRECTUS_ADMIN_EMAIL || (needs.prepare.outputs.target == 'staging' && secrets.STAGING_DIRECTUS_ADMIN_EMAIL || secrets.TESTING_DIRECTUS_ADMIN_EMAIL || secrets.DIRECTUS_ADMIN_EMAIL)) }}
|
||||
DIRECTUS_ADMIN_PASSWORD: ${{ secrets.DIRECTUS_ADMIN_PASSWORD || (needs.prepare.outputs.target == 'production' && secrets.DIRECTUS_ADMIN_PASSWORD || (needs.prepare.outputs.target == 'staging' && secrets.STAGING_DIRECTUS_ADMIN_PASSWORD || secrets.TESTING_DIRECTUS_ADMIN_PASSWORD || secrets.DIRECTUS_ADMIN_PASSWORD)) }}
|
||||
DIRECTUS_DB_NAME: ${{ secrets.DIRECTUS_DB_NAME || 'directus' }}
|
||||
DIRECTUS_DB_USER: ${{ secrets.DIRECTUS_DB_USER || 'directus' }}
|
||||
DIRECTUS_DB_PASSWORD: ${{ secrets.DIRECTUS_DB_PASSWORD || (needs.prepare.outputs.target == 'production' && secrets.DIRECTUS_DB_PASSWORD || (needs.prepare.outputs.target == 'staging' && secrets.STAGING_DIRECTUS_DB_PASSWORD || secrets.TESTING_DIRECTUS_DB_PASSWORD || secrets.DIRECTUS_DB_PASSWORD)) }}
|
||||
DIRECTUS_API_TOKEN: ${{ secrets.DIRECTUS_API_TOKEN || (needs.prepare.outputs.target == 'production' && secrets.DIRECTUS_API_TOKEN || (needs.prepare.outputs.target == 'staging' && secrets.STAGING_DIRECTUS_API_TOKEN || secrets.TESTING_DIRECTUS_API_TOKEN || secrets.DIRECTUS_API_TOKEN)) }}
|
||||
GATEKEEPER_PASSWORD: ${{ secrets.GATEKEEPER_PASSWORD || 'klz2026' }}
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
fetch-depth: 1
|
||||
|
||||
- name: 🚀 Deploy to ${{ env.TARGET }}
|
||||
shell: bash
|
||||
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 ""
|
||||
|
||||
# Setup SSH with logging
|
||||
echo "🔐 Setting up SSH connection..."
|
||||
echo "Deploying $TARGET → $IMAGE_TAG"
|
||||
|
||||
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"
|
||||
fi
|
||||
echo ""
|
||||
|
||||
# Create .env file content
|
||||
echo "📝 Preparing environment configuration..."
|
||||
|
||||
cat > /tmp/klz-cables.env << EOF
|
||||
# ============================================================================
|
||||
# KLZ Cables - Production Environment Configuration
|
||||
# ============================================================================
|
||||
# Auto-generated by CI/CD workflow
|
||||
# DO NOT EDIT MANUALLY - Changes will be overwritten on next deployment
|
||||
# ============================================================================
|
||||
|
||||
# Application
|
||||
# Generated by CI - $TARGET - $(date -u)
|
||||
NODE_ENV=production
|
||||
NEXT_PUBLIC_BASE_URL=${{ secrets.NEXT_PUBLIC_BASE_URL }}
|
||||
|
||||
# Analytics (Umami)
|
||||
NEXT_PUBLIC_UMAMI_WEBSITE_ID=${{ secrets.NEXT_PUBLIC_UMAMI_WEBSITE_ID }}
|
||||
NEXT_PUBLIC_UMAMI_SCRIPT_URL=${{ secrets.NEXT_PUBLIC_UMAMI_SCRIPT_URL }}
|
||||
|
||||
# Error Tracking (GlitchTip/Sentry)
|
||||
SENTRY_DSN=${{ secrets.SENTRY_DSN }}
|
||||
|
||||
# Email Configuration (Mailgun)
|
||||
MAIL_HOST=${{ secrets.MAIL_HOST }}
|
||||
MAIL_PORT=${{ secrets.MAIL_PORT }}
|
||||
MAIL_USERNAME=${{ secrets.MAIL_USERNAME }}
|
||||
MAIL_PASSWORD=${{ secrets.MAIL_PASSWORD }}
|
||||
MAIL_FROM=${{ secrets.MAIL_FROM }}
|
||||
MAIL_RECIPIENTS=${{ secrets.MAIL_RECIPIENTS }}
|
||||
|
||||
# Redis Cache
|
||||
REDIS_URL=${{ secrets.REDIS_URL }}
|
||||
REDIS_KEY_PREFIX=${{ secrets.REDIS_KEY_PREFIX }}
|
||||
|
||||
# Varnish Cache Size
|
||||
VARNISH_CACHE_SIZE=256m
|
||||
NEXT_PUBLIC_BASE_URL=$NEXT_PUBLIC_BASE_URL
|
||||
NEXT_PUBLIC_TARGET=$TARGET
|
||||
UMAMI_WEBSITE_ID=$UMAMI_WEBSITE_ID
|
||||
UMAMI_API_ENDPOINT=$UMAMI_API_ENDPOINT
|
||||
SENTRY_DSN=$SENTRY_DSN
|
||||
LOG_LEVEL=$( [[ "$TARGET" == "testing" || "$TARGET" == "development" ]] && echo "debug" || echo "info" )
|
||||
MAIL_HOST=$MAIL_HOST
|
||||
MAIL_PORT=$MAIL_PORT
|
||||
MAIL_USERNAME=$MAIL_USERNAME
|
||||
MAIL_PASSWORD=$MAIL_PASSWORD
|
||||
MAIL_FROM=$MAIL_FROM
|
||||
MAIL_RECIPIENTS=$MAIL_RECIPIENTS
|
||||
|
||||
# Directus
|
||||
DIRECTUS_URL=$DIRECTUS_URL
|
||||
DIRECTUS_HOST=$DIRECTUS_HOST
|
||||
DIRECTUS_KEY=$DIRECTUS_KEY
|
||||
DIRECTUS_SECRET=$DIRECTUS_SECRET
|
||||
DIRECTUS_ADMIN_EMAIL=$DIRECTUS_ADMIN_EMAIL
|
||||
DIRECTUS_ADMIN_PASSWORD=$DIRECTUS_ADMIN_PASSWORD
|
||||
DIRECTUS_DB_NAME=$DIRECTUS_DB_NAME
|
||||
DIRECTUS_DB_USER=$DIRECTUS_DB_USER
|
||||
DIRECTUS_DB_PASSWORD=$DIRECTUS_DB_PASSWORD
|
||||
DIRECTUS_API_TOKEN=$DIRECTUS_API_TOKEN
|
||||
INTERNAL_DIRECTUS_URL=http://directus:8055
|
||||
GATEKEEPER_PASSWORD=$GATEKEEPER_PASSWORD
|
||||
|
||||
TARGET=$TARGET
|
||||
SENTRY_ENVIRONMENT=$TARGET
|
||||
IMAGE_TAG=$IMAGE_TAG
|
||||
TRAEFIK_HOST=$TRAEFIK_HOST
|
||||
ENV_FILE=$ENV_FILE
|
||||
AUTH_MIDDLEWARE=$( [[ "$TARGET" == "production" ]] && echo "compress" || echo "${PROJECT_NAME}-auth,compress" )
|
||||
PROJECT_NAME=$PROJECT_NAME
|
||||
COOKIE_DOMAIN=.$(echo $NEXT_PUBLIC_BASE_URL | sed 's|https://||')
|
||||
EOF
|
||||
|
||||
echo "✅ Environment file prepared"
|
||||
echo ""
|
||||
|
||||
# Execute deployment commands with detailed logging
|
||||
echo "📡 Connecting to server and executing deployment..."
|
||||
echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"
|
||||
echo ""
|
||||
|
||||
# Copy .env file to server
|
||||
echo "📤 Uploading environment configuration..."
|
||||
scp -o StrictHostKeyChecking=accept-new \
|
||||
-o ServerAliveInterval=30 \
|
||||
-o ServerAliveCountMax=3 \
|
||||
-o ConnectTimeout=10 \
|
||||
/tmp/klz-cables.env \
|
||||
root@alpha.mintel.me:/home/deploy/sites/klz-cables.com/.env
|
||||
|
||||
if [ $? -eq 0 ]; then
|
||||
echo "✅ Environment file uploaded successfully"
|
||||
else
|
||||
echo "❌ Failed to upload environment file"
|
||||
exit 1
|
||||
fi
|
||||
echo ""
|
||||
|
||||
# SSH to server and run deployment
|
||||
echo "🚀 Executing deployment on server..."
|
||||
ssh -o StrictHostKeyChecking=accept-new \
|
||||
-o ServerAliveInterval=30 \
|
||||
-o ServerAliveCountMax=3 \
|
||||
-o ConnectTimeout=10 \
|
||||
root@alpha.mintel.me bash << EOF
|
||||
set -e
|
||||
|
||||
PROJECT_DIR="/home/deploy/sites/klz-cables.com"
|
||||
cd "\$PROJECT_DIR"
|
||||
|
||||
echo "🔒 Securing environment file..."
|
||||
chmod 600 .env
|
||||
chown deploy:deploy .env
|
||||
|
||||
echo "🔐 Logging into Docker registry..."
|
||||
echo "${{ secrets.REGISTRY_PASS }}" | docker login registry.infra.mintel.me -u "${{ secrets.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..."
|
||||
MAX_RETRIES=12
|
||||
RETRY_COUNT=0
|
||||
until curl -s -f http://localhost:3000/health > /dev/null || [ $RETRY_COUNT -eq $MAX_RETRIES ]; do
|
||||
echo " • Waiting for health check... ($((RETRY_COUNT + 1))/$MAX_RETRIES)"
|
||||
sleep 5
|
||||
RETRY_COUNT=$((RETRY_COUNT + 1))
|
||||
done
|
||||
|
||||
if [ $RETRY_COUNT -eq $MAX_RETRIES ]; then
|
||||
echo "❌ Health check failed after $MAX_RETRIES retries"
|
||||
echo "🔍 Container logs:"
|
||||
docker-compose logs --tail=50
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo "✅ Health check passed!"
|
||||
|
||||
echo "🔍 Checking service status..."
|
||||
docker-compose ps
|
||||
|
||||
echo ""
|
||||
echo "✅ Deployment complete!"
|
||||
# 1. Cleanup and Create Directories on server BEFORE SCP
|
||||
ssh -o StrictHostKeyChecking=accept-new root@alpha.mintel.me bash << 'EOF'
|
||||
set -e
|
||||
mkdir -p /home/deploy/sites/klz-cables.com/varnish
|
||||
mkdir -p /home/deploy/sites/klz-cables.com/directus/uploads /home/deploy/sites/klz-cables.com/directus/extensions
|
||||
if [ -d "/home/deploy/sites/klz-cables.com/varnish/default.vcl" ]; then
|
||||
echo "🧹 Removing directory 'varnish/default.vcl' created by Docker..."
|
||||
rm -rf /home/deploy/sites/klz-cables.com/varnish/default.vcl
|
||||
fi
|
||||
chown -R deploy:deploy /home/deploy/sites/klz-cables.com/directus /home/deploy/sites/klz-cables.com/varnish
|
||||
EOF
|
||||
|
||||
# 2. Transfer files
|
||||
scp -o StrictHostKeyChecking=accept-new /tmp/klz-cables.env root@alpha.mintel.me:/home/deploy/sites/klz-cables.com/$ENV_FILE
|
||||
scp -o StrictHostKeyChecking=accept-new docker-compose.yml root@alpha.mintel.me:/home/deploy/sites/klz-cables.com/docker-compose.yml
|
||||
scp -r -o StrictHostKeyChecking=accept-new varnish root@alpha.mintel.me:/home/deploy/sites/klz-cables.com/
|
||||
|
||||
ssh -o StrictHostKeyChecking=accept-new root@alpha.mintel.me IMAGE_TAG="$IMAGE_TAG" ENV_FILE="$ENV_FILE" PROJECT_NAME="$PROJECT_NAME" bash << 'EOF'
|
||||
set -e
|
||||
cd /home/deploy/sites/klz-cables.com
|
||||
chmod 600 "$ENV_FILE"
|
||||
chown deploy:deploy "$ENV_FILE"
|
||||
|
||||
echo "${{ secrets.REGISTRY_PASS }}" | docker login registry.infra.mintel.me -u "${{ secrets.REGISTRY_USER }}" --password-stdin
|
||||
echo "→ Pulling image: $IMAGE_TAG"
|
||||
docker compose -p "$PROJECT_NAME" --env-file "$ENV_FILE" pull
|
||||
echo "→ Starting containers..."
|
||||
docker compose -p "$PROJECT_NAME" --env-file "$ENV_FILE" up -d --remove-orphans
|
||||
docker system prune -f --filter "until=24h"
|
||||
echo "→ Waiting 15s for warmup..."
|
||||
sleep 15
|
||||
echo "→ Container status:"
|
||||
docker compose -p "$PROJECT_NAME" --env-file "$ENV_FILE" ps
|
||||
if ! docker compose -p "$PROJECT_NAME" --env-file "$ENV_FILE" ps | grep -q "Up"; then
|
||||
echo "❌ Fehler: Container nicht Up!"
|
||||
docker compose -p "$PROJECT_NAME" --env-file "$ENV_FILE" logs --tail=150
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo "→ Verifying Varnish Backend Health..."
|
||||
docker compose -p "$PROJECT_NAME" --env-file "$ENV_FILE" exec -T varnish varnishadm backend.list
|
||||
if ! docker compose -p "$PROJECT_NAME" --env-file "$ENV_FILE" exec -T varnish varnishadm backend.list | grep -q "healthy"; then
|
||||
echo "❌ Fehler: Varnish Backend ist SICK!"
|
||||
docker compose -p "$PROJECT_NAME" --env-file "$ENV_FILE" logs varnish
|
||||
exit 1
|
||||
fi
|
||||
echo "✅ Varnish Backend ist Healthy."
|
||||
|
||||
# ──────────────────────────────────────────────────────────────────────────────
|
||||
# JOB 5: PageSpeed Test
|
||||
# ──────────────────────────────────────────────────────────────────────────────
|
||||
pagespeed:
|
||||
name: ⚡ PageSpeed
|
||||
needs: [prepare, deploy]
|
||||
if: |
|
||||
always() &&
|
||||
needs.prepare.outputs.target != 'skip' &&
|
||||
needs.deploy.result == 'success' &&
|
||||
github.event.inputs.skip_long_checks != 'true'
|
||||
runs-on: docker
|
||||
container:
|
||||
image: catthehacker/ubuntu:act-latest
|
||||
# outputs:
|
||||
# report_url: ${{ steps.save.outputs.report_url }}
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
fetch-depth: 1
|
||||
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: 20
|
||||
|
||||
- name: Install dependencies
|
||||
run: npm ci --legacy-peer-deps
|
||||
|
||||
- name: 🔍 Install Chromium (Native & ARM64)
|
||||
run: |
|
||||
apt-get update
|
||||
apt-get install -y gnupg wget ca-certificates
|
||||
|
||||
DEPLOY_EXIT_CODE=$?
|
||||
echo ""
|
||||
# Detect OS
|
||||
OS_ID=$(. /etc/os-release && echo $ID)
|
||||
CODENAME=$(. /etc/os-release && echo $VERSION_CODENAME)
|
||||
|
||||
# Clean up temporary env file
|
||||
rm -f /tmp/klz-cables.env
|
||||
|
||||
if [ $DEPLOY_EXIT_CODE -eq 0 ]; then
|
||||
echo "✅ Deployment completed successfully at: $(date -u +'%Y-%m-%d %H:%M:%S UTC')"
|
||||
if [ "$OS_ID" = "debian" ]; then
|
||||
echo "🎯 Debian detected - installing native chromium"
|
||||
apt-get install -y chromium
|
||||
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"
|
||||
echo " • Verify all required secrets are set in Gitea"
|
||||
exit $DEPLOY_EXIT_CODE
|
||||
fi
|
||||
echo ""
|
||||
|
||||
# ═══════════════════════════════════════════════════════════════════════════════
|
||||
# LOGGING: Workflow Summary
|
||||
# ═══════════════════════════════════════════════════════════════════════════════
|
||||
- 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 " • .env file is auto-generated from Gitea secrets"
|
||||
echo " • .env file has 600 permissions on server"
|
||||
echo ""
|
||||
echo "╔══════════════════════════════════════════════════════════════════════════════╗"
|
||||
if [ "${{ job.status }}" == "success" ]; then
|
||||
echo "║ ✅ DEPLOYMENT SUCCESSFUL ║"
|
||||
else
|
||||
echo "║ ❌ DEPLOYMENT FAILED ║"
|
||||
fi
|
||||
echo "╚══════════════════════════════════════════════════════════════════════════════╝"
|
||||
|
||||
# ═══════════════════════════════════════════════════════════════════════════════
|
||||
# NOTIFICATION: Gotify
|
||||
# ═══════════════════════════════════════════════════════════════════════════════
|
||||
- name: 🔔 Gotify Notification (Success)
|
||||
if: success()
|
||||
run: |
|
||||
echo "Sending success notification to Gotify..."
|
||||
RESPONSE=$(curl -k -s -w "\n%{http_code}" -X POST "${{ secrets.GOTIFY_URL }}/message?token=${{ secrets.GOTIFY_TOKEN }}" \
|
||||
-F "title=✅ Deployment Success: ${{ github.repository }}" \
|
||||
-F "message=The deployment of ${{ github.repository }} (branch: ${{ github.ref }}) was successful.
|
||||
echo "🎯 Ubuntu detected - adding xtradeb PPA"
|
||||
mkdir -p /etc/apt/keyrings
|
||||
KEY_ID="82BB6851C64F6880"
|
||||
|
||||
Commit: ${{ github.sha }}
|
||||
Actor: ${{ github.actor }}
|
||||
Run ID: ${{ github.run_id }}" \
|
||||
-F "priority=5")
|
||||
|
||||
HTTP_CODE=$(echo "$RESPONSE" | tail -n1)
|
||||
BODY=$(echo "$RESPONSE" | sed '$d')
|
||||
|
||||
echo "HTTP Status: $HTTP_CODE"
|
||||
echo "Response Body: $BODY"
|
||||
|
||||
if [ "$HTTP_CODE" -lt 200 ] || [ "$HTTP_CODE" -ge 300 ]; then
|
||||
echo "Failed to send Gotify notification"
|
||||
exit 0 # Don't fail the workflow because of notification failure
|
||||
# Multi-method Key Fetch
|
||||
SUCCESS=false
|
||||
echo "Fetching key $KEY_ID..."
|
||||
|
||||
# Method 1: gpg --recv-keys (standard)
|
||||
for server in "hkp://keyserver.ubuntu.com:80" "hkp://keyserver.ubuntu.com:11371"; do
|
||||
if gpg --no-default-keyring --keyring /tmp/xtradeb.gpg --keyserver "$server" --recv-keys "$KEY_ID"; then
|
||||
gpg --no-default-keyring --keyring /tmp/xtradeb.gpg --export > /etc/apt/keyrings/xtradeb.gpg
|
||||
SUCCESS=true && break
|
||||
fi
|
||||
done
|
||||
|
||||
# Method 2: Direct wget (fallback)
|
||||
if [ "$SUCCESS" = false ]; then
|
||||
wget -qO- "https://keyserver.ubuntu.com/pks/lookup?op=get&search=0x$KEY_ID" | gpg --dearmor > /etc/apt/keyrings/xtradeb.gpg && SUCCESS=true
|
||||
fi
|
||||
|
||||
if [ "$SUCCESS" = true ]; then
|
||||
echo "deb [signed-by=/etc/apt/keyrings/xtradeb.gpg] http://ppa.launchpad.net/xtradeb/apps/ubuntu $CODENAME main" > /etc/apt/sources.list.d/xtradeb-ppa.list
|
||||
else
|
||||
echo "⚠️ GPG fetch failed, using legacy apt-key as last resort..."
|
||||
apt-key adv --keyserver keyserver.ubuntu.com --recv-keys "$KEY_ID" || true
|
||||
echo "deb http://ppa.launchpad.net/xtradeb/apps/ubuntu $CODENAME main" > /etc/apt/sources.list.d/xtradeb-ppa.list
|
||||
fi
|
||||
|
||||
# PRIORITY PINNING: Force PPA over Snap-dummy
|
||||
printf "Package: *\nPin: release o=LP-PPA-xtradeb-apps\nPin-Priority: 1001\n" > /etc/apt/preferences.d/xtradeb
|
||||
|
||||
apt-get update
|
||||
apt-get install -y --allow-downgrades chromium || apt-get install -y chromium-browser
|
||||
fi
|
||||
|
||||
# Force clean paths (remove existing dead links/files if they are snap wrappers)
|
||||
rm -f /usr/bin/google-chrome /usr/bin/chromium-browser
|
||||
[ -f /usr/bin/chromium ] && ln -sf /usr/bin/chromium /usr/bin/google-chrome
|
||||
[ -f /usr/bin/chromium ] && ln -sf /usr/bin/chromium /usr/bin/chromium-browser
|
||||
|
||||
echo "✅ Binary check:"
|
||||
ls -l /usr/bin/chromium* /usr/bin/google-chrome || true
|
||||
continue-on-error: true
|
||||
|
||||
- name: 🔔 Gotify Notification (Failure)
|
||||
if: failure()
|
||||
- name: 🧪 Run PageSpeed (Lighthouse)
|
||||
env:
|
||||
NEXT_PUBLIC_BASE_URL: ${{ needs.prepare.outputs.next_public_base_url }}
|
||||
GATEKEEPER_PASSWORD: ${{ secrets.GATEKEEPER_PASSWORD || 'klz2026' }}
|
||||
PAGESPEED_LIMIT: 8
|
||||
PUPPETEER_EXECUTABLE_PATH: /usr/bin/chromium
|
||||
CHROME_PATH: /usr/bin/chromium
|
||||
run: npm run pagespeed:test
|
||||
|
||||
|
||||
|
||||
# ──────────────────────────────────────────────────────────────────────────────
|
||||
# JOB 6: Notifications
|
||||
# ──────────────────────────────────────────────────────────────────────────────
|
||||
notifications:
|
||||
name: 🔔 Notifications
|
||||
needs: [prepare, qa, build-app, deploy, pagespeed]
|
||||
if: always()
|
||||
runs-on: docker
|
||||
container:
|
||||
image: catthehacker/ubuntu:act-latest
|
||||
steps:
|
||||
- name: 📊 Deployment Summary
|
||||
run: |
|
||||
echo "Sending failure notification to Gotify..."
|
||||
RESPONSE=$(curl -k -s -w "\n%{http_code}" -X POST "${{ secrets.GOTIFY_URL }}/message?token=${{ secrets.GOTIFY_TOKEN }}" \
|
||||
-F "title=❌ Deployment Failed: ${{ github.repository }}" \
|
||||
-F "message=The deployment of ${{ github.repository }} (branch: ${{ github.ref }}) failed!
|
||||
|
||||
Commit: ${{ github.sha }}
|
||||
Actor: ${{ github.actor }}
|
||||
Run ID: ${{ github.run_id }}
|
||||
|
||||
Please check the logs for details." \
|
||||
-F "priority=8")
|
||||
|
||||
HTTP_CODE=$(echo "$RESPONSE" | tail -n1)
|
||||
BODY=$(echo "$RESPONSE" | sed '$d')
|
||||
|
||||
echo "HTTP Status: $HTTP_CODE"
|
||||
echo "Response Body: $BODY"
|
||||
|
||||
if [ "$HTTP_CODE" -lt 200 ] || [ "$HTTP_CODE" -ge 300 ]; then
|
||||
echo "Failed to send Gotify notification"
|
||||
exit 0 # Don't fail the workflow because of notification failure
|
||||
fi
|
||||
echo "┌──────────────────────────────┐"
|
||||
echo "│ Deployment Summary │"
|
||||
echo "├──────────────────────────────┤"
|
||||
echo "│ Status: ${{ needs.deploy.result }} │"
|
||||
echo "│ Umgebung: ${{ needs.prepare.outputs.target || 'skipped' }} │"
|
||||
echo "│ Version: ${{ needs.prepare.outputs.image_tag }} │"
|
||||
echo "│ Commit: ${{ needs.prepare.outputs.short_sha }} │"
|
||||
echo "│ Message: ${{ needs.prepare.outputs.commit_msg }} │"
|
||||
echo "└──────────────────────────────┘"
|
||||
|
||||
- name: 🔔 Gotify - Success
|
||||
if: needs.deploy.result == 'success'
|
||||
run: |
|
||||
curl -s -k -X POST "${{ secrets.GOTIFY_URL }}/message?token=${{ secrets.GOTIFY_TOKEN }}" \
|
||||
-F "title=${{ needs.prepare.outputs.gotify_title }}" \
|
||||
-F "message=Erfolgreich deployt auf **${{ needs.prepare.outputs.target }}**\n\nVersion: **${{ needs.prepare.outputs.image_tag }}**\nCommit: ${{ needs.prepare.outputs.short_sha }} (${{ needs.prepare.outputs.commit_msg }})\nVon: ${{ github.actor }}\nRun: ${{ github.run_id }}" \
|
||||
-F "priority=4" || true
|
||||
|
||||
- name: 🔔 Gotify - Failure
|
||||
if: |
|
||||
needs.prepare.result == 'failure' ||
|
||||
needs.qa.result == 'failure' ||
|
||||
needs.build-app.result == 'failure' ||
|
||||
needs.deploy.result == 'failure' ||
|
||||
needs.pagespeed.result == 'failure'
|
||||
run: |
|
||||
curl -s -k -X POST "${{ secrets.GOTIFY_URL }}/message?token=${{ secrets.GOTIFY_TOKEN }}" \
|
||||
-F "title=❌ Deployment FEHLGESCHLAGEN – ${{ needs.prepare.outputs.target || 'unknown' }}" \
|
||||
-F "message=**Fehler beim Deploy auf ${{ needs.prepare.outputs.target }}**\n\nVersion: ${{ needs.prepare.outputs.image_tag || '?' }}\nCommit: ${{ needs.prepare.outputs.short_sha || '?' }}\nVon: ${{ github.actor }}\nRun: ${{ github.run_id }}\n\nBitte Logs prüfen!" \
|
||||
-F "priority=8" || true
|
||||
|
||||
7
.gitignore
vendored
7
.gitignore
vendored
@@ -1,2 +1,7 @@
|
||||
node_modules
|
||||
.next
|
||||
.next
|
||||
.DS_Store
|
||||
|
||||
# Directus
|
||||
directus/uploads
|
||||
!directus/extensions/
|
||||
1
.husky/commit-msg
Executable file
1
.husky/commit-msg
Executable file
@@ -0,0 +1 @@
|
||||
npx --no -- commitlint --edit "$1"
|
||||
1
.husky/pre-commit
Executable file
1
.husky/pre-commit
Executable file
@@ -0,0 +1 @@
|
||||
npx lint-staged
|
||||
11
.lintstagedrc.js
Normal file
11
.lintstagedrc.js
Normal file
@@ -0,0 +1,11 @@
|
||||
const path = require('path');
|
||||
|
||||
const buildEslintCommand = (filenames) =>
|
||||
`next lint --fix --file ${filenames
|
||||
.map((f) => path.relative(process.cwd(), f))
|
||||
.join(' --file ')}`;
|
||||
|
||||
module.exports = {
|
||||
'*.{js,jsx,ts,tsx}': [buildEslintCommand, 'prettier --write'],
|
||||
'*.{json,md,css,scss}': ['prettier --write'],
|
||||
};
|
||||
7
.prettierrc
Normal file
7
.prettierrc
Normal file
@@ -0,0 +1,7 @@
|
||||
{
|
||||
"semi": true,
|
||||
"singleQuote": true,
|
||||
"tabWidth": 2,
|
||||
"trailingComma": "all",
|
||||
"printWidth": 100
|
||||
}
|
||||
23
Dockerfile
23
Dockerfile
@@ -3,12 +3,12 @@ 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
|
||||
COPY package.json package-lock.json* ./
|
||||
RUN npm ci
|
||||
RUN --mount=type=cache,target=/root/.npm npm ci --legacy-peer-deps
|
||||
|
||||
|
||||
# Rebuild the source code only when needed
|
||||
@@ -25,22 +25,33 @@ 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_BASE_URL
|
||||
ARG UMAMI_WEBSITE_ID
|
||||
ARG NEXT_PUBLIC_UMAMI_WEBSITE_ID
|
||||
ARG UMAMI_API_ENDPOINT
|
||||
ARG UMAMI_SCRIPT_URL
|
||||
ARG NEXT_PUBLIC_UMAMI_SCRIPT_URL
|
||||
ARG NEXT_PUBLIC_TARGET
|
||||
ARG DIRECTUS_URL
|
||||
|
||||
ENV NEXT_PUBLIC_BASE_URL=$NEXT_PUBLIC_BASE_URL
|
||||
ENV NEXT_PUBLIC_UMAMI_WEBSITE_ID=$NEXT_PUBLIC_UMAMI_WEBSITE_ID
|
||||
ENV NEXT_PUBLIC_UMAMI_SCRIPT_URL=$NEXT_PUBLIC_UMAMI_SCRIPT_URL
|
||||
ENV UMAMI_WEBSITE_ID=${UMAMI_WEBSITE_ID:-$NEXT_PUBLIC_UMAMI_WEBSITE_ID}
|
||||
ENV UMAMI_API_ENDPOINT=${UMAMI_API_ENDPOINT:-${UMAMI_SCRIPT_URL:-$NEXT_PUBLIC_UMAMI_SCRIPT_URL}}
|
||||
ENV NEXT_PUBLIC_TARGET=$NEXT_PUBLIC_TARGET
|
||||
ENV DIRECTUS_URL=$DIRECTUS_URL
|
||||
|
||||
# Validate environment variables during build
|
||||
RUN npx tsx scripts/validate-env.ts
|
||||
RUN SKIP_RUNTIME_ENV_VALIDATION=true npx tsx scripts/validate-env.ts
|
||||
|
||||
RUN npm run build
|
||||
RUN --mount=type=cache,target=/app/.next/cache npm run build
|
||||
|
||||
# Production image, copy all the files and run next
|
||||
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
|
||||
|
||||
@@ -145,8 +145,6 @@ Ensure these secrets are configured in your Gitea repository:
|
||||
- `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 (e.g., `klz:`)
|
||||
|
||||
**Infrastructure:**
|
||||
- `REGISTRY_USER` - Docker registry username
|
||||
|
||||
114
README.md
114
README.md
@@ -5,11 +5,13 @@ A complete WordPress to Next.js static site migration for KLZ Cables, transformi
|
||||
## 🚀 Quick Start
|
||||
|
||||
### Prerequisites
|
||||
- Node.js 18+
|
||||
|
||||
- Node.js 18+
|
||||
- npm or yarn
|
||||
|
||||
### Installation
|
||||
```bash
|
||||
|
||||
````bash
|
||||
# Install dependencies
|
||||
npm install --legacy-peer-deps
|
||||
|
||||
@@ -29,9 +31,40 @@ npm run export
|
||||
|
||||
# Or run development server
|
||||
npm run dev
|
||||
|
||||
### 🏗️ CMS (Strapi)
|
||||
The CMS runs in Docker. Use the following npm scripts for local development:
|
||||
|
||||
```bash
|
||||
# Start Strapi and its database
|
||||
npm run cms:dev
|
||||
|
||||
# View logs
|
||||
npm run cms:logs
|
||||
|
||||
# Stop the CMS
|
||||
npm run cms:stop
|
||||
````
|
||||
|
||||
Once running, you can access the Strapi admin panel at `http://localhost:1337/admin`.
|
||||
|
||||
### 🔄 Data & Migration
|
||||
|
||||
To sync data or migrate existing content:
|
||||
|
||||
```bash
|
||||
# Export local data
|
||||
npm run cms:export -- my-data.tar.gz
|
||||
|
||||
# Import data
|
||||
npm run cms:import -- my-data.tar.gz
|
||||
|
||||
# Migrate existing MDX data to Strapi
|
||||
npm run cms:migrate
|
||||
```
|
||||
|
||||
### Environment Variables
|
||||
|
||||
```bash
|
||||
# .env
|
||||
SITE_URL=https://klz-cables.com
|
||||
@@ -40,23 +73,19 @@ TURNSTILE_SITE_KEY=your_turnstile_key
|
||||
TURNSTILE_SECRET_KEY=your_turnstile_secret
|
||||
|
||||
# Umami
|
||||
NEXT_PUBLIC_UMAMI_WEBSITE_ID=your_umami_website_id
|
||||
NEXT_PUBLIC_UMAMI_SCRIPT_URL=https://analytics.infra.mintel.me/script.js
|
||||
UMAMI_WEBSITE_ID=your_umami_website_id
|
||||
UMAMI_API_ENDPOINT=https://analytics.infra.mintel.me
|
||||
|
||||
# GlitchTip (Sentry compatible)
|
||||
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
|
||||
|
||||
### Migration Statistics
|
||||
|
||||
- **Content Exported**: 141 items
|
||||
- 18 pages (9 EN + 9 DE)
|
||||
- 59 posts (29 EN + 30 DE)
|
||||
@@ -67,6 +96,7 @@ REDIS_KEY_PREFIX=klz:
|
||||
- **Translation Pairs**: 16
|
||||
|
||||
### Performance Benefits
|
||||
|
||||
- **Before**: Dynamic WordPress with database queries
|
||||
- **After**: Static HTML with CDN delivery
|
||||
- **Load Time**: <100ms (vs 500ms+)
|
||||
@@ -75,15 +105,18 @@ REDIS_KEY_PREFIX=klz:
|
||||
## 🏗️ Architecture
|
||||
|
||||
### Tech Stack
|
||||
|
||||
- **Framework**: Next.js 14 (App Router)
|
||||
- **Language**: TypeScript
|
||||
- **Styling**: SCSS
|
||||
- **Data**: Static JSON (WordPress export)
|
||||
- **CMS**: Strapi (Source of Truth)
|
||||
- **Data**: Static JSON (WordPress export) & Strapi API
|
||||
- **Email**: Resend
|
||||
- **Analytics**: Vercel (consent-based)
|
||||
- **CAPTCHA**: Cloudflare Turnstile
|
||||
|
||||
### Project Structure
|
||||
|
||||
```
|
||||
app/
|
||||
├── layout.tsx # Root layout
|
||||
@@ -108,7 +141,7 @@ app/
|
||||
├── api/
|
||||
│ └── contact/route.ts # Contact API
|
||||
├── sitemap.ts # Sitemap generator
|
||||
└── robots.ts # Robots.txt generator
|
||||
├── robots.ts # Robots.txt generator
|
||||
|
||||
lib/
|
||||
├── data.ts # Data access
|
||||
@@ -119,7 +152,7 @@ components/
|
||||
├── LocaleSwitcher.tsx # Language switcher
|
||||
├── ContactForm.tsx # Contact form
|
||||
├── CookieConsent.tsx # GDPR banner
|
||||
└── SEO.tsx # SEO utilities
|
||||
├── SEO.tsx # SEO utilities
|
||||
|
||||
data/
|
||||
├── raw/ # WordPress export
|
||||
@@ -138,6 +171,7 @@ scripts/
|
||||
## 🎯 Features
|
||||
|
||||
### ✅ Implemented
|
||||
|
||||
- **Multi-language**: EN/DE with `/de/` prefix routing
|
||||
- **Contact Forms**: Resend integration with validation
|
||||
- **GDPR Compliance**: Cookie consent banner
|
||||
@@ -150,12 +184,14 @@ scripts/
|
||||
- **Asset Management**: WordPress → local path mapping
|
||||
|
||||
### 🔄 In Progress
|
||||
|
||||
- Analytics integration (consent-based)
|
||||
- Turnstile CAPTCHA
|
||||
- Build testing
|
||||
- Deployment configuration
|
||||
|
||||
### 📝 Remaining
|
||||
|
||||
- Performance optimization
|
||||
- Final QA testing
|
||||
- Documentation updates
|
||||
@@ -163,6 +199,7 @@ scripts/
|
||||
## 📝 Content Management
|
||||
|
||||
### Data Export
|
||||
|
||||
```bash
|
||||
# Export from WordPress
|
||||
npm run data:export
|
||||
@@ -178,6 +215,7 @@ npm run data:improve-mapping
|
||||
```
|
||||
|
||||
### Adding New Content
|
||||
|
||||
1. Export new content from WordPress
|
||||
2. Process the data
|
||||
3. Rebuild the site
|
||||
@@ -185,17 +223,20 @@ npm run data:improve-mapping
|
||||
## 🎨 Design System
|
||||
|
||||
### Colors
|
||||
|
||||
- Primary: `#0066cc` (KLZ Blue)
|
||||
- Secondary: `#00a896` (Teal)
|
||||
- Text: `#1a1a1a`
|
||||
- Background: `#f8f9fa`
|
||||
|
||||
### Typography
|
||||
|
||||
- Font: Inter
|
||||
- Base: 16px
|
||||
- Scale: 1.25 (Major Third)
|
||||
|
||||
### Layout
|
||||
|
||||
- Max width: 1200px
|
||||
- Responsive grid
|
||||
- Mobile-first
|
||||
@@ -203,6 +244,7 @@ npm run data:improve-mapping
|
||||
## 🔧 API Endpoints
|
||||
|
||||
### Contact Form
|
||||
|
||||
```
|
||||
POST /api/contact
|
||||
{
|
||||
@@ -214,11 +256,13 @@ POST /api/contact
|
||||
```
|
||||
|
||||
### Sitemap
|
||||
|
||||
```
|
||||
GET /sitemap.xml
|
||||
```
|
||||
|
||||
### Robots
|
||||
|
||||
```
|
||||
GET /robots.txt
|
||||
```
|
||||
@@ -227,21 +271,32 @@ GET /robots.txt
|
||||
|
||||
### Automatic Deployment (Current Setup)
|
||||
|
||||
The project uses **Gitea Actions** for CI/CD. Every push to `main` triggers:
|
||||
The project uses **Gitea Actions** for CI/CD. Every push to `main` or `staging` triggers:
|
||||
|
||||
1. **Build**: Docker image built for `linux/arm64`
|
||||
2. **Push**: Image pushed to `registry.infra.mintel.me`
|
||||
3. **Deploy**: SSH to production server, pull and restart containers
|
||||
1. **Build**: Docker image built for `linux/arm64` with branch-specific build args
|
||||
2. **Push**: Image pushed to `registry.infra.mintel.me` with commit SHA tag
|
||||
3. **Deploy**: SSH to production server, pull and restart containers using branch-specific `.env` files
|
||||
|
||||
**Workflow**: `.gitea/workflows/deploy.yml`
|
||||
|
||||
**Branch Deployments**:
|
||||
|
||||
- `main` branch: Deploys to production using `.env.prod`
|
||||
- `staging` branch: Deploys to staging using `.env.staging`
|
||||
|
||||
**Environment Overrides**:
|
||||
The CI/CD workflow supports `STAGING_`-prefixed secrets (e.g., `STAGING_NEXT_PUBLIC_BASE_URL`) to override default secrets when deploying the `staging` branch.
|
||||
|
||||
**Required Secrets** (configure in Gitea repository settings):
|
||||
|
||||
- `REGISTRY_USER` - Docker registry username
|
||||
- `REGISTRY_PASS` - Docker registry password
|
||||
- `ALPHA_SSH_KEY` - SSH private key for deployment
|
||||
- `NEXT_PUBLIC_UMAMI_WEBSITE_ID` - Analytics ID
|
||||
- `NEXT_PUBLIC_UMAMI_SCRIPT_URL` - Analytics script URL
|
||||
- `NEXT_PUBLIC_BASE_URL` - Application base URL (e.g., `https://klz-cables.com`)
|
||||
- `UMAMI_WEBSITE_ID` - Analytics ID
|
||||
- `UMAMI_API_ENDPOINT` - Analytics API endpoint (formerly NEXT_PUBLIC_UMAMI_SCRIPT_URL)
|
||||
- `SENTRY_DSN` - Error tracking DSN
|
||||
- `MAIL_HOST`, `MAIL_PORT`, `MAIL_USERNAME`, `MAIL_PASSWORD`, `MAIL_FROM`, `MAIL_RECIPIENTS` - Email configuration
|
||||
|
||||
### Manual Deployment
|
||||
|
||||
@@ -259,6 +314,7 @@ docker image prune -f
|
||||
```
|
||||
|
||||
Or use the convenience script:
|
||||
|
||||
```bash
|
||||
bash scripts/deploy-webhook.sh
|
||||
```
|
||||
@@ -266,17 +322,18 @@ bash scripts/deploy-webhook.sh
|
||||
### Architecture
|
||||
|
||||
```
|
||||
Client → Traefik (TLS) → Varnish (Cache) → Next.js App
|
||||
Client → Traefik (TLS) → Next.js App
|
||||
```
|
||||
|
||||
**Domains**:
|
||||
|
||||
- `klz-cables.com` - Production
|
||||
- `www.klz-cables.com` - Production (www)
|
||||
- `staging.klz-cables.com` - Staging
|
||||
|
||||
**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).
|
||||
@@ -284,25 +341,30 @@ For detailed deployment documentation, see [`docs/DEPLOYMENT.md`](docs/DEPLOYMEN
|
||||
## 📈 Performance
|
||||
|
||||
### Build Time
|
||||
|
||||
- **Target**: < 2 minutes
|
||||
- **Current**: ~1-2 minutes
|
||||
|
||||
### Page Load
|
||||
|
||||
- **Target**: < 100ms
|
||||
- **Current**: Static HTML from CDN
|
||||
|
||||
### Bundle Size
|
||||
|
||||
- **Target**: < 100KB gzipped
|
||||
- **Current**: Optimized with code splitting
|
||||
|
||||
## 🔒 Security
|
||||
|
||||
### Environment Variables
|
||||
|
||||
- Never commit `.env` file
|
||||
- Rotate keys regularly
|
||||
- Use secrets in deployment platform
|
||||
|
||||
### Form Security
|
||||
|
||||
- Email validation
|
||||
- Rate limiting (recommended)
|
||||
- Turnstile CAPTCHA (pending)
|
||||
@@ -310,6 +372,7 @@ For detailed deployment documentation, see [`docs/DEPLOYMENT.md`](docs/DEPLOYMEN
|
||||
## 🎓 WordPress Specifics
|
||||
|
||||
### WPBakery Shortcodes Removed
|
||||
|
||||
- `[vc_row]`, `[vc_column]`, `[vc_column_text]`
|
||||
- `[nectar_*]` (Salient theme)
|
||||
- `[image_with_animation]`
|
||||
@@ -317,13 +380,16 @@ For detailed deployment documentation, see [`docs/DEPLOYMENT.md`](docs/DEPLOYMEN
|
||||
- `[divider]`
|
||||
|
||||
### HTML Sanitization
|
||||
|
||||
- Removes inline event handlers
|
||||
- Strips scripts
|
||||
- Normalizes classes
|
||||
- Preserves structure
|
||||
|
||||
### Asset Mapping
|
||||
|
||||
WordPress URLs → Local paths:
|
||||
|
||||
```
|
||||
https://klz-cables.com/wp-content/uploads/... → /media/...
|
||||
```
|
||||
@@ -331,11 +397,13 @@ https://klz-cables.com/wp-content/uploads/... → /media/...
|
||||
## 📚 Documentation
|
||||
|
||||
### Internal
|
||||
|
||||
- `PROJECT_STRUCTURE.md` - Detailed structure
|
||||
- `IMPLEMENTATION_SUMMARY.md` - Progress tracking
|
||||
- `FINAL_SUMMARY.md` - Complete overview
|
||||
|
||||
### External
|
||||
|
||||
- [Next.js Docs](https://nextjs.org/docs)
|
||||
- [WordPress REST API](https://developer.wordpress.org/rest-api/)
|
||||
- [Resend Docs](https://resend.com/docs)
|
||||
@@ -346,17 +414,20 @@ https://klz-cables.com/wp-content/uploads/... → /media/...
|
||||
### Common Issues
|
||||
|
||||
**TypeScript Errors**
|
||||
|
||||
- The TypeScript errors shown in the editor are expected
|
||||
- They occur because modules reference each other
|
||||
- The build process resolves these correctly
|
||||
- Run `npm run build` to verify
|
||||
|
||||
**Build Failures**
|
||||
|
||||
- Check environment variables
|
||||
- Verify data files exist
|
||||
- Clear `.next` cache: `rm -rf .next`
|
||||
|
||||
**Missing Modules**
|
||||
|
||||
- Run `npm install --legacy-peer-deps`
|
||||
- Check `package.json` dependencies
|
||||
|
||||
@@ -371,11 +442,12 @@ https://klz-cables.com/wp-content/uploads/... → /media/...
|
||||
✅ **i18n**: Multi-language support
|
||||
✅ **SEO**: Metadata and sitemaps
|
||||
✅ **Compatibility**: WPBakery content handled
|
||||
✅ **Media**: All images downloaded
|
||||
✅ **Media**: All images downloaded
|
||||
|
||||
## 📞 Support
|
||||
|
||||
For issues or questions:
|
||||
|
||||
1. Check the documentation
|
||||
2. Review the troubleshooting section
|
||||
3. Check environment variables
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { ImageResponse } from 'next/og';
|
||||
import { getPageBySlug } from '@/lib/pages';
|
||||
import { OGImageTemplate } from '@/components/OGImageTemplate';
|
||||
import { getOgFonts, OG_IMAGE_SIZE } from '@/lib/og-helper';
|
||||
|
||||
export const runtime = 'nodejs';
|
||||
|
||||
@@ -8,11 +9,11 @@ export default async function Image({ params: { locale, slug } }: { params: { lo
|
||||
const pageData = await getPageBySlug(slug, locale);
|
||||
|
||||
if (!pageData) {
|
||||
return new ImageResponse(
|
||||
<div style={{ display: 'flex', width: '100%', height: '100%', backgroundColor: '#001a4d' }} />
|
||||
);
|
||||
return new Response('Page not found', { status: 404 });
|
||||
}
|
||||
|
||||
const fonts = await getOgFonts();
|
||||
|
||||
return new ImageResponse(
|
||||
(
|
||||
<OGImageTemplate
|
||||
@@ -22,8 +23,9 @@ export default async function Image({ params: { locale, slug } }: { params: { lo
|
||||
/>
|
||||
),
|
||||
{
|
||||
width: 1200,
|
||||
height: 630,
|
||||
...OG_IMAGE_SIZE,
|
||||
fonts,
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -1,10 +1,12 @@
|
||||
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';
|
||||
import { SITE_URL } from '@/lib/schema';
|
||||
|
||||
interface PageProps {
|
||||
params: {
|
||||
@@ -29,7 +31,7 @@ export async function generateStaticParams() {
|
||||
|
||||
export async function generateMetadata({ params: { locale, slug } }: PageProps): Promise<Metadata> {
|
||||
const pageData = await getPageBySlug(slug, locale);
|
||||
|
||||
|
||||
if (!pageData) return {};
|
||||
|
||||
return {
|
||||
@@ -38,15 +40,16 @@ export async function generateMetadata({ params: { locale, slug } }: PageProps):
|
||||
alternates: {
|
||||
canonical: `/${locale}/${slug}`,
|
||||
languages: {
|
||||
'de': `/de/${slug}`,
|
||||
'en': `/en/${slug}`,
|
||||
de: `/de/${slug}`,
|
||||
en: `/en/${slug}`,
|
||||
'x-default': `/en/${slug}`,
|
||||
},
|
||||
},
|
||||
openGraph: {
|
||||
title: `${pageData.frontmatter.title} | KLZ Cables`,
|
||||
description: pageData.frontmatter.excerpt || '',
|
||||
url: `https://klz-cables.com/${locale}/${slug}`,
|
||||
url: `${SITE_URL}/${locale}/${slug}`,
|
||||
images: getOGImageMetadata(slug, pageData.frontmatter.title, locale),
|
||||
},
|
||||
twitter: {
|
||||
card: 'summary_large_image',
|
||||
@@ -73,10 +76,12 @@ export default async function StandardPage({ params: { locale, slug } }: PagePro
|
||||
</div>
|
||||
<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">
|
||||
<Badge variant="accent" className="mb-4 md:mb-6">
|
||||
{t('badge')}
|
||||
</Badge>
|
||||
<Heading level={1} className="text-white mb-0">
|
||||
{pageData.frontmatter.title}
|
||||
</h1>
|
||||
</Heading>
|
||||
</div>
|
||||
</Container>
|
||||
</section>
|
||||
@@ -104,9 +109,14 @@ export default async function StandardPage({ params: { locale, slug } }: PagePro
|
||||
<div className="relative z-10 max-w-2xl">
|
||||
<h3 className="text-2xl md:text-3xl font-bold mb-4">{t('needHelp')}</h3>
|
||||
<p className="text-lg text-white/70 mb-8">{t('supportTeamAvailable')}</p>
|
||||
<a href={`/${locale}/contact`} className="inline-flex items-center px-8 py-4 bg-accent text-primary-dark font-bold rounded-full hover:bg-white transition-all duration-300 group/link">
|
||||
{t('contactUs')}
|
||||
<span className="ml-2 transition-transform group-hover/link:translate-x-1">→</span>
|
||||
<a
|
||||
href={`/${locale}/contact`}
|
||||
className="inline-flex items-center px-8 py-4 bg-accent text-primary-dark font-bold rounded-full hover:bg-white transition-all duration-300 group/link"
|
||||
>
|
||||
{t('contactUs')}
|
||||
<span className="ml-2 transition-transform group-hover/link:translate-x-1">
|
||||
→
|
||||
</span>
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
@@ -114,4 +124,4 @@ export default async function StandardPage({ params: { locale, slug } }: PagePro
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
74
app/[locale]/api/og/product/route.tsx
Normal file
74
app/[locale]/api/og/product/route.tsx
Normal file
@@ -0,0 +1,74 @@
|
||||
import { ImageResponse } from 'next/og';
|
||||
import { getProductBySlug } from '@/lib/mdx';
|
||||
import { getTranslations } from 'next-intl/server';
|
||||
import { OGImageTemplate } from '@/components/OGImageTemplate';
|
||||
import { NextRequest } from 'next/server';
|
||||
import { getOgFonts, OG_IMAGE_SIZE } from '@/lib/og-helper';
|
||||
|
||||
export const runtime = 'nodejs';
|
||||
|
||||
export async function GET(
|
||||
request: NextRequest,
|
||||
{ params }: { params: { locale: string } }
|
||||
) {
|
||||
const { searchParams, origin } = new URL(request.url);
|
||||
const slug = searchParams.get('slug');
|
||||
const locale = params.locale || 'en';
|
||||
|
||||
if (!slug) {
|
||||
return new Response('Missing slug', { status: 400 });
|
||||
}
|
||||
|
||||
const fonts = await getOgFonts();
|
||||
const t = await getTranslations({ locale, namespace: 'Products' });
|
||||
|
||||
// Check if it's a category page
|
||||
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"
|
||||
/>
|
||||
),
|
||||
{
|
||||
...OG_IMAGE_SIZE,
|
||||
fonts,
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
const product = await getProductBySlug(slug, locale);
|
||||
|
||||
if (!product) {
|
||||
return new Response('Product not found', { status: 404 });
|
||||
}
|
||||
|
||||
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}
|
||||
/>
|
||||
),
|
||||
{
|
||||
...OG_IMAGE_SIZE,
|
||||
fonts,
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,36 +1,44 @@
|
||||
import { ImageResponse } from 'next/og';
|
||||
import { getPostBySlug } from '@/lib/blog';
|
||||
import { OGImageTemplate } from '@/components/OGImageTemplate';
|
||||
import { getOgFonts, OG_IMAGE_SIZE } from '@/lib/og-helper';
|
||||
import { SITE_URL } from '@/lib/schema';
|
||||
|
||||
export const runtime = 'nodejs';
|
||||
|
||||
export default async function Image({ params: { locale, slug } }: { params: { locale: string, slug: string } }) {
|
||||
export default async function Image({
|
||||
params: { locale, slug },
|
||||
}: {
|
||||
params: { locale: string; slug: string };
|
||||
}) {
|
||||
const post = await getPostBySlug(slug, locale);
|
||||
|
||||
if (!post) {
|
||||
return new ImageResponse(
|
||||
<div style={{ display: 'flex', width: '100%', height: '100%', backgroundColor: '#001a4d' }} />
|
||||
);
|
||||
return new Response('Post not found', { status: 404 });
|
||||
}
|
||||
|
||||
const fonts = await getOgFonts();
|
||||
|
||||
// We don't have request.url here, but we can assume the domain from SITE_URL or config
|
||||
// For local images during dev, relative paths in <img> might not work in Satori
|
||||
// but if we are in nodejs runtime, we could potentially read from disk.
|
||||
// For now, let's just make sure it's absolute.
|
||||
const featuredImage = post.frontmatter.featuredImage
|
||||
? (post.frontmatter.featuredImage.startsWith('http')
|
||||
? post.frontmatter.featuredImage
|
||||
: `https://klz-cables.com${post.frontmatter.featuredImage}`)
|
||||
? post.frontmatter.featuredImage.startsWith('http')
|
||||
? post.frontmatter.featuredImage
|
||||
: `${SITE_URL}${post.frontmatter.featuredImage}`
|
||||
: undefined;
|
||||
|
||||
return new ImageResponse(
|
||||
(
|
||||
<OGImageTemplate
|
||||
title={post.frontmatter.title}
|
||||
description={post.frontmatter.excerpt}
|
||||
label={post.frontmatter.category || 'Blog'}
|
||||
image={featuredImage}
|
||||
/>
|
||||
),
|
||||
<OGImageTemplate
|
||||
title={post.frontmatter.title}
|
||||
description={post.frontmatter.excerpt}
|
||||
label={post.frontmatter.category || 'Blog'}
|
||||
image={featuredImage}
|
||||
/>,
|
||||
{
|
||||
width: 1200,
|
||||
height: 630,
|
||||
}
|
||||
...OG_IMAGE_SIZE,
|
||||
fonts,
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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: {
|
||||
@@ -18,9 +20,11 @@ interface BlogPostProps {
|
||||
};
|
||||
}
|
||||
|
||||
export async function generateMetadata({ params: { locale, slug } }: BlogPostProps): Promise<Metadata> {
|
||||
export async function generateMetadata({
|
||||
params: { locale, slug },
|
||||
}: BlogPostProps): Promise<Metadata> {
|
||||
const post = await getPostBySlug(slug, locale);
|
||||
|
||||
|
||||
if (!post) return {};
|
||||
|
||||
const description = post.frontmatter.excerpt || '';
|
||||
@@ -30,8 +34,8 @@ export async function generateMetadata({ params: { locale, slug } }: BlogPostPro
|
||||
alternates: {
|
||||
canonical: `/${locale}/blog/${slug}`,
|
||||
languages: {
|
||||
'de': `/de/blog/${slug}`,
|
||||
'en': `/en/blog/${slug}`,
|
||||
de: `/de/blog/${slug}`,
|
||||
en: `/en/blog/${slug}`,
|
||||
'x-default': `/en/blog/${slug}`,
|
||||
},
|
||||
},
|
||||
@@ -41,7 +45,8 @@ export async function generateMetadata({ params: { locale, slug } }: BlogPostPro
|
||||
type: 'article',
|
||||
publishedTime: post.frontmatter.date,
|
||||
authors: ['KLZ Cables'],
|
||||
url: `https://klz-cables.com/${locale}/blog/${slug}`,
|
||||
url: `${SITE_URL}/${locale}/blog/${slug}`,
|
||||
images: getOGImageMetadata(`blog/${slug}`, post.frontmatter.title, locale),
|
||||
},
|
||||
twitter: {
|
||||
card: 'summary_large_image',
|
||||
@@ -63,16 +68,15 @@ export default async function BlogPost({ params: { locale, slug } }: BlogPostPro
|
||||
|
||||
return (
|
||||
<article className="bg-white min-h-screen font-sans selection:bg-primary/10 selection:text-primary">
|
||||
|
||||
{/* Featured Image Header */}
|
||||
{post.frontmatter.featuredImage ? (
|
||||
<div className="relative w-full h-[70vh] min-h-[500px] overflow-hidden group">
|
||||
<div
|
||||
<div
|
||||
className="absolute inset-0 bg-cover bg-center transition-transform duration-[3s] ease-out scale-110 group-hover:scale-100"
|
||||
style={{ backgroundImage: `url(${post.frontmatter.featuredImage})` }}
|
||||
/>
|
||||
<div className="absolute inset-0 bg-gradient-to-t from-neutral-dark via-neutral-dark/40 to-transparent" />
|
||||
|
||||
|
||||
{/* Title overlay on image */}
|
||||
<div className="absolute inset-0 flex flex-col justify-end pb-16 md:pb-24">
|
||||
<div className="container mx-auto px-4">
|
||||
@@ -84,15 +88,18 @@ 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, {
|
||||
year: 'numeric',
|
||||
month: 'long',
|
||||
day: 'numeric'
|
||||
day: 'numeric',
|
||||
})}
|
||||
</time>
|
||||
<span className="w-1 h-1 bg-white/30 rounded-full" />
|
||||
@@ -112,15 +119,15 @@ 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, {
|
||||
year: 'numeric',
|
||||
month: 'long',
|
||||
day: 'numeric'
|
||||
day: 'numeric',
|
||||
})}
|
||||
</time>
|
||||
<span className="w-1 h-1 bg-neutral-300 rounded-full" />
|
||||
@@ -165,8 +172,18 @@ export default async function BlogPost({ params: { locale, slug } }: BlogPostPro
|
||||
href={`/${locale}/blog`}
|
||||
className="inline-flex items-center gap-3 text-text-secondary hover:text-primary font-bold text-sm uppercase tracking-widest transition-all group"
|
||||
>
|
||||
<svg className="w-5 h-5 transition-transform group-hover:-translate-x-2" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M15 19l-7-7 7-7" />
|
||||
<svg
|
||||
className="w-5 h-5 transition-transform group-hover:-translate-x-2"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2}
|
||||
d="M15 19l-7-7 7-7"
|
||||
/>
|
||||
</svg>
|
||||
{locale === 'de' ? 'Zurück zur Übersicht' : 'Back to Overview'}
|
||||
</Link>
|
||||
@@ -185,57 +202,63 @@ export default async function BlogPost({ params: { locale, slug } }: BlogPostPro
|
||||
{/* Structured Data */}
|
||||
<JsonLd
|
||||
id={`jsonld-${slug}`}
|
||||
data={{
|
||||
'@context': 'https://schema.org',
|
||||
'@type': 'BlogPosting',
|
||||
headline: post.frontmatter.title,
|
||||
datePublished: post.frontmatter.date,
|
||||
dateModified: post.frontmatter.date,
|
||||
image: post.frontmatter.featuredImage ? `https://klz-cables.com${post.frontmatter.featuredImage}` : undefined,
|
||||
author: {
|
||||
'@type': 'Organization',
|
||||
name: 'KLZ Cables',
|
||||
url: 'https://klz-cables.com',
|
||||
logo: 'https://klz-cables.com/logo-blue.svg'
|
||||
},
|
||||
publisher: {
|
||||
'@type': 'Organization',
|
||||
name: 'KLZ Cables',
|
||||
logo: {
|
||||
'@type': 'ImageObject',
|
||||
url: 'https://klz-cables.com/logo-blue.svg',
|
||||
data={
|
||||
{
|
||||
'@context': 'https://schema.org',
|
||||
'@type': 'BlogPosting',
|
||||
headline: post.frontmatter.title,
|
||||
datePublished: post.frontmatter.date,
|
||||
dateModified: post.frontmatter.date,
|
||||
image: post.frontmatter.featuredImage
|
||||
? `${SITE_URL}${post.frontmatter.featuredImage}`
|
||||
: undefined,
|
||||
author: {
|
||||
'@type': 'Organization',
|
||||
name: 'KLZ Cables',
|
||||
url: SITE_URL,
|
||||
logo: `${SITE_URL}/logo-blue.svg`,
|
||||
},
|
||||
},
|
||||
description: post.frontmatter.excerpt,
|
||||
mainEntityOfPage: {
|
||||
'@type': 'WebPage',
|
||||
'@id': `https://klz-cables.com/${locale}/blog/${slug}`,
|
||||
},
|
||||
articleSection: post.frontmatter.category,
|
||||
wordCount: post.content.split(/\s+/).length,
|
||||
timeRequired: `PT${getReadingTime(post.content)}M`
|
||||
} as any}
|
||||
publisher: {
|
||||
'@type': 'Organization',
|
||||
name: 'KLZ Cables',
|
||||
logo: {
|
||||
'@type': 'ImageObject',
|
||||
url: `${SITE_URL}/logo-blue.svg`,
|
||||
},
|
||||
},
|
||||
description: post.frontmatter.excerpt,
|
||||
mainEntityOfPage: {
|
||||
'@type': 'WebPage',
|
||||
'@id': `${SITE_URL}/${locale}/blog/${slug}`,
|
||||
},
|
||||
articleSection: post.frontmatter.category,
|
||||
wordCount: post.content.split(/\s+/).length,
|
||||
timeRequired: `PT${getReadingTime(post.content)}M`,
|
||||
} as any
|
||||
}
|
||||
/>
|
||||
<JsonLd
|
||||
id={`breadcrumb-${slug}`}
|
||||
data={{
|
||||
'@context': 'https://schema.org',
|
||||
'@type': 'BreadcrumbList',
|
||||
itemListElement: [
|
||||
{
|
||||
'@type': 'ListItem',
|
||||
position: 1,
|
||||
name: 'Blog',
|
||||
item: `https://klz-cables.com/${locale}/blog`,
|
||||
},
|
||||
{
|
||||
'@type': 'ListItem',
|
||||
position: 2,
|
||||
name: post.frontmatter.title,
|
||||
item: `https://klz-cables.com/${locale}/blog/${slug}`,
|
||||
},
|
||||
],
|
||||
} as any}
|
||||
data={
|
||||
{
|
||||
'@context': 'https://schema.org',
|
||||
'@type': 'BreadcrumbList',
|
||||
itemListElement: [
|
||||
{
|
||||
'@type': 'ListItem',
|
||||
position: 1,
|
||||
name: 'Blog',
|
||||
item: `${SITE_URL}/${locale}/blog`,
|
||||
},
|
||||
{
|
||||
'@type': 'ListItem',
|
||||
position: 2,
|
||||
name: post.frontmatter.title,
|
||||
item: `${SITE_URL}/${locale}/blog/${slug}`,
|
||||
},
|
||||
],
|
||||
} as any
|
||||
}
|
||||
/>
|
||||
</article>
|
||||
);
|
||||
|
||||
25
app/[locale]/blog/opengraph-image.tsx
Normal file
25
app/[locale]/blog/opengraph-image.tsx
Normal file
@@ -0,0 +1,25 @@
|
||||
import { ImageResponse } from 'next/og';
|
||||
import { getTranslations } from 'next-intl/server';
|
||||
import { OGImageTemplate } from '@/components/OGImageTemplate';
|
||||
import { getOgFonts, OG_IMAGE_SIZE } from '@/lib/og-helper';
|
||||
|
||||
export const runtime = 'nodejs';
|
||||
|
||||
export default async function Image({ params: { locale } }: { params: { locale: string } }) {
|
||||
const t = await getTranslations({ locale, namespace: 'Blog.meta' });
|
||||
const fonts = await getOgFonts();
|
||||
|
||||
return new ImageResponse(
|
||||
(
|
||||
<OGImageTemplate
|
||||
title={t('title')}
|
||||
description={t('description')}
|
||||
label="Blog"
|
||||
/>
|
||||
),
|
||||
{
|
||||
...OG_IMAGE_SIZE,
|
||||
fonts,
|
||||
}
|
||||
);
|
||||
}
|
||||
@@ -3,6 +3,8 @@ 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';
|
||||
import { SITE_URL } from '@/lib/schema';
|
||||
|
||||
interface BlogIndexProps {
|
||||
params: {
|
||||
@@ -18,15 +20,16 @@ export async function generateMetadata({ params: { locale } }: BlogIndexProps) {
|
||||
alternates: {
|
||||
canonical: `/${locale}/blog`,
|
||||
languages: {
|
||||
'de': '/de/blog',
|
||||
'en': '/en/blog',
|
||||
de: '/de/blog',
|
||||
en: '/en/blog',
|
||||
'x-default': '/en/blog',
|
||||
},
|
||||
},
|
||||
openGraph: {
|
||||
title: `${t('title')} | KLZ Cables`,
|
||||
description: t('description'),
|
||||
url: `https://klz-cables.com/${locale}/blog`,
|
||||
url: `${SITE_URL}/${locale}/blog`,
|
||||
images: getOGImageMetadata('blog', t('title'), locale),
|
||||
},
|
||||
twitter: {
|
||||
card: 'summary_large_image',
|
||||
@@ -39,10 +42,10 @@ export async function generateMetadata({ params: { locale } }: BlogIndexProps) {
|
||||
export default async function BlogIndex({ params: { locale } }: BlogIndexProps) {
|
||||
const t = await getTranslations('Blog');
|
||||
const posts = await getAllPosts(locale);
|
||||
|
||||
|
||||
// Sort posts by date descending
|
||||
const sortedPosts = [...posts].sort((a, b) =>
|
||||
new Date(b.frontmatter.date).getTime() - new Date(a.frontmatter.date).getTime()
|
||||
const sortedPosts = [...posts].sort(
|
||||
(a, b) => new Date(b.frontmatter.date).getTime() - new Date(a.frontmatter.date).getTime(),
|
||||
);
|
||||
|
||||
const featuredPost = sortedPosts[0];
|
||||
@@ -63,21 +66,30 @@ export default async function BlogIndex({ params: { locale } }: BlogIndexProps)
|
||||
<div className="absolute inset-0 image-overlay-gradient" />
|
||||
</>
|
||||
)}
|
||||
|
||||
|
||||
<Container className="relative z-10">
|
||||
<div className="max-w-4xl animate-slide-up">
|
||||
<Badge variant="saturated" className="mb-4 md:mb-6">{t('featuredPost')}</Badge>
|
||||
<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>
|
||||
<Button href={`/${locale}/blog/${featuredPost.slug}`} variant="accent" size="lg" className="group w-full md:w-auto md:h-16 md:px-10 md:text-xl">
|
||||
<Button
|
||||
href={`/${locale}/blog/${featuredPost.slug}`}
|
||||
variant="accent"
|
||||
size="lg"
|
||||
className="group w-full md:w-auto md:h-16 md:px-10 md:text-xl"
|
||||
>
|
||||
{t('readFullArticle')}
|
||||
<span className="ml-3 transition-transform group-hover:translate-x-2">→</span>
|
||||
<span className="ml-3 transition-transform group-hover:translate-x-2">
|
||||
→
|
||||
</span>
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
@@ -95,10 +107,30 @@ export default async function BlogIndex({ params: { locale } }: BlogIndexProps)
|
||||
</Heading>
|
||||
<div className="flex flex-wrap gap-2 md:gap-4">
|
||||
{/* Category filters could go here */}
|
||||
<Badge variant="primary" className="cursor-pointer hover:bg-primary hover:text-white transition-colors touch-target px-3 md:px-4">{t('categories.all')}</Badge>
|
||||
<Badge variant="neutral" className="cursor-pointer hover:bg-primary hover:text-white transition-colors touch-target px-3 md:px-4">{t('categories.industry')}</Badge>
|
||||
<Badge variant="neutral" className="cursor-pointer hover:bg-primary hover:text-white transition-colors touch-target px-3 md:px-4">{t('categories.technical')}</Badge>
|
||||
<Badge variant="neutral" className="cursor-pointer hover:bg-primary hover:text-white transition-colors touch-target px-3 md:px-4">{t('categories.sustainability')}</Badge>
|
||||
<Badge
|
||||
variant="primary"
|
||||
className="cursor-pointer hover:bg-primary hover:text-white transition-colors touch-target px-3 md:px-4"
|
||||
>
|
||||
{t('categories.all')}
|
||||
</Badge>
|
||||
<Badge
|
||||
variant="neutral"
|
||||
className="cursor-pointer hover:bg-primary hover:text-white transition-colors touch-target px-3 md:px-4"
|
||||
>
|
||||
{t('categories.industry')}
|
||||
</Badge>
|
||||
<Badge
|
||||
variant="neutral"
|
||||
className="cursor-pointer hover:bg-primary hover:text-white transition-colors touch-target px-3 md:px-4"
|
||||
>
|
||||
{t('categories.technical')}
|
||||
</Badge>
|
||||
<Badge
|
||||
variant="neutral"
|
||||
className="cursor-pointer hover:bg-primary hover:text-white transition-colors touch-target px-3 md:px-4"
|
||||
>
|
||||
{t('categories.sustainability')}
|
||||
</Badge>
|
||||
</div>
|
||||
</div>
|
||||
</Reveal>
|
||||
@@ -118,7 +150,10 @@ export default async function BlogIndex({ params: { locale } }: BlogIndexProps)
|
||||
/>
|
||||
<div className="absolute inset-0 image-overlay-gradient opacity-0 group-hover:opacity-100 transition-opacity duration-500" />
|
||||
{post.frontmatter.category && (
|
||||
<Badge variant="accent" className="absolute top-3 left-3 md:top-6 md:left-6 shadow-lg">
|
||||
<Badge
|
||||
variant="accent"
|
||||
className="absolute top-3 left-3 md:top-6 md:left-6 shadow-lg"
|
||||
>
|
||||
{post.frontmatter.category}
|
||||
</Badge>
|
||||
)}
|
||||
@@ -129,7 +164,7 @@ export default async function BlogIndex({ params: { locale } }: BlogIndexProps)
|
||||
{new Date(post.frontmatter.date).toLocaleDateString(locale, {
|
||||
year: 'numeric',
|
||||
month: 'long',
|
||||
day: 'numeric'
|
||||
day: 'numeric',
|
||||
})}
|
||||
</div>
|
||||
<h3 className="text-lg md:text-2xl font-bold text-primary mb-3 md:mb-6 group-hover:text-accent-dark transition-colors line-clamp-2 leading-tight">
|
||||
@@ -143,8 +178,18 @@ export default async function BlogIndex({ params: { locale } }: BlogIndexProps)
|
||||
{t('readMore')}
|
||||
</span>
|
||||
<div className="w-8 h-8 md:w-10 md:h-10 rounded-full bg-primary-light flex items-center justify-center text-saturated group-hover:bg-accent group-hover:text-primary-dark transition-all duration-300">
|
||||
<svg className="w-4 h-4 md:w-5 md:h-5 transition-transform group-hover:translate-x-1" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M17 8l4 4m0 0l-4 4m4-4H3" />
|
||||
<svg
|
||||
className="w-4 h-4 md:w-5 md:h-5 transition-transform group-hover:translate-x-1"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2}
|
||||
d="M17 8l4 4m0 0l-4 4m4-4H3"
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
</div>
|
||||
@@ -154,13 +199,21 @@ export default async function BlogIndex({ params: { locale } }: BlogIndexProps)
|
||||
</Reveal>
|
||||
))}
|
||||
</div>
|
||||
|
||||
|
||||
{/* Pagination Placeholder */}
|
||||
<div className="mt-12 md:mt-24 flex justify-center gap-2 md:gap-4">
|
||||
<Button variant="outline" size="sm" className="md:h-11 md:px-6 md:text-base" disabled>{t('prev')}</Button>
|
||||
<Button variant="primary" size="sm" className="md:h-11 md:px-6 md:text-base">1</Button>
|
||||
<Button variant="outline" size="sm" className="md:h-11 md:px-6 md:text-base">2</Button>
|
||||
<Button variant="outline" size="sm" className="md:h-11 md:px-6 md:text-base">{t('next')}</Button>
|
||||
<Button variant="outline" size="sm" className="md:h-11 md:px-6 md:text-base" disabled>
|
||||
{t('prev')}
|
||||
</Button>
|
||||
<Button variant="primary" size="sm" className="md:h-11 md:px-6 md:text-base">
|
||||
1
|
||||
</Button>
|
||||
<Button variant="outline" size="sm" className="md:h-11 md:px-6 md:text-base">
|
||||
2
|
||||
</Button>
|
||||
<Button variant="outline" size="sm" className="md:h-11 md:px-6 md:text-base">
|
||||
{t('next')}
|
||||
</Button>
|
||||
</div>
|
||||
</Container>
|
||||
</Section>
|
||||
|
||||
@@ -1,11 +1,14 @@
|
||||
import { ImageResponse } from 'next/og';
|
||||
import { getTranslations } from 'next-intl/server';
|
||||
import { OGImageTemplate } from '@/components/OGImageTemplate';
|
||||
import { getOgFonts, OG_IMAGE_SIZE } from '@/lib/og-helper';
|
||||
|
||||
export const runtime = 'nodejs';
|
||||
|
||||
export default async function Image({ params: { locale } }: { params: { locale: string } }) {
|
||||
const t = await getTranslations({ locale, namespace: 'Contact' });
|
||||
const fonts = await getOgFonts();
|
||||
|
||||
const title = t('meta.title') || t('title');
|
||||
const description = t('meta.description') || t('subtitle');
|
||||
|
||||
@@ -18,8 +21,8 @@ export default async function Image({ params: { locale } }: { params: { locale:
|
||||
/>
|
||||
),
|
||||
{
|
||||
width: 1200,
|
||||
height: 630,
|
||||
...OG_IMAGE_SIZE,
|
||||
fonts,
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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'), {
|
||||
@@ -21,7 +23,9 @@ interface ContactPageProps {
|
||||
};
|
||||
}
|
||||
|
||||
export async function generateMetadata({ params: { locale } }: ContactPageProps): Promise<Metadata> {
|
||||
export async function generateMetadata({
|
||||
params: { locale },
|
||||
}: ContactPageProps): Promise<Metadata> {
|
||||
const t = await getTranslations({ locale, namespace: 'Contact' });
|
||||
const title = t('meta.title') || t('title');
|
||||
const description = t('meta.description') || t('subtitle');
|
||||
@@ -29,7 +33,7 @@ export async function generateMetadata({ params: { locale } }: ContactPageProps)
|
||||
title,
|
||||
description,
|
||||
alternates: {
|
||||
canonical: `https://klz-cables.com/${locale}/contact`,
|
||||
canonical: `${SITE_URL}/${locale}/contact`,
|
||||
languages: {
|
||||
'de-DE': '/de/contact',
|
||||
'en-US': '/en/contact',
|
||||
@@ -38,16 +42,9 @@ export async function generateMetadata({ params: { locale } }: ContactPageProps)
|
||||
openGraph: {
|
||||
title: `${title} | KLZ Cables`,
|
||||
description,
|
||||
url: `https://klz-cables.com/${locale}/contact`,
|
||||
url: `${SITE_URL}/${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 +52,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,
|
||||
@@ -83,7 +80,7 @@ export default async function ContactPage({ params }: ContactPageProps) {
|
||||
'@type': 'ListItem',
|
||||
position: 1,
|
||||
name: t('title'),
|
||||
item: `https://klz-cables.com/${locale}/contact`,
|
||||
item: `${SITE_URL}/${locale}/contact`,
|
||||
},
|
||||
],
|
||||
}}
|
||||
@@ -94,9 +91,9 @@ export default async function ContactPage({ params }: ContactPageProps) {
|
||||
'@context': 'https://schema.org',
|
||||
'@type': 'LocalBusiness',
|
||||
name: 'KLZ Cables',
|
||||
image: 'https://klz-cables.com/logo.png',
|
||||
'@id': 'https://klz-cables.com',
|
||||
url: 'https://klz-cables.com',
|
||||
image: `${SITE_URL}/logo.png`,
|
||||
'@id': SITE_URL,
|
||||
url: SITE_URL,
|
||||
address: {
|
||||
'@type': 'PostalAddress',
|
||||
streetAddress: 'Raiffeisenstraße 22',
|
||||
@@ -112,20 +109,12 @@ export default async function ContactPage({ params }: ContactPageProps) {
|
||||
openingHoursSpecification: [
|
||||
{
|
||||
'@type': 'OpeningHoursSpecification',
|
||||
dayOfWeek: [
|
||||
'Monday',
|
||||
'Tuesday',
|
||||
'Wednesday',
|
||||
'Thursday',
|
||||
'Friday'
|
||||
],
|
||||
dayOfWeek: ['Monday', 'Tuesday', 'Wednesday', 'Thursday', 'Friday'],
|
||||
opens: '08:00',
|
||||
closes: '17:00'
|
||||
}
|
||||
closes: '17:00',
|
||||
},
|
||||
],
|
||||
sameAs: [
|
||||
'https://www.linkedin.com/company/klz-cables'
|
||||
]
|
||||
sameAs: ['https://www.linkedin.com/company/klz-cables'],
|
||||
}}
|
||||
/>
|
||||
{/* Hero Section */}
|
||||
@@ -159,36 +148,71 @@ export default async function ContactPage({ params }: ContactPageProps) {
|
||||
<div className="space-y-4 md:space-y-8">
|
||||
<div className="flex items-start gap-4 md:gap-6 group">
|
||||
<div className="w-10 h-10 md:w-14 md:h-14 rounded-xl md:rounded-2xl bg-saturated/10 flex items-center justify-center text-saturated group-hover:bg-accent group-hover:text-primary-dark transition-all duration-300 shadow-sm flex-shrink-0">
|
||||
<svg className="w-5 h-5 md:w-7 md:h-7" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M17.657 16.657L13.414 20.9a1.998 1.998 0 01-2.827 0l-4.244-4.243a8 8 0 1111.314 0z" />
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M15 11a3 3 0 11-6 0 3 3 0 016 0z" />
|
||||
<svg
|
||||
className="w-5 h-5 md:w-7 md:h-7"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor"
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2}
|
||||
d="M17.657 16.657L13.414 20.9a1.998 1.998 0 01-2.827 0l-4.244-4.243a8 8 0 1111.314 0z"
|
||||
/>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2}
|
||||
d="M15 11a3 3 0 11-6 0 3 3 0 016 0z"
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
<div>
|
||||
<h4 className="text-base md:text-xl font-bold text-primary mb-1 md:mb-2">{t('info.office')}</h4>
|
||||
<h4 className="text-base md:text-xl font-bold text-primary mb-1 md:mb-2">
|
||||
{t('info.office')}
|
||||
</h4>
|
||||
<p className="text-sm md:text-lg text-text-secondary leading-relaxed whitespace-pre-line">
|
||||
{t('info.address')}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
<div className="flex items-start gap-4 md:gap-6 group">
|
||||
<div className="w-10 h-10 md:w-14 md:h-14 rounded-xl md:rounded-2xl bg-saturated/10 flex items-center justify-center text-saturated group-hover:bg-accent group-hover:text-primary-dark transition-all duration-300 shadow-sm flex-shrink-0">
|
||||
<svg className="w-5 h-5 md:w-7 md:h-7" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M3 8l7.89 5.26a2 2 0 002.22 0L21 8M5 19h14a2 2 0 002-2V7a2 2 0 00-2-2H5a2 2 0 00-2 2v10a2 2 0 002 2z" />
|
||||
<svg
|
||||
className="w-5 h-5 md:w-7 md:h-7"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor"
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2}
|
||||
d="M3 8l7.89 5.26a2 2 0 002.22 0L21 8M5 19h14a2 2 0 002-2V7a2 2 0 00-2-2H5a2 2 0 00-2 2v10a2 2 0 002 2z"
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
<div>
|
||||
<h4 className="text-base md:text-xl font-bold text-primary mb-1 md:mb-2">{t('info.email')}</h4>
|
||||
<a href="mailto:info@klz-cables.com" className="text-sm md:text-lg text-text-secondary hover:text-primary transition-colors font-medium touch-target">info@klz-cables.com</a>
|
||||
<h4 className="text-base md:text-xl font-bold text-primary mb-1 md:mb-2">
|
||||
{t('info.email')}
|
||||
</h4>
|
||||
<a
|
||||
href="mailto:info@klz-cables.com"
|
||||
className="text-sm md:text-lg text-text-secondary hover:text-primary transition-colors font-medium touch-target"
|
||||
>
|
||||
info@klz-cables.com
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="p-6 md:p-10 bg-white rounded-2xl md:rounded-3xl border border-neutral-medium shadow-sm animate-fade-in">
|
||||
<Heading level={4} className="mb-4 md:mb-6">{t('hours.title')}</Heading>
|
||||
<Heading level={4} className="mb-4 md:mb-6">
|
||||
{t('hours.title')}
|
||||
</Heading>
|
||||
<ul className="space-y-2 md:space-y-4 list-none m-0 p-0">
|
||||
<li className="flex justify-between items-center pb-2 md:pb-4 border-b border-neutral-medium text-sm md:text-base">
|
||||
<span className="font-bold text-primary">{t('hours.weekdays')}</span>
|
||||
@@ -204,24 +228,28 @@ export default async function ContactPage({ params }: ContactPageProps) {
|
||||
|
||||
{/* Contact Form */}
|
||||
<div className="lg:col-span-7">
|
||||
<Suspense fallback={<div className="animate-pulse bg-neutral-medium h-96 rounded-2xl md:rounded-3xl"></div>}>
|
||||
<Suspense
|
||||
fallback={
|
||||
<div className="animate-pulse bg-neutral-medium h-96 rounded-2xl md:rounded-3xl"></div>
|
||||
}
|
||||
>
|
||||
<ContactForm />
|
||||
</Suspense>
|
||||
</div>
|
||||
</div>
|
||||
</Container>
|
||||
</Section>
|
||||
|
||||
|
||||
{/* Map Section */}
|
||||
<section className="h-[300px] md:h-[500px] bg-neutral-medium relative overflow-hidden grayscale hover:grayscale-0 transition-all duration-1000">
|
||||
<Suspense fallback={<div className="h-full w-full bg-neutral-medium animate-pulse flex items-center justify-center">
|
||||
<div className="text-primary font-medium">Loading Map...</div>
|
||||
</div>}>
|
||||
<LeafletMap
|
||||
address={t('info.address')}
|
||||
lat={48.8144}
|
||||
lng={9.4144}
|
||||
/>
|
||||
<Suspense
|
||||
fallback={
|
||||
<div className="h-full w-full bg-neutral-medium animate-pulse flex items-center justify-center">
|
||||
<div className="text-primary font-medium">Loading Map...</div>
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<LeafletMap address={t('info.address')} lat={48.8144} lng={9.4144} />
|
||||
</Suspense>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
@@ -2,11 +2,24 @@ 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 CMSConnectivityNotice from '@/components/CMSConnectivityNotice';
|
||||
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';
|
||||
import { config } from '@/lib/config';
|
||||
|
||||
export const metadata: Metadata = {
|
||||
metadataBase: new URL(SITE_URL),
|
||||
icons: {
|
||||
icon: [
|
||||
{ url: '/favicon.ico', sizes: 'any' },
|
||||
{ url: '/logo-blue.svg', type: 'image/svg+xml' },
|
||||
],
|
||||
apple: [{ url: '/apple-touch-icon.png', sizes: '180x180', type: 'image/png' }],
|
||||
},
|
||||
};
|
||||
|
||||
export const viewport: Viewport = {
|
||||
width: 'device-width',
|
||||
@@ -16,31 +29,30 @@ export const viewport: Viewport = {
|
||||
viewportFit: 'cover',
|
||||
themeColor: '#001a4d',
|
||||
};
|
||||
|
||||
|
||||
export default async function LocaleLayout({
|
||||
children,
|
||||
params: {locale}
|
||||
params: { locale },
|
||||
}: {
|
||||
children: React.ReactNode;
|
||||
params: {locale: string};
|
||||
params: { locale: string };
|
||||
}) {
|
||||
// Providing all messages to the client
|
||||
// side is the easiest way to get started
|
||||
const messages = await getMessages();
|
||||
|
||||
|
||||
return (
|
||||
<html lang={locale} className="scroll-smooth overflow-x-hidden">
|
||||
<body className="flex flex-col min-h-screen font-sans selection:bg-accent selection:text-primary-dark antialiased overflow-x-hidden">
|
||||
<NextIntlClientProvider messages={messages} locale={locale}>
|
||||
<JsonLd />
|
||||
<Header />
|
||||
<main className="flex-grow animate-fade-in overflow-visible">
|
||||
{children}
|
||||
</main>
|
||||
<main className="flex-grow animate-fade-in overflow-visible">{children}</main>
|
||||
<Footer />
|
||||
|
||||
<CMSConnectivityNotice />
|
||||
|
||||
{/* Sends pageviews for client-side navigations */}
|
||||
<AnalyticsProvider />
|
||||
<AnalyticsProvider websiteId={config.analytics.umami.websiteId} />
|
||||
</NextIntlClientProvider>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
@@ -1,11 +1,13 @@
|
||||
import { ImageResponse } from 'next/og';
|
||||
import { getTranslations } from 'next-intl/server';
|
||||
import { OGImageTemplate } from '@/components/OGImageTemplate';
|
||||
import { getOgFonts, OG_IMAGE_SIZE } from '@/lib/og-helper';
|
||||
|
||||
export const runtime = 'edge';
|
||||
export const runtime = 'nodejs';
|
||||
|
||||
export default async function Image({ params: { locale } }: { params: { locale: string } }) {
|
||||
const t = await getTranslations({ locale, namespace: 'Index.meta' });
|
||||
const fonts = await getOgFonts();
|
||||
|
||||
return new ImageResponse(
|
||||
(
|
||||
@@ -16,8 +18,9 @@ export default async function Image({ params: { locale } }: { params: { locale:
|
||||
/>
|
||||
),
|
||||
{
|
||||
width: 1200,
|
||||
height: 630,
|
||||
...OG_IMAGE_SIZE,
|
||||
fonts,
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -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,31 +13,52 @@ 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 (
|
||||
<div className="flex flex-col min-h-screen">
|
||||
<JsonLd
|
||||
id="breadcrumb-home"
|
||||
data={getBreadcrumbSchema([
|
||||
{ name: 'Home', item: `/${locale}` },
|
||||
])}
|
||||
data={getBreadcrumbSchema([{ name: 'Home', item: `/${locale}` }])}
|
||||
/>
|
||||
<Hero />
|
||||
<Reveal><ProductCategories /></Reveal>
|
||||
<Reveal><WhatWeDo /></Reveal>
|
||||
<Reveal><RecentPosts locale={locale} /></Reveal>
|
||||
<Reveal><Experience /></Reveal>
|
||||
<Reveal><WhyChooseUs /></Reveal>
|
||||
<Reveal><MeetTheTeam /></Reveal>
|
||||
<Reveal><GallerySection /></Reveal>
|
||||
<Reveal><VideoSection /></Reveal>
|
||||
<Reveal><CTA /></Reveal>
|
||||
<Reveal>
|
||||
<ProductCategories />
|
||||
</Reveal>
|
||||
<Reveal>
|
||||
<WhatWeDo />
|
||||
</Reveal>
|
||||
<Reveal>
|
||||
<RecentPosts locale={locale} />
|
||||
</Reveal>
|
||||
<Reveal>
|
||||
<Experience />
|
||||
</Reveal>
|
||||
<Reveal>
|
||||
<WhyChooseUs />
|
||||
</Reveal>
|
||||
<Reveal>
|
||||
<MeetTheTeam />
|
||||
</Reveal>
|
||||
<Reveal>
|
||||
<GallerySection />
|
||||
</Reveal>
|
||||
<Reveal>
|
||||
<VideoSection />
|
||||
</Reveal>
|
||||
<Reveal>
|
||||
<CTA />
|
||||
</Reveal>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export async function generateMetadata({ params: { locale } }: { params: { locale: string } }): Promise<Metadata> {
|
||||
export async function generateMetadata({
|
||||
params: { locale },
|
||||
}: {
|
||||
params: { locale: string };
|
||||
}): Promise<Metadata> {
|
||||
// Use translations for meta where available (namespace: Index.meta)
|
||||
// Fallback to a sensible default if translation keys are missing.
|
||||
let t;
|
||||
@@ -61,15 +82,16 @@ export async function generateMetadata({ params: { locale } }: { params: { local
|
||||
alternates: {
|
||||
canonical: `/${locale}`,
|
||||
languages: {
|
||||
'de': '/de',
|
||||
'en': '/en',
|
||||
de: '/de',
|
||||
en: '/en',
|
||||
'x-default': '/en',
|
||||
},
|
||||
},
|
||||
openGraph: {
|
||||
title: `${title} | KLZ Cables`,
|
||||
description,
|
||||
url: `https://klz-cables.com/${locale}`,
|
||||
url: `${SITE_URL}/${locale}`,
|
||||
images: getOGImageMetadata('', title, locale),
|
||||
},
|
||||
twitter: {
|
||||
card: 'summary_large_image',
|
||||
|
||||
@@ -5,12 +5,14 @@ import ProductSidebar from '@/components/ProductSidebar';
|
||||
import ProductTabs from '@/components/ProductTabs';
|
||||
import ProductTechnicalData from '@/components/ProductTechnicalData';
|
||||
import RelatedProducts from '@/components/RelatedProducts';
|
||||
import { Badge, Container, Section } from '@/components/ui';
|
||||
import DatasheetDownload from '@/components/DatasheetDownload';
|
||||
import { Badge, Container, Heading, Section } from '@/components/ui';
|
||||
import { getDatasheetPath } from '@/lib/datasheets';
|
||||
import { getAllProducts, getProductBySlug } from '@/lib/mdx';
|
||||
import { mapFileSlugToTranslated, mapSlugToFileSlug } from '@/lib/slugs';
|
||||
import { Metadata } from 'next';
|
||||
import { getTranslations } from 'next-intl/server';
|
||||
import { getProductOGImageMetadata } from '@/lib/metadata';
|
||||
import { MDXRemote } from 'next-mdx-remote/rsc';
|
||||
import Image from 'next/image';
|
||||
import Link from 'next/link';
|
||||
@@ -29,12 +31,23 @@ export async function generateMetadata({ params }: ProductPageProps): Promise<Me
|
||||
const t = await getTranslations('Products');
|
||||
|
||||
// Check if it's a category page
|
||||
const categories = ['low-voltage-cables', 'medium-voltage-cables', 'high-voltage-cables', 'solar-cables'];
|
||||
const categories = [
|
||||
'low-voltage-cables',
|
||||
'medium-voltage-cables',
|
||||
'high-voltage-cables',
|
||||
'solar-cables',
|
||||
];
|
||||
const fileSlug = await mapSlugToFileSlug(productSlug, locale);
|
||||
if (categories.includes(fileSlug)) {
|
||||
const categoryKey = fileSlug.replace(/-cables$/, '').replace(/-([a-z])/g, (g) => g[1].toUpperCase());
|
||||
const categoryTitle = t.has(`categories.${categoryKey}.title`) ? t(`categories.${categoryKey}.title`) : fileSlug;
|
||||
const categoryDesc = t.has(`categories.${categoryKey}.description`) ? t(`categories.${categoryKey}.description`) : '';
|
||||
const categoryKey = fileSlug
|
||||
.replace(/-cables$/, '')
|
||||
.replace(/-([a-z])/g, (g) => g[1].toUpperCase());
|
||||
const categoryTitle = t.has(`categories.${categoryKey}.title`)
|
||||
? t(`categories.${categoryKey}.title`)
|
||||
: fileSlug;
|
||||
const categoryDesc = t.has(`categories.${categoryKey}.description`)
|
||||
? t(`categories.${categoryKey}.description`)
|
||||
: '';
|
||||
|
||||
return {
|
||||
title: categoryTitle,
|
||||
@@ -42,15 +55,16 @@ export async function generateMetadata({ params }: ProductPageProps): Promise<Me
|
||||
alternates: {
|
||||
canonical: `/${locale}/products/${productSlug}`,
|
||||
languages: {
|
||||
'de': `/de/products/${await mapFileSlugToTranslated(productSlug, 'de')}`,
|
||||
'en': `/en/products/${await mapFileSlugToTranslated(productSlug, 'en')}`,
|
||||
de: `/de/products/${await mapFileSlugToTranslated(productSlug, 'de')}`,
|
||||
en: `/en/products/${await mapFileSlugToTranslated(productSlug, 'en')}`,
|
||||
'x-default': `/en/products/${await mapFileSlugToTranslated(productSlug, 'en')}`,
|
||||
},
|
||||
},
|
||||
openGraph: {
|
||||
title: `${categoryTitle} | KLZ Cables`,
|
||||
description: categoryDesc,
|
||||
url: `https://klz-cables.com/${locale}/products/${productSlug}`,
|
||||
url: `${SITE_URL}/${locale}/products/${productSlug}`,
|
||||
images: getProductOGImageMetadata(fileSlug, categoryTitle, locale),
|
||||
},
|
||||
twitter: {
|
||||
card: 'summary_large_image',
|
||||
@@ -69,8 +83,8 @@ export async function generateMetadata({ params }: ProductPageProps): Promise<Me
|
||||
alternates: {
|
||||
canonical: `/${locale}/products/${slug.join('/')}`,
|
||||
languages: {
|
||||
'de': `/de/products/${await mapFileSlugToTranslated(slug[0], 'de')}/${await mapFileSlugToTranslated(productSlug, 'de')}`,
|
||||
'en': `/en/products/${await mapFileSlugToTranslated(slug[0], 'en')}/${await mapFileSlugToTranslated(productSlug, 'en')}`,
|
||||
de: `/de/products/${await mapFileSlugToTranslated(slug[0], 'de')}/${await mapFileSlugToTranslated(productSlug, 'de')}`,
|
||||
en: `/en/products/${await mapFileSlugToTranslated(slug[0], 'en')}/${await mapFileSlugToTranslated(productSlug, 'en')}`,
|
||||
'x-default': `/en/products/${await mapFileSlugToTranslated(slug[0], 'en')}/${await mapFileSlugToTranslated(productSlug, 'en')}`,
|
||||
},
|
||||
},
|
||||
@@ -78,7 +92,8 @@ export async function generateMetadata({ params }: ProductPageProps): Promise<Me
|
||||
title: `${product.frontmatter.title} | KLZ Cables`,
|
||||
description: product.frontmatter.description,
|
||||
type: 'website',
|
||||
url: `https://klz-cables.com/${locale}/products/${slug.join('/')}`,
|
||||
url: `${SITE_URL}/${locale}/products/${slug.join('/')}`,
|
||||
images: getProductOGImageMetadata(productSlug, product.frontmatter.title, locale),
|
||||
},
|
||||
twitter: {
|
||||
card: 'summary_large_image',
|
||||
@@ -91,20 +106,36 @@ export async function generateMetadata({ params }: ProductPageProps): Promise<Me
|
||||
const components = {
|
||||
ProductTechnicalData,
|
||||
ProductTabs,
|
||||
p: (props: any) => <p {...props} className="text-lg md:text-xl text-text-secondary leading-relaxed mb-8 font-medium" />,
|
||||
p: (props: any) => (
|
||||
<p
|
||||
{...props}
|
||||
className="text-lg md:text-xl text-text-secondary leading-relaxed mb-8 font-medium"
|
||||
/>
|
||||
),
|
||||
h2: (props: any) => (
|
||||
<div className="relative mb-16">
|
||||
<h2 {...props} className="text-3xl md:text-4xl font-black text-primary tracking-tighter uppercase mb-6" />
|
||||
<h2
|
||||
{...props}
|
||||
className="text-3xl md:text-4xl font-black text-primary tracking-tighter uppercase mb-6"
|
||||
/>
|
||||
<div className="w-20 h-1.5 bg-accent rounded-full" />
|
||||
</div>
|
||||
),
|
||||
h3: (props: any) => <h3 {...props} className="text-2xl md:text-3xl font-black text-primary mb-10 tracking-tight uppercase" />,
|
||||
h3: (props: any) => (
|
||||
<h3
|
||||
{...props}
|
||||
className="text-2xl md:text-3xl font-black text-primary mb-10 tracking-tight uppercase"
|
||||
/>
|
||||
),
|
||||
ul: (props: any) => <ul {...props} className="list-none pl-0 mb-10" />,
|
||||
section: (props: any) => <div {...props} className="block" />,
|
||||
li: (props: any) => (
|
||||
<li className="flex items-start gap-4 group mb-4 last:mb-0">
|
||||
<div className="mt-2.5 w-2 h-2 rounded-full bg-accent flex-shrink-0 group-hover:scale-125 transition-transform" />
|
||||
<span {...props} className="text-lg md:text-xl text-text-secondary leading-relaxed font-medium" />
|
||||
<span
|
||||
{...props}
|
||||
className="text-lg md:text-xl text-text-secondary leading-relaxed font-medium"
|
||||
/>
|
||||
</li>
|
||||
),
|
||||
strong: (props: any) => <strong {...props} className="font-black text-primary" />,
|
||||
@@ -113,13 +144,26 @@ const components = {
|
||||
<table {...props} className="min-w-full divide-y divide-neutral-dark/10" />
|
||||
</div>
|
||||
),
|
||||
th: (props: any) => <th {...props} className="px-8 py-6 bg-neutral-light/50 text-left text-[10px] font-black uppercase tracking-[0.25em] text-primary/60" />,
|
||||
td: (props: any) => <td {...props} className="px-8 py-6 text-text-secondary border-t border-neutral-dark/5 text-lg md:text-xl font-medium" />,
|
||||
th: (props: any) => (
|
||||
<th
|
||||
{...props}
|
||||
className="px-8 py-6 bg-neutral-light/50 text-left text-[10px] font-black uppercase tracking-[0.25em] text-primary/60"
|
||||
/>
|
||||
),
|
||||
td: (props: any) => (
|
||||
<td
|
||||
{...props}
|
||||
className="px-8 py-6 text-text-secondary border-t border-neutral-dark/5 text-lg md:text-xl font-medium"
|
||||
/>
|
||||
),
|
||||
hr: () => <hr className="my-24 border-t-2 border-neutral-dark/5" />,
|
||||
blockquote: (props: any) => (
|
||||
<div className="my-20 p-10 md:p-16 bg-primary-dark rounded-[40px] relative overflow-hidden group">
|
||||
<div className="absolute top-0 right-0 w-64 h-64 bg-accent/10 rounded-full -translate-y-1/2 translate-x-1/2 blur-3xl group-hover:bg-accent/20 transition-colors duration-700" />
|
||||
<div className="relative z-10 italic text-2xl md:text-3xl text-white/90 leading-relaxed font-black tracking-tight" {...props} />
|
||||
<div
|
||||
className="relative z-10 italic text-2xl md:text-3xl text-white/90 leading-relaxed font-black tracking-tight"
|
||||
{...props}
|
||||
/>
|
||||
</div>
|
||||
),
|
||||
};
|
||||
@@ -130,28 +174,36 @@ export default async function ProductPage({ params }: ProductPageProps) {
|
||||
const t = await getTranslations('Products');
|
||||
|
||||
// Check if it's a category page
|
||||
const categories = ['low-voltage-cables', 'medium-voltage-cables', 'high-voltage-cables', 'solar-cables'];
|
||||
const categories = [
|
||||
'low-voltage-cables',
|
||||
'medium-voltage-cables',
|
||||
'high-voltage-cables',
|
||||
'solar-cables',
|
||||
];
|
||||
const fileSlug = await mapSlugToFileSlug(productSlug, locale);
|
||||
|
||||
|
||||
if (categories.includes(fileSlug)) {
|
||||
const allProducts = await getAllProducts(locale);
|
||||
const categoryKey = fileSlug.replace(/-cables$/, '').replace(/-([a-z])/g, (g) => g[1].toUpperCase());
|
||||
const categoryTitle = t.has(`categories.${categoryKey}.title`) ? t(`categories.${categoryKey}.title`) : fileSlug;
|
||||
|
||||
const categoryKey = fileSlug
|
||||
.replace(/-cables$/, '')
|
||||
.replace(/-([a-z])/g, (g) => g[1].toUpperCase());
|
||||
const categoryTitle = t.has(`categories.${categoryKey}.title`)
|
||||
? t(`categories.${categoryKey}.title`)
|
||||
: fileSlug;
|
||||
|
||||
// Filter products for this category
|
||||
const filteredProducts = allProducts.filter(p =>
|
||||
p.frontmatter.categories.some(cat =>
|
||||
cat.toLowerCase().replace(/\s+/g, '-') === fileSlug ||
|
||||
cat === categoryTitle
|
||||
)
|
||||
const filteredProducts = allProducts.filter((p) =>
|
||||
p.frontmatter.categories.some(
|
||||
(cat) => cat.toLowerCase().replace(/\s+/g, '-') === fileSlug || cat === categoryTitle,
|
||||
),
|
||||
);
|
||||
|
||||
// Get translated product slugs
|
||||
const productsWithTranslatedSlugs = await Promise.all(
|
||||
filteredProducts.map(async (p) => ({
|
||||
...p,
|
||||
translatedSlug: await mapFileSlugToTranslated(p.slug, locale)
|
||||
}))
|
||||
translatedSlug: await mapFileSlugToTranslated(p.slug, locale),
|
||||
})),
|
||||
);
|
||||
|
||||
return (
|
||||
@@ -160,13 +212,15 @@ export default async function ProductPage({ params }: ProductPageProps) {
|
||||
<Container className="relative z-10">
|
||||
<div className="max-w-4xl animate-slide-up">
|
||||
<nav className="flex items-center mb-8 text-white/40 text-sm font-bold uppercase tracking-widest">
|
||||
<Link href={`/${locale}/products`} className="hover:text-accent transition-colors">{t('title')}</Link>
|
||||
<Link href={`/${locale}/products`} className="hover:text-accent transition-colors">
|
||||
{t('title')}
|
||||
</Link>
|
||||
<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>
|
||||
@@ -198,7 +252,10 @@ export default async function ProductPage({ params }: ProductPageProps) {
|
||||
<div className="p-8 md:p-10">
|
||||
<div className="flex flex-wrap gap-2 mb-4">
|
||||
{product.frontmatter.categories.map((cat, i) => (
|
||||
<span key={i} className="text-[10px] font-bold uppercase tracking-widest text-primary/40">
|
||||
<span
|
||||
key={i}
|
||||
className="text-[10px] font-bold uppercase tracking-widest text-primary/40"
|
||||
>
|
||||
{cat}
|
||||
</span>
|
||||
))}
|
||||
@@ -213,8 +270,18 @@ export default async function ProductPage({ params }: ProductPageProps) {
|
||||
<span className="border-b-2 border-primary/10 group-hover:border-accent-dark transition-colors pb-1">
|
||||
{t('details')}
|
||||
</span>
|
||||
<svg className="w-5 h-5 ml-3 transition-transform group-hover:translate-x-1" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M17 8l4 4m0 0l-4 4m4-4H3" />
|
||||
<svg
|
||||
className="w-5 h-5 ml-3 transition-transform group-hover:translate-x-1"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2}
|
||||
d="M17 8l4 4m0 0l-4 4m4-4H3"
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
</div>
|
||||
@@ -234,7 +301,9 @@ export default async function ProductPage({ params }: ProductPageProps) {
|
||||
}
|
||||
|
||||
// Extract technical data for schema
|
||||
const technicalDataMatch = product.content.match(/technicalData=\{<ProductTechnicalData data=\{(.*?)\}\s*\/>\}/s);
|
||||
const technicalDataMatch = product.content.match(
|
||||
/technicalData=\{<ProductTechnicalData data=\{(.*?)\}\s*\/>\}/s,
|
||||
);
|
||||
let technicalItems = [];
|
||||
if (technicalDataMatch) {
|
||||
try {
|
||||
@@ -249,11 +318,15 @@ export default async function ProductPage({ params }: ProductPageProps) {
|
||||
const isFallback = (product.frontmatter as any).isFallback;
|
||||
const categorySlug = slug[0];
|
||||
const categoryFileSlug = await mapSlugToFileSlug(categorySlug, locale);
|
||||
const categoryKey = categoryFileSlug.replace(/-cables$/, '').replace(/-([a-z])/g, (g) => g[1].toUpperCase());
|
||||
const categoryTitle = t.has(`categories.${categoryKey}.title`) ? t(`categories.${categoryKey}.title`) : categoryFileSlug;
|
||||
const categoryKey = categoryFileSlug
|
||||
.replace(/-cables$/, '')
|
||||
.replace(/-([a-z])/g, (g) => g[1].toUpperCase());
|
||||
const categoryTitle = t.has(`categories.${categoryKey}.title`)
|
||||
? t(`categories.${categoryKey}.title`)
|
||||
: categoryFileSlug;
|
||||
|
||||
const sidebar = (
|
||||
<ProductSidebar
|
||||
<ProductSidebar
|
||||
productName={product.frontmatter.title}
|
||||
productImage={product.frontmatter.images?.[0]}
|
||||
datasheetPath={datasheetPath}
|
||||
@@ -283,17 +356,24 @@ export default async function ProductPage({ params }: ProductPageProps) {
|
||||
{/* Background Decorative Elements */}
|
||||
<div className="absolute top-0 right-0 w-1/2 h-full bg-gradient-to-l from-accent/5 to-transparent pointer-events-none" />
|
||||
<div className="absolute -top-24 -right-24 w-96 h-96 bg-accent/10 rounded-full blur-3xl pointer-events-none" />
|
||||
|
||||
|
||||
<Container className="relative z-10">
|
||||
<div className="max-w-4xl animate-slide-up">
|
||||
<nav className="flex items-center mb-12 text-white/40 text-[10px] font-black uppercase tracking-[0.2em]">
|
||||
<Link href={`/${locale}/products`} className="hover:text-accent transition-colors">{t('title')}</Link>
|
||||
<Link href={`/${locale}/products`} className="hover:text-accent transition-colors">
|
||||
{t('title')}
|
||||
</Link>
|
||||
<span className="mx-4 opacity-20">/</span>
|
||||
<Link href={`/${locale}/products/${categorySlug}`} className="hover:text-accent transition-colors">{categoryTitle}</Link>
|
||||
<Link
|
||||
href={`/${locale}/products/${categorySlug}`}
|
||||
className="hover:text-accent transition-colors"
|
||||
>
|
||||
{categoryTitle}
|
||||
</Link>
|
||||
<span className="mx-4 opacity-20">/</span>
|
||||
<span className="text-white/90">{product.frontmatter.title}</span>
|
||||
</nav>
|
||||
|
||||
|
||||
<div className="flex flex-col lg:flex-row lg:items-center justify-between gap-12">
|
||||
<div className="flex-1">
|
||||
{isFallback && (
|
||||
@@ -304,14 +384,18 @@ export default async function ProductPage({ params }: ProductPageProps) {
|
||||
)}
|
||||
<div className="flex flex-wrap gap-3 mb-8">
|
||||
{product.frontmatter.categories.map((cat, idx) => (
|
||||
<Badge key={idx} variant="accent" className="bg-white/5 text-white/80 border-white/10 backdrop-blur-md px-5 py-2 text-[10px] font-black tracking-[0.15em]">
|
||||
<Badge
|
||||
key={idx}
|
||||
variant="accent"
|
||||
className="bg-white/5 text-white/80 border-white/10 backdrop-blur-md px-5 py-2 text-[10px] font-black tracking-[0.15em]"
|
||||
>
|
||||
{cat}
|
||||
</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>
|
||||
@@ -325,11 +409,14 @@ export default async function ProductPage({ params }: ProductPageProps) {
|
||||
<Container className="relative">
|
||||
{/* Large Product Image Section */}
|
||||
{product.frontmatter.images && product.frontmatter.images.length > 0 && (
|
||||
<div className="relative -mt-32 mb-32 animate-slide-up" style={{ animationDelay: '200ms' }}>
|
||||
<div
|
||||
className="relative -mt-32 mb-32 animate-slide-up"
|
||||
style={{ animationDelay: '200ms' }}
|
||||
>
|
||||
<div className="bg-white shadow-[0_32px_64px_-12px_rgba(0,0,0,0.1)] rounded-[48px] border border-neutral-dark/5 overflow-hidden p-12 md:p-20 lg:p-24">
|
||||
<div className="relative w-full aspect-[21/9]">
|
||||
<Image
|
||||
src={product.frontmatter.images[0]}
|
||||
<Image
|
||||
src={product.frontmatter.images[0]}
|
||||
alt={product.frontmatter.title}
|
||||
fill
|
||||
className="object-contain transition-transform duration-1000 hover:scale-105"
|
||||
@@ -338,12 +425,20 @@ export default async function ProductPage({ params }: ProductPageProps) {
|
||||
{/* Subtle reflection/shadow effect */}
|
||||
<div className="absolute bottom-0 left-1/2 -translate-x-1/2 w-3/4 h-12 bg-black/5 blur-3xl rounded-[100%]" />
|
||||
</div>
|
||||
|
||||
|
||||
{product.frontmatter.images.length > 1 && (
|
||||
<div className="flex justify-center gap-8 mt-20">
|
||||
{product.frontmatter.images.slice(0, 5).map((img, idx) => (
|
||||
<div key={idx} className="relative w-24 h-24 md:w-32 md:h-32 border-2 border-neutral-dark/5 rounded-3xl overflow-hidden bg-neutral-light/30 hover:border-accent transition-all duration-500 cursor-pointer group p-4">
|
||||
<Image src={img} alt="" fill className="object-contain p-4 transition-transform duration-700 group-hover:scale-110" />
|
||||
<div
|
||||
key={idx}
|
||||
className="relative w-24 h-24 md:w-32 md:h-32 border-2 border-neutral-dark/5 rounded-3xl overflow-hidden bg-neutral-light/30 hover:border-accent transition-all duration-500 cursor-pointer group p-4"
|
||||
>
|
||||
<Image
|
||||
src={img}
|
||||
alt=""
|
||||
fill
|
||||
className="object-contain p-4 transition-transform duration-700 group-hover:scale-110"
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
@@ -356,51 +451,68 @@ export default async function ProductPage({ params }: ProductPageProps) {
|
||||
<div className="w-full">
|
||||
{/* Main Content Area */}
|
||||
<div className="max-w-none">
|
||||
<MDXRemote source={processedContent} components={productComponents} />
|
||||
<MDXRemote source={processedContent} components={productComponents} />
|
||||
</div>
|
||||
|
||||
{/* Datasheet Download Section - Only for Medium Voltage for now */}
|
||||
{categoryFileSlug === 'medium-voltage-cables' && datasheetPath && (
|
||||
<div className="mt-24 pt-24 border-t-2 border-neutral-dark/5">
|
||||
<div className="mb-12">
|
||||
<h2 className="text-3xl md:text-4xl font-black text-primary tracking-tighter uppercase mb-4">
|
||||
{t('downloadDatasheet')}
|
||||
</h2>
|
||||
<div className="h-1.5 w-24 bg-accent rounded-full" />
|
||||
</div>
|
||||
<DatasheetDownload datasheetPath={datasheetPath} />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Structured Data */}
|
||||
<JsonLd
|
||||
id={`jsonld-${product.slug}`}
|
||||
data={{
|
||||
'@context': 'https://schema.org',
|
||||
'@type': 'Product',
|
||||
name: product.frontmatter.title,
|
||||
description: product.frontmatter.description,
|
||||
sku: product.frontmatter.sku || product.slug.toUpperCase(),
|
||||
image: product.frontmatter.images?.[0] ? `https://klz-cables.com${product.frontmatter.images[0]}` : undefined,
|
||||
brand: {
|
||||
'@type': 'Brand',
|
||||
name: 'KLZ Cables',
|
||||
},
|
||||
offers: {
|
||||
'@type': 'Offer',
|
||||
availability: 'https://schema.org/InStock',
|
||||
priceCurrency: 'EUR',
|
||||
url: `https://klz-cables.com/${locale}/products/${slug.join('/')}`,
|
||||
itemCondition: 'https://schema.org/NewCondition',
|
||||
},
|
||||
additionalProperty: technicalItems.map((item: any) => ({
|
||||
'@type': 'PropertyValue',
|
||||
name: item.label,
|
||||
value: item.value,
|
||||
})),
|
||||
category: product.frontmatter.categories.join(', '),
|
||||
mainEntityOfPage: {
|
||||
'@type': 'WebPage',
|
||||
'@id': `https://klz-cables.com/${locale}/products/${slug.join('/')}`,
|
||||
},
|
||||
} as any}
|
||||
data={
|
||||
{
|
||||
'@context': 'https://schema.org',
|
||||
'@type': 'Product',
|
||||
name: product.frontmatter.title,
|
||||
description: product.frontmatter.description,
|
||||
sku: product.frontmatter.sku || product.slug.toUpperCase(),
|
||||
image: product.frontmatter.images?.[0]
|
||||
? `${SITE_URL}${product.frontmatter.images[0]}`
|
||||
: undefined,
|
||||
brand: {
|
||||
'@type': 'Brand',
|
||||
name: 'KLZ Cables',
|
||||
},
|
||||
offers: {
|
||||
'@type': 'Offer',
|
||||
availability: 'https://schema.org/InStock',
|
||||
priceCurrency: 'EUR',
|
||||
url: `${SITE_URL}/${locale}/products/${slug.join('/')}`,
|
||||
itemCondition: 'https://schema.org/NewCondition',
|
||||
},
|
||||
additionalProperty: technicalItems.map((item: any) => ({
|
||||
'@type': 'PropertyValue',
|
||||
name: item.label,
|
||||
value: item.value,
|
||||
})),
|
||||
category: product.frontmatter.categories.join(', '),
|
||||
mainEntityOfPage: {
|
||||
'@type': 'WebPage',
|
||||
'@id': `${SITE_URL}/${locale}/products/${slug.join('/')}`,
|
||||
},
|
||||
} as any
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Related Products Section */}
|
||||
<div className="mt-16 pt-16 border-t border-neutral-dark/5">
|
||||
<RelatedProducts
|
||||
currentSlug={productSlug}
|
||||
categories={product.frontmatter.categories}
|
||||
locale={locale}
|
||||
<RelatedProducts
|
||||
currentSlug={productSlug}
|
||||
categories={product.frontmatter.categories}
|
||||
locale={locale}
|
||||
/>
|
||||
</div>
|
||||
</Container>
|
||||
|
||||
@@ -1,62 +1,29 @@
|
||||
import { ImageResponse } from 'next/og';
|
||||
import { getProductBySlug } from '@/lib/mdx';
|
||||
import { getTranslations } from 'next-intl/server';
|
||||
import { OGImageTemplate } from '@/components/OGImageTemplate';
|
||||
import { getOgFonts, OG_IMAGE_SIZE } from '@/lib/og-helper';
|
||||
|
||||
export const runtime = 'nodejs';
|
||||
|
||||
export default async function Image({ params: { locale, slug } }: { params: { locale: string, slug: string[] } }) {
|
||||
const productSlug = slug[slug.length - 1];
|
||||
const t = await getTranslations('Products');
|
||||
export default async function Image({ params: { locale } }: { params: { locale: string } }) {
|
||||
const t = await getTranslations({ locale, namespace: 'Products' });
|
||||
const fonts = await getOgFonts();
|
||||
|
||||
// Check if it's a category page
|
||||
const categories = ['low-voltage-cables', 'medium-voltage-cables', 'high-voltage-cables', 'solar-cables'];
|
||||
if (categories.includes(productSlug)) {
|
||||
const categoryKey = productSlug.replace(/-cables$/, '').replace(/-([a-z])/g, (g) => g[1].toUpperCase());
|
||||
const categoryTitle = t.has(`categories.${categoryKey}.title`) ? t(`categories.${categoryKey}.title`) : productSlug;
|
||||
const categoryDesc = t.has(`categories.${categoryKey}.description`) ? t(`categories.${categoryKey}.description`) : '';
|
||||
|
||||
return new ImageResponse(
|
||||
(
|
||||
<OGImageTemplate
|
||||
title={categoryTitle}
|
||||
description={categoryDesc}
|
||||
label="Product Category"
|
||||
/>
|
||||
),
|
||||
{
|
||||
width: 1200,
|
||||
height: 630,
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
const product = await getProductBySlug(productSlug, locale);
|
||||
|
||||
if (!product) {
|
||||
return new ImageResponse(
|
||||
<div style={{ display: 'flex', width: '100%', height: '100%', backgroundColor: '#001a4d' }} />
|
||||
);
|
||||
}
|
||||
|
||||
const featuredImage = product.frontmatter.images?.[0]
|
||||
? (product.frontmatter.images[0].startsWith('http')
|
||||
? product.frontmatter.images[0]
|
||||
: `https://klz-cables.com${product.frontmatter.images[0]}`)
|
||||
: undefined;
|
||||
const title = t('meta.title') || t('title');
|
||||
const description = t('meta.description') || t('subtitle');
|
||||
|
||||
return new ImageResponse(
|
||||
(
|
||||
<OGImageTemplate
|
||||
title={product.frontmatter.title}
|
||||
description={product.frontmatter.description}
|
||||
label={product.frontmatter.categories?.[0] || 'Product'}
|
||||
image={featuredImage}
|
||||
title={title}
|
||||
description={description}
|
||||
label="Products"
|
||||
/>
|
||||
),
|
||||
{
|
||||
width: 1200,
|
||||
height: 630,
|
||||
...OG_IMAGE_SIZE,
|
||||
fonts,
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -1,11 +1,13 @@
|
||||
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';
|
||||
import { SITE_URL } from '@/lib/schema';
|
||||
|
||||
interface ProductsPageProps {
|
||||
params: {
|
||||
@@ -13,7 +15,9 @@ interface ProductsPageProps {
|
||||
};
|
||||
}
|
||||
|
||||
export async function generateMetadata({ params: { locale } }: ProductsPageProps): Promise<Metadata> {
|
||||
export async function generateMetadata({
|
||||
params: { locale },
|
||||
}: ProductsPageProps): Promise<Metadata> {
|
||||
const t = await getTranslations({ locale, namespace: 'Products' });
|
||||
const title = t('meta.title') || t('title');
|
||||
const description = t('meta.description') || t('subtitle');
|
||||
@@ -23,15 +27,16 @@ export async function generateMetadata({ params: { locale } }: ProductsPageProps
|
||||
alternates: {
|
||||
canonical: `/${locale}/products`,
|
||||
languages: {
|
||||
'de': '/de/products',
|
||||
'en': '/en/products',
|
||||
de: '/de/products',
|
||||
en: '/en/products',
|
||||
'x-default': '/en/products',
|
||||
},
|
||||
},
|
||||
openGraph: {
|
||||
title: `${title} | KLZ Cables`,
|
||||
description,
|
||||
url: `https://klz-cables.com/${locale}/products`,
|
||||
url: `${SITE_URL}/${locale}/products`,
|
||||
images: getOGImageMetadata('products', title, locale),
|
||||
},
|
||||
twitter: {
|
||||
card: 'summary_large_image',
|
||||
@@ -56,29 +61,29 @@ export default async function ProductsPage({ params }: ProductsPageProps) {
|
||||
desc: t('categories.lowVoltage.description'),
|
||||
img: '/uploads/2024/11/low-voltage-category.webp',
|
||||
icon: '/uploads/2024/11/Low-Voltage.svg',
|
||||
href: `/${params.locale}/products/${lowVoltageSlug}`
|
||||
href: `/${params.locale}/products/${lowVoltageSlug}`,
|
||||
},
|
||||
{
|
||||
title: t('categories.mediumVoltage.title'),
|
||||
desc: t('categories.mediumVoltage.description'),
|
||||
img: '/uploads/2024/11/medium-voltage-category.webp',
|
||||
icon: '/uploads/2024/11/Medium-Voltage.svg',
|
||||
href: `/${params.locale}/products/${mediumVoltageSlug}`
|
||||
href: `/${params.locale}/products/${mediumVoltageSlug}`,
|
||||
},
|
||||
{
|
||||
title: t('categories.highVoltage.title'),
|
||||
desc: t('categories.highVoltage.description'),
|
||||
img: '/uploads/2024/11/high-voltage-category.webp',
|
||||
icon: '/uploads/2024/11/High-Voltage.svg',
|
||||
href: `/${params.locale}/products/${highVoltageSlug}`
|
||||
href: `/${params.locale}/products/${highVoltageSlug}`,
|
||||
},
|
||||
{
|
||||
title: t('categories.solar.title'),
|
||||
desc: t('categories.solar.description'),
|
||||
img: '/uploads/2024/11/solar-category.webp',
|
||||
icon: '/uploads/2024/11/Solar.svg',
|
||||
href: `/${params.locale}/products/${solarSlug}`
|
||||
}
|
||||
href: `/${params.locale}/products/${solarSlug}`,
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
@@ -87,24 +92,35 @@ export default async function ProductsPage({ params }: ProductsPageProps) {
|
||||
<section className="relative min-h-[50vh] md:min-h-[70vh] flex items-center pt-32 pb-20 md:pt-40 md:pb-32 overflow-hidden bg-primary-dark">
|
||||
<Container className="relative z-10">
|
||||
<div className="max-w-4xl animate-slide-up">
|
||||
<Badge variant="saturated" className="mb-4 md:mb-8 shadow-lg px-3 py-1 md:px-4 md:py-1.5">
|
||||
<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">
|
||||
<span className="relative z-10 text-accent italic">{chunks}</span>
|
||||
<Scribble variant="circle" className="w-[140%] h-[140%] -top-[20%] -left-[20%] text-accent/30 hidden md:block" />
|
||||
<Scribble
|
||||
variant="circle"
|
||||
className="w-[140%] h-[140%] -top-[20%] -left-[20%] text-accent/30 hidden md:block"
|
||||
/>
|
||||
</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>
|
||||
<div className="flex flex-wrap gap-4 md:gap-6">
|
||||
<Button href="#categories" variant="accent" size="lg" className="group w-full md:w-auto">
|
||||
<Button
|
||||
href="#categories"
|
||||
variant="accent"
|
||||
size="lg"
|
||||
className="group w-full md:w-auto"
|
||||
>
|
||||
{t('viewProducts')}
|
||||
<span className="ml-3 transition-transform group-hover:translate-y-1">↓</span>
|
||||
</Button>
|
||||
@@ -121,8 +137,8 @@ export default async function ProductsPage({ params }: ProductsPageProps) {
|
||||
<Link key={idx} href={category.href} className="group block">
|
||||
<Card className="h-full border-none shadow-sm hover:shadow-2xl transition-all duration-500 rounded-[24px] md:rounded-[48px] overflow-hidden bg-white active:scale-[0.98]">
|
||||
<div className="relative h-[200px] md:h-[400px] overflow-hidden">
|
||||
<Image
|
||||
src={category.img}
|
||||
<Image
|
||||
src={category.img}
|
||||
alt={category.title}
|
||||
fill
|
||||
className="object-cover transition-transform duration-1000 group-hover:scale-105"
|
||||
@@ -130,13 +146,22 @@ export default async function ProductsPage({ params }: ProductsPageProps) {
|
||||
unoptimized
|
||||
/>
|
||||
<div className="absolute inset-0 image-overlay-gradient opacity-80 group-hover:opacity-90 transition-opacity duration-500" />
|
||||
|
||||
|
||||
<div className="absolute top-3 right-3 md:top-8 md:right-8 w-10 h-10 md:w-20 md:h-20 bg-white/10 backdrop-blur-md rounded-xl md:rounded-[24px] flex items-center justify-center border border-white/20 shadow-2xl transition-all duration-500 group-hover:scale-110 group-hover:bg-white/20">
|
||||
<Image src={category.icon} alt="" width={24} height={24} className="w-6 h-6 md:w-12 md:h-12 brightness-0 invert opacity-80" />
|
||||
<Image
|
||||
src={category.icon}
|
||||
alt=""
|
||||
width={24}
|
||||
height={24}
|
||||
className="w-6 h-6 md:w-12 md:h-12 brightness-0 invert opacity-80"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="absolute bottom-4 left-4 md:bottom-10 md:left-10 right-4 md:right-10">
|
||||
<Badge variant="accent" className="mb-2 md:mb-4 shadow-lg bg-accent text-primary-dark border-none text-[10px] md:text-xs">
|
||||
<Badge
|
||||
variant="accent"
|
||||
className="mb-2 md:mb-4 shadow-lg bg-accent text-primary-dark border-none text-[10px] md:text-xs"
|
||||
>
|
||||
{t('categoryLabel')}
|
||||
</Badge>
|
||||
<h2 className="text-xl md:text-4xl font-bold text-white leading-tight transition-transform duration-500 group-hover:translate-x-1">
|
||||
@@ -153,8 +178,18 @@ export default async function ProductsPage({ params }: ProductsPageProps) {
|
||||
{t('viewProducts')}
|
||||
</span>
|
||||
<div className="ml-3 md:ml-4 w-8 h-8 md:w-10 md:h-10 rounded-full bg-primary-light flex items-center justify-center text-saturated group-hover:bg-accent group-hover:text-primary-dark transition-all duration-300 shadow-sm">
|
||||
<svg className="w-4 h-4 md:w-5 md:h-5 transition-transform group-hover:translate-x-1" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M17 8l4 4m0 0l-4 4m4-4H3" />
|
||||
<svg
|
||||
className="w-4 h-4 md:w-5 md:h-5 transition-transform group-hover:translate-x-1"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2}
|
||||
d="M17 8l4 4m0 0l-4 4m4-4H3"
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
</div>
|
||||
@@ -166,7 +201,7 @@ export default async function ProductsPage({ params }: ProductsPageProps) {
|
||||
</div>
|
||||
</Container>
|
||||
</Section>
|
||||
|
||||
|
||||
{/* Technical Support CTA */}
|
||||
<Reveal>
|
||||
<Section className="bg-white py-12 md:py-28">
|
||||
@@ -175,14 +210,23 @@ export default async function ProductsPage({ params }: ProductsPageProps) {
|
||||
<div className="absolute top-0 right-0 w-1/2 h-full bg-accent/5 -skew-x-12 translate-x-1/4" />
|
||||
<div className="relative z-10 flex flex-col lg:flex-row items-center justify-between gap-6 md:gap-12">
|
||||
<div className="max-w-2xl text-center lg:text-left">
|
||||
<h2 className="text-2xl md:text-5xl lg:text-6xl font-bold text-white mb-4 md:mb-8 tracking-tight">{t('cta.title')}</h2>
|
||||
<h2 className="text-2xl md:text-5xl lg:text-6xl font-bold text-white mb-4 md:mb-8 tracking-tight">
|
||||
{t('cta.title')}
|
||||
</h2>
|
||||
<p className="text-base md:text-xl text-white/70 leading-relaxed">
|
||||
{t('cta.description')}
|
||||
</p>
|
||||
</div>
|
||||
<Button href={`/${params.locale}/contact`} variant="accent" size="lg" className="group whitespace-nowrap w-full md:w-auto md:h-16 md:px-10 md:text-xl">
|
||||
<Button
|
||||
href={`/${params.locale}/contact`}
|
||||
variant="accent"
|
||||
size="lg"
|
||||
className="group whitespace-nowrap w-full md:w-auto md:h-16 md:px-10 md:text-xl"
|
||||
>
|
||||
{t('cta.button')}
|
||||
<span className="ml-4 transition-transform group-hover:translate-x-2">→</span>
|
||||
<span className="ml-4 transition-transform group-hover:translate-x-2">
|
||||
→
|
||||
</span>
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1,11 +1,14 @@
|
||||
import { ImageResponse } from 'next/og';
|
||||
import { getTranslations } from 'next-intl/server';
|
||||
import { OGImageTemplate } from '@/components/OGImageTemplate';
|
||||
import { getOgFonts, OG_IMAGE_SIZE } from '@/lib/og-helper';
|
||||
|
||||
export const runtime = 'nodejs';
|
||||
|
||||
export default async function Image({ params: { locale } }: { params: { locale: string } }) {
|
||||
const t = await getTranslations({ locale, namespace: 'Team' });
|
||||
const fonts = await getOgFonts();
|
||||
|
||||
const title = t('meta.title') || t('hero.subtitle');
|
||||
const description = t('meta.description') || t('hero.title');
|
||||
|
||||
@@ -15,12 +18,12 @@ export default async function Image({ params: { locale } }: { params: { locale:
|
||||
title={title}
|
||||
description={description}
|
||||
label="Our Team"
|
||||
image="https://klz-cables.com/uploads/2024/12/DSC07655-Large.webp"
|
||||
/>
|
||||
),
|
||||
{
|
||||
width: 1200,
|
||||
height: 630,
|
||||
...OG_IMAGE_SIZE,
|
||||
fonts,
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -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';
|
||||
@@ -23,15 +24,16 @@ export async function generateMetadata({ params: { locale } }: TeamPageProps): P
|
||||
alternates: {
|
||||
canonical: `/${locale}/team`,
|
||||
languages: {
|
||||
'de': '/de/team',
|
||||
'en': '/en/team',
|
||||
de: '/de/team',
|
||||
en: '/en/team',
|
||||
'x-default': '/en/team',
|
||||
},
|
||||
},
|
||||
openGraph: {
|
||||
title: `${title} | KLZ Cables`,
|
||||
description,
|
||||
url: `https://klz-cables.com/${locale}/team`,
|
||||
url: `${SITE_URL}/${locale}/team`,
|
||||
images: getOGImageMetadata('team', title, locale),
|
||||
},
|
||||
twitter: {
|
||||
card: 'summary_large_image',
|
||||
@@ -48,9 +50,7 @@ export default async function TeamPage({ params: { locale } }: TeamPageProps) {
|
||||
<div className="flex flex-col min-h-screen bg-neutral-light">
|
||||
<JsonLd
|
||||
id="breadcrumb-team"
|
||||
data={getBreadcrumbSchema([
|
||||
{ name: t('hero.subtitle'), item: `/team` },
|
||||
])}
|
||||
data={getBreadcrumbSchema([{ name: t('hero.subtitle'), item: `/team` }])}
|
||||
/>
|
||||
<JsonLd
|
||||
id="person-michael"
|
||||
@@ -63,10 +63,8 @@ export default async function TeamPage({ params: { locale } }: TeamPageProps) {
|
||||
'@type': 'Organization',
|
||||
name: 'KLZ Cables',
|
||||
},
|
||||
sameAs: [
|
||||
'https://www.linkedin.com/in/michael-bodemer-33b493122/'
|
||||
],
|
||||
image: `${SITE_URL}/uploads/2024/12/DSC07768-Large.webp`
|
||||
sameAs: ['https://www.linkedin.com/in/michael-bodemer-33b493122/'],
|
||||
image: `${SITE_URL}/uploads/2024/12/DSC07768-Large.webp`,
|
||||
}}
|
||||
/>
|
||||
<JsonLd
|
||||
@@ -80,10 +78,8 @@ export default async function TeamPage({ params: { locale } }: TeamPageProps) {
|
||||
'@type': 'Organization',
|
||||
name: 'KLZ Cables',
|
||||
},
|
||||
sameAs: [
|
||||
'https://www.linkedin.com/in/klaus-mintel-b80a8b193/'
|
||||
],
|
||||
image: `${SITE_URL}/uploads/2024/12/DSC07963-Large.webp`
|
||||
sameAs: ['https://www.linkedin.com/in/klaus-mintel-b80a8b193/'],
|
||||
image: `${SITE_URL}/uploads/2024/12/DSC07963-Large.webp`,
|
||||
}}
|
||||
/>
|
||||
{/* Hero Section */}
|
||||
@@ -99,12 +95,14 @@ export default async function TeamPage({ params: { locale } }: TeamPageProps) {
|
||||
/>
|
||||
<div className="absolute inset-0 bg-gradient-to-b from-primary-dark/80 via-primary-dark/40 to-primary-dark/80" />
|
||||
</div>
|
||||
|
||||
|
||||
<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">
|
||||
<Badge variant="saturated" className="mb-4 md:mb-8 shadow-lg">
|
||||
{t('hero.badge')}
|
||||
</Badge>
|
||||
<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>
|
||||
@@ -118,7 +116,9 @@ export default async function TeamPage({ params: { locale } }: TeamPageProps) {
|
||||
<Reveal className="w-full lg:w-1/2 p-6 md:p-24 lg:p-32 flex flex-col justify-center bg-primary-dark text-white relative order-2 lg:order-1">
|
||||
<div className="absolute top-0 right-0 w-32 h-full bg-accent/5 -skew-x-12 translate-x-1/2" />
|
||||
<div className="relative z-10">
|
||||
<Badge variant="accent" className="mb-4 md:mb-8">{t('michael.role')}</Badge>
|
||||
<Badge variant="accent" className="mb-4 md:mb-8">
|
||||
{t('michael.role')}
|
||||
</Badge>
|
||||
<Heading level={2} className="text-white mb-6 md:mb-10 text-3xl md:text-5xl">
|
||||
<span className="text-white">{t('michael.name')}</span>
|
||||
</Heading>
|
||||
@@ -131,9 +131,9 @@ export default async function TeamPage({ params: { locale } }: TeamPageProps) {
|
||||
<p className="text-base md:text-xl leading-relaxed text-white/70 mb-6 md:mb-12 max-w-xl">
|
||||
{t('michael.description')}
|
||||
</p>
|
||||
<Button
|
||||
href="https://www.linkedin.com/in/michael-bodemer-33b493122/"
|
||||
variant="accent"
|
||||
<Button
|
||||
href="https://www.linkedin.com/in/michael-bodemer-33b493122/"
|
||||
variant="accent"
|
||||
size="lg"
|
||||
className="group w-full md:w-auto md:h-16 md:px-10 md:text-xl active:scale-95 transition-transform"
|
||||
>
|
||||
@@ -171,26 +171,36 @@ export default async function TeamPage({ params: { locale } }: TeamPageProps) {
|
||||
<Container className="relative z-10">
|
||||
<div className="grid grid-cols-1 lg:grid-cols-12 gap-8 md:gap-16 items-center">
|
||||
<div className="lg:col-span-6">
|
||||
<Heading level={2} subtitle={t('legacy.subtitle')} className="text-white mb-6 md:mb-10">
|
||||
<Heading
|
||||
level={2}
|
||||
subtitle={t('legacy.subtitle')}
|
||||
className="text-white mb-6 md:mb-10"
|
||||
>
|
||||
<span className="text-white">{t('legacy.title')}</span>
|
||||
</Heading>
|
||||
<div className="space-y-4 md:space-y-8 text-base md:text-2xl text-white/80 leading-relaxed font-medium">
|
||||
<p className="border-l-4 border-accent pl-5 md:pl-8 py-2 bg-white/5 backdrop-blur-sm rounded-r-xl md:rounded-r-2xl">
|
||||
{t('legacy.p1')}
|
||||
</p>
|
||||
<p className="pl-6 md:pl-9 line-clamp-3 md:line-clamp-none">
|
||||
{t('legacy.p2')}
|
||||
</p>
|
||||
<p className="pl-6 md:pl-9 line-clamp-3 md:line-clamp-none">{t('legacy.p2')}</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="lg:col-span-6 grid grid-cols-2 md:grid-cols-2 gap-3 md:gap-6">
|
||||
<div className="p-4 md:p-8 bg-white/5 backdrop-blur-md border border-white/10 rounded-2xl md:rounded-[32px] hover:bg-white/10 transition-colors">
|
||||
<div className="text-xl md:text-4xl font-extrabold text-accent mb-1 md:mb-2">{t('legacy.expertise')}</div>
|
||||
<div className="text-[10px] md:text-sm font-bold uppercase tracking-widest text-white/50">{t('legacy.expertiseDesc')}</div>
|
||||
<div className="text-xl md:text-4xl font-extrabold text-accent mb-1 md:mb-2">
|
||||
{t('legacy.expertise')}
|
||||
</div>
|
||||
<div className="text-[10px] md:text-sm font-bold uppercase tracking-widest text-white/50">
|
||||
{t('legacy.expertiseDesc')}
|
||||
</div>
|
||||
</div>
|
||||
<div className="p-4 md:p-8 bg-white/5 backdrop-blur-md border border-white/10 rounded-2xl md:rounded-[32px] hover:bg-white/10 transition-colors">
|
||||
<div className="text-xl md:text-4xl font-extrabold text-accent mb-1 md:mb-2">{t('legacy.network')}</div>
|
||||
<div className="text-[10px] md:text-sm font-bold uppercase tracking-widest text-white/50">{t('legacy.networkDesc')}</div>
|
||||
<div className="text-xl md:text-4xl font-extrabold text-accent mb-1 md:mb-2">
|
||||
{t('legacy.network')}
|
||||
</div>
|
||||
<div className="text-[10px] md:text-sm font-bold uppercase tracking-widest text-white/50">
|
||||
{t('legacy.networkDesc')}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -214,7 +224,9 @@ export default async function TeamPage({ params: { locale } }: TeamPageProps) {
|
||||
<Reveal className="w-full lg:w-1/2 p-6 md:p-24 lg:p-32 flex flex-col justify-center bg-neutral-light text-saturated relative order-2">
|
||||
<div className="absolute top-0 left-0 w-32 h-full bg-saturated/5 skew-x-12 -translate-x-1/2" />
|
||||
<div className="relative z-10">
|
||||
<Badge variant="saturated" className="mb-4 md:mb-8">{t('klaus.role')}</Badge>
|
||||
<Badge variant="saturated" className="mb-4 md:mb-8">
|
||||
{t('klaus.role')}
|
||||
</Badge>
|
||||
<Heading level={2} className="text-saturated mb-6 md:mb-10 text-3xl md:text-6xl">
|
||||
{t('klaus.name')}
|
||||
</Heading>
|
||||
@@ -227,9 +239,9 @@ export default async function TeamPage({ params: { locale } }: TeamPageProps) {
|
||||
<p className="text-base md:text-xl leading-relaxed text-text-secondary mb-6 md:mb-12 max-w-xl">
|
||||
{t('klaus.description')}
|
||||
</p>
|
||||
<Button
|
||||
href="https://www.linkedin.com/in/klaus-mintel-b80a8b193/"
|
||||
variant="saturated"
|
||||
<Button
|
||||
href="https://www.linkedin.com/in/klaus-mintel-b80a8b193/"
|
||||
variant="saturated"
|
||||
size="lg"
|
||||
className="group w-full md:w-auto md:h-16 md:px-10 md:text-xl active:scale-95 transition-transform"
|
||||
>
|
||||
@@ -253,11 +265,14 @@ export default async function TeamPage({ params: { locale } }: TeamPageProps) {
|
||||
<p className="text-base md:text-xl text-text-secondary leading-relaxed">
|
||||
{t('manifesto.tagline')}
|
||||
</p>
|
||||
|
||||
|
||||
{/* Mobile-only progress indicator */}
|
||||
<div className="flex lg:hidden mt-8 gap-2">
|
||||
{[0, 1, 2, 3, 4, 5].map((i) => (
|
||||
<div key={i} className="h-1.5 flex-1 bg-neutral-medium rounded-full overflow-hidden">
|
||||
<div
|
||||
key={i}
|
||||
className="h-1.5 flex-1 bg-neutral-medium rounded-full overflow-hidden"
|
||||
>
|
||||
<div className="h-full bg-accent w-0 group-active:w-full transition-all duration-500" />
|
||||
</div>
|
||||
))}
|
||||
@@ -266,12 +281,21 @@ export default async function TeamPage({ params: { locale } }: TeamPageProps) {
|
||||
</div>
|
||||
<div className="sticky-narrative-content grid grid-cols-1 md:grid-cols-2 gap-4 md:gap-10">
|
||||
{[0, 1, 2, 3, 4, 5].map((idx) => (
|
||||
<div key={idx} className="p-6 md:p-10 bg-neutral-light rounded-2xl md:rounded-[40px] border border-transparent hover:border-accent hover:bg-white hover:shadow-2xl transition-all duration-500 group active:scale-[0.98] touch-target-none">
|
||||
<div
|
||||
key={idx}
|
||||
className="p-6 md:p-10 bg-neutral-light rounded-2xl md:rounded-[40px] border border-transparent hover:border-accent hover:bg-white hover:shadow-2xl transition-all duration-500 group active:scale-[0.98] touch-target-none"
|
||||
>
|
||||
<div className="w-10 h-10 md:w-16 md:h-16 bg-white rounded-xl md:rounded-2xl flex items-center justify-center mb-4 md:mb-8 shadow-sm group-hover:bg-accent transition-colors duration-500">
|
||||
<span className="text-primary font-extrabold text-lg md:text-2xl group-hover:text-primary-dark">0{idx + 1}</span>
|
||||
<span className="text-primary font-extrabold text-lg md:text-2xl group-hover:text-primary-dark">
|
||||
0{idx + 1}
|
||||
</span>
|
||||
</div>
|
||||
<h3 className="text-lg md:text-2xl font-bold mb-2 md:mb-4 text-primary">{t(`manifesto.items.${idx}.title`)}</h3>
|
||||
<p className="text-sm md:text-lg text-text-secondary leading-relaxed">{t(`manifesto.items.${idx}.description`)}</p>
|
||||
<h3 className="text-lg md:text-2xl font-bold mb-2 md:mb-4 text-primary">
|
||||
{t(`manifesto.items.${idx}.title`)}
|
||||
</h3>
|
||||
<p className="text-sm md:text-lg text-text-secondary leading-relaxed">
|
||||
{t(`manifesto.items.${idx}.description`)}
|
||||
</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
@@ -1,46 +1,131 @@
|
||||
"use server";
|
||||
'use server';
|
||||
|
||||
import { sendEmail } from "@/lib/mail/mailer";
|
||||
import ContactEmail from "@/components/emails/ContactEmail";
|
||||
import React from "react";
|
||||
import { getServerAppServices } from "@/lib/services/create-services.server";
|
||||
import client, { ensureAuthenticated } from '@/lib/directus';
|
||||
import { createItem } from '@directus/sdk';
|
||||
import { sendEmail } from '@/lib/mail/mailer';
|
||||
import { render, ContactFormNotification, ConfirmationMessage } from '@mintel/mail';
|
||||
import React from 'react';
|
||||
import { getServerAppServices } from '@/lib/services/create-services.server';
|
||||
|
||||
export async function sendContactFormAction(formData: FormData) {
|
||||
const services = getServerAppServices();
|
||||
const logger = services.logger.child({ action: 'sendContactFormAction' });
|
||||
const name = formData.get("name") as string;
|
||||
const email = formData.get("email") as string;
|
||||
const message = formData.get("message") as string;
|
||||
const productName = formData.get("productName") as string | null;
|
||||
const name = formData.get('name') as string;
|
||||
const email = formData.get('email') as string;
|
||||
const message = formData.get('message') as string;
|
||||
const productName = formData.get('productName') as string | null;
|
||||
|
||||
if (!name || !email || !message) {
|
||||
logger.warn('Missing required fields in contact form', { name: !!name, email: !!email, message: !!message });
|
||||
return { success: false, error: "Missing required fields" };
|
||||
logger.warn('Missing required fields in contact form', {
|
||||
name: !!name,
|
||||
email: !!email,
|
||||
message: !!message,
|
||||
});
|
||||
return { success: false, error: 'Missing required fields' };
|
||||
}
|
||||
|
||||
logger.info('Sending contact form email', { email, productName });
|
||||
|
||||
const subject = productName
|
||||
? `Product Inquiry: ${productName}`
|
||||
: "New Contact Form Submission";
|
||||
|
||||
const result = await sendEmail({
|
||||
subject,
|
||||
template: React.createElement(ContactEmail, {
|
||||
name,
|
||||
email,
|
||||
message,
|
||||
productName: productName || undefined,
|
||||
subject,
|
||||
}),
|
||||
});
|
||||
|
||||
if (result.success) {
|
||||
logger.info('Contact form email sent successfully', { messageId: result.messageId });
|
||||
} else {
|
||||
logger.error('Failed to send contact form email', { error: result.error });
|
||||
services.errors.captureException(result.error, { action: 'sendContactFormAction', email });
|
||||
// 1. Save to Directus
|
||||
try {
|
||||
await ensureAuthenticated();
|
||||
if (productName) {
|
||||
await client.request(
|
||||
createItem('product_requests', {
|
||||
product_name: productName,
|
||||
email,
|
||||
message,
|
||||
}),
|
||||
);
|
||||
logger.info('Product request stored in Directus');
|
||||
} else {
|
||||
await client.request(
|
||||
createItem('contact_submissions', {
|
||||
name,
|
||||
email,
|
||||
message,
|
||||
}),
|
||||
);
|
||||
logger.info('Contact submission stored in Directus');
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error('Failed to store submission in Directus', { error });
|
||||
services.errors.captureException(error, { action: 'directus_store_submission' });
|
||||
}
|
||||
|
||||
return result;
|
||||
// 2. Send Emails
|
||||
logger.info('Sending branded emails', { email, productName });
|
||||
|
||||
const notificationSubject = productName
|
||||
? `Product Inquiry: ${productName}`
|
||||
: 'New Contact Form Submission';
|
||||
const confirmationSubject = 'Thank you for your inquiry';
|
||||
|
||||
try {
|
||||
// 2a. Send notification to Mintel/Client
|
||||
const notificationHtml = await render(
|
||||
React.createElement(ContactFormNotification, {
|
||||
name,
|
||||
email,
|
||||
message,
|
||||
productName: productName || undefined,
|
||||
}),
|
||||
);
|
||||
|
||||
const notificationResult = await sendEmail({
|
||||
replyTo: email,
|
||||
subject: notificationSubject,
|
||||
html: notificationHtml,
|
||||
});
|
||||
|
||||
if (notificationResult.success) {
|
||||
logger.info('Notification email sent successfully', {
|
||||
messageId: notificationResult.messageId,
|
||||
});
|
||||
}
|
||||
|
||||
// 2b. Send confirmation to Customer (branded as KLZ Cables)
|
||||
const confirmationHtml = await render(
|
||||
React.createElement(ConfirmationMessage, {
|
||||
name,
|
||||
clientName: 'KLZ Cables',
|
||||
// brandColor: '#82ed20', // Optional: could be KLZ specific
|
||||
}),
|
||||
);
|
||||
|
||||
const confirmationResult = await sendEmail({
|
||||
to: email,
|
||||
subject: confirmationSubject,
|
||||
html: confirmationHtml,
|
||||
});
|
||||
|
||||
if (confirmationResult.success) {
|
||||
logger.info('Confirmation email sent successfully', {
|
||||
messageId: confirmationResult.messageId,
|
||||
});
|
||||
}
|
||||
|
||||
// Notify via Gotify (Internal)
|
||||
await services.notifications.notify({
|
||||
title: `📩 ${notificationSubject}`,
|
||||
message: `New message from ${name} (${email}):\n\n${message}`,
|
||||
priority: 5,
|
||||
});
|
||||
|
||||
return { success: true };
|
||||
} catch (error) {
|
||||
const errorMsg = error instanceof Error ? error.message : String(error);
|
||||
logger.error('Failed to send branded emails', {
|
||||
error: errorMsg,
|
||||
stack: error instanceof Error ? error.stack : undefined,
|
||||
});
|
||||
|
||||
services.errors.captureException(error, { action: 'sendContactFormAction', email });
|
||||
|
||||
await services.notifications.notify({
|
||||
title: '🚨 Contact Form Error',
|
||||
message: `Failed to send emails for ${name} (${email}). Error: ${errorMsg}`,
|
||||
priority: 8,
|
||||
});
|
||||
|
||||
return { success: false, error: errorMsg };
|
||||
}
|
||||
}
|
||||
|
||||
9
app/api/health/cms/route.ts
Normal file
9
app/api/health/cms/route.ts
Normal file
@@ -0,0 +1,9 @@
|
||||
import { checkHealth } from '@/lib/directus';
|
||||
import { NextResponse } from 'next/server';
|
||||
|
||||
export const dynamic = 'force-dynamic';
|
||||
|
||||
export async function GET() {
|
||||
const health = await checkHealth();
|
||||
return NextResponse.json(health, { status: health.status === 'ok' ? 200 : 503 });
|
||||
}
|
||||
@@ -15,6 +15,11 @@ export default function manifest(): MetadataRoute.Manifest {
|
||||
sizes: 'any',
|
||||
type: 'image/x-icon',
|
||||
},
|
||||
{
|
||||
src: '/logo.png',
|
||||
sizes: '512x512',
|
||||
type: 'image/png',
|
||||
},
|
||||
{
|
||||
src: '/apple-touch-icon.png',
|
||||
sizes: '180x180',
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
import { config } from '@/lib/config';
|
||||
import { MetadataRoute } from 'next';
|
||||
|
||||
export default function robots(): MetadataRoute.Robots {
|
||||
const baseUrl = config.baseUrl || 'https://klz-cables.com';
|
||||
return {
|
||||
rules: [
|
||||
{
|
||||
@@ -11,8 +13,8 @@ export default function robots(): MetadataRoute.Robots {
|
||||
{
|
||||
userAgent: ['GPTBot', 'ClaudeBot', 'PerplexityBot', 'Google-Extended', 'OAI-SearchBot'],
|
||||
allow: '/',
|
||||
}
|
||||
},
|
||||
],
|
||||
sitemap: 'https://klz-cables.com/sitemap.xml',
|
||||
sitemap: `${baseUrl}/sitemap.xml`,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -1,10 +1,13 @@
|
||||
import { config } from '@/lib/config';
|
||||
import { MetadataRoute } from 'next';
|
||||
import { getAllProducts } from '@/lib/mdx';
|
||||
import { getAllPosts } from '@/lib/blog';
|
||||
import { getAllPages } from '@/lib/pages';
|
||||
import { getAllProductsMetadata } from '@/lib/mdx';
|
||||
import { getAllPostsMetadata } from '@/lib/blog';
|
||||
import { getAllPagesMetadata } from '@/lib/pages';
|
||||
|
||||
export const revalidate = 3600; // Revalidate every hour
|
||||
|
||||
export default async function sitemap(): Promise<MetadataRoute.Sitemap> {
|
||||
const baseUrl = 'https://klz-cables.com';
|
||||
const baseUrl = config.baseUrl || 'https://klz-cables.com';
|
||||
const locales = ['de', 'en'];
|
||||
|
||||
const routes = [
|
||||
@@ -33,12 +36,12 @@ export default async function sitemap(): Promise<MetadataRoute.Sitemap> {
|
||||
}
|
||||
|
||||
// Products
|
||||
const products = await getAllProducts(locale);
|
||||
for (const product of products) {
|
||||
// We need to find the category for the product to build the URL
|
||||
// In this project, products are under /products/[category]/[slug]
|
||||
// The category is in product.frontmatter.categories
|
||||
const category = product.frontmatter.categories[0]?.toLowerCase().replace(/\s+/g, '-') || 'other';
|
||||
const productsMetadata = await getAllProductsMetadata(locale);
|
||||
for (const product of productsMetadata) {
|
||||
if (!product.frontmatter || !product.slug) continue;
|
||||
|
||||
const category =
|
||||
product.frontmatter.categories[0]?.toLowerCase().replace(/\s+/g, '-') || 'other';
|
||||
sitemapEntries.push({
|
||||
url: `${baseUrl}/${locale}/products/${category}/${product.slug}`,
|
||||
lastModified: new Date(),
|
||||
@@ -48,8 +51,10 @@ export default async function sitemap(): Promise<MetadataRoute.Sitemap> {
|
||||
}
|
||||
|
||||
// Blog posts
|
||||
const posts = await getAllPosts(locale);
|
||||
for (const post of posts) {
|
||||
const postsMetadata = await getAllPostsMetadata(locale);
|
||||
for (const post of postsMetadata) {
|
||||
if (!post.frontmatter || !post.slug) continue;
|
||||
|
||||
sitemapEntries.push({
|
||||
url: `${baseUrl}/${locale}/blog/${post.slug}`,
|
||||
lastModified: new Date(post.frontmatter.date),
|
||||
@@ -59,8 +64,10 @@ export default async function sitemap(): Promise<MetadataRoute.Sitemap> {
|
||||
}
|
||||
|
||||
// Static pages
|
||||
const pages = await getAllPages(locale);
|
||||
for (const page of pages) {
|
||||
const pagesMetadata = await getAllPagesMetadata(locale);
|
||||
for (const page of pagesMetadata) {
|
||||
if (!page.slug) continue;
|
||||
|
||||
sitemapEntries.push({
|
||||
url: `${baseUrl}/${locale}/${page.slug}`,
|
||||
lastModified: new Date(),
|
||||
|
||||
8
commitlint.config.js
Normal file
8
commitlint.config.js
Normal file
@@ -0,0 +1,8 @@
|
||||
module.exports = {
|
||||
extends: ['@commitlint/config-conventional'],
|
||||
rules: {
|
||||
'header-max-length': [2, 'always', 500],
|
||||
'subject-case': [0],
|
||||
'subject-full-stop': [0],
|
||||
},
|
||||
};
|
||||
84
components/CMSConnectivityNotice.tsx
Normal file
84
components/CMSConnectivityNotice.tsx
Normal file
@@ -0,0 +1,84 @@
|
||||
'use client';
|
||||
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import { AlertCircle, RefreshCw, Database } from 'lucide-react';
|
||||
import { config } from '../lib/config';
|
||||
|
||||
export default function CMSConnectivityNotice() {
|
||||
const [status, setStatus] = useState<'checking' | 'ok' | 'error'>('checking');
|
||||
const [errorMsg, setErrorMsg] = useState('');
|
||||
const [isVisible, setIsVisible] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
// Only show if we've detected an issue AND we are in a context where we want to see it
|
||||
const checkCMS = async () => {
|
||||
const isDebug = new URLSearchParams(window.location.search).has('cms_debug');
|
||||
const isLocal = config.isDevelopment;
|
||||
const isTesting = config.isTesting;
|
||||
|
||||
// Only proceed with check if it's developer context (Local or Testing)
|
||||
// Staging and Production should NEVER see this unless forced with ?cms_debug
|
||||
if (!isLocal && !isTesting && !isDebug) return;
|
||||
|
||||
try {
|
||||
const response = await fetch('/api/health/cms');
|
||||
const data = await response.json();
|
||||
|
||||
if (data.status !== 'ok') {
|
||||
setStatus('error');
|
||||
setErrorMsg(data.message);
|
||||
setIsVisible(true);
|
||||
} else {
|
||||
setStatus('ok');
|
||||
setIsVisible(false);
|
||||
}
|
||||
} catch (err) {
|
||||
// If it's a connection error, only show if we are really debugging
|
||||
if (isDebug || isLocal) {
|
||||
setStatus('error');
|
||||
setErrorMsg('Could not connect to CMS health endpoint');
|
||||
setIsVisible(true);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
checkCMS();
|
||||
}, []);
|
||||
|
||||
if (!isVisible) return null;
|
||||
|
||||
return (
|
||||
<div className="fixed bottom-4 right-4 z-[9999] animate-slide-up">
|
||||
<div className="bg-red-500/90 backdrop-blur-md border border-red-400 text-white p-4 rounded-2xl shadow-2xl max-w-sm">
|
||||
<div className="flex items-start gap-3">
|
||||
<div className="bg-white/20 p-2 rounded-lg">
|
||||
<AlertCircle className="w-5 h-5" />
|
||||
</div>
|
||||
<div className="flex-1">
|
||||
<h4 className="font-bold text-sm mb-1">CMS Issue Detected</h4>
|
||||
<p className="text-xs opacity-90 leading-relaxed mb-3">
|
||||
{errorMsg === 'relation "products" does not exist'
|
||||
? 'The database schema is missing. Please sync your local data to this environment.'
|
||||
: errorMsg || 'The application cannot connect to the Directus CMS.'}
|
||||
</p>
|
||||
<div className="flex gap-2">
|
||||
<button
|
||||
onClick={() => window.location.reload()}
|
||||
className="bg-white text-red-600 text-[10px] font-bold uppercase tracking-wider px-3 py-1.5 rounded-lg flex items-center gap-2 hover:bg-neutral-100 transition-colors"
|
||||
>
|
||||
<RefreshCw className="w-3 h-3" />
|
||||
Retry
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setIsVisible(false)}
|
||||
className="bg-black/20 text-white text-[10px] font-bold uppercase tracking-wider px-3 py-1.5 rounded-lg hover:bg-black/30 transition-colors"
|
||||
>
|
||||
Dismiss
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -17,10 +17,10 @@ export default function ContactForm() {
|
||||
|
||||
const formData = new FormData(e.currentTarget);
|
||||
const email = formData.get('email') as string;
|
||||
|
||||
|
||||
try {
|
||||
const result = await sendContactFormAction(formData);
|
||||
if (result.success) {
|
||||
if (result?.success) {
|
||||
trackEvent('contact_form_submission', {
|
||||
form_type: 'general',
|
||||
email,
|
||||
@@ -41,7 +41,12 @@ export default function ContactForm() {
|
||||
return (
|
||||
<Card className="p-6 md:p-12 rounded-2xl md:rounded-[40px] border-none shadow-2xl text-center">
|
||||
<div className="w-20 h-20 bg-accent rounded-full flex items-center justify-center mx-auto mb-6 shadow-lg shadow-accent/20">
|
||||
<svg className="w-10 h-10 text-primary-dark" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<svg
|
||||
className="w-10 h-10 text-primary-dark"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={3} d="M5 13l4 4L19 7" />
|
||||
</svg>
|
||||
</div>
|
||||
@@ -49,7 +54,8 @@ export default function ContactForm() {
|
||||
{t('form.successTitle') || 'Message Sent!'}
|
||||
</Heading>
|
||||
<p className="text-text-secondary text-lg mb-8">
|
||||
{t('form.successDesc') || 'Thank you for your message. We will get back to you as soon as possible.'}
|
||||
{t('form.successDesc') ||
|
||||
'Thank you for your message. We will get back to you as soon as possible.'}
|
||||
</p>
|
||||
<Button onClick={() => setStatus('idle')} variant="saturated">
|
||||
{t('form.sendAnother') || 'Send another message'}
|
||||
@@ -62,7 +68,13 @@ export default function ContactForm() {
|
||||
return (
|
||||
<Card className="p-6 md:p-12 rounded-2xl md:rounded-[40px] border-destructive/20 shadow-2xl text-center bg-destructive/5 animate-slide-up">
|
||||
<div className="w-20 h-20 bg-destructive rounded-full flex items-center justify-center mx-auto mb-6 shadow-lg shadow-destructive/20">
|
||||
<svg className="w-10 h-10 text-destructive-foreground" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth="2">
|
||||
<svg
|
||||
className="w-10 h-10 text-destructive-foreground"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2"
|
||||
>
|
||||
<circle cx="12" cy="12" r="10" />
|
||||
<line x1="15" y1="9" x2="9" y2="15" strokeLinecap="round" strokeLinejoin="round" />
|
||||
<line x1="9" y1="9" x2="15" y2="15" strokeLinecap="round" strokeLinejoin="round" />
|
||||
@@ -74,7 +86,12 @@ export default function ContactForm() {
|
||||
<p className="text-destructive/80 text-lg mb-8 leading-relaxed font-medium">
|
||||
{t('form.error') || 'Something went wrong. Please check your input and try again.'}
|
||||
</p>
|
||||
<Button onClick={() => setStatus('idle')} variant="saturated" size="lg" className="w-full bg-destructive hover:bg-destructive/90 text-destructive-foreground border-destructive shadow-lg shadow-destructive/20">
|
||||
<Button
|
||||
onClick={() => setStatus('idle')}
|
||||
variant="destructive"
|
||||
size="lg"
|
||||
className="w-full"
|
||||
>
|
||||
{t('form.tryAgain') || 'Try Again'}
|
||||
</Button>
|
||||
</Card>
|
||||
@@ -89,9 +106,9 @@ export default function ContactForm() {
|
||||
<form onSubmit={handleSubmit} className="grid grid-cols-1 md:grid-cols-2 gap-4 md:gap-8">
|
||||
<div className="space-y-1 md:space-y-2">
|
||||
<Label htmlFor="name">{t('form.name')}</Label>
|
||||
<Input
|
||||
type="text"
|
||||
id="name"
|
||||
<Input
|
||||
type="text"
|
||||
id="name"
|
||||
name="name"
|
||||
autoComplete="name"
|
||||
enterKeyHint="next"
|
||||
@@ -101,9 +118,9 @@ export default function ContactForm() {
|
||||
</div>
|
||||
<div className="space-y-1 md:space-y-2">
|
||||
<Label htmlFor="email">{t('form.email')}</Label>
|
||||
<Input
|
||||
type="email"
|
||||
id="email"
|
||||
<Input
|
||||
type="email"
|
||||
id="email"
|
||||
name="email"
|
||||
autoComplete="email"
|
||||
inputMode="email"
|
||||
@@ -114,32 +131,50 @@ export default function ContactForm() {
|
||||
</div>
|
||||
<div className="md:col-span-2 space-y-1 md:space-y-2">
|
||||
<Label htmlFor="message">{t('form.message')}</Label>
|
||||
<Textarea
|
||||
id="message"
|
||||
<Textarea
|
||||
id="message"
|
||||
name="message"
|
||||
rows={4}
|
||||
rows={4}
|
||||
enterKeyHint="send"
|
||||
placeholder={t('form.messagePlaceholder')}
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
<div className="md:col-span-2 pt-2 md:pt-4">
|
||||
<Button
|
||||
type="submit"
|
||||
variant="saturated"
|
||||
size="lg"
|
||||
<Button
|
||||
type="submit"
|
||||
variant="saturated"
|
||||
size="lg"
|
||||
disabled={status === 'submitting'}
|
||||
className="w-full shadow-xl shadow-saturated/20 md:h-16 md:px-10 md:text-xl active:scale-[0.98] transition-transform"
|
||||
>
|
||||
{status === 'submitting' ? (
|
||||
<span className="flex items-center gap-2">
|
||||
<svg className="animate-spin h-5 w-5 text-white" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24">
|
||||
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4"></circle>
|
||||
<path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
|
||||
<svg
|
||||
className="animate-spin h-5 w-5 text-white"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<circle
|
||||
className="opacity-25"
|
||||
cx="12"
|
||||
cy="12"
|
||||
r="10"
|
||||
stroke="currentColor"
|
||||
strokeWidth="4"
|
||||
></circle>
|
||||
<path
|
||||
className="opacity-75"
|
||||
fill="currentColor"
|
||||
d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"
|
||||
></path>
|
||||
</svg>
|
||||
{t('form.submitting') || 'Sending...'}
|
||||
</span>
|
||||
) : t('form.submit')}
|
||||
) : (
|
||||
t('form.submit')
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
68
components/DatasheetDownload.tsx
Normal file
68
components/DatasheetDownload.tsx
Normal file
@@ -0,0 +1,68 @@
|
||||
'use client';
|
||||
|
||||
import { cn } from '@/components/ui/utils';
|
||||
import { useTranslations } from 'next-intl';
|
||||
|
||||
interface DatasheetDownloadProps {
|
||||
datasheetPath: string;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export default function DatasheetDownload({ datasheetPath, className }: DatasheetDownloadProps) {
|
||||
const t = useTranslations('Products');
|
||||
|
||||
return (
|
||||
<div className={cn("mt-8 animate-slight-fade-in-from-bottom", className)}>
|
||||
<a
|
||||
href={datasheetPath}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="group relative block w-full overflow-hidden rounded-[32px] bg-primary-dark p-1 transition-all duration-500 hover:shadow-[0_20px_50px_rgba(0,0,0,0.2)] hover:-translate-y-1"
|
||||
>
|
||||
{/* Animated Background Gradient */}
|
||||
<div className="absolute inset-0 bg-gradient-to-r from-accent via-saturated to-accent opacity-20 group-hover:opacity-40 transition-opacity duration-500 animate-gradient-x" />
|
||||
|
||||
{/* Inner Content */}
|
||||
<div className="relative flex items-center gap-6 rounded-[31px] bg-primary-dark/90 backdrop-blur-xl p-6 md:p-8 border border-white/10">
|
||||
{/* Icon Container */}
|
||||
<div className="relative flex h-16 w-16 flex-shrink-0 items-center justify-center rounded-2xl bg-white/5 border border-white/10 group-hover:bg-accent group-hover:border-white/20 transition-all duration-500">
|
||||
<div className="absolute inset-0 rounded-2xl bg-accent/20 blur-xl opacity-0 group-hover:opacity-100 transition-opacity duration-500" />
|
||||
<svg
|
||||
className="relative h-8 w-8 text-white transition-transform duration-500 group-hover:scale-110 group-hover:rotate-3"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={1.5}
|
||||
d="M12 10v6m0 0l-3-3m3 3l3-3m2 8H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z"
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
|
||||
{/* Text Content */}
|
||||
<div className="flex-1">
|
||||
<div className="flex items-center gap-2 mb-1">
|
||||
<span className="text-[10px] font-black uppercase tracking-[0.2em] text-accent">PDF Datasheet</span>
|
||||
</div>
|
||||
<h3 className="text-xl md:text-2xl font-black text-white uppercase tracking-tighter leading-none group-hover:text-accent transition-colors duration-300">
|
||||
{t('downloadDatasheet')}
|
||||
</h3>
|
||||
<p className="mt-2 text-sm font-medium text-white/60 leading-relaxed">
|
||||
{t('downloadDatasheetDesc')}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Arrow Icon */}
|
||||
<div className="flex h-10 w-10 items-center justify-center rounded-full bg-white/5 text-white/20 group-hover:bg-accent group-hover:text-white group-hover:translate-x-1 transition-all duration-500">
|
||||
<svg className="h-6 w-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2.5} d="M9 5l7 7-7 7" />
|
||||
</svg>
|
||||
</div>
|
||||
</div>
|
||||
</a>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -29,7 +29,7 @@ export function OGImageTemplate({
|
||||
backgroundColor: mode === 'light' ? '#ffffff' : primaryBlue,
|
||||
padding: '80px',
|
||||
position: 'relative',
|
||||
fontFamily: 'Inter, sans-serif',
|
||||
fontFamily: 'Inter',
|
||||
};
|
||||
|
||||
return (
|
||||
@@ -39,13 +39,18 @@ export function OGImageTemplate({
|
||||
<div
|
||||
style={{
|
||||
position: 'absolute',
|
||||
inset: 0,
|
||||
top: 0,
|
||||
left: 0,
|
||||
right: 0,
|
||||
bottom: 0,
|
||||
display: 'flex',
|
||||
}}
|
||||
>
|
||||
<img
|
||||
src={image}
|
||||
alt=""
|
||||
width="1200"
|
||||
height="630"
|
||||
style={{
|
||||
width: '100%',
|
||||
height: '100%',
|
||||
@@ -55,23 +60,26 @@ export function OGImageTemplate({
|
||||
<div
|
||||
style={{
|
||||
position: 'absolute',
|
||||
inset: 0,
|
||||
background: 'linear-gradient(to right, rgba(0,26,77,0.9) 0%, rgba(0,26,77,0.4) 100%)',
|
||||
top: 0,
|
||||
left: 0,
|
||||
right: 0,
|
||||
bottom: 0,
|
||||
background: 'linear-gradient(to right, rgba(0,26,77,0.95), rgba(0,26,77,0.6))',
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Decorative Scribble Circle (Simplified for Satori) */}
|
||||
{/* Decorative Brand Accent (Top Right) */}
|
||||
<div
|
||||
style={{
|
||||
position: 'absolute',
|
||||
top: '-100px',
|
||||
right: '-100px',
|
||||
top: '-150px',
|
||||
right: '-150px',
|
||||
width: '600px',
|
||||
height: '600px',
|
||||
borderRadius: '50%',
|
||||
background: `radial-gradient(circle, ${accentGreen}1a 0%, transparent 70%)`,
|
||||
borderRadius: '300px',
|
||||
backgroundColor: `${accentGreen}15`,
|
||||
display: 'flex',
|
||||
}}
|
||||
/>
|
||||
@@ -82,11 +90,11 @@ export function OGImageTemplate({
|
||||
<div
|
||||
style={{
|
||||
fontSize: '24px',
|
||||
fontWeight: 'bold',
|
||||
fontWeight: 700,
|
||||
color: accentGreen,
|
||||
textTransform: 'uppercase',
|
||||
letterSpacing: '0.2em',
|
||||
marginBottom: '24px',
|
||||
letterSpacing: '0.3em',
|
||||
marginBottom: '32px',
|
||||
display: 'flex',
|
||||
}}
|
||||
>
|
||||
@@ -97,13 +105,14 @@ export function OGImageTemplate({
|
||||
{/* Title */}
|
||||
<div
|
||||
style={{
|
||||
fontSize: '72px',
|
||||
fontWeight: '900',
|
||||
fontSize: title.length > 40 ? '64px' : '82px',
|
||||
fontWeight: 700,
|
||||
color: 'white',
|
||||
lineHeight: '1.1',
|
||||
maxWidth: '900px',
|
||||
marginBottom: '32px',
|
||||
lineHeight: '1.05',
|
||||
maxWidth: '950px',
|
||||
marginBottom: '40px',
|
||||
display: 'flex',
|
||||
letterSpacing: '-0.02em',
|
||||
}}
|
||||
>
|
||||
{title}
|
||||
@@ -114,13 +123,14 @@ export function OGImageTemplate({
|
||||
<div
|
||||
style={{
|
||||
fontSize: '32px',
|
||||
color: 'rgba(255,255,255,0.8)',
|
||||
maxWidth: '800px',
|
||||
color: 'rgba(255,255,255,0.7)',
|
||||
maxWidth: '850px',
|
||||
lineHeight: '1.4',
|
||||
display: 'flex',
|
||||
fontWeight: 400,
|
||||
}}
|
||||
>
|
||||
{description}
|
||||
{description.length > 160 ? description.substring(0, 157) + '...' : description}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
@@ -137,33 +147,34 @@ export function OGImageTemplate({
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
width: '120px',
|
||||
height: '8px',
|
||||
width: '80px',
|
||||
height: '6px',
|
||||
backgroundColor: accentGreen,
|
||||
borderRadius: '4px',
|
||||
borderRadius: '3px',
|
||||
marginRight: '24px',
|
||||
}}
|
||||
/>
|
||||
<div
|
||||
style={{
|
||||
fontSize: '24px',
|
||||
fontWeight: 'bold',
|
||||
fontWeight: 700,
|
||||
color: 'white',
|
||||
textTransform: 'uppercase',
|
||||
letterSpacing: '0.1em',
|
||||
letterSpacing: '0.15em',
|
||||
display: 'flex',
|
||||
}}
|
||||
>
|
||||
KLZ Cables
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Saturated Blue Accent */}
|
||||
{/* Saturated Blue Brand Strip */}
|
||||
<div
|
||||
style={{
|
||||
position: 'absolute',
|
||||
top: 0,
|
||||
right: 0,
|
||||
width: '10px',
|
||||
width: '12px',
|
||||
height: '100%',
|
||||
backgroundColor: saturatedBlue,
|
||||
}}
|
||||
@@ -171,3 +182,4 @@ export function OGImageTemplate({
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
import Image from 'next/image';
|
||||
import { useTranslations } from 'next-intl';
|
||||
import RequestQuoteForm from '@/components/RequestQuoteForm';
|
||||
import DatasheetDownload from '@/components/DatasheetDownload';
|
||||
import Scribble from '@/components/Scribble';
|
||||
import { cn } from '@/components/ui/utils';
|
||||
|
||||
@@ -64,33 +65,7 @@ export default function ProductSidebar({ productName, productImage, datasheetPat
|
||||
|
||||
{/* Datasheet Download */}
|
||||
{datasheetPath && (
|
||||
<a
|
||||
href={datasheetPath}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="block bg-white rounded-2xl border border-neutral-medium overflow-hidden group transition-all duration-500 hover:shadow-xl hover:border-saturated/30 hover:-translate-y-0.5"
|
||||
>
|
||||
<div className="p-4 flex items-center gap-4">
|
||||
<div className="w-12 h-12 rounded-xl bg-neutral-medium/20 flex items-center justify-center flex-shrink-0 group-hover:bg-saturated group-hover:text-white transition-all duration-500 text-saturated border border-transparent group-hover:border-white/20">
|
||||
<svg className="w-6 h-6 transition-transform duration-500 group-hover:scale-110" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.5} d="M12 10v6m0 0l-3-3m3 3l3-3m2 8H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z" />
|
||||
</svg>
|
||||
</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<h3 className="text-sm md:text-base font-heading font-black text-neutral-dark m-0 uppercase tracking-tighter leading-tight group-hover:text-saturated transition-colors duration-300">
|
||||
{t('downloadDatasheet')}
|
||||
</h3>
|
||||
<p className="text-text-secondary text-[10px] md:text-xs m-0 mt-0.5 font-semibold leading-tight truncate uppercase tracking-widest opacity-60">
|
||||
{t('downloadDatasheetDesc')}
|
||||
</p>
|
||||
</div>
|
||||
<div className="text-neutral-dark/20 group-hover:text-saturated transition-all duration-500 transform group-hover:translate-x-1">
|
||||
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2.5} d="M9 5l7 7-7 7" />
|
||||
</svg>
|
||||
</div>
|
||||
</div>
|
||||
</a>
|
||||
<DatasheetDownload datasheetPath={datasheetPath} className="mt-0" />
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -49,14 +49,28 @@ export default function RequestQuoteForm({ productName }: RequestQuoteFormProps)
|
||||
|
||||
if (status === 'success') {
|
||||
return (
|
||||
<div className="bg-accent/5 border border-accent/20 text-primary-dark p-4 rounded-xl text-center animate-fade-in !mt-0">
|
||||
<div className="w-10 h-10 bg-accent rounded-full flex items-center justify-center mx-auto mb-3 shadow-lg shadow-accent/20">
|
||||
<svg className="w-5 h-5 text-primary-dark" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={3} d="M5 13l4 4L19 7" />
|
||||
</svg>
|
||||
<div className="bg-accent/5 border border-accent/20 text-primary-dark p-4 rounded-xl text-center animate-fade-in !mt-0 w-full">
|
||||
<div className="flex justify-center mb-3">
|
||||
<div className="w-10 h-10 bg-accent rounded-full flex items-center justify-center shadow-lg shadow-accent/20">
|
||||
<svg
|
||||
className="w-5 h-5 text-primary-dark"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={3}
|
||||
d="M5 13l4 4L19 7"
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
</div>
|
||||
<h3 className="text-base font-extrabold mb-1 tracking-tight !mt-0">{t('successTitle')}</h3>
|
||||
<p className="text-text-secondary text-xs leading-tight mb-4 !mt-0">
|
||||
<h3 className="text-base font-extrabold mb-1 tracking-tight !mt-0 text-center w-full">
|
||||
{t('successTitle')}
|
||||
</h3>
|
||||
<p className="text-text-secondary text-xs leading-tight mb-4 !mt-0 text-center w-full">
|
||||
{t('successDesc', { productName })}
|
||||
</p>
|
||||
<button
|
||||
@@ -73,26 +87,36 @@ export default function RequestQuoteForm({ productName }: RequestQuoteFormProps)
|
||||
|
||||
if (status === 'error') {
|
||||
return (
|
||||
<div className="bg-destructive/5 border border-destructive/20 text-destructive p-4 rounded-xl text-center animate-fade-in !mt-0">
|
||||
<div className="w-10 h-10 bg-destructive rounded-full flex items-center justify-center mx-auto mb-3 shadow-lg shadow-destructive/20">
|
||||
<svg className="w-5 h-5 text-destructive-foreground" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth="3">
|
||||
<circle cx="12" cy="12" r="10" />
|
||||
<line x1="15" y1="9" x2="9" y2="15" />
|
||||
<line x1="9" y1="9" x2="15" y2="15" />
|
||||
</svg>
|
||||
<div className="bg-destructive/5 border border-destructive/20 text-destructive p-4 rounded-xl text-center animate-fade-in !mt-0 w-full">
|
||||
<div className="flex justify-center mb-3">
|
||||
<div className="w-10 h-10 bg-destructive rounded-full flex items-center justify-center shadow-lg shadow-destructive/20">
|
||||
<svg
|
||||
className="w-5 h-5 text-destructive-foreground"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor"
|
||||
strokeWidth="3"
|
||||
>
|
||||
<circle cx="12" cy="12" r="10" />
|
||||
<line x1="15" y1="9" x2="9" y2="15" />
|
||||
<line x1="9" y1="9" x2="15" y2="15" />
|
||||
</svg>
|
||||
</div>
|
||||
</div>
|
||||
<h3 className="text-base font-extrabold mb-1 tracking-tight !mt-0 text-destructive">{t('errorTitle') || 'Submission Failed'}</h3>
|
||||
<p className="text-destructive/80 text-xs leading-tight mb-4 !mt-0">
|
||||
<h3 className="text-base font-extrabold mb-1 tracking-tight !mt-0 text-destructive text-center w-full">
|
||||
{t('errorTitle') || 'Submission Failed'}
|
||||
</h3>
|
||||
<p className="text-destructive/80 text-xs leading-tight mb-4 !mt-0 text-center w-full">
|
||||
{t('errorDesc') || 'Something went wrong. Please try again.'}
|
||||
</p>
|
||||
<button
|
||||
<Button
|
||||
onClick={() => setStatus('idle')}
|
||||
className="inline-flex items-center text-[9px] font-bold uppercase tracking-[0.2em] text-destructive hover:text-destructive/80 transition-colors group"
|
||||
variant="destructive"
|
||||
size="sm"
|
||||
className="w-full"
|
||||
>
|
||||
<span className="border-b-2 border-destructive/10 group-hover:border-destructive/30 transition-colors pb-1">
|
||||
{t('tryAgain') || 'Try Again'}
|
||||
</span>
|
||||
</button>
|
||||
{t('tryAgain') || 'Try Again'}
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -133,22 +157,48 @@ export default function RequestQuoteForm({ productName }: RequestQuoteFormProps)
|
||||
>
|
||||
{status === 'submitting' ? (
|
||||
<>
|
||||
<svg className="animate-spin h-3 w-3 text-white" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24">
|
||||
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4"></circle>
|
||||
<path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
|
||||
<svg
|
||||
className="animate-spin h-3 w-3 text-white"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<circle
|
||||
className="opacity-25"
|
||||
cx="12"
|
||||
cy="12"
|
||||
r="10"
|
||||
stroke="currentColor"
|
||||
strokeWidth="4"
|
||||
></circle>
|
||||
<path
|
||||
className="opacity-75"
|
||||
fill="currentColor"
|
||||
d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"
|
||||
></path>
|
||||
</svg>
|
||||
<span className="text-xs">{t('submitting')}</span>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<span className="text-xs">{t('submit')}</span>
|
||||
<svg className="w-3 h-3 transition-transform group-hover:translate-x-1" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M17 8l4 4m0 0l-4 4m4-4H3" />
|
||||
<svg
|
||||
className="w-3 h-3 transition-transform group-hover:translate-x-1"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2}
|
||||
d="M17 8l4 4m0 0l-4 4m4-4H3"
|
||||
/>
|
||||
</svg>
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
|
||||
|
||||
<p className="text-[7px] text-center text-text-secondary/40 uppercase tracking-[0.15em] font-medium px-2 !mt-1 !mb-0">
|
||||
{t('privacyNote')}
|
||||
</p>
|
||||
|
||||
@@ -3,7 +3,6 @@
|
||||
import { useEffect } from 'react';
|
||||
import { usePathname, useSearchParams } from 'next/navigation';
|
||||
import { getAppServices } from '@/lib/services/create-services';
|
||||
import Script from 'next/script';
|
||||
|
||||
/**
|
||||
* AnalyticsProvider Component
|
||||
@@ -11,48 +10,35 @@ import Script from 'next/script';
|
||||
* Automatically tracks pageviews on client-side route changes.
|
||||
* This component should be placed inside your layout to handle navigation events.
|
||||
*
|
||||
* @param {Object} props - Component props
|
||||
* @param {string} [props.websiteId] - The Umami website ID (passed from server config)
|
||||
*
|
||||
* @example
|
||||
* ```tsx
|
||||
* // In your layout.tsx
|
||||
* <NextIntlClientProvider messages={messages} locale={locale}>
|
||||
* <UmamiScript />
|
||||
* <Header />
|
||||
* <main>{children}</main>
|
||||
* <Footer />
|
||||
* <AnalyticsProvider />
|
||||
* </NextIntlClientProvider>
|
||||
* const { websiteId } = config.analytics.umami;
|
||||
* <AnalyticsProvider websiteId={websiteId} />
|
||||
* ```
|
||||
*/
|
||||
export default function AnalyticsProvider() {
|
||||
export default function AnalyticsProvider({ websiteId }: { websiteId?: string }) {
|
||||
const pathname = usePathname();
|
||||
const searchParams = useSearchParams();
|
||||
|
||||
useEffect(() => {
|
||||
if (!pathname) return;
|
||||
|
||||
|
||||
const services = getAppServices();
|
||||
const url = `${pathname}${searchParams?.size ? `?${searchParams.toString()}` : ''}`;
|
||||
|
||||
|
||||
// Track pageview with the full URL
|
||||
services.analytics.trackPageview(url);
|
||||
|
||||
|
||||
if (process.env.NODE_ENV === 'development') {
|
||||
console.log('[Umami] Tracked pageview:', url);
|
||||
}
|
||||
}, [pathname, searchParams]);
|
||||
|
||||
const websiteId = process.env.NEXT_PUBLIC_UMAMI_WEBSITE_ID;
|
||||
if (!websiteId) return null;
|
||||
|
||||
return (
|
||||
<Script
|
||||
id="umami-analytics"
|
||||
src="/stats/script.js"
|
||||
data-website-id={websiteId}
|
||||
strategy="afterInteractive"
|
||||
data-domains="klz-cables.com"
|
||||
defer
|
||||
/>
|
||||
);
|
||||
return null;
|
||||
}
|
||||
|
||||
|
||||
@@ -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 }}
|
||||
|
||||
@@ -3,24 +3,43 @@ import Link from 'next/link';
|
||||
import { cn } from './utils';
|
||||
|
||||
export interface ButtonProps extends React.ButtonHTMLAttributes<HTMLButtonElement> {
|
||||
variant?: 'primary' | 'secondary' | 'accent' | 'saturated' | 'outline' | 'ghost' | 'white';
|
||||
variant?:
|
||||
| 'primary'
|
||||
| 'secondary'
|
||||
| 'accent'
|
||||
| 'saturated'
|
||||
| 'outline'
|
||||
| 'ghost'
|
||||
| 'white'
|
||||
| 'destructive';
|
||||
size?: 'sm' | 'md' | 'lg' | 'xl';
|
||||
href?: string;
|
||||
className?: string;
|
||||
children?: React.ReactNode;
|
||||
}
|
||||
|
||||
export function Button({ className, variant = 'primary', size = 'md', href, ...props }: ButtonProps) {
|
||||
const baseStyles = 'inline-flex items-center justify-center rounded-full font-semibold transition-all duration-500 ease-out focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary focus-visible:ring-offset-2 disabled:opacity-50 disabled:pointer-events-none active:scale-95 hover:-translate-y-1 hover:scale-[1.02] relative overflow-hidden group/btn isolate';
|
||||
|
||||
export function Button({
|
||||
className,
|
||||
variant = 'primary',
|
||||
size = 'md',
|
||||
href,
|
||||
...props
|
||||
}: ButtonProps) {
|
||||
const baseStyles =
|
||||
'inline-flex items-center justify-center rounded-full font-semibold transition-all duration-500 ease-out focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary focus-visible:ring-offset-2 disabled:opacity-50 disabled:pointer-events-none active:scale-95 hover:-translate-y-1 hover:scale-[1.02] relative overflow-hidden group/btn isolate';
|
||||
|
||||
const variants = {
|
||||
primary: 'bg-primary text-white shadow-md hover:shadow-primary/30 hover:shadow-2xl',
|
||||
secondary: 'bg-secondary text-white shadow-md hover:shadow-secondary/30 hover:shadow-2xl',
|
||||
accent: 'bg-accent text-primary-dark shadow-md hover:shadow-accent/30 hover:shadow-2xl',
|
||||
saturated: 'bg-saturated text-white shadow-md hover:shadow-primary/30 hover:shadow-2xl',
|
||||
outline: 'border-2 border-primary bg-transparent text-primary hover:text-white hover:shadow-primary/20 hover:shadow-xl',
|
||||
outline:
|
||||
'border-2 border-primary bg-transparent text-primary hover:text-white hover:shadow-primary/20 hover:shadow-xl',
|
||||
ghost: 'text-primary hover:shadow-lg',
|
||||
white: 'bg-white text-primary shadow-md hover:shadow-primary/30 hover:shadow-2xl hover:text-white',
|
||||
white:
|
||||
'bg-white text-primary shadow-md hover:shadow-primary/30 hover:shadow-2xl hover:text-white',
|
||||
destructive:
|
||||
'bg-destructive text-destructive-foreground shadow-md hover:shadow-destructive/30 hover:shadow-2xl',
|
||||
};
|
||||
|
||||
const sizes = {
|
||||
@@ -40,20 +59,25 @@ export function Button({ className, variant = 'primary', size = 'md', href, ...p
|
||||
outline: 'bg-primary',
|
||||
ghost: 'bg-primary-light/10',
|
||||
white: 'bg-primary-light',
|
||||
destructive: 'bg-destructive/90',
|
||||
};
|
||||
|
||||
const content = (
|
||||
<>
|
||||
<span className={cn(
|
||||
"relative z-10 flex items-center justify-center gap-2 transition-colors duration-500",
|
||||
variant === 'white' ? "group-hover/btn:text-primary-dark" : ""
|
||||
)}>
|
||||
<span
|
||||
className={cn(
|
||||
'relative z-10 flex items-center justify-center gap-2 transition-colors duration-500',
|
||||
variant === 'white' ? 'group-hover/btn:text-primary-dark' : '',
|
||||
)}
|
||||
>
|
||||
{props.children}
|
||||
</span>
|
||||
<span className={cn(
|
||||
"absolute inset-0 translate-y-[101%] group-hover/btn:translate-y-0 transition-transform duration-500 ease-out",
|
||||
overlayColors[variant]
|
||||
)} />
|
||||
<span
|
||||
className={cn(
|
||||
'absolute inset-0 translate-y-[101%] group-hover/btn:translate-y-0 transition-transform duration-500 ease-out',
|
||||
overlayColors[variant],
|
||||
)}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
|
||||
|
||||
@@ -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]',
|
||||
|
||||
4
cookies.txt
Normal file
4
cookies.txt
Normal file
@@ -0,0 +1,4 @@
|
||||
# Netscape HTTP Cookie File
|
||||
# https://curl.se/docs/http-cookies.html
|
||||
# This file was generated by libcurl! Edit at your own risk.
|
||||
|
||||
Binary file not shown.
Binary file not shown.
@@ -5,7 +5,7 @@ featuredImage: null
|
||||
locale: de
|
||||
---
|
||||
|
||||
*Stand November 2024*
|
||||
*Stand Januar 2026*
|
||||
|
||||
## 1. Allgemeines
|
||||
|
||||
@@ -21,13 +21,12 @@ Sofern nicht ausdrücklich als bindend bezeichnet, sind unsere Angebote freiblei
|
||||
|
||||
## 3. Preise
|
||||
|
||||
Alle von uns genannten Preise verstehen sich zzgl. der jeweiligen gesetzlichen Mehrwertsteuer vor Metallzuschlag fracht- frei innerhalb der Bundesrepublik Deutschland (Festland), jedoch ohne Abladen. Die Verkaufspreise, soweit sie als Hohlpreis deklariert sind, enthalten keinerlei Metallwerte. Diese werden zusätzlich separat berechnet.
|
||||
Die Preise gelten für den in unseren Angeboten und Auftragsbestätigungen aufgeführten Leistungs- und Lieferumfang. Mehrleistungen werden gesondert berechnet. Die Hohlpreise verstehen sich in Euro zuzüglich Metallzuschlag, gegebenenfalls Verpackung, auftragsspezifischer Schnittkosten und der gesetzlichen Mehrwertsteuer.
|
||||
|
||||
## 4. Metallnotierung
|
||||
|
||||
Basis zur Kupferabrechnung ist die Notierung "LME Copper official price cash offer", Durchschnitt des Liefervormonats zuzüglich der dann aktuellen von uns benannten Kupfer-Prämie.
|
||||
|
||||
Basis zur Aluminiumabrechnung ist die Notierung "LME Aluminium official price cash offer", Durchschnitt des Liefervormonats zuzüglich der dann von uns benannten Aluminium-Prämie. USD werden auf Basis des EUR/USD LME-FX-Rate (MTLE) in EUR umgerechnet. Die entsprechenden Notierungen können Sie der Web-Seite [www.westmetall.com](https://www.westmetall.com) entnehmen. Die Prämienzuschläge können stark variieren und KLZ behält sich das Recht vor, diese fristgerecht anzupassen, ungeachtet der Angebotslegung.
|
||||
Basis zur Kupferabrechnung ist die Notierung „LME Copper official price cash offer“, Durchschnitt des Liefervormonats zuzüglich der dann aktuellen von uns benannten Kupfer-Prämie.
|
||||
Basis zur Aluminiumabrechnung ist die Notierung „LME Aluminium official price cash offer“, Durchschnitt des Liefervormonats zuzüglich der dann von uns benannten Aluminium-Prämie. USD werden auf Basis des EUR/USD LME-FX-Rate (MTLE) in EUR umgerechnet. Die entsprechenden Notierungen können Sie der Web-Seite [www.westmetall.com](https://www.westmetall.com) entnehmen. Die Prämienzuschläge können stark variieren und KLZ behält sich das Recht vor, diese fristgerecht anzupassen, ungeachtet der Angebotslegung.
|
||||
|
||||
## 5. Metallzahl
|
||||
|
||||
@@ -43,7 +42,7 @@ Wir behalten uns an den von uns gelieferten Waren – nachfolgend: Vorbehaltswar
|
||||
|
||||
## 8. Zahlungsbedingungen | Aufrechnung | Zurückbehaltungsrechte
|
||||
|
||||
Unsere Rechnungen sind 14 Tage nach Rechnungsdatum ohne jeden Abzug zahlbar. Bei Nichteinhaltung der vereinbarten Zahlungsbedingungen sind wir berechtigt, Zinsen in Höhe von 7 %-Punkten über dem Basiszinssatz zu berechnen; das Recht zur Geltendmachung weitergehender Schäden, insbesondere nachgewiesener höherer Zinsen, bleibt hiervon unberührt.
|
||||
Unsere Rechnungen sind 10 Tage nach Rechnungsdatum ohne jeden Abzug zahlbar. Rechnungsstellung bzw. Datum ist grundsätzlich der Tag der Übergabe an den Spediteur soweit wir aus unseren deutschen Lägern liefern. Ansonsten gilt bei Direktimporten der Tag der Verzollung, der zeitnah zum Anliefertag liegt. Bei Nichteinhaltung der vereinbarten Zahlungsbedingungen sind wir berechtigt, Zinsen in Höhe von 7 %-Punkten über dem Basiszinssatz zu berechnen; das Recht zur Geltendmachung weitergehender Schäden, insbesondere nachgewiesener höherer Zinsen, bleibt hiervon unberührt.
|
||||
|
||||
## 9. Liefervorbehalt | Teillieferungen
|
||||
|
||||
@@ -69,13 +68,13 @@ Wird uns ein Abrufauftrag erteilt und werden über die Abruftermine keine gesond
|
||||
|
||||
## 12. Maß- und Gewichtsangaben
|
||||
|
||||
Alle Angaben über Durchmesser, Gewicht, technische Gestaltung, Herstellung und Umfang der von uns zu liefernden Ware stehen unter dem Vorbehalt der Abweichung innerhalb der handelsüblichen zulässigen Toleranzen. Darüber hinaus behalten wir uns Änderungen, die einer technischen Verbesserung dienen, jederzeit vor. Farbabweichungen und/oder Abweichungen in der äußeren Beschaffenheit der von uns zu liefernden Ware, die jedoch deren Qualität und technische Wirksamkeit unbeeinflusst lassen, begründen keine Mängelhaftungsansprüche des Bestellers.
|
||||
Alle Angaben über Durchmesser, Gewicht, technische Gestaltung, Herstellung und Umfang der von uns zu liefernden Ware stehen unter dem Vorbehalt der Abweichung innerhalb der handelsüblichen zulässigen Toleranzen. Darüber hinaus behalten wir uns Änderungen, die einer technischen Verbesserung dienen, jederzeit vor. Farbabweichungen und/oder Abweichungen in der äußeren Beschaffenheit der von uns zu liefernden Ware, die jedoch deren Qualität und technische Wirksamkeit unbeeinflusst lässt, begründen keine Mängelhaftungsansprüche des Bestellers.
|
||||
|
||||
## 13. Gefahrübergang und -tragung
|
||||
|
||||
Die Lieferung erfolgt DAP frei Bestimmungsort Deutschland, wo auch der Erfüllungsort für die Lieferung und eine etwaige Nacherfüllung ist.
|
||||
|
||||
Wird die bestellte Ware von uns versandbereit gestellt und/oder verzögert sich die Versendung und/oder der Abruf aus Gründen, die vom Besteller zu vertreten sind, sind wir berechtigt, Ersatz des hieraus entstehenden Schadens einschließlich Mehraufwendungen zu verlangen. Hierfür berechnen wir eine pauschale Entschädigung i.H.v 2% des Rechnungsbetrages für jeden angefangenen Monat, maximal jedoch 10 % insgesamt beginnend mit der Lieferfrist bzw. – mangels einer Lieferfrist – mit der Mitteilung der Versandbereitschaft der Ware.
|
||||
Wird die bestellte Ware von uns versandbereit gestellt und/oder verzögert sich die Versendung und/oder der Abruf aus Gründen, die vom Besteller zu vertreten sind, sind wir berechtigt, Ersatz des hieraus entstehenden Schadens einschließlich Mehraufwendungen für Einlagerungen zu verlangen. Hierfür berechnen wir eine pauschale Entschädigung i.H. von 2% des Rechnungsbetrages für jeden angefangenen Monat, maximal jedoch 10 % insgesamt beginnend mit der Lieferfrist bzw. – mangels einer Lieferfrist – mit der Mitteilung der Versandbereitschaft der Ware.
|
||||
|
||||
Der Nachweis eines höheren Schadens und unsere gesetzlichen Ansprüche (insbesondere Ersatz von Mehraufwendungen, angemessene Entschädigung, Kündigung) bleiben unberührt; die Pauschale ist aber auf weitergehende Geldansprüche anzurechnen. Dem Besteller bleibt der Nachweis gestattet, dass uns überhaupt kein oder nur ein wesentlich geringerer Schaden als vorstehende Pauschale entstanden ist. Rücksendungen an uns, die nicht vorher von uns schriftlich bestätigt worden sind, erfolgen auf alleinige Gefahr des Bestellers.
|
||||
|
||||
@@ -93,7 +92,9 @@ Weitergehende Ansprüche des Bestellers, gleich aus welchem Rechtsgrund, sind na
|
||||
|
||||
Die Verjährungsfristen für Mängelhaftungsansprüche beträgt 24 Monate ab Übergabe der Ware.
|
||||
|
||||
Sollte es bei einer Mängelrüge zu unterschiedlichen Meinungen bezüglich des Kabelschaden kommen, gilt hier im Zweifelsfall nur die Expertise des VDE-Instituts selbst. Andere, auch akkreditierte Testlabore, akzeptieren wir nicht. Wir weisen ausdrücklich daraufhin, dass beim Verlegen des Kabels in den Graben oder in Rohren, bzw. in Bauwerke eine ständige Sichtkontrolle durch den Kabelverleger vorzunehmen ist, ob Auffälligkeiten zu vermerken sind. Eine spätere Reklamation, die fahrlässiges Verhalten vermuten lässt, schränkt sich damit ein.
|
||||
Sollte es bei einer Mängelrüge zu unterschiedlichen Meinungen bezüglich des Kabelschaden kommen, gilt hier im Zweifelsfall nur die Expertise des VDE-Instituts selbst. Andere, auch akkreditierte Testlabore, akzeptieren wir nicht.
|
||||
|
||||
Wir weisen ausdrücklich daraufhin, dass beim Verlegen des Kabels in den Gräben oder in Rohren, bzw. in Bauwerke eine ständige Sichtkontrolle durch den Kabelverleger vorzunehmen ist, ob Auffälligkeiten zu vermerken sind. Eine spätere Reklamation, die fahrlässiges Verhalten vermuten lässt, schränkt sich damit ein. Dies gilt auch bei der Annahme der Ware, wo offensichtliche Beschädigungen direkt zu kommunizieren sind. Spätere Ansprüche nach Akzeptanz einer einwandfreien Belieferung sind detailliert zu beweisen.
|
||||
|
||||
## 15. Schadenersatz | Gesamthaftung
|
||||
|
||||
@@ -109,4 +110,6 @@ Es gilt ausschließlich das Recht der Bundesrepublik Deutschland unter Ausschlus
|
||||
|
||||
Mit der Veröffentlichung der vorliegenden L&Z im Internet werden alle von uns früher verwendeten Bedingungen gegenstandslos.
|
||||
|
||||
Remshalden, 28.1.2026
|
||||
|
||||
[Download als PDF](/AGB-KLZ-1-2026.pdf)
|
||||
|
||||
@@ -5,7 +5,7 @@ featuredImage: null
|
||||
locale: en
|
||||
---
|
||||
|
||||
*Status November 2024*
|
||||
*Status January 2026*
|
||||
|
||||
## 1. General
|
||||
|
||||
@@ -21,7 +21,7 @@ Unless expressly designated as binding, our offers are non-binding; the customer
|
||||
|
||||
## 3. Prices
|
||||
|
||||
All prices stated by us are understood plus the respective statutory value-added tax, before metal surcharge, freight-free within the Federal Republic of Germany (mainland), however without unloading. The sales prices, insofar as they are declared as hollow prices, contain no metal values whatsoever. These are additionally calculated separately.
|
||||
The prices apply to the scope of services and deliveries listed in our offers and order confirmations. Additional services will be charged separately. The hollow prices are in Euro plus metal surcharge, if applicable packaging, order-specific cutting costs and the statutory value-added tax.
|
||||
|
||||
## 4. Metal quotation
|
||||
|
||||
@@ -43,7 +43,7 @@ We retain title to the goods delivered by us – hereinafter: reserved goods –
|
||||
|
||||
## 8. Payment terms | Offsetting | Right of retention
|
||||
|
||||
Our invoices are payable 14 days after invoice date without any deduction. In case of non-compliance with the agreed payment terms, we are entitled to calculate interest at a rate of 7 percentage points above the base interest rate; the right to assert further damages, in particular proven higher interest, remains unaffected by this.
|
||||
Our invoices are payable 10 days after invoice date without any deduction. Invoicing or date is basically the day of handover to the forwarder as far as we deliver from our German warehouses. Otherwise, in the case of direct imports, the day of customs clearance, which is close to the delivery day, applies. In case of non-compliance with the agreed payment terms, we are entitled to calculate interest at a rate of 7 percentage points above the base interest rate; the right to assert further damages, in particular proven higher interest, remains unaffected by this.
|
||||
|
||||
## 9. Delivery reservation | Partial deliveries
|
||||
|
||||
@@ -69,13 +69,13 @@ If a call-off order is issued to us and no separate written agreements are made
|
||||
|
||||
## 12. Dimension and weight specifications
|
||||
|
||||
All information about diameter, weight, technical design, manufacture and scope of the goods to be delivered by us are subject to the reservation of deviation within the commercially usual permissible tolerances. Darüber hinaus behalten wir uns Änderungen, die einer technischen Verbesserung dienen, jederzeit vor. Color deviations and/or deviations in the external characteristics of the goods to be delivered by us, which however leave their quality and technical effectiveness unaffected, do not give rise to any claims for defects by the orderer.
|
||||
All information about diameter, weight, technical design, manufacture and scope of the goods to be delivered by us are subject to the reservation of deviation within the commercially usual permissible tolerances. Furthermore, we reserve the right to make changes that serve technical improvement at any time. Color deviations and/or deviations in the external characteristics of the goods to be delivered by us, which however leave their quality and technical effectiveness unaffected, do not give rise to any claims for defects by the orderer.
|
||||
|
||||
## 13. Transfer of risk and burden
|
||||
|
||||
Delivery is made DAP free destination Germany, where the place of performance for delivery and any subsequent performance is also located.
|
||||
|
||||
If the ordered goods are made ready for shipment by us and/or the dispatch and/or the call-off is delayed for reasons for which the orderer is responsible, we are entitled to demand compensation for the damage resulting therefrom including additional expenses. For this, we calculate a flat-rate compensation of 2% of the invoice amount for each month started, however maximum 10% in total, starting with the delivery period or – lacking a delivery period – with the notification of readiness for shipment of the goods.
|
||||
If the ordered goods are made ready for shipment by us and/or the dispatch and/or the call-off is delayed for reasons for which the orderer is responsible, we are entitled to demand compensation for the damage resulting therefrom including additional expenses for storage. For this, we calculate a flat-rate compensation of 2% of the invoice amount for each month started, however maximum 10% in total, starting with the delivery period or – lacking a delivery period – with the notification of readiness for shipment of the goods.
|
||||
|
||||
Proof of higher damage and our legal claims (in particular compensation for additional expenses, reasonable compensation, termination) remain unaffected; the flat-rate is however to be credited against further monetary claims. The orderer is permitted to prove that no damage or only a substantially lower damage than the aforementioned flat-rate has arisen for us. Returns to us, which have not been confirmed by us in writing beforehand, are at the sole risk of the orderer.
|
||||
|
||||
@@ -93,7 +93,9 @@ Further claims of the orderer, regardless of the legal basis, are excluded or li
|
||||
|
||||
The limitation periods for claims for defects are 24 months from delivery of the goods.
|
||||
|
||||
If there are different opinions regarding cable damage in the event of a defect notification, only the expertise of the VDE Institute itself applies in case of doubt. We do not accept other, even accredited test laboratories. We expressly point out that when laying the cable in the trench or in pipes, or in buildings, a constant visual inspection must be carried out by the cable layer to check for any noticeable features. A later complaint that suggests negligent behavior is thus restricted.
|
||||
If there are different opinions regarding cable damage in the event of a defect notification, only the expertise of the VDE Institute itself applies in case of doubt. We do not accept other, even accredited test laboratories.
|
||||
|
||||
We expressly point out that when laying the cable in the trench or in pipes, or in buildings, a constant visual inspection must be carried out by the cable layer to check for any noticeable features. A later complaint that suggests negligent behavior is thus restricted. This also applies to the acceptance of the goods, where obvious damage must be communicated directly. Subsequent claims after acceptance of a faultless delivery must be proven in detail.
|
||||
|
||||
## 15. Damages | Total liability
|
||||
|
||||
@@ -109,4 +111,6 @@ Only the law of the Federal Republic of Germany applies, excluding the UN Conven
|
||||
|
||||
With the publication of these DPT on the Internet, all previously used conditions of ours become void.
|
||||
|
||||
Remshalden, January 28, 2026
|
||||
|
||||
[Download as PDF](/AGB-KLZ-1-2026.pdf)
|
||||
|
||||
@@ -10,6 +10,12 @@ categories:
|
||||
- Solarkabel
|
||||
images:
|
||||
- /uploads/2025/06/H1Z2Z2-K-scaled.webp
|
||||
application: >
|
||||
Das H1Z2Z2-K entspricht der Norm DIN EN 50618 (VDE 0283-618) und ist speziell für die
|
||||
Verkabelung von Photovoltaiksystemen konzipiert. Es kann fest verlegt oder flexibel
|
||||
geführt werden – im Gebäude, im Freien, in Industrieanlagen, landwirtschaftlichen
|
||||
Betrieben oder sogar in explosionsgefährdeten Bereichen. Die Leitung ist UV-, ozon-
|
||||
und wasserbeständig (AD7) und darf direkt in der Erde verlegt werden.
|
||||
locale: de
|
||||
---
|
||||
<ProductTabs technicalData={<ProductTechnicalData data={{
|
||||
|
||||
@@ -10,6 +10,12 @@ categories:
|
||||
- Niederspannungskabel
|
||||
images:
|
||||
- /uploads/2025/01/N2X2Y-scaled.webp
|
||||
application: >
|
||||
Das N2X2Y entspricht den Normen HD 603 S1 Teil 5G und HD 627 S1 Teil 4H (gleichlautend
|
||||
mit DIN VDE 0276-603 und -627) und ist für eine Betriebsfrequenz von 50 Hz ausgelegt.
|
||||
Es eignet sich für die feste Verlegung in Innenräumen, im Erdreich, im Freien und in
|
||||
Industrieumgebungen mit hohen Temperatur- und Belastungsanforderungen. Die maximale
|
||||
Betriebstemperatur liegt bei +90 °C, im Kurzschlussfall sind +250 °C zulässig.
|
||||
locale: de
|
||||
---
|
||||
<ProductTabs technicalData={<ProductTechnicalData data={{
|
||||
|
||||
@@ -9,6 +9,12 @@ description: >
|
||||
categories:
|
||||
- Hochspannungskabel
|
||||
images: []
|
||||
application: >
|
||||
Die N2X(F)K2Y-Hochspannungskabelserie ist speziell für den Einsatz in
|
||||
Hochspannungsanwendungen konzipiert, wobei sie eine optimale Leistung durch die
|
||||
Verwendung von hochleitfähigem Kupfer und einer fortschrittlichen XLPE-Isolierung
|
||||
bietet. Diese Kombination gewährleistet eine hohe Durchschlagsfestigkeit und eine
|
||||
effiziente Thermozyklierung unter verschiedenen Betriebsbedingungen.
|
||||
locale: de
|
||||
---
|
||||
<ProductTabs technicalData={<ProductTechnicalData data={{
|
||||
|
||||
@@ -9,6 +9,13 @@ description: >
|
||||
categories:
|
||||
- Hochspannungskabel
|
||||
images: []
|
||||
application: >
|
||||
Die N2X(F)KLD2Y-Hochspannungskabel Serie 2 sind speziell für den Einsatz in
|
||||
Hochspannungsanwendungen konzipiert, wobei sie eine optimale Leistung durch die
|
||||
Verwendung von hochleitfähigen Kupferleitern und einer fortschrittlichen
|
||||
XLPE-Isolierung bieten. Diese Kabelserie ist besonders geeignet für anspruchsvolle
|
||||
industrielle Umgebungen, wo hohe Durchschlagsfestigkeit und
|
||||
Thermozyklierungsfähigkeit erforderlich sind.
|
||||
locale: de
|
||||
---
|
||||
<ProductTabs technicalData={<ProductTechnicalData data={{
|
||||
|
||||
@@ -10,6 +10,12 @@ categories:
|
||||
- Mittelspannungskabel
|
||||
images:
|
||||
- /uploads/2025/01/N2XS2Y-scaled.webp
|
||||
application: >
|
||||
Das N2XS2Y erfüllt die Normen DIN VDE 0276-620, HD 620 S2 und IEC 60502. Es eignet sich
|
||||
zur Verlegung in Innenräumen, Kabelkanälen, im Freien, im Wasser, auf Kabelpritschen
|
||||
und insbesondere im Erdreich. Aufgrund seines widerstandsfähigen Mantels wird es
|
||||
häufig in Industrieanlagen, Kraftwerken und Schaltstationen eingesetzt, wo Stabilität
|
||||
und Langlebigkeit gefordert sind.
|
||||
locale: de
|
||||
---
|
||||
<ProductTabs technicalData={<ProductTechnicalData data={{
|
||||
|
||||
@@ -10,6 +10,12 @@ categories:
|
||||
- Mittelspannungskabel
|
||||
images:
|
||||
- /uploads/2025/01/N2XSF2Y-3-scaled.webp
|
||||
application: >
|
||||
Das N2XS(F)2Y erfüllt die gängigen Normen DIN VDE 0276-620, HD 620 S2 und IEC 60502 und
|
||||
ist für die Verlegung in Innenräumen, Kabelkanälen, im Freien, in Wasser, Erde und auf
|
||||
Kabelpritschen geeignet. Besonders in EVU-Netzen, Industrieanlagen und Kraftwerken
|
||||
spielt dieses Kabel seine Stärken aus – überall dort, wo Langlebigkeit,
|
||||
Wasserdichtigkeit und Sicherheit gefragt sind.
|
||||
locale: de
|
||||
---
|
||||
<ProductTabs technicalData={<ProductTechnicalData data={{
|
||||
|
||||
@@ -10,6 +10,12 @@ categories:
|
||||
- Hochspannungskabel
|
||||
images:
|
||||
- /uploads/2025/06/N2XSFL2Y-3-scaled.webp
|
||||
application: >
|
||||
Das N2XS(FL)2Y ist konzipiert für die Verlegung im Erdreich, in Kabelkanälen, in Rohren,
|
||||
im Freien und in Innenräumen. Es entspricht der Norm IEC 60840 und lässt sich
|
||||
individuell auf projektspezifische Anforderungen anpassen. Typisch eingesetzt wird es
|
||||
in Übertragungsnetzen, Umspannwerken und großen Industrieanlagen, wo maximale
|
||||
Zuverlässigkeit gefordert ist.
|
||||
locale: de
|
||||
---
|
||||
<ProductTabs technicalData={<ProductTechnicalData data={{
|
||||
|
||||
@@ -10,6 +10,12 @@ categories:
|
||||
- Mittelspannungskabel
|
||||
images:
|
||||
- /uploads/2025/01/N2XSFL2Y-2-scaled.webp
|
||||
application: >
|
||||
Das N2XS(FL)2Y erfüllt die Standards DIN VDE 0276-620, HD 620 S2 und IEC 60502. Es
|
||||
eignet sich hervorragend für die Verlegung in Innenräumen, Kabelkanälen, im Freien, in
|
||||
Erde, im Wasser sowie auf Kabelpritschen – insbesondere in EVU-Netzen,
|
||||
Industrieanlagen und Schaltstationen, wo erhöhte Anforderungen an mechanische
|
||||
Belastbarkeit und Wasserdichtigkeit bestehen.
|
||||
locale: de
|
||||
---
|
||||
<ProductTabs technicalData={<ProductTechnicalData data={{
|
||||
|
||||
@@ -10,6 +10,12 @@ categories:
|
||||
- Mittelspannungskabel
|
||||
images:
|
||||
- /uploads/2025/01/N2XSY-scaled.webp
|
||||
application: >
|
||||
Das N2XSY erfüllt die Normen DIN VDE 0276-620, HD 620 S2 und IEC 60502. Es ist
|
||||
ausgelegt für die Verlegung in Innenräumen, Kabelkanälen, im Wasser, im Erdreich oder
|
||||
im Freien (bei geschützter Installation). Ob in Industrieanlagen, Kraftwerken oder
|
||||
Schaltanlagen – dieses Kabel sorgt für eine sichere und verlustarme
|
||||
Energieübertragung im Mittelspannungsbereich.
|
||||
locale: de
|
||||
---
|
||||
<ProductTabs technicalData={<ProductTechnicalData data={{
|
||||
|
||||
@@ -10,6 +10,13 @@ categories:
|
||||
- Niederspannungskabel
|
||||
images:
|
||||
- /uploads/2025/01/N2XY-scaled.webp
|
||||
application: >
|
||||
Das N2XY wird in Niederspannungsanlagen zur Energieverteilung eingesetzt – zum Beispiel
|
||||
in Kabeltrassen, Rohren, auf Wänden oder direkt im Erdreich. Es lässt sich sowohl im
|
||||
Innen- als auch im Außenbereich installieren und ist auch für feuchte Umgebungen
|
||||
geeignet. Dank verschiedener Aderkonfigurationen (einadrig bis vieradrig) und
|
||||
Querschnitten bis 630 mm² lässt sich das Kabel flexibel an die jeweilige Anwendung
|
||||
anpassen.
|
||||
locale: de
|
||||
---
|
||||
<ProductTabs technicalData={<ProductTechnicalData data={{
|
||||
|
||||
@@ -10,6 +10,12 @@ categories:
|
||||
- Niederspannungskabel
|
||||
images:
|
||||
- /uploads/2025/01/NA2X2Y-scaled.webp
|
||||
application: >
|
||||
Das NA2X2Y entspricht der Norm DIN VDE 0276-603 (HD 603) und ist ausgelegt für die
|
||||
feste Verlegung in Innenräumen, Kabelkanälen, im Erdreich, im Wasser oder im Freien.
|
||||
Es kommt bevorzugt in Kraftwerken, Industrieanlagen, Schaltanlagen und Ortsnetzen zum
|
||||
Einsatz – überall dort, wo robuste Kabel gefragt sind, die im Betrieb und bei der
|
||||
Verlegung hohen mechanischen Belastungen standhalten.
|
||||
locale: de
|
||||
---
|
||||
<ProductTabs technicalData={<ProductTechnicalData data={{
|
||||
|
||||
@@ -9,6 +9,12 @@ description: >
|
||||
categories:
|
||||
- Hochspannungskabel
|
||||
images: []
|
||||
application: >
|
||||
Die NA2X(F)K2Y-Hochspannungskabelserie ist speziell für den Einsatz in
|
||||
Hochspannungsanwendungen entwickelt worden, wobei sie sich durch eine hohe
|
||||
Strombelastbarkeit und exzellente Kurzschlussstromfestigkeit auszeichnet. Diese Kabel
|
||||
sind mit einer fortschrittlichen XLPE-Isolierung ausgestattet, die eine hohe
|
||||
Durchschlagsfestigkeit and Thermozyklierungsfähigkeit bietet.
|
||||
locale: de
|
||||
---
|
||||
<ProductTabs technicalData={<ProductTechnicalData data={{
|
||||
|
||||
@@ -9,6 +9,12 @@ description: >
|
||||
categories:
|
||||
- Hochspannungskabel
|
||||
images: []
|
||||
application: >
|
||||
Die NA2X(F)KLD2Y-Hochspannungskabelserie ist für den Einsatz in
|
||||
Hochspannungsanwendungen konzipiert und bietet eine optimale Lösung für
|
||||
anspruchsvolle industrielle Umgebungen. Mit ihrer fortschrittlichen
|
||||
XLPE-Isolierung und Kupferleitern erfüllt sie hohe Anforderungen an die
|
||||
Durchschlagsfestigkeit und Thermozyklierung.
|
||||
locale: de
|
||||
---
|
||||
<ProductTabs technicalData={<ProductTechnicalData data={{
|
||||
|
||||
@@ -10,6 +10,12 @@ categories:
|
||||
- Mittelspannungskabel
|
||||
images:
|
||||
- /uploads/2025/01/NA2XS2Y-scaled.webp
|
||||
application: >
|
||||
Das NA2XS2Y erfüllt die Normen DIN VDE 0276-620, HD 620 S2 und IEC 60502 und ist
|
||||
speziell für die feste Verlegung in Innenräumen, Kabelkanälen, im Freien, in Erde und
|
||||
in Wasser ausgelegt. Es findet seinen Einsatz in Industrieanlagen, Schaltstationen
|
||||
und Kraftwerken, besonders dort, wo das Kabel beim Verlegen oder im Betrieb
|
||||
mechanisch stark beansprucht wird.
|
||||
locale: de
|
||||
---
|
||||
<ProductTabs technicalData={<ProductTechnicalData data={{
|
||||
|
||||
@@ -10,6 +10,12 @@ categories:
|
||||
- Mittelspannungskabel
|
||||
images:
|
||||
- /uploads/2025/01/NA2XSF2Y-3-scaled.webp
|
||||
application: >
|
||||
Das NA2XS(F)2Y entspricht den Normen DIN VDE 0276-620, HD 620 S2 und IEC 60502. Es
|
||||
eignet sich für die Verlegung in Innenräumen, Kabelkanälen, im Erdreich, im Wasser, im
|
||||
Freien oder auf Kabelpritschen. Der Einsatzschwerpunkt liegt in EVU-Netzen,
|
||||
Industrieanlagen und Umspannwerken, wo zusätzliche Sicherheitsreserven gegen
|
||||
eindringende Feuchtigkeit und mechanische Belastung erforderlich sind.
|
||||
locale: de
|
||||
---
|
||||
<ProductTabs technicalData={<ProductTechnicalData data={{
|
||||
@@ -392,6 +398,24 @@ locale: de
|
||||
"55",
|
||||
"4195"
|
||||
]
|
||||
},
|
||||
{
|
||||
"configuration": "1x1200/35",
|
||||
"cells": [
|
||||
"Al",
|
||||
"RM",
|
||||
"0,95",
|
||||
"48,5",
|
||||
"0,0247",
|
||||
"3,4",
|
||||
"Auf Anfrage",
|
||||
"Auf Anfrage",
|
||||
"113",
|
||||
"2,4",
|
||||
"885",
|
||||
"59",
|
||||
"4800"
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
@@ -731,6 +755,24 @@ locale: de
|
||||
"60",
|
||||
"4634"
|
||||
]
|
||||
},
|
||||
{
|
||||
"configuration": "1x1200/35",
|
||||
"cells": [
|
||||
"Al",
|
||||
"RM",
|
||||
"1,05",
|
||||
"52,3",
|
||||
"0,0247",
|
||||
"5,5",
|
||||
"Auf Anfrage",
|
||||
"Auf Anfrage",
|
||||
"113",
|
||||
"2,4",
|
||||
"990",
|
||||
"66",
|
||||
"5200"
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
@@ -1070,6 +1112,24 @@ locale: de
|
||||
"65",
|
||||
"5093"
|
||||
]
|
||||
},
|
||||
{
|
||||
"configuration": "1x1200/35",
|
||||
"cells": [
|
||||
"Al",
|
||||
"RM",
|
||||
"1,15",
|
||||
"57,5",
|
||||
"0,0247",
|
||||
"8,0",
|
||||
"Auf Anfrage",
|
||||
"Auf Anfrage",
|
||||
"113",
|
||||
"2,4",
|
||||
"1065",
|
||||
"71",
|
||||
"5900"
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
@@ -10,6 +10,12 @@ categories:
|
||||
- Hochspannungskabel
|
||||
images:
|
||||
- /uploads/2025/06/NA2XSFL2Y-3-scaled.webp
|
||||
application: >
|
||||
Das NA2XS(FL)2Y erfüllt die Anforderungen der IEC 60840 und eignet sich für die
|
||||
Verlegung im Erdreich, in Kabelkanälen, in Innenräumen, in Rohren und im Freien. Es
|
||||
wird projektbezogen gefertigt und kommt insbesondere in Übertragungsnetzen,
|
||||
Versorgungs-Infrastrukturen und Umspannwerken zum Einsatz, wo Sicherheit und
|
||||
Langlebigkeit an erster Stelle stehen.
|
||||
locale: de
|
||||
---
|
||||
<ProductTabs technicalData={<ProductTechnicalData data={{
|
||||
|
||||
@@ -10,6 +10,13 @@ categories:
|
||||
- Mittelspannungskabel
|
||||
images:
|
||||
- /uploads/2025/01/NA2XSFL2Y-3-scaled.webp
|
||||
application: >
|
||||
Das NA2XS(FL)2Y erfüllt die Normen DIN VDE 0276-620, HD 620 S2 und IEC 60502. Es ist
|
||||
ideal für die Verlegung in Energieversorgungsnetzen (EVU), Innenräumen, Kabelkanälen,
|
||||
im Freien, in Erde und in Wasser geeignet. Dank seiner Konstruktion mit
|
||||
längswasserdichter Ausführung und Alu-PE-Schichtenmantel bleibt es auch bei
|
||||
Beschädigungen betriebssicher – der Wassereinfluss wird gezielt auf die Schadstelle
|
||||
begrenzt.
|
||||
locale: de
|
||||
---
|
||||
<ProductTabs technicalData={<ProductTechnicalData data={{
|
||||
|
||||
@@ -11,6 +11,12 @@ categories:
|
||||
- Mittelspannungskabel
|
||||
images:
|
||||
- /uploads/2025/01/NA2XSY-scaled.webp
|
||||
application: >
|
||||
Das NA2XSY erfüllt die Anforderungen der Normen DIN VDE 0276-620, HD 620 S2 und IEC
|
||||
60502. Es eignet sich für die Verlegung in Innenräumen, Kabelkanälen, im Erdreich, im
|
||||
Wasser oder im Freien – allerdings nur bei geschützter Verlegung. Typische
|
||||
Einsatzorte sind Industrieanlagen, Kraftwerke und Schaltanlagen, in denen
|
||||
Mittelspannung mit hoher Betriebssicherheit transportiert werden muss.
|
||||
locale: de
|
||||
---
|
||||
<ProductTabs technicalData={<ProductTechnicalData data={{
|
||||
|
||||
@@ -10,6 +10,11 @@ categories:
|
||||
- Niederspannungskabel
|
||||
images:
|
||||
- /uploads/2025/01/NA2XY-scaled.webp
|
||||
application: >
|
||||
Das NA2XY entspricht der Norm DIN VDE 0276-603 (HD 603) und ist ideal für die feste
|
||||
Verlegung in Innenräumen, Kabelkanälen, im Freien, im Wasser oder im Erdreich
|
||||
geeignet. Typische Einsatzorte sind Kraftwerke, Industrieanlagen, Schaltanlagen sowie
|
||||
Ortsnetze, bei denen mechanische Belastung im Betrieb berücksichtigt werden muss.
|
||||
locale: de
|
||||
---
|
||||
<ProductTabs technicalData={<ProductTechnicalData data={{
|
||||
|
||||
@@ -11,6 +11,12 @@ categories:
|
||||
- Niederspannungskabel
|
||||
images:
|
||||
- /uploads/2025/01/NAY2Y-scaled.webp
|
||||
application: >
|
||||
Das NAY2Y erfüllt die Anforderungen der Norm TP PRAKAB 12/03 in Anlehnung an VDE
|
||||
0276-603 und eignet sich für die feste Verlegung in Innenräumen, Kabelkanälen, im
|
||||
Erdreich, im Wasser und im Außenbereich. Es ist ideal für Anwendungen in Kraftwerken,
|
||||
Industrie- und Schaltanlagen sowie in lokalen Versorgungsnetzen – überall dort, wo
|
||||
mechanische Belastung im Betrieb oder bei der Verlegung eine Rolle spielt.
|
||||
locale: de
|
||||
---
|
||||
<ProductTabs technicalData={<ProductTechnicalData data={{
|
||||
|
||||
@@ -10,6 +10,12 @@ categories:
|
||||
- Niederspannungskabel
|
||||
images:
|
||||
- /uploads/2025/01/NAYCWY-scaled.webp
|
||||
application: >
|
||||
Das NAYCWY entspricht der Norm DIN VDE 0276-603 (HD 603) und eignet sich für den
|
||||
Einsatz in Kraftwerken, Industrieanlagen, Schaltanlagen und Ortsnetzen. Es lässt sich
|
||||
fest verlegen – in Innenräumen, Kabelkanälen, im Freien, im Erdreich oder in Wasser.
|
||||
Dank des konzentrischen Leiters bietet es zusätzlichen Schutz bei mechanischer
|
||||
Beschädigung und ermöglicht eine sichere Potenzialführung.
|
||||
locale: de
|
||||
---
|
||||
<ProductTabs technicalData={<ProductTechnicalData data={{
|
||||
|
||||
@@ -11,6 +11,12 @@ categories:
|
||||
- Niederspannungskabel
|
||||
images:
|
||||
- /uploads/2025/01/NAYY-scaled.webp
|
||||
application: >
|
||||
Das NAYY ist ein Energieverteilungskabel nach VDE 0276-603, das sich besonders für
|
||||
Anwendungen in Kraftwerken, Ortsnetzen, Industrie- und Schaltanlagen eignet. Dank
|
||||
seiner robusten Konstruktion lässt es sich fest verlegen – sei es im Innenraum, im
|
||||
Kabelkanal, im Freien oder im Erdreich. Auch bei Installation in Wasser bleibt das
|
||||
Kabel zuverlässig im Betrieb.
|
||||
locale: de
|
||||
---
|
||||
<ProductTabs technicalData={<ProductTechnicalData data={{
|
||||
|
||||
@@ -10,6 +10,12 @@ categories:
|
||||
- Niederspannungskabel
|
||||
images:
|
||||
- /uploads/2025/01/NY2Y-scaled.webp
|
||||
application: >
|
||||
Das NY2Y ist ein Niederspannungskabel für den Einsatz in Kraftwerken, Industrie- und
|
||||
Schaltanlagen sowie in Ortsnetzen. Es eignet sich für die feste Verlegung in
|
||||
Innenräumen, Kabelkanälen, im Freien, im Wasser und im Erdreich – überall dort, wo
|
||||
starke mechanische Belastungen beim Verlegen und im Betrieb zu erwarten sind. Die
|
||||
Konstruktion erfüllt die Vorgaben gemäß TP PRAKAB 16/03 in Anlehnung an VDE 0276-603.
|
||||
locale: de
|
||||
---
|
||||
<ProductTabs technicalData={<ProductTechnicalData data={{
|
||||
|
||||
@@ -10,6 +10,13 @@ categories:
|
||||
- Niederspannungskabel
|
||||
images:
|
||||
- /uploads/2025/01/NYCWY-scaled.webp
|
||||
application: >
|
||||
Das NYCWY gehört zu den klassischen Niederspannungskabeln nach VDE-Standard und ist
|
||||
für Nennspannungen bis 1 kV ausgelegt. Es kommt überall dort zum Einsatz, wo Energie
|
||||
zuverlässig verteilt werden muss – in Gebäuden, Industrieanlagen, Trafostationen oder
|
||||
direkt im Erdreich. Auch in Kabeltrassen, Betonumgebungen oder unter Wasser lässt es
|
||||
sich problemlos verlegen. Die Materialwahl sorgt dafür, dass dieses Kabel selbst
|
||||
unter rauen Bedingungen durchhält – ganz ohne zusätzliche Schutzmaßnahmen.
|
||||
locale: de
|
||||
---
|
||||
<ProductTabs technicalData={<ProductTechnicalData data={{
|
||||
|
||||
@@ -9,6 +9,9 @@ categories:
|
||||
- Niederspannungskabel
|
||||
images:
|
||||
- /uploads/2025/01/NYY-scaled.webp
|
||||
application: |
|
||||
Verwendung
|
||||
Das Kabel entspricht den Normen DIN VDE 0276-603. Es eignet sich für die Verlegung in Innenräumen, Kabelkanälen, im Erdreich, im Wasser, im Freien oder auf Kabelpritschen, sofern keine besonderen mechanischen Beanspruchungen zu erwarten sind. Der Einsatzschwerpunkt liegt in Kraftwerken, Industrieanlagen und Ortsnetzen.
|
||||
locale: de
|
||||
---
|
||||
<ProductTabs technicalData={<ProductTechnicalData data={{
|
||||
|
||||
@@ -9,6 +9,12 @@ categories:
|
||||
- Solar Cables
|
||||
images:
|
||||
- /uploads/2025/06/H1Z2Z2-K-scaled.webp
|
||||
application: >
|
||||
The H1Z2Z2-K complies with DIN EN 50618 (VDE 0283-618) and is specifically designed for
|
||||
the cabling of photovoltaic systems. It can be installed permanently or used flexibly –
|
||||
indoors, outdoors, in industrial facilities, agricultural operations, or even in
|
||||
hazardous (explosive) areas. The cable is UV-, ozone- and water-resistant (AD7) and
|
||||
can be laid directly in the ground.
|
||||
locale: en
|
||||
---
|
||||
<ProductTabs technicalData={<ProductTechnicalData data={{
|
||||
|
||||
@@ -10,6 +10,12 @@ categories:
|
||||
- Low Voltage Cables
|
||||
images:
|
||||
- /uploads/2025/01/N2X2Y-scaled.webp
|
||||
application: >
|
||||
The N2X2Y complies with HD 603 S1 Part 5G and HD 627 S1 Part 4H (equivalent to DIN VDE
|
||||
0276-603 and -627) and is designed for an operating frequency of 50 Hz. It is suitable
|
||||
for fixed installation indoors, underground, outdoors, and in industrial environments
|
||||
with high temperature and load requirements. The maximum operating temperature is
|
||||
+90 °C, and +250 °C is permissible under short-circuit conditions.
|
||||
locale: en
|
||||
---
|
||||
<ProductTabs technicalData={<ProductTechnicalData data={{
|
||||
|
||||
@@ -10,6 +10,12 @@ description: >
|
||||
categories:
|
||||
- High Voltage Cables
|
||||
images: []
|
||||
application: >
|
||||
The N2X(F)K2Y high-voltage cable series is tailored for robust performance in
|
||||
high-voltage power systems, featuring copper conductors and cross-linked
|
||||
polyethylene (XLPE) insulation. This combination ensures high dielectric strength
|
||||
and excellent thermal cycling resistance, crucial for maintaining integrity and
|
||||
functionality over long operational periods.
|
||||
locale: en
|
||||
---
|
||||
<ProductTabs technicalData={<ProductTechnicalData data={{
|
||||
|
||||
@@ -8,6 +8,11 @@ description: >
|
||||
categories:
|
||||
- High Voltage Cables
|
||||
images: []
|
||||
application: >
|
||||
The N2X(F)KLD2Y-high-voltage-cables-2 series is engineered to meet the rigorous
|
||||
demands of high-voltage power transmission with a focus on durability, efficiency,
|
||||
and safety. This series is ideal for applications requiring high dielectric
|
||||
strength and excellent thermal cycling resistance.
|
||||
locale: en
|
||||
---
|
||||
<ProductTabs technicalData={<ProductTechnicalData data={{
|
||||
|
||||
@@ -10,6 +10,12 @@ categories:
|
||||
- Medium Voltage Cables
|
||||
images:
|
||||
- /uploads/2025/01/N2XS2Y-scaled.webp
|
||||
application: >
|
||||
The N2XS2Y complies with the standards DIN VDE 0276-620, HD 620 S2 and IEC 60502. It is
|
||||
suitable for installation indoors, in cable ducts, outdoors, in water, on cable
|
||||
trays, and especially underground. Thanks to its robust sheath, it is frequently
|
||||
used in industrial plants, power stations, and switching stations, where stability
|
||||
and durability are essential.
|
||||
locale: en
|
||||
---
|
||||
<ProductTabs technicalData={<ProductTechnicalData data={{
|
||||
|
||||
@@ -10,6 +10,12 @@ categories:
|
||||
- Medium Voltage Cables
|
||||
images:
|
||||
- /uploads/2025/01/N2XSF2Y-3-scaled.webp
|
||||
application: >
|
||||
The N2XSF2Y complies with common standards DIN VDE 0276-620, HD 620 S2 and IEC 60502,
|
||||
and is suitable for installation indoors, in cable ducts, outdoors, in water,
|
||||
underground, and on cable trays. This cable proves its strengths especially in
|
||||
utility grids, industrial plants, and power stations – wherever durability,
|
||||
watertightness, and safety are essential.
|
||||
locale: en
|
||||
---
|
||||
<ProductTabs technicalData={<ProductTechnicalData data={{
|
||||
|
||||
@@ -10,6 +10,12 @@ categories:
|
||||
- High Voltage Cables
|
||||
images:
|
||||
- /uploads/2025/06/N2XSFL2Y-3-scaled.webp
|
||||
application: >
|
||||
The N2XS(FL)2Y is designed for installation in the ground, in cable ducts, pipes,
|
||||
outdoor areas, and indoor spaces. It complies with the IEC 60840 standard and can be
|
||||
tailored to specific project requirements. It is typically used in transmission
|
||||
networks, substations, and large industrial facilities where maximum reliability is
|
||||
essential.
|
||||
locale: en
|
||||
---
|
||||
<ProductTabs technicalData={<ProductTechnicalData data={{
|
||||
|
||||
@@ -10,6 +10,11 @@ categories:
|
||||
- Medium Voltage Cables
|
||||
images:
|
||||
- /uploads/2025/01/N2XSFL2Y-2-scaled.webp
|
||||
application: >
|
||||
The N2XS(FL)2Y meets the standards DIN VDE 0276-620, HD 620 S2 and IEC 60502. It is
|
||||
ideally suited for installation indoors, in cable ducts, outdoors, in soil, in water,
|
||||
and on cable trays – especially in utility grids, industrial plants, and switching
|
||||
stations, where high demands on mechanical strength and water resistance apply.
|
||||
locale: en
|
||||
---
|
||||
<ProductTabs technicalData={<ProductTechnicalData data={{
|
||||
|
||||
@@ -10,6 +10,11 @@ categories:
|
||||
- Medium Voltage Cables
|
||||
images:
|
||||
- /uploads/2025/01/N2XSY-scaled.webp
|
||||
application: >
|
||||
The N2XSY meets the standards DIN VDE 0276-620, HD 620 S2 and IEC 60502. It is designed
|
||||
for installation indoors, in cable ducts, in water, underground, or outdoors (when
|
||||
protected). Whether in industrial plants, power stations, or substations – this cable
|
||||
ensures safe and low-loss power transmission in medium-voltage networks.
|
||||
locale: en
|
||||
---
|
||||
<ProductTabs technicalData={<ProductTechnicalData data={{
|
||||
|
||||
@@ -10,6 +10,12 @@ categories:
|
||||
- Low Voltage Cables
|
||||
images:
|
||||
- /uploads/2025/01/N2XY-scaled.webp
|
||||
application: >
|
||||
The N2XY is used in low-voltage systems for power distribution – for example in cable
|
||||
trays, conduits, on walls, or directly underground. It can be installed both indoors
|
||||
and outdoors and is also suitable for humid environments. Thanks to various core
|
||||
configurations (single-core to four-core) and cross-sections up to 630 mm², the cable
|
||||
can be flexibly adapted to the respective application.
|
||||
locale: en
|
||||
---
|
||||
<ProductTabs technicalData={<ProductTechnicalData data={{
|
||||
|
||||
@@ -10,6 +10,12 @@ categories:
|
||||
- Low Voltage Cables
|
||||
images:
|
||||
- /uploads/2025/01/NA2X2Y-scaled.webp
|
||||
application: >
|
||||
The NA2X2Y complies with DIN VDE 0276-603 (HD 603) and is designed for fixed
|
||||
installation indoors, in cable ducts, underground, in water, or outdoors. It is
|
||||
primarily used in power plants, industrial facilities, and switching stations as well
|
||||
as local distribution networks – wherever robust cables are needed that can withstand
|
||||
high mechanical stress during installation and operation.
|
||||
locale: en
|
||||
---
|
||||
<ProductTabs technicalData={<ProductTechnicalData data={{
|
||||
|
||||
@@ -9,6 +9,11 @@ description: >
|
||||
categories:
|
||||
- High Voltage Cables
|
||||
images: []
|
||||
application: >
|
||||
The NA2X(F)K2Y high-voltage cable series is engineered to meet the rigorous demands
|
||||
of modern industrial and power distribution applications. It combines high-grade
|
||||
copper conductors with cross-linked polyethylene (XLPE) insulation, ensuring
|
||||
superior dielectric strength and thermal cycling resilience.
|
||||
locale: en
|
||||
---
|
||||
<ProductTabs technicalData={<ProductTechnicalData data={{
|
||||
|
||||
@@ -9,6 +9,11 @@ description: >
|
||||
categories:
|
||||
- High Voltage Cables
|
||||
images: []
|
||||
application: >
|
||||
The NA2X(F)KLD2Y high-voltage cable series is engineered to meet the rigorous
|
||||
demands of modern industrial electrical networks, offering high dielectric
|
||||
strength and excellent thermal cycling resistance. This series is ideal for
|
||||
applications that require reliable power distribution in high-voltage settings.
|
||||
locale: en
|
||||
---
|
||||
<ProductTabs technicalData={<ProductTechnicalData data={{
|
||||
|
||||
@@ -10,6 +10,12 @@ categories:
|
||||
- Medium Voltage Cables
|
||||
images:
|
||||
- /uploads/2025/01/NA2XS2Y-scaled.webp
|
||||
application: >
|
||||
The NA2XS2Y complies with DIN VDE 0276-620, HD 620 S2 and IEC 60502 standards, and is
|
||||
specifically designed for fixed installation indoors, in cable ducts, outdoors, in
|
||||
soil and water. It is commonly used in industrial facilities, switching stations, and
|
||||
power plants, especially where the cable is exposed to high mechanical stress during
|
||||
installation or operation.
|
||||
locale: en
|
||||
---
|
||||
<ProductTabs technicalData={<ProductTechnicalData data={{
|
||||
|
||||
@@ -10,6 +10,12 @@ categories:
|
||||
- Medium Voltage Cables
|
||||
images:
|
||||
- /uploads/2025/01/NA2XSF2Y-3-scaled.webp
|
||||
application: >
|
||||
The NA2XS(F)2Y complies with standards DIN VDE 0276-620, HD 620 S2 and IEC 60502. It is
|
||||
suitable for installation indoors, in cable ducts, underground, in water, outdoors,
|
||||
or on cable trays. Its main applications are in utility grids, industrial facilities,
|
||||
and substations, where additional safety reserves against moisture ingress and
|
||||
mechanical stress are required.
|
||||
locale: en
|
||||
---
|
||||
<ProductTabs technicalData={<ProductTechnicalData data={{
|
||||
@@ -392,6 +398,24 @@ locale: en
|
||||
"55",
|
||||
"4195"
|
||||
]
|
||||
},
|
||||
{
|
||||
"configuration": "1x1200/35",
|
||||
"cells": [
|
||||
"Al",
|
||||
"RM",
|
||||
"0.95",
|
||||
"48.5",
|
||||
"0.0247",
|
||||
"3.4",
|
||||
"On Request",
|
||||
"On Request",
|
||||
"113",
|
||||
"2.4",
|
||||
"885",
|
||||
"59",
|
||||
"4800"
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
@@ -731,6 +755,24 @@ locale: en
|
||||
"60",
|
||||
"4634"
|
||||
]
|
||||
},
|
||||
{
|
||||
"configuration": "1x1200/35",
|
||||
"cells": [
|
||||
"Al",
|
||||
"RM",
|
||||
"1.05",
|
||||
"52.3",
|
||||
"0.0247",
|
||||
"5.5",
|
||||
"On Request",
|
||||
"On Request",
|
||||
"113",
|
||||
"2.4",
|
||||
"990",
|
||||
"66",
|
||||
"5200"
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
@@ -1070,6 +1112,24 @@ locale: en
|
||||
"65",
|
||||
"5093"
|
||||
]
|
||||
},
|
||||
{
|
||||
"configuration": "1x1200/35",
|
||||
"cells": [
|
||||
"Al",
|
||||
"RM",
|
||||
"1.15",
|
||||
"57.5",
|
||||
"0.0247",
|
||||
"8",
|
||||
"On Request",
|
||||
"On Request",
|
||||
"113",
|
||||
"2.4",
|
||||
"1065",
|
||||
"71",
|
||||
"5900"
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
@@ -10,6 +10,12 @@ categories:
|
||||
- High Voltage Cables
|
||||
images:
|
||||
- /uploads/2025/06/NA2XSFL2Y-3-scaled.webp
|
||||
application: >
|
||||
The NA2XS(FL)2Y meets the requirements of IEC 60840 and is suitable for installation
|
||||
in the ground, in cable ducts, indoors, in pipes, and outdoors. It is manufactured
|
||||
based on project specifications and is particularly used in transmission networks,
|
||||
utility infrastructures, and substations where safety and durability are top
|
||||
priorities.
|
||||
locale: en
|
||||
---
|
||||
<ProductTabs technicalData={<ProductTechnicalData data={{
|
||||
|
||||
@@ -10,6 +10,12 @@ categories:
|
||||
- Medium Voltage Cables
|
||||
images:
|
||||
- /uploads/2025/01/NA2XSFL2Y-3-scaled.webp
|
||||
application: >
|
||||
The NA2XS(FL)2Y cable complies with standards DIN VDE 0276-620, HD 620 S2 and IEC
|
||||
60502. It is ideal for installation in power supply networks (utilities), indoors, in
|
||||
cable ducts, outdoors, in soil, and in water. Thanks to its longitudinally watertight
|
||||
design and Al/PE sheath construction, it remains operational even when damaged –
|
||||
water ingress is effectively limited to the affected area.
|
||||
locale: en
|
||||
---
|
||||
<ProductTabs technicalData={<ProductTechnicalData data={{
|
||||
|
||||
@@ -11,6 +11,12 @@ categories:
|
||||
- Medium Voltage Cables
|
||||
images:
|
||||
- /uploads/2025/01/NA2XSY-scaled.webp
|
||||
application: >
|
||||
The NA2XSY meets the requirements of DIN VDE 0276-620, HD 620 S2, and IEC 60502. It is
|
||||
suitable for installation indoors, in cable ducts, underground, in water, or outdoors
|
||||
– but only when installed with protection. Typical areas of application include
|
||||
industrial plants, power stations, and switching stations where medium voltage must
|
||||
be transported with high operational safety.
|
||||
locale: en
|
||||
---
|
||||
<ProductTabs technicalData={<ProductTechnicalData data={{
|
||||
|
||||
@@ -10,6 +10,12 @@ categories:
|
||||
- Low Voltage Cables
|
||||
images:
|
||||
- /uploads/2025/01/NA2XY-scaled.webp
|
||||
application: >
|
||||
The NA2XY complies with DIN VDE 0276-603 (HD 603) and is ideal for fixed installation
|
||||
in indoor spaces, cable ducts, outdoors, in water, or underground. Typical areas of
|
||||
application include power plants, industrial facilities, and switching stations as
|
||||
well as local distribution networks, where mechanical stress during operation must be
|
||||
considered.
|
||||
locale: en
|
||||
---
|
||||
<ProductTabs technicalData={<ProductTechnicalData data={{
|
||||
|
||||
@@ -11,6 +11,12 @@ categories:
|
||||
- Low Voltage Cables
|
||||
images:
|
||||
- /uploads/2025/01/NAY2Y-scaled.webp
|
||||
application: >
|
||||
The NAY2Y meets the requirements of the TP PRAKAB 12/03 standard based on VDE 0276-603
|
||||
and is suitable for fixed installation indoors, in cable ducts, underground, in
|
||||
water, and outdoors. It is ideal for applications in power plants, industrial and
|
||||
switching stations, as well as local supply networks – wherever mechanical stress
|
||||
during installation or operation plays a significant role.
|
||||
locale: en
|
||||
---
|
||||
<ProductTabs technicalData={<ProductTechnicalData data={{
|
||||
|
||||
@@ -10,6 +10,12 @@ categories:
|
||||
- Low Voltage Cables
|
||||
images:
|
||||
- /uploads/2025/01/NAYCWY-scaled.webp
|
||||
application: >
|
||||
The NAYCWY complies with DIN VDE 0276-603 (HD 603) and is suitable for use in power
|
||||
plants, industrial facilities, switching stations, and local networks. It can be
|
||||
permanently installed – indoors, in cable ducts, outdoors, underground, or in water.
|
||||
Thanks to its concentric conductor, it offers additional protection in case of
|
||||
mechanical damage and enables safe potential equalization.
|
||||
locale: en
|
||||
---
|
||||
<ProductTabs technicalData={<ProductTechnicalData data={{
|
||||
|
||||
@@ -11,6 +11,12 @@ categories:
|
||||
- Low Voltage Cables
|
||||
images:
|
||||
- /uploads/2025/01/NAYY-scaled.webp
|
||||
application: >
|
||||
The NAYY is a power distribution cable according to VDE 0276-603, particularly
|
||||
suitable for use in power plants, local networks, industrial facilities, and
|
||||
switching stations. Thanks to its robust design, it can be installed permanently –
|
||||
whether indoors, in cable ducts, outdoors, or underground. Even when installed in
|
||||
water, the cable remains reliable in operation.
|
||||
locale: en
|
||||
---
|
||||
<ProductTabs technicalData={<ProductTechnicalData data={{
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user