Compare commits
118 Commits
v1.0.0-rc.
...
v1.0.0-rc.
| Author | SHA1 | Date | |
|---|---|---|---|
| cb51c37207 | |||
| 8872d2424a | |||
| eb388610de | |||
| 6451a9e28e | |||
| 7ec826dae3 | |||
| 453a603392 | |||
| 5cfcc16dc2 | |||
| 5b43349205 | |||
| 96b296da12 | |||
| d5eb20a341 | |||
| 333111f03b | |||
| 698141f70b | |||
| 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 |
@@ -1,5 +1,6 @@
|
||||
node_modules
|
||||
.next
|
||||
!.next/cache
|
||||
.git
|
||||
.DS_Store
|
||||
.env
|
||||
|
||||
29
.env
29
.env
@@ -1,16 +1,10 @@
|
||||
# Application
|
||||
NODE_ENV=production
|
||||
NEXT_PUBLIC_BASE_URL=https://klz-cables.com
|
||||
UMAMI_SCRIPT_URL=https://analytics.infra.mintel.me/script.js
|
||||
NEXT_PUBLIC_UMAMI_WEBSITE_ID=59a7db94-0100-4c7e-98ef-99f45b17f9c3
|
||||
UMAMI_API_ENDPOINT=https://analytics.infra.mintel.me
|
||||
SENTRY_DSN=https://c10957d0182245b1a2a806ac2d34a197@errors.infra.mintel.me/1
|
||||
LOG_LEVEL=info
|
||||
|
||||
# WooCommerce & WordPress
|
||||
WOOCOMMERCE_URL=https://klz-cables.com
|
||||
WOOCOMMERCE_CONSUMER_KEY=ck_38d97df86880e8fefbd54ab5cdf47a9c5a9e5b39
|
||||
WOOCOMMERCE_CONSUMER_SECRET=cs_d675ee2ac2ec7c22de84ae5451c07e42b1717759
|
||||
WORDPRESS_APP_PASSWORD="DlJH 49dp fC3a Itc3 Sl7Z Wz0k"
|
||||
NEXT_PUBLIC_FEEDBACK_ENABLED=true
|
||||
|
||||
# SMTP Configuration
|
||||
MAIL_HOST=smtp.eu.mailgun.org
|
||||
@@ -19,3 +13,22 @@ 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_API_TOKEN=59fb8f4c1a51b18fe28ad947f713914e
|
||||
DIRECTUS_DB_NAME=directus
|
||||
DIRECTUS_DB_USER=directus
|
||||
# Local Development
|
||||
PROJECT_NAME=klz-cables
|
||||
GATEKEEPER_BYPASS_ENABLED=true
|
||||
TRAEFIK_HOST=klz.localhost
|
||||
DIRECTUS_HOST=cms.klz.localhost
|
||||
GATEKEEPER_PASSWORD=klz2026
|
||||
COOKIE_DOMAIN=localhost
|
||||
INFRA_DIRECTUS_URL=http://localhost:8059
|
||||
INFRA_DIRECTUS_TOKEN=59fb8f4c1a51b18fe28ad947f713914e
|
||||
|
||||
31
.env.example
31
.env.example
@@ -10,13 +10,18 @@
|
||||
# ────────────────────────────────────────────────────────────────────────────
|
||||
NODE_ENV=development
|
||||
NEXT_PUBLIC_BASE_URL=http://localhost:3000
|
||||
DIRECTUS_PORT=8055
|
||||
# TARGET is used to differentiate between environments (testing, staging, production)
|
||||
# NEXT_PUBLIC_TARGET makes this information available to the frontend
|
||||
TARGET=development
|
||||
NEXT_PUBLIC_FEEDBACK_ENABLED=false
|
||||
|
||||
# ────────────────────────────────────────────────────────────────────────────
|
||||
# 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)
|
||||
@@ -39,11 +44,23 @@ MAIL_RECIPIENTS=info@klz-cables.com
|
||||
# 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
|
||||
@@ -61,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,6 +26,5 @@ MAIL_PASSWORD=
|
||||
MAIL_FROM=KLZ Cables <noreply@klz-cables.com>
|
||||
MAIL_RECIPIENTS=info@klz-cables.com
|
||||
|
||||
|
||||
# Varnish Cache Size (optional)
|
||||
VARNISH_CACHE_SIZE=256m
|
||||
|
||||
@@ -1,5 +0,0 @@
|
||||
.next/
|
||||
node_modules/
|
||||
reference/
|
||||
public/
|
||||
dist/
|
||||
@@ -1,12 +0,0 @@
|
||||
{
|
||||
"extends": ["next/core-web-vitals", "next/typescript"],
|
||||
"rules": {
|
||||
"@typescript-eslint/no-explicit-any": "off",
|
||||
"@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,184 +2,525 @@ 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 }}
|
||||
traefik_host_rule: ${{ steps.determine.outputs.traefik_host_rule }}
|
||||
primary_host: ${{ steps.determine.outputs.primary_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 "🚀 Starting deployment for ${{ github.repository }} (${{ github.ref }})"
|
||||
echo " • Commit: ${{ github.sha }}"
|
||||
echo " • Timestamp: $(date -u +'%Y-%m-%d %H:%M:%S UTC')"
|
||||
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
|
||||
|
||||
# ═══════════════════════════════════════════════════════════════════════════════
|
||||
# LOGGING: Registry Login Phase
|
||||
# ═══════════════════════════════════════════════════════════════════════════════
|
||||
- name: 🔐 Login to private registry
|
||||
|
||||
- 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
|
||||
|
||||
if [[ "$TRAEFIK_HOST" == *","* ]]; then
|
||||
# Multi-domain: Host(`a.com`) || Host(`b.com`)
|
||||
TRAEFIK_HOST_RULE=$(echo "$TRAEFIK_HOST" | sed 's/,/ /g' | awk '{for(i=1;i<=NF;i++) printf "Host(`%s`)%s", $i, (i==NF?"":" || ")}')
|
||||
PRIMARY_HOST=$(echo "$TRAEFIK_HOST" | cut -d',' -f1 | sed 's/ //g')
|
||||
else
|
||||
# Single domain: Host(`domain.com`)
|
||||
TRAEFIK_HOST_RULE="Host(\`$TRAEFIK_HOST\`)"
|
||||
PRIMARY_HOST="$TRAEFIK_HOST"
|
||||
fi
|
||||
|
||||
{
|
||||
echo "target=$TARGET"
|
||||
echo "image_tag=$IMAGE_TAG"
|
||||
echo "env_file=$ENV_FILE"
|
||||
echo "traefik_host=$TRAEFIK_HOST"
|
||||
echo "traefik_host_rule=$TRAEFIK_HOST_RULE"
|
||||
echo "primary_host=$PRIMARY_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
|
||||
|
||||
- name: Install dependencies
|
||||
run: npm ci --legacy-peer-deps
|
||||
|
||||
- name: 🧪 Run Checks in Parallel
|
||||
if: github.event.inputs.skip_long_checks != 'true'
|
||||
run: |
|
||||
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 "🔐 Authenticating with registry.infra.mintel.me..."
|
||||
echo "${{ secrets.REGISTRY_PASS }}" | docker login registry.infra.mintel.me -u "${{ secrets.REGISTRY_USER }}" --password-stdin
|
||||
|
||||
# ═══════════════════════════════════════════════════════════════════════════════
|
||||
# LOGGING: Build Phase
|
||||
# ═══════════════════════════════════════════════════════════════════════════════
|
||||
- name: 🏗️ Build Docker image
|
||||
- name: 🏗️ App bauen & pushen
|
||||
env:
|
||||
IMAGE_TAG: ${{ needs.prepare.outputs.image_tag }}
|
||||
TARGET: ${{ needs.prepare.outputs.target }}
|
||||
NEXT_PUBLIC_BASE_URL: ${{ needs.prepare.outputs.next_public_base_url }}
|
||||
DIRECTUS_URL: ${{ needs.prepare.outputs.directus_url }}
|
||||
run: |
|
||||
echo "🏗️ Building Docker image (linux/arm64)..."
|
||||
docker buildx build \
|
||||
--pull \
|
||||
--platform linux/arm64 \
|
||||
--build-arg NEXT_PUBLIC_BASE_URL="${{ secrets.NEXT_PUBLIC_BASE_URL }}" \
|
||||
--build-arg NEXT_PUBLIC_UMAMI_WEBSITE_ID="${{ secrets.NEXT_PUBLIC_UMAMI_WEBSITE_ID }}" \
|
||||
--build-arg NEXT_PUBLIC_UMAMI_SCRIPT_URL="${{ secrets.NEXT_PUBLIC_UMAMI_SCRIPT_URL }}" \
|
||||
-t registry.infra.mintel.me/mintel/klz-cables.com:latest \
|
||||
--build-arg NEXT_PUBLIC_BASE_URL="$NEXT_PUBLIC_BASE_URL" \
|
||||
--build-arg NEXT_PUBLIC_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 .
|
||||
|
||||
# ═══════════════════════════════════════════════════════════════════════════════
|
||||
# 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.primary_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 }}
|
||||
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 "🚀 Deploying to alpha.mintel.me..."
|
||||
|
||||
# Setup SSH
|
||||
echo "Deploying $TARGET → $IMAGE_TAG"
|
||||
|
||||
mkdir -p ~/.ssh
|
||||
echo "${{ secrets.ALPHA_SSH_KEY }}" > ~/.ssh/id_ed25519
|
||||
chmod 600 ~/.ssh/id_ed25519
|
||||
ssh-keyscan -H alpha.mintel.me >> ~/.ssh/known_hosts 2>/dev/null
|
||||
|
||||
# Create .env file content
|
||||
|
||||
# Generated by CI - $TARGET - $(date -u)
|
||||
# Determine dynamic values before writing the file
|
||||
LOG_LEVEL=$( [[ "$TARGET" == "testing" || "$TARGET" == "development" ]] && echo "debug" || echo "info" )
|
||||
COOKIE_DOMAIN=.$(echo $NEXT_PUBLIC_BASE_URL | sed 's|https://||')
|
||||
|
||||
cat > /tmp/klz-cables.env << EOF
|
||||
# ============================================================================
|
||||
# KLZ Cables - Production Environment Configuration
|
||||
# ============================================================================
|
||||
# Auto-generated by CI/CD workflow
|
||||
# DO NOT EDIT MANUALLY - Changes will be overwritten on next deployment
|
||||
# ============================================================================
|
||||
|
||||
# Application
|
||||
NODE_ENV=production
|
||||
NEXT_PUBLIC_BASE_URL=${{ secrets.NEXT_PUBLIC_BASE_URL }}
|
||||
|
||||
# Analytics (Umami)
|
||||
NEXT_PUBLIC_UMAMI_WEBSITE_ID=${{ secrets.NEXT_PUBLIC_UMAMI_WEBSITE_ID }}
|
||||
NEXT_PUBLIC_UMAMI_SCRIPT_URL=${{ secrets.NEXT_PUBLIC_UMAMI_SCRIPT_URL }}
|
||||
|
||||
# Error Tracking (GlitchTip/Sentry)
|
||||
SENTRY_DSN=${{ secrets.SENTRY_DSN }}
|
||||
|
||||
# Email Configuration (Mailgun)
|
||||
MAIL_HOST=${{ secrets.MAIL_HOST }}
|
||||
MAIL_PORT=${{ secrets.MAIL_PORT }}
|
||||
MAIL_USERNAME=${{ secrets.MAIL_USERNAME }}
|
||||
MAIL_PASSWORD=${{ secrets.MAIL_PASSWORD }}
|
||||
MAIL_FROM=${{ secrets.MAIL_FROM }}
|
||||
MAIL_RECIPIENTS=${{ secrets.MAIL_RECIPIENTS }}
|
||||
|
||||
# Generated by CI - $TARGET - $(date -u)
|
||||
IMAGE_TAG=$IMAGE_TAG
|
||||
NEXT_PUBLIC_BASE_URL=$NEXT_PUBLIC_BASE_URL
|
||||
UMAMI_WEBSITE_ID=$UMAMI_WEBSITE_ID
|
||||
UMAMI_API_ENDPOINT=$UMAMI_API_ENDPOINT
|
||||
SENTRY_DSN=$SENTRY_DSN
|
||||
LOG_LEVEL=$LOG_LEVEL
|
||||
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
|
||||
PROJECT_NAME=$PROJECT_NAME
|
||||
COOKIE_DOMAIN=$COOKIE_DOMAIN
|
||||
TRAEFIK_HOST=$TRAEFIK_HOST
|
||||
EOF
|
||||
|
||||
# Upload .env and deploy
|
||||
scp -o StrictHostKeyChecking=accept-new /tmp/klz-cables.env root@alpha.mintel.me:/home/deploy/sites/klz-cables.com/.env
|
||||
|
||||
ssh -o StrictHostKeyChecking=accept-new root@alpha.mintel.me bash << EOF
|
||||
set -e
|
||||
cd /home/deploy/sites/klz-cables.com
|
||||
|
||||
chmod 600 .env
|
||||
chown deploy:deploy .env
|
||||
|
||||
echo "${{ secrets.REGISTRY_PASS }}" | docker login registry.infra.mintel.me -u "${{ secrets.REGISTRY_USER }}" --password-stdin
|
||||
docker pull registry.infra.mintel.me/mintel/klz-cables.com:latest
|
||||
|
||||
docker-compose down
|
||||
|
||||
echo "🚀 Starting containers..."
|
||||
docker-compose up -d
|
||||
|
||||
echo "⏳ Giving the app a few seconds to warm up..."
|
||||
sleep 10
|
||||
|
||||
echo "🔍 Checking container status..."
|
||||
docker-compose ps
|
||||
|
||||
if ! docker-compose ps | grep -q "Up"; then
|
||||
echo "❌ Container failed to start"
|
||||
docker-compose logs --tail=100
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo "✅ Deployment complete!"
|
||||
# Append complex variables that contain backticks using printf to avoid shell expansion hits
|
||||
printf "AUTH_MIDDLEWARE=%s\n" "$( [[ "$TARGET" == "production" ]] && echo "${PROJECT_NAME}-compress" || echo "${PROJECT_NAME}-auth,${PROJECT_NAME}-compress" )" >> /tmp/klz-cables.env
|
||||
printf "TRAEFIK_HOST_RULE='%s'\n" '${{ needs.prepare.outputs.traefik_host_rule }}' >> /tmp/klz-cables.env
|
||||
|
||||
# 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 \
|
||||
/home/deploy/sites/klz-cables.com/directus/schema
|
||||
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
|
||||
|
||||
rm -f /tmp/klz-cables.env
|
||||
|
||||
# ═══════════════════════════════════════════════════════════════════════════════
|
||||
# LOGGING: Workflow Summary
|
||||
# ═══════════════════════════════════════════════════════════════════════════════
|
||||
- name: 📊 Workflow Summary
|
||||
if: always()
|
||||
run: |
|
||||
echo "📊 Status: ${{ job.status }}"
|
||||
echo "🎯 Target: alpha.mintel.me"
|
||||
# 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 directus/schema root@alpha.mintel.me:/home/deploy/sites/klz-cables.com/directus/
|
||||
scp -r -o StrictHostKeyChecking=accept-new varnish root@alpha.mintel.me:/home/deploy/sites/klz-cables.com/
|
||||
|
||||
# ═══════════════════════════════════════════════════════════════════════════════
|
||||
# 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.
|
||||
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 "→ Applying Directus Schema Snapshot..."
|
||||
# Note: We check if snapshot exists first to avoid failing if no snapshot is committed yet
|
||||
if docker compose -p "$PROJECT_NAME" --env-file "$ENV_FILE" exec -T directus ls /directus/schema/snapshot.yaml >/dev/null 2>&1; then
|
||||
docker compose -p "$PROJECT_NAME" --env-file "$ENV_FILE" exec -T directus npx directus schema apply /directus/schema/snapshot.yaml --yes
|
||||
else
|
||||
echo "ℹ️ No snapshot.yaml found, skipping schema apply."
|
||||
fi
|
||||
|
||||
Commit: ${{ github.sha }}
|
||||
Actor: ${{ github.actor }}
|
||||
Run ID: ${{ github.run_id }}" \
|
||||
-F "priority=5")
|
||||
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
|
||||
|
||||
HTTP_CODE=$(echo "$RESPONSE" | tail -n1)
|
||||
BODY=$(echo "$RESPONSE" | sed '$d')
|
||||
# Detect OS
|
||||
OS_ID=$(. /etc/os-release && echo $ID)
|
||||
CODENAME=$(. /etc/os-release && echo $VERSION_CODENAME)
|
||||
|
||||
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
|
||||
if [ "$OS_ID" = "debian" ]; then
|
||||
echo "🎯 Debian detected - installing native chromium"
|
||||
apt-get install -y chromium
|
||||
else
|
||||
echo "🎯 Ubuntu detected - adding xtradeb PPA"
|
||||
mkdir -p /etc/apt/keyrings
|
||||
KEY_ID="82BB6851C64F6880"
|
||||
|
||||
# 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
|
||||
|
||||
9
.gitignore
vendored
9
.gitignore
vendored
@@ -1,2 +1,9 @@
|
||||
node_modules
|
||||
.next
|
||||
.next
|
||||
.DS_Store
|
||||
|
||||
# Directus
|
||||
directus/uploads
|
||||
!directus/extensions/
|
||||
!directus/schema/
|
||||
!directus/migrations/
|
||||
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
|
||||
9
.lintstagedrc.js
Normal file
9
.lintstagedrc.js
Normal file
@@ -0,0 +1,9 @@
|
||||
const path = require('path');
|
||||
|
||||
const buildEslintCommand = (filenames) =>
|
||||
`eslint --fix ${filenames.map((f) => path.relative(process.cwd(), f)).join(' ')}`;
|
||||
|
||||
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
|
||||
}
|
||||
@@ -1 +0,0 @@
|
||||
Sheet 1
|
||||
File diff suppressed because one or more lines are too long
@@ -1,237 +0,0 @@
|
||||
# Analytics Migration Complete ✅
|
||||
|
||||
## Summary
|
||||
|
||||
Successfully migrated analytics data from Independent Analytics (WordPress) to Umami.
|
||||
|
||||
## Files Created
|
||||
|
||||
### 1. Migration Script
|
||||
**Location:** `scripts/migrate-analytics-to-umami.py`
|
||||
- Converts Independent Analytics CSV to Umami format
|
||||
- Supports 3 output formats: JSON (API), SQL (database), API payload
|
||||
- Preserves page view counts and average duration data
|
||||
|
||||
### 2. Deployment Script
|
||||
**Location:** `scripts/deploy-analytics-to-umami.sh`
|
||||
- Tailored for your server setup (`deploy@alpha.mintel.me`)
|
||||
- Copies files to your Umami server
|
||||
- Provides import instructions for your specific environment
|
||||
|
||||
### 3. Output Files
|
||||
|
||||
#### JSON Import File
|
||||
**Location:** `data/umami-import.json`
|
||||
- **Size:** 2.1 MB
|
||||
- **Records:** 7,634 page view events
|
||||
- **Website ID:** `59a7db94-0100-4c7e-98ef-99f45b17f9c3`
|
||||
- **Use:** Import via Umami API
|
||||
|
||||
#### SQL Import File
|
||||
**Location:** `data/umami-import.sql`
|
||||
- **Size:** 1.8 MB
|
||||
- **Records:** 5,250 SQL statements
|
||||
- **Website ID:** `59a7db94-0100-4c7e-98ef-99f45b17f9c3`
|
||||
- **Use:** Direct database import
|
||||
|
||||
### 4. Documentation
|
||||
|
||||
**Location:** `scripts/README-migration.md`
|
||||
- Step-by-step migration guide
|
||||
- Prerequisites and setup instructions
|
||||
- Import methods (API and database)
|
||||
- Troubleshooting tips
|
||||
|
||||
**Location:** `MIGRATION_SUMMARY.md`
|
||||
- Complete migration overview
|
||||
- Data summary and limitations
|
||||
- Verification steps
|
||||
- Next steps
|
||||
|
||||
**Location:** `ANALYTICS_MIGRATION_COMPLETE.md` (this file)
|
||||
- Quick reference guide
|
||||
- Deployment instructions
|
||||
|
||||
## Quick Start
|
||||
|
||||
### Option 1: Automated Deployment (Recommended)
|
||||
|
||||
```bash
|
||||
# Run the deployment script
|
||||
./scripts/deploy-analytics-to-umami.sh
|
||||
```
|
||||
|
||||
This script will:
|
||||
1. Copy files to your server
|
||||
2. Provide import instructions
|
||||
3. Show you the exact commands to run
|
||||
|
||||
### Option 2: Manual Deployment
|
||||
|
||||
#### Step 1: Copy files to server
|
||||
```bash
|
||||
scp data/umami-import.json deploy@alpha.mintel.me:/home/deploy/sites/klz-cables.com/data/
|
||||
```
|
||||
|
||||
#### Step 2: SSH into server
|
||||
```bash
|
||||
ssh deploy@alpha.mintel.me
|
||||
cd /home/deploy/sites/klz-cables.com
|
||||
```
|
||||
|
||||
#### Step 3: Import data
|
||||
|
||||
**Method A: API Import (if API key is available)**
|
||||
```bash
|
||||
# Get your API key from Umami dashboard
|
||||
# Add to .env: UMAMI_API_KEY=your-api-key
|
||||
|
||||
curl -X POST \
|
||||
-H "Content-Type: application/json" \
|
||||
-H "Authorization: Bearer YOUR_API_KEY" \
|
||||
-d @data/umami-import.json \
|
||||
http://localhost:3000/api/import
|
||||
```
|
||||
|
||||
**Method B: Database Import (direct)**
|
||||
```bash
|
||||
# Import SQL file into PostgreSQL
|
||||
docker exec -i $(docker compose ps -q postgres) psql -U umami -d umami < data/umami-import.sql
|
||||
```
|
||||
|
||||
**Method C: Manual via Umami Dashboard**
|
||||
1. Access Umami dashboard: https://analytics.infra.mintel.me
|
||||
2. Go to Settings → Import
|
||||
3. Upload `data/umami-import.json`
|
||||
4. Select website ID: `59a7db94-0100-4c7e-98ef-99f45b17f9c3`
|
||||
5. Click Import
|
||||
|
||||
## Your Umami Configuration
|
||||
|
||||
**Website ID:** `59a7db94-0100-4c7e-98ef-99f45b17f9c3`
|
||||
|
||||
**Environment Variables** (from docker-compose.yml):
|
||||
```bash
|
||||
NEXT_PUBLIC_UMAMI_WEBSITE_ID=59a7db94-0100-4c7e-98ef-99f45b17f9c3
|
||||
NEXT_PUBLIC_UMAMI_SCRIPT_URL=https://analytics.infra.mintel.me/script.js
|
||||
```
|
||||
|
||||
**Server Details:**
|
||||
- **Host:** alpha.mintel.me
|
||||
- **User:** deploy
|
||||
- **Path:** /home/deploy/sites/klz-cables.com
|
||||
- **Umami API:** http://localhost:3000/api/import
|
||||
|
||||
## Data Summary
|
||||
|
||||
### What Was Migrated
|
||||
- **Source:** Independent Analytics CSV (220 unique pages)
|
||||
- **Migrated:** 7,634 simulated page view events
|
||||
- **Metrics:** Page views, visitor counts, average duration
|
||||
- **Website ID:** `59a7db94-0100-4c7e-98ef-99f45b17f9c3`
|
||||
|
||||
### What Was NOT Migrated
|
||||
- Individual user sessions
|
||||
- Real-time data
|
||||
- Geographic data
|
||||
- Referrer data
|
||||
- Device/browser data
|
||||
- Custom events
|
||||
|
||||
**Note:** The CSV contains aggregated data, not raw event data. The migration creates simulated historical data for reference only.
|
||||
|
||||
## Verification
|
||||
|
||||
### After Import
|
||||
1. **Check Umami dashboard:** https://analytics.infra.mintel.me
|
||||
2. **Verify page view counts** match your expectations
|
||||
3. **Check top pages** appear correctly
|
||||
4. **Monitor for a few days** to ensure new data is being collected
|
||||
|
||||
### Expected Results
|
||||
- ✅ 7,634 events imported
|
||||
- ✅ 220 unique pages
|
||||
- ✅ Historical view counts preserved
|
||||
- ✅ Duration data maintained
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
### Issue: "SSH connection failed"
|
||||
**Solution:** Check your SSH key and ensure `deploy@alpha.mintel.me` has access
|
||||
|
||||
### Issue: "API import failed"
|
||||
**Solution:**
|
||||
1. Check if Umami API is running: `docker compose ps`
|
||||
2. Verify API key in `.env`: `UMAMI_API_KEY=your-key`
|
||||
3. Try database import instead
|
||||
|
||||
### Issue: "Database import failed"
|
||||
**Solution:**
|
||||
1. Ensure PostgreSQL is running: `docker compose ps`
|
||||
2. Check database credentials
|
||||
3. Run migrations first: `docker exec -it $(docker compose ps -q postgres) psql -U umami -d umami -c "SELECT 1;"`
|
||||
|
||||
### Issue: "No data appears in dashboard"
|
||||
**Solution:**
|
||||
1. Verify import completed successfully
|
||||
2. Check Umami logs: `docker compose logs app`
|
||||
3. Ensure website ID matches: `59a7db94-0100-4c7e-98ef-99f45b17f9c3`
|
||||
|
||||
## Next Steps
|
||||
|
||||
### 1. Import the Data
|
||||
Choose one of the import methods above and run it.
|
||||
|
||||
### 2. Verify the Migration
|
||||
- Check Umami dashboard
|
||||
- Verify page view counts
|
||||
- Confirm data appears correctly
|
||||
|
||||
### 3. Update Your Website
|
||||
Your website is already configured with:
|
||||
```bash
|
||||
NEXT_PUBLIC_UMAMI_WEBSITE_ID=59a7db94-0100-4c7e-98ef-99f45b17f9c3
|
||||
NEXT_PUBLIC_UMAMI_SCRIPT_URL=https://analytics.infra.mintel.me/script.js
|
||||
```
|
||||
|
||||
### 4. Monitor for a Few Days
|
||||
- Ensure Umami is collecting new data
|
||||
- Compare with any remaining Independent Analytics data
|
||||
- Verify tracking code is working
|
||||
|
||||
### 5. Clean Up
|
||||
- Keep the original CSV as backup: `data/pages(1).csv`
|
||||
- Store migration files for future reference
|
||||
- Remove old Independent Analytics plugin from WordPress
|
||||
|
||||
## Support Resources
|
||||
|
||||
- **Umami Documentation:** https://umami.is/docs
|
||||
- **Umami GitHub:** https://github.com/umami-software/umami
|
||||
- **Independent Analytics:** https://independentanalytics.com/
|
||||
|
||||
## Migration Details
|
||||
|
||||
**Migration Date:** 2026-01-25
|
||||
**Source Plugin:** Independent Analytics v2.9.7
|
||||
**Target Platform:** Umami Analytics
|
||||
**Website ID:** `59a7db94-0100-4c7e-98ef-99f45b17f9c3`
|
||||
**Server:** alpha.mintel.me (deploy user)
|
||||
**Status:** ✅ Ready for import
|
||||
|
||||
---
|
||||
|
||||
**Quick Command Reference:**
|
||||
|
||||
```bash
|
||||
# Deploy to server
|
||||
./scripts/deploy-analytics-to-umami.sh
|
||||
|
||||
# Or manually:
|
||||
scp data/umami-import.json deploy@alpha.mintel.me:/home/deploy/sites/klz-cables.com/data/
|
||||
ssh deploy@alpha.mintel.me
|
||||
cd /home/deploy/sites/klz-cables.com
|
||||
docker exec -i $(docker compose ps -q postgres) psql -U umami -d umami < data/umami-import.sql
|
||||
```
|
||||
|
||||
**Need help?** Check `scripts/README-migration.md` for detailed instructions.
|
||||
14
Dockerfile
14
Dockerfile
@@ -8,7 +8,7 @@ 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,17 +25,17 @@ ENV NEXT_TELEMETRY_DISABLED=1
|
||||
# Build-time environment variables for Next.js
|
||||
# These are baked into the client bundle during build
|
||||
ARG NEXT_PUBLIC_BASE_URL
|
||||
ARG NEXT_PUBLIC_UMAMI_WEBSITE_ID
|
||||
ARG NEXT_PUBLIC_UMAMI_SCRIPT_URL
|
||||
ARG 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 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
|
||||
|
||||
@@ -1,272 +0,0 @@
|
||||
# Environment Variables Cleanup - Summary
|
||||
|
||||
## What Was Done
|
||||
|
||||
Cleaned up the fragile, overkill environment variable mess and replaced it with a simple, clean, robust **fully automated** system.
|
||||
|
||||
## Changes Made
|
||||
|
||||
### 1. Dockerfile ✅
|
||||
**Before**: 4 build args including runtime-only variables (SENTRY_DSN)
|
||||
**After**: 3 build args - only `NEXT_PUBLIC_*` variables that need to be baked into the client bundle
|
||||
|
||||
```dockerfile
|
||||
# Only these build args now:
|
||||
ARG NEXT_PUBLIC_BASE_URL
|
||||
ARG NEXT_PUBLIC_UMAMI_WEBSITE_ID
|
||||
ARG NEXT_PUBLIC_UMAMI_SCRIPT_URL
|
||||
```
|
||||
|
||||
### 2. docker-compose.yml ✅
|
||||
**Before**: 12+ individual environment variables listed
|
||||
**After**: Single `env_file: .env` directive
|
||||
|
||||
```yaml
|
||||
app:
|
||||
image: registry.infra.mintel.me/mintel/klz-cables.com:latest
|
||||
env_file:
|
||||
- .env # All runtime vars loaded from here
|
||||
```
|
||||
|
||||
### 3. .gitea/workflows/deploy.yml ✅
|
||||
**Before**: Passing 12+ environment variables individually via SSH command (fragile!)
|
||||
**After**: **Fully automated** - workflow creates `.env` file from Gitea secrets and uploads it
|
||||
|
||||
```yaml
|
||||
# Before (FRAGILE):
|
||||
ssh root@alpha.mintel.me \
|
||||
"MAIL_FROM='${{ secrets.MAIL_FROM }}' \
|
||||
MAIL_HOST='${{ secrets.MAIL_HOST }}' \
|
||||
... (12+ variables) \
|
||||
/home/deploy/deploy.sh"
|
||||
|
||||
# After (AUTOMATED):
|
||||
# 1. Create .env from secrets
|
||||
cat > /tmp/klz-cables.env << EOF
|
||||
NODE_ENV=production
|
||||
NEXT_PUBLIC_BASE_URL=${{ secrets.NEXT_PUBLIC_BASE_URL }}
|
||||
# ... all other vars from secrets
|
||||
EOF
|
||||
|
||||
# 2. Upload to server
|
||||
scp /tmp/klz-cables.env root@alpha.mintel.me:/home/deploy/sites/klz-cables.com/.env
|
||||
|
||||
# 3. Deploy
|
||||
ssh root@alpha.mintel.me "cd /home/deploy/sites/klz-cables.com && docker-compose up -d"
|
||||
```
|
||||
|
||||
### 4. New Files Created ✅
|
||||
|
||||
- **`.env.production`** - Template for reference (not used in automation)
|
||||
- **`docs/DEPLOYMENT.md`** - Complete deployment guide
|
||||
- **`docs/SERVER_SETUP.md`** - Server setup instructions
|
||||
- **`docs/ENV_MIGRATION.md`** - Migration guide from old to new system
|
||||
|
||||
### 5. Updated Files ✅
|
||||
|
||||
- **`.env.example`** - Clear documentation of all variables with build-time vs runtime notes
|
||||
|
||||
## Architecture
|
||||
|
||||
### Build Time (CI/CD)
|
||||
```
|
||||
Gitea Workflow
|
||||
↓
|
||||
Only passes NEXT_PUBLIC_* as --build-arg
|
||||
↓
|
||||
Docker Build
|
||||
↓
|
||||
Validates env vars
|
||||
↓
|
||||
Bakes NEXT_PUBLIC_* into client bundle
|
||||
↓
|
||||
Push to Registry
|
||||
```
|
||||
|
||||
### Runtime (Production Server) - FULLY AUTOMATED
|
||||
```
|
||||
Gitea Secrets
|
||||
↓
|
||||
Workflow creates .env file
|
||||
↓
|
||||
SCP uploads to server
|
||||
↓
|
||||
Secured (chmod 600, chown deploy:deploy)
|
||||
↓
|
||||
docker-compose.yml (env_file: .env)
|
||||
↓
|
||||
Loads .env into container
|
||||
↓
|
||||
Application runs with full config
|
||||
```
|
||||
|
||||
## Key Benefits
|
||||
|
||||
### 1. Simplicity
|
||||
- **Before**: 15+ Gitea secrets, variables in 3+ places
|
||||
- **After**: All secrets in Gitea, automatically deployed
|
||||
|
||||
### 2. Clarity
|
||||
- **Before**: Confusing duplication, unclear which vars go where
|
||||
- **After**: Clear separation - build args vs runtime env file
|
||||
|
||||
### 3. Robustness
|
||||
- **Before**: Fragile SSH command with 12+ inline variables
|
||||
- **After**: Robust automated file generation and upload
|
||||
|
||||
### 4. Security
|
||||
- **Before**: Secrets potentially exposed in CI logs
|
||||
- **After**: Secrets masked in logs, .env auto-secured on server
|
||||
|
||||
### 5. Maintainability
|
||||
- **Before**: Update in 3 places (Dockerfile, docker-compose.yml, deploy.yml)
|
||||
- **After**: Update Gitea secrets only - deployment is automatic
|
||||
|
||||
### 6. **Zero Manual Steps** 🎉
|
||||
- **Before**: Manual .env file creation on server (error-prone, can be forgotten)
|
||||
- **After**: **Fully automated** - .env file created and uploaded on every deployment
|
||||
|
||||
## What You Need to Do
|
||||
|
||||
### Required Gitea Secrets
|
||||
|
||||
Ensure these secrets are configured in your Gitea repository:
|
||||
|
||||
**Build-Time (NEXT_PUBLIC_*):**
|
||||
- `NEXT_PUBLIC_BASE_URL` - Production URL (e.g., `https://klz-cables.com`)
|
||||
- `NEXT_PUBLIC_UMAMI_WEBSITE_ID` - Umami analytics ID
|
||||
- `NEXT_PUBLIC_UMAMI_SCRIPT_URL` - Umami script URL
|
||||
|
||||
**Runtime:**
|
||||
- `SENTRY_DSN` - Error tracking DSN
|
||||
- `MAIL_HOST` - SMTP server
|
||||
- `MAIL_PORT` - SMTP port (e.g., `587`)
|
||||
- `MAIL_USERNAME` - SMTP username
|
||||
- `MAIL_PASSWORD` - SMTP password
|
||||
- `MAIL_FROM` - Sender email
|
||||
- `MAIL_RECIPIENTS` - Recipient emails (comma-separated)
|
||||
|
||||
**Infrastructure:**
|
||||
- `REGISTRY_USER` - Docker registry username
|
||||
- `REGISTRY_PASS` - Docker registry password
|
||||
- `ALPHA_SSH_KEY` - SSH private key for deployment server
|
||||
|
||||
**Notifications:**
|
||||
- `GOTIFY_URL` - Gotify notification server URL
|
||||
- `GOTIFY_TOKEN` - Gotify application token
|
||||
|
||||
### That's It!
|
||||
|
||||
**No manual steps required.** Just push to main branch and the workflow will:
|
||||
1. ✅ Build Docker image with NEXT_PUBLIC_* build args
|
||||
2. ✅ Create .env file from all secrets
|
||||
3. ✅ Upload .env to server
|
||||
4. ✅ Secure .env file (600 permissions, deploy:deploy ownership)
|
||||
5. ✅ Pull latest image
|
||||
6. ✅ Deploy with docker-compose
|
||||
|
||||
## Files Changed
|
||||
|
||||
```
|
||||
Modified:
|
||||
├── Dockerfile (removed redundant build args)
|
||||
├── docker-compose.yml (use env_file instead of individual vars)
|
||||
├── .gitea/workflows/deploy.yml (automated .env creation & upload)
|
||||
├── .env.example (clear documentation)
|
||||
├── lib/services/create-services.ts (removed redundant dotenv usage)
|
||||
└── scripts/migrate-*.ts (removed redundant dotenv usage)
|
||||
|
||||
Created:
|
||||
├── .env.production (reference template)
|
||||
├── docs/DEPLOYMENT.md (deployment guide)
|
||||
├── docs/SERVER_SETUP.md (server setup guide)
|
||||
├── docs/ENV_MIGRATION.md (migration guide)
|
||||
└── ENV_CLEANUP_SUMMARY.md (this file)
|
||||
```
|
||||
|
||||
## Deployment Flow
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────────────────────────────┐
|
||||
│ Developer pushes to main branch │
|
||||
└─────────────────────────────────────────────────────────────┘
|
||||
↓
|
||||
┌─────────────────────────────────────────────────────────────┐
|
||||
│ Gitea Workflow Triggered │
|
||||
│ │
|
||||
│ 1. Build Docker image (NEXT_PUBLIC_* build args) │
|
||||
│ 2. Push to registry │
|
||||
│ 3. Generate .env from secrets │
|
||||
│ 4. Upload .env to server via SCP │
|
||||
│ 5. SSH to server and deploy │
|
||||
└─────────────────────────────────────────────────────────────┘
|
||||
↓
|
||||
┌─────────────────────────────────────────────────────────────┐
|
||||
│ Production Server │
|
||||
│ │
|
||||
│ 1. .env file secured (600, deploy:deploy) │
|
||||
│ 2. Docker login to registry │
|
||||
│ 3. Pull latest image │
|
||||
│ 4. docker-compose down │
|
||||
│ 5. docker-compose up -d (loads .env) │
|
||||
│ 6. Health checks pass │
|
||||
└─────────────────────────────────────────────────────────────┘
|
||||
↓
|
||||
┌─────────────────────────────────────────────────────────────┐
|
||||
│ ✅ Deployment Complete - Gotify Notification Sent │
|
||||
└─────────────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
## Comparison: Before vs After
|
||||
|
||||
| Aspect | Before | After |
|
||||
|--------|--------|-------|
|
||||
| **Gitea Secrets** | 15+ secrets | Same secrets, better organized |
|
||||
| **Build Args** | 4 vars (including runtime-only) | 3 vars (NEXT_PUBLIC_* only) |
|
||||
| **Runtime Vars** | Passed via SSH command | Auto-generated .env file |
|
||||
| **Manual Steps** | ❌ Manual .env creation | ✅ Fully automated |
|
||||
| **Maintenance** | Update in 3 places | Update Gitea secrets only |
|
||||
| **Security** | Secrets in CI logs | Secrets masked, .env secured |
|
||||
| **Clarity** | Confusing duplication | Clear separation |
|
||||
| **Robustness** | Fragile SSH command | Robust automation |
|
||||
| **Error-Prone** | ❌ Can forget .env | ✅ Impossible to forget |
|
||||
|
||||
## Documentation
|
||||
|
||||
- **[DEPLOYMENT.md](docs/DEPLOYMENT.md)** - Complete deployment guide
|
||||
- **[SERVER_SETUP.md](docs/SERVER_SETUP.md)** - Server setup instructions (mostly automated now)
|
||||
- **[ENV_MIGRATION.md](docs/ENV_MIGRATION.md)** - Migration from old to new system
|
||||
- **[.env.example](.env.example)** - Environment variables reference
|
||||
- **[.env.production](.env.production)** - Production template (for reference)
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
### Deployment Fails
|
||||
|
||||
1. **Check Gitea secrets** - Ensure all required secrets are set
|
||||
2. **Check workflow logs** - Look for specific error messages
|
||||
3. **SSH to server** - Verify .env file exists and has correct permissions
|
||||
4. **Check container logs** - `docker-compose logs -f app`
|
||||
|
||||
### .env File Issues
|
||||
|
||||
The workflow automatically:
|
||||
- Creates .env from secrets
|
||||
- Uploads to server
|
||||
- Sets 600 permissions
|
||||
- Sets deploy:deploy ownership
|
||||
|
||||
If there are issues, check the workflow logs for the "📝 Preparing environment configuration" step.
|
||||
|
||||
### Missing Environment Variables
|
||||
|
||||
If a variable is missing:
|
||||
1. Add it to Gitea secrets
|
||||
2. Update `.gitea/workflows/deploy.yml` to include it in the .env generation
|
||||
3. Push to trigger new deployment
|
||||
|
||||
---
|
||||
|
||||
**Result**: Environment variable management is now simple, clean, robust, and **fully automated**! 🎉
|
||||
|
||||
No more manual .env file creation. No more forgotten configuration. No more fragile SSH commands. Just push and deploy!
|
||||
@@ -1,193 +0,0 @@
|
||||
# Analytics Migration Summary: Independent Analytics → Umami
|
||||
|
||||
## Overview
|
||||
|
||||
Successfully migrated analytics data from Independent Analytics WordPress plugin to Umami format.
|
||||
|
||||
## Files Created
|
||||
|
||||
### 1. Migration Script
|
||||
- **Location:** `scripts/migrate-analytics-to-umami.py`
|
||||
- **Purpose:** Converts Independent Analytics CSV data to Umami format
|
||||
- **Features:**
|
||||
- JSON format (for API import)
|
||||
- SQL format (for direct database import)
|
||||
- API payload format (for manual import)
|
||||
|
||||
### 2. Migration Documentation
|
||||
- **Location:** `scripts/README-migration.md`
|
||||
- **Purpose:** Step-by-step guide for migration
|
||||
- **Contents:**
|
||||
- Prerequisites
|
||||
- Migration options
|
||||
- Import instructions
|
||||
- Troubleshooting guide
|
||||
|
||||
### 3. Output Files
|
||||
|
||||
#### JSON Import File
|
||||
- **Location:** `data/umami-import.json`
|
||||
- **Size:** 2.1 MB
|
||||
- **Records:** 7,634 simulated page view events
|
||||
- **Format:** JSON array of Umami-compatible events
|
||||
- **Use Case:** Import via Umami API
|
||||
|
||||
#### SQL Import File
|
||||
- **Location:** `data/umami-import.sql`
|
||||
- **Size:** 1.8 MB
|
||||
- **Records:** 5,250 SQL INSERT statements
|
||||
- **Format:** PostgreSQL-compatible SQL
|
||||
- **Use Case:** Direct database import
|
||||
|
||||
## Data Migrated
|
||||
|
||||
### Source Data
|
||||
- **File:** `data/pages(1).csv`
|
||||
- **Records:** 220 unique pages
|
||||
- **Metrics:**
|
||||
- Page titles
|
||||
- Visitor counts
|
||||
- View counts
|
||||
- Average view duration
|
||||
- Bounce rates
|
||||
- URLs
|
||||
- Page types (Page, Post, Product, Category, etc.)
|
||||
|
||||
### Migrated Data
|
||||
- **Total Events:** 7,634 simulated page views
|
||||
- **Unique Pages:** 220
|
||||
- **Data Points:**
|
||||
- Website ID: `klz-cables`
|
||||
- Path: Page URLs
|
||||
- Duration: Preserved from average view duration
|
||||
- Timestamp: Current time (for historical reference)
|
||||
|
||||
## Migration Process
|
||||
|
||||
### Step 1: Run Migration Script
|
||||
```bash
|
||||
python3 scripts/migrate-analytics-to-umami.py \
|
||||
--input data/pages\(1\).csv \
|
||||
--output data/umami-import.json \
|
||||
--format json \
|
||||
--site-id klz-cables
|
||||
```
|
||||
|
||||
### Step 2: Choose Import Method
|
||||
|
||||
#### Option A: API Import (Recommended)
|
||||
```bash
|
||||
curl -X POST \
|
||||
-H "Content-Type: application/json" \
|
||||
-H "Authorization: Bearer YOUR_API_KEY" \
|
||||
-d @data/umami-import.json \
|
||||
https://your-umami-instance.com/api/import
|
||||
```
|
||||
|
||||
#### Option B: Database Import
|
||||
```bash
|
||||
psql -U umami -d umami -f data/umami-import.sql
|
||||
```
|
||||
|
||||
### Step 3: Verify Migration
|
||||
1. Check Umami dashboard
|
||||
2. Verify page view counts
|
||||
3. Confirm data appears correctly
|
||||
|
||||
## Important Notes
|
||||
|
||||
### Data Limitations
|
||||
The CSV export contains **aggregated data**, not raw event data:
|
||||
- ✅ Page views (total counts)
|
||||
- ✅ Visitor counts
|
||||
- ✅ Average view duration
|
||||
- ❌ Individual user sessions
|
||||
- ❌ Real-time data
|
||||
- ❌ Geographic data
|
||||
- ❌ Referrer data
|
||||
- ❌ Device/browser data
|
||||
|
||||
### What Gets Imported
|
||||
The migration creates **simulated historical data**:
|
||||
- Each page view becomes a separate event
|
||||
- Timestamps are set to current time
|
||||
- Duration is preserved from average view duration
|
||||
- No session tracking (each view is independent)
|
||||
|
||||
### Recommendations
|
||||
1. **Start fresh with Umami** - Let Umami collect new data going forward
|
||||
2. **Keep the original CSV** - Store as backup for future reference
|
||||
3. **Update your website** - Replace Independent Analytics tracking with Umami tracking
|
||||
4. **Monitor for a few days** - Verify Umami is collecting data correctly
|
||||
|
||||
## Verification
|
||||
|
||||
### Check Generated Files
|
||||
```bash
|
||||
# Verify JSON file
|
||||
ls -lh data/umami-import.json
|
||||
head -20 data/umami-import.json
|
||||
|
||||
# Verify SQL file
|
||||
ls -lh data/umami-import.sql
|
||||
head -20 data/umami-import.sql
|
||||
```
|
||||
|
||||
### Expected Results
|
||||
- ✅ JSON file: ~2.1 MB, 7,634 records
|
||||
- ✅ SQL file: ~1.8 MB, 5,250 statements
|
||||
- ✅ Both files contain valid data for Umami import
|
||||
|
||||
## Next Steps
|
||||
|
||||
1. **Set up Umami instance** (if not already done)
|
||||
2. **Create a website** in Umami dashboard
|
||||
3. **Get your Website ID** and API key
|
||||
4. **Run the migration script** with your credentials
|
||||
5. **Import the data** using your preferred method
|
||||
6. **Verify the migration** in Umami dashboard
|
||||
7. **Update your website** to use Umami tracking code
|
||||
8. **Monitor for a few days** to ensure data collection works
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
### Issue: "ModuleNotFoundError"
|
||||
**Solution:** Ensure Python 3 is installed: `python3 --version`
|
||||
|
||||
### Issue: "Permission denied"
|
||||
**Solution:** Make script executable: `chmod +x scripts/migrate-analytics-to-umami.py`
|
||||
|
||||
### Issue: API import fails
|
||||
**Solution:** Check API key, website ID, and Umami instance accessibility
|
||||
|
||||
### Issue: SQL import fails
|
||||
**Solution:** Verify database credentials and run migrations first
|
||||
|
||||
## Support Resources
|
||||
|
||||
- **Umami Documentation:** https://umami.is/docs
|
||||
- **Umami GitHub:** https://github.com/umami-software/umami
|
||||
- **Independent Analytics:** https://independentanalytics.com/
|
||||
|
||||
## Summary
|
||||
|
||||
✅ **Completed:**
|
||||
- Created migration script with 3 output formats
|
||||
- Generated JSON import file (2.1 MB, 7,634 events)
|
||||
- Generated SQL import file (1.8 MB, 5,250 statements)
|
||||
- Created comprehensive documentation
|
||||
|
||||
📊 **Data Migrated:**
|
||||
- 220 unique pages
|
||||
- 7,634 simulated page view events
|
||||
- Historical view counts and durations
|
||||
|
||||
🎯 **Ready for Import:**
|
||||
- Choose API or SQL import method
|
||||
- Follow instructions in `scripts/README-migration.md`
|
||||
- Verify data in Umami dashboard
|
||||
|
||||
**Migration Date:** 2026-01-25
|
||||
**Source:** Independent Analytics v2.9.7
|
||||
**Target:** Umami Analytics
|
||||
**Site ID:** klz-cables
|
||||
106
README.md
106
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,8 +73,8 @@ 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
|
||||
@@ -52,6 +85,7 @@ NEXT_PUBLIC_SENTRY_DSN=https://PUBLIC_KEY@errors.infra.mintel.me/PROJECT_ID
|
||||
## 📊 Project Overview
|
||||
|
||||
### Migration Statistics
|
||||
|
||||
- **Content Exported**: 141 items
|
||||
- 18 pages (9 EN + 9 DE)
|
||||
- 59 posts (29 EN + 30 DE)
|
||||
@@ -62,6 +96,7 @@ NEXT_PUBLIC_SENTRY_DSN=https://PUBLIC_KEY@errors.infra.mintel.me/PROJECT_ID
|
||||
- **Translation Pairs**: 16
|
||||
|
||||
### Performance Benefits
|
||||
|
||||
- **Before**: Dynamic WordPress with database queries
|
||||
- **After**: Static HTML with CDN delivery
|
||||
- **Load Time**: <100ms (vs 500ms+)
|
||||
@@ -70,15 +105,18 @@ NEXT_PUBLIC_SENTRY_DSN=https://PUBLIC_KEY@errors.infra.mintel.me/PROJECT_ID
|
||||
## 🏗️ 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
|
||||
@@ -103,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
|
||||
@@ -114,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
|
||||
@@ -133,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
|
||||
@@ -145,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
|
||||
@@ -158,6 +199,7 @@ scripts/
|
||||
## 📝 Content Management
|
||||
|
||||
### Data Export
|
||||
|
||||
```bash
|
||||
# Export from WordPress
|
||||
npm run data:export
|
||||
@@ -173,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
|
||||
@@ -180,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
|
||||
@@ -198,6 +244,7 @@ npm run data:improve-mapping
|
||||
## 🔧 API Endpoints
|
||||
|
||||
### Contact Form
|
||||
|
||||
```
|
||||
POST /api/contact
|
||||
{
|
||||
@@ -209,11 +256,13 @@ POST /api/contact
|
||||
```
|
||||
|
||||
### Sitemap
|
||||
|
||||
```
|
||||
GET /sitemap.xml
|
||||
```
|
||||
|
||||
### Robots
|
||||
|
||||
```
|
||||
GET /robots.txt
|
||||
```
|
||||
@@ -222,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
|
||||
|
||||
@@ -254,6 +314,7 @@ docker image prune -f
|
||||
```
|
||||
|
||||
Or use the convenience script:
|
||||
|
||||
```bash
|
||||
bash scripts/deploy-webhook.sh
|
||||
```
|
||||
@@ -265,11 +326,13 @@ 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)
|
||||
- `traefik`: Reverse proxy (external)
|
||||
|
||||
@@ -278,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)
|
||||
@@ -304,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]`
|
||||
@@ -311,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/...
|
||||
```
|
||||
@@ -325,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)
|
||||
@@ -340,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
|
||||
|
||||
@@ -365,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,
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -6,12 +6,13 @@ 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: {
|
||||
params: Promise<{
|
||||
locale: string;
|
||||
slug: string;
|
||||
};
|
||||
}>;
|
||||
}
|
||||
|
||||
export async function generateStaticParams() {
|
||||
@@ -28,9 +29,10 @@ export async function generateStaticParams() {
|
||||
return params;
|
||||
}
|
||||
|
||||
export async function generateMetadata({ params: { locale, slug } }: PageProps): Promise<Metadata> {
|
||||
export async function generateMetadata({ params }: PageProps): Promise<Metadata> {
|
||||
const { locale, slug } = await params;
|
||||
const pageData = await getPageBySlug(slug, locale);
|
||||
|
||||
|
||||
if (!pageData) return {};
|
||||
|
||||
return {
|
||||
@@ -39,15 +41,15 @@ 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: {
|
||||
@@ -58,7 +60,8 @@ export async function generateMetadata({ params: { locale, slug } }: PageProps):
|
||||
};
|
||||
}
|
||||
|
||||
export default async function StandardPage({ params: { locale, slug } }: PageProps) {
|
||||
export default async function StandardPage({ params }: PageProps) {
|
||||
const { locale, slug } = await params;
|
||||
const pageData = await getPageBySlug(slug, locale);
|
||||
const t = await getTranslations('StandardPage');
|
||||
|
||||
@@ -75,7 +78,9 @@ 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>
|
||||
<Badge variant="accent" className="mb-4 md:mb-6">
|
||||
{t('badge')}
|
||||
</Badge>
|
||||
<Heading level={1} className="text-white mb-0">
|
||||
{pageData.frontmatter.title}
|
||||
</Heading>
|
||||
@@ -106,9 +111,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>
|
||||
@@ -116,4 +126,4 @@ export default async function StandardPage({ params: { locale, slug } }: PagePro
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,72 +3,74 @@ 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 } }
|
||||
{ params }: { params: Promise<{ locale: string }> },
|
||||
) {
|
||||
const { searchParams } = new URL(request.url);
|
||||
const { searchParams, origin } = new URL(request.url);
|
||||
const slug = searchParams.get('slug');
|
||||
const locale = params.locale || 'en';
|
||||
const { locale } = await params;
|
||||
|
||||
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'];
|
||||
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`) : '';
|
||||
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"
|
||||
/>
|
||||
),
|
||||
<OGImageTemplate title={categoryTitle} description={categoryDesc} label="Product Category" />,
|
||||
{
|
||||
width: 1200,
|
||||
height: 630,
|
||||
}
|
||||
...OG_IMAGE_SIZE,
|
||||
fonts,
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
const product = await getProductBySlug(slug, locale);
|
||||
|
||||
if (!product) {
|
||||
return new ImageResponse(
|
||||
<div style={{ display: 'flex', width: '100%', height: '100%', backgroundColor: '#001a4d' }} />
|
||||
);
|
||||
return new Response('Product not found', { status: 404 });
|
||||
}
|
||||
|
||||
const { origin } = new URL(request.url);
|
||||
const featuredImage = product.frontmatter.images?.[0]
|
||||
? (product.frontmatter.images[0].startsWith('http')
|
||||
? product.frontmatter.images[0]
|
||||
: `${origin}${product.frontmatter.images[0]}`)
|
||||
? product.frontmatter.images[0].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}
|
||||
/>
|
||||
),
|
||||
<OGImageTemplate
|
||||
title={product.frontmatter.title}
|
||||
description={product.frontmatter.description}
|
||||
label={product.frontmatter.categories?.[0] || 'Product'}
|
||||
image={featuredImage}
|
||||
/>,
|
||||
{
|
||||
width: 1200,
|
||||
height: 630,
|
||||
}
|
||||
...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,
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
@@ -14,15 +14,16 @@ import { Heading } from '@/components/ui';
|
||||
import { getOGImageMetadata } from '@/lib/metadata';
|
||||
|
||||
interface BlogPostProps {
|
||||
params: {
|
||||
params: Promise<{
|
||||
locale: string;
|
||||
slug: string;
|
||||
};
|
||||
}>;
|
||||
}
|
||||
|
||||
export async function generateMetadata({ params: { locale, slug } }: BlogPostProps): Promise<Metadata> {
|
||||
export async function generateMetadata({ params }: BlogPostProps): Promise<Metadata> {
|
||||
const { locale, slug } = await params;
|
||||
const post = await getPostBySlug(slug, locale);
|
||||
|
||||
|
||||
if (!post) return {};
|
||||
|
||||
const description = post.frontmatter.excerpt || '';
|
||||
@@ -32,8 +33,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}`,
|
||||
},
|
||||
},
|
||||
@@ -43,7 +44,7 @@ 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: {
|
||||
@@ -54,7 +55,8 @@ export async function generateMetadata({ params: { locale, slug } }: BlogPostPro
|
||||
};
|
||||
}
|
||||
|
||||
export default async function BlogPost({ params: { locale, slug } }: BlogPostProps) {
|
||||
export default async function BlogPost({ params }: BlogPostProps) {
|
||||
const { locale, slug } = await params;
|
||||
const post = await getPostBySlug(slug, locale);
|
||||
const { prev, next } = await getAdjacentPosts(slug, locale);
|
||||
|
||||
@@ -66,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">
|
||||
@@ -87,7 +88,10 @@ export default async function BlogPost({ params: { locale, slug } }: BlogPostPro
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
<Heading level={1} className="text-white mb-8 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}
|
||||
</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]">
|
||||
@@ -95,7 +99,7 @@ export default async function BlogPost({ params: { locale, slug } }: BlogPostPro
|
||||
{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" />
|
||||
@@ -123,7 +127,7 @@ export default async function BlogPost({ params: { locale, slug } }: BlogPostPro
|
||||
{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" />
|
||||
@@ -168,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>
|
||||
@@ -188,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>
|
||||
);
|
||||
|
||||
@@ -1,25 +1,25 @@
|
||||
import { ImageResponse } from 'next/og';
|
||||
import { getTranslations } from 'next-intl/server';
|
||||
import { OGImageTemplate } from '@/components/OGImageTemplate';
|
||||
import { getOgFonts, OG_IMAGE_SIZE } from '@/lib/og-helper';
|
||||
|
||||
export const runtime = 'nodejs';
|
||||
|
||||
export default async function Image({ params: { locale } }: { params: { locale: string } }) {
|
||||
const t = await getTranslations({ locale, namespace: 'Blog.meta' });
|
||||
const title = t('title');
|
||||
const description = t('description');
|
||||
const fonts = await getOgFonts();
|
||||
|
||||
return new ImageResponse(
|
||||
(
|
||||
<OGImageTemplate
|
||||
title={title}
|
||||
description={description}
|
||||
title={t('title')}
|
||||
description={t('description')}
|
||||
label="Blog"
|
||||
/>
|
||||
),
|
||||
{
|
||||
width: 1200,
|
||||
height: 630,
|
||||
...OG_IMAGE_SIZE,
|
||||
fonts,
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
@@ -4,14 +4,16 @@ import { Section, Container, Heading, Card, Badge, Button } from '@/components/u
|
||||
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: {
|
||||
params: Promise<{
|
||||
locale: string;
|
||||
};
|
||||
}>;
|
||||
}
|
||||
|
||||
export async function generateMetadata({ params: { locale } }: BlogIndexProps) {
|
||||
export async function generateMetadata({ params }: BlogIndexProps) {
|
||||
const { locale } = await params;
|
||||
const t = await getTranslations({ locale, namespace: 'Blog.meta' });
|
||||
return {
|
||||
title: t('title'),
|
||||
@@ -19,15 +21,15 @@ 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: {
|
||||
@@ -38,13 +40,14 @@ export async function generateMetadata({ params: { locale } }: BlogIndexProps) {
|
||||
};
|
||||
}
|
||||
|
||||
export default async function BlogIndex({ params: { locale } }: BlogIndexProps) {
|
||||
export default async function BlogIndex({ params }: BlogIndexProps) {
|
||||
const { locale } = await params;
|
||||
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];
|
||||
@@ -65,10 +68,12 @@ 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 && (
|
||||
<>
|
||||
<Heading level={1} className="text-white mb-4 md:mb-8">
|
||||
@@ -77,9 +82,16 @@ export default async function BlogIndex({ params: { locale } }: BlogIndexProps)
|
||||
<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>
|
||||
</>
|
||||
)}
|
||||
@@ -97,10 +109,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>
|
||||
@@ -120,7 +152,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>
|
||||
)}
|
||||
@@ -131,7 +166,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">
|
||||
@@ -145,8 +180,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>
|
||||
@@ -156,13 +201,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,
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
@@ -7,23 +7,16 @@ 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'), {
|
||||
ssr: false,
|
||||
loading: () => (
|
||||
<div className="h-full w-full bg-neutral-medium flex items-center justify-center">
|
||||
<div className="animate-pulse text-primary font-medium">Loading Map...</div>
|
||||
</div>
|
||||
),
|
||||
});
|
||||
import ContactMap from '@/components/ContactMap';
|
||||
|
||||
interface ContactPageProps {
|
||||
params: {
|
||||
params: Promise<{
|
||||
locale: string;
|
||||
};
|
||||
}>;
|
||||
}
|
||||
|
||||
export async function generateMetadata({ params: { locale } }: ContactPageProps): Promise<Metadata> {
|
||||
export async function generateMetadata({ params }: ContactPageProps): Promise<Metadata> {
|
||||
const { locale } = await params;
|
||||
const t = await getTranslations({ locale, namespace: 'Contact' });
|
||||
const title = t('meta.title') || t('title');
|
||||
const description = t('meta.description') || t('subtitle');
|
||||
@@ -31,7 +24,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',
|
||||
@@ -40,7 +33,7 @@ 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: getOGImageMetadata('contact', title, locale),
|
||||
locale: `${locale.toUpperCase()}_DE`,
|
||||
@@ -64,7 +57,7 @@ export async function generateStaticParams() {
|
||||
}
|
||||
|
||||
export default async function ContactPage({ params }: ContactPageProps) {
|
||||
const { locale } = params;
|
||||
const { locale } = await params;
|
||||
const t = await getTranslations({ locale, namespace: 'Contact' });
|
||||
return (
|
||||
<div className="flex flex-col min-h-screen bg-neutral-light">
|
||||
@@ -78,7 +71,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`,
|
||||
},
|
||||
],
|
||||
}}
|
||||
@@ -89,9 +82,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',
|
||||
@@ -107,20 +100,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 */}
|
||||
@@ -154,36 +139,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>
|
||||
@@ -199,24 +219,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>
|
||||
}
|
||||
>
|
||||
<ContactMap address={t('info.address')} lat={48.8144} lng={9.4144} />
|
||||
</Suspense>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
@@ -2,14 +2,24 @@ import Footer from '@/components/Footer';
|
||||
import Header from '@/components/Header';
|
||||
import JsonLd from '@/components/JsonLd';
|
||||
import AnalyticsProvider from '@/components/analytics/AnalyticsProvider';
|
||||
import CMSConnectivityNotice from '@/components/CMSConnectivityNotice';
|
||||
import { FeedbackOverlay } from '@mintel/next-feedback';
|
||||
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 = {
|
||||
@@ -20,31 +30,60 @@ export const viewport: Viewport = {
|
||||
viewportFit: 'cover',
|
||||
themeColor: '#001a4d',
|
||||
};
|
||||
|
||||
|
||||
export default async function LocaleLayout({
|
||||
children,
|
||||
params: {locale}
|
||||
params,
|
||||
}: {
|
||||
children: React.ReactNode;
|
||||
params: {locale: string};
|
||||
params: Promise<{ locale: string }>;
|
||||
}) {
|
||||
// Providing all messages to the client
|
||||
// side is the easiest way to get started
|
||||
const messages = await getMessages();
|
||||
|
||||
const { locale } = await params;
|
||||
|
||||
// Ensure locale is a valid string, fallback to 'en'
|
||||
const supportedLocales = ['en', 'de'];
|
||||
const localeStr = (typeof locale === 'string' ? locale : '').trim();
|
||||
const safeLocale = supportedLocales.includes(localeStr) ? localeStr : 'en';
|
||||
|
||||
let messages = {};
|
||||
try {
|
||||
messages = await getMessages();
|
||||
} catch (error) {
|
||||
console.error(`Failed to load messages for locale '${safeLocale}':`, error);
|
||||
messages = {};
|
||||
}
|
||||
|
||||
// Track pageview on the server with high-fidelity header context
|
||||
const { getServerAppServices } = await import('@/lib/services/create-services.server');
|
||||
const serverServices = getServerAppServices();
|
||||
|
||||
const { headers } = await import('next/headers');
|
||||
const requestHeaders = await headers();
|
||||
|
||||
if ('setServerContext' in serverServices.analytics) {
|
||||
(serverServices.analytics as any).setServerContext({
|
||||
userAgent: requestHeaders.get('user-agent') || undefined,
|
||||
language: requestHeaders.get('accept-language')?.split(',')[0] || undefined,
|
||||
referrer: requestHeaders.get('referer') || undefined,
|
||||
ip: requestHeaders.get('x-forwarded-for')?.split(',')[0] || undefined,
|
||||
});
|
||||
}
|
||||
|
||||
// Track initial server-side pageview
|
||||
serverServices.analytics.trackPageview();
|
||||
|
||||
return (
|
||||
<html lang={locale} className="scroll-smooth overflow-x-hidden">
|
||||
<html lang={safeLocale} 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}>
|
||||
<NextIntlClientProvider messages={messages} locale={safeLocale}>
|
||||
<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 />
|
||||
|
||||
{/* Sends pageviews for client-side navigations */}
|
||||
<CMSConnectivityNotice />
|
||||
|
||||
<AnalyticsProvider />
|
||||
{config.feedbackEnabled && <FeedbackOverlay />}
|
||||
</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 = '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,
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -15,30 +15,56 @@ import { getTranslations } from 'next-intl/server';
|
||||
import { Metadata } from 'next';
|
||||
import { getOGImageMetadata } from '@/lib/metadata';
|
||||
|
||||
export default function HomePage({ params: { locale } }: { params: { locale: string } }) {
|
||||
export default async function HomePage({
|
||||
params,
|
||||
}: {
|
||||
params: Promise<{ locale: string }>;
|
||||
}) {
|
||||
const { locale } = await params;
|
||||
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,
|
||||
}: {
|
||||
params: Promise<{ locale: string }>;
|
||||
}): Promise<Metadata> {
|
||||
const { locale } = await params;
|
||||
// Use translations for meta where available (namespace: Index.meta)
|
||||
// Fallback to a sensible default if translation keys are missing.
|
||||
let t;
|
||||
@@ -62,15 +88,15 @@ 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: {
|
||||
|
||||
@@ -5,6 +5,7 @@ import ProductSidebar from '@/components/ProductSidebar';
|
||||
import ProductTabs from '@/components/ProductTabs';
|
||||
import ProductTechnicalData from '@/components/ProductTechnicalData';
|
||||
import RelatedProducts from '@/components/RelatedProducts';
|
||||
import DatasheetDownload from '@/components/DatasheetDownload';
|
||||
import { Badge, Container, Heading, Section } from '@/components/ui';
|
||||
import { getDatasheetPath } from '@/lib/datasheets';
|
||||
import { getAllProducts, getProductBySlug } from '@/lib/mdx';
|
||||
@@ -18,24 +19,35 @@ import Link from 'next/link';
|
||||
import { notFound } from 'next/navigation';
|
||||
|
||||
interface ProductPageProps {
|
||||
params: {
|
||||
params: Promise<{
|
||||
locale: string;
|
||||
slug: string[];
|
||||
};
|
||||
}>;
|
||||
}
|
||||
|
||||
export async function generateMetadata({ params }: ProductPageProps): Promise<Metadata> {
|
||||
const { locale, slug } = params;
|
||||
const { locale, slug } = await params;
|
||||
const productSlug = slug[slug.length - 1];
|
||||
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,
|
||||
@@ -43,15 +55,15 @@ 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: {
|
||||
@@ -71,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')}`,
|
||||
},
|
||||
},
|
||||
@@ -80,7 +92,7 @@ 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: {
|
||||
@@ -94,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" />,
|
||||
@@ -116,45 +144,66 @@ 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>
|
||||
),
|
||||
};
|
||||
|
||||
export default async function ProductPage({ params }: ProductPageProps) {
|
||||
const { locale, slug } = params;
|
||||
const { locale, slug } = await params;
|
||||
const productSlug = slug[slug.length - 1];
|
||||
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 (
|
||||
@@ -163,7 +212,9 @@ 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>
|
||||
@@ -201,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>
|
||||
))}
|
||||
@@ -216,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>
|
||||
@@ -237,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 {
|
||||
@@ -252,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}
|
||||
@@ -286,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 && (
|
||||
@@ -307,7 +384,11 @@ 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>
|
||||
))}
|
||||
@@ -328,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"
|
||||
@@ -341,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>
|
||||
@@ -359,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,83 +1,29 @@
|
||||
import { ImageResponse } from 'next/og';
|
||||
import { getProductBySlug } from '@/lib/mdx';
|
||||
import { getTranslations } from 'next-intl/server';
|
||||
import { OGImageTemplate } from '@/components/OGImageTemplate';
|
||||
import { getOgFonts, OG_IMAGE_SIZE } from '@/lib/og-helper';
|
||||
|
||||
export const runtime = 'nodejs';
|
||||
|
||||
export default async function Image({ params: { locale, slug } }: { params: { locale: string, slug?: string[] } }) {
|
||||
const t = await getTranslations('Products');
|
||||
export default async function Image({ params: { locale } }: { params: { locale: string } }) {
|
||||
const t = await getTranslations({ locale, namespace: 'Products' });
|
||||
const fonts = await getOgFonts();
|
||||
|
||||
// If no slug, it's the main products page
|
||||
if (!slug || slug.length === 0) {
|
||||
const title = t('meta.title') || t('title');
|
||||
const description = t('meta.description') || t('subtitle');
|
||||
|
||||
return new ImageResponse(
|
||||
(
|
||||
<OGImageTemplate
|
||||
title={title}
|
||||
description={description}
|
||||
label="Products"
|
||||
/>
|
||||
),
|
||||
{
|
||||
width: 1200,
|
||||
height: 630,
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
const productSlug = slug[slug.length - 1];
|
||||
|
||||
// Check if it's a category page
|
||||
const categories = ['low-voltage-cables', 'medium-voltage-cables', 'high-voltage-cables', 'solar-cables'];
|
||||
if (categories.includes(productSlug)) {
|
||||
const categoryKey = productSlug.replace(/-cables$/, '').replace(/-([a-z])/g, (g) => g[1].toUpperCase());
|
||||
const categoryTitle = t.has(`categories.${categoryKey}.title`) ? t(`categories.${categoryKey}.title`) : productSlug;
|
||||
const categoryDesc = t.has(`categories.${categoryKey}.description`) ? t(`categories.${categoryKey}.description`) : '';
|
||||
|
||||
return new ImageResponse(
|
||||
(
|
||||
<OGImageTemplate
|
||||
title={categoryTitle}
|
||||
description={categoryDesc}
|
||||
label="Product Category"
|
||||
/>
|
||||
),
|
||||
{
|
||||
width: 1200,
|
||||
height: 630,
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
const product = await getProductBySlug(productSlug, locale);
|
||||
|
||||
if (!product) {
|
||||
return new ImageResponse(
|
||||
<div style={{ display: 'flex', width: '100%', height: '100%', backgroundColor: '#001a4d' }} />
|
||||
);
|
||||
}
|
||||
|
||||
const featuredImage = product.frontmatter.images?.[0]
|
||||
? (product.frontmatter.images[0].startsWith('http')
|
||||
? product.frontmatter.images[0]
|
||||
: `https://klz-cables.com${product.frontmatter.images[0]}`)
|
||||
: undefined;
|
||||
const title = t('meta.title') || t('title');
|
||||
const description = t('meta.description') || t('subtitle');
|
||||
|
||||
return new ImageResponse(
|
||||
(
|
||||
<OGImageTemplate
|
||||
title={product.frontmatter.title}
|
||||
description={product.frontmatter.description}
|
||||
label={product.frontmatter.categories?.[0] || 'Product'}
|
||||
image={featuredImage}
|
||||
title={title}
|
||||
description={description}
|
||||
label="Products"
|
||||
/>
|
||||
),
|
||||
{
|
||||
width: 1200,
|
||||
height: 630,
|
||||
...OG_IMAGE_SIZE,
|
||||
fonts,
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -7,14 +7,16 @@ 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: {
|
||||
params: Promise<{
|
||||
locale: string;
|
||||
};
|
||||
}>;
|
||||
}
|
||||
|
||||
export async function generateMetadata({ params: { locale } }: ProductsPageProps): Promise<Metadata> {
|
||||
export async function generateMetadata({ params }: ProductsPageProps): Promise<Metadata> {
|
||||
const { locale } = await params;
|
||||
const t = await getTranslations({ locale, namespace: 'Products' });
|
||||
const title = t('meta.title') || t('title');
|
||||
const description = t('meta.description') || t('subtitle');
|
||||
@@ -24,15 +26,15 @@ 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: {
|
||||
@@ -44,13 +46,14 @@ export async function generateMetadata({ params: { locale } }: ProductsPageProps
|
||||
}
|
||||
|
||||
export default async function ProductsPage({ params }: ProductsPageProps) {
|
||||
const { locale } = await params;
|
||||
const t = await getTranslations('Products');
|
||||
|
||||
// Get translated category slugs
|
||||
const lowVoltageSlug = await mapFileSlugToTranslated('low-voltage-cables', params.locale);
|
||||
const mediumVoltageSlug = await mapFileSlugToTranslated('medium-voltage-cables', params.locale);
|
||||
const highVoltageSlug = await mapFileSlugToTranslated('high-voltage-cables', params.locale);
|
||||
const solarSlug = await mapFileSlugToTranslated('solar-cables', params.locale);
|
||||
const lowVoltageSlug = await mapFileSlugToTranslated('low-voltage-cables', locale);
|
||||
const mediumVoltageSlug = await mapFileSlugToTranslated('medium-voltage-cables', locale);
|
||||
const highVoltageSlug = await mapFileSlugToTranslated('high-voltage-cables', locale);
|
||||
const solarSlug = await mapFileSlugToTranslated('solar-cables', locale);
|
||||
|
||||
const categories = [
|
||||
{
|
||||
@@ -58,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: `/${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: `/${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: `/${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: `/${locale}/products/${solarSlug}`,
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
@@ -89,7 +92,10 @@ 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>
|
||||
<Heading level={1} className="text-white mb-4 md:mb-8">
|
||||
@@ -97,16 +103,24 @@ export default async function ProductsPage({ params }: ProductsPageProps) {
|
||||
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>
|
||||
)
|
||||
),
|
||||
})}
|
||||
</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>
|
||||
@@ -123,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"
|
||||
@@ -132,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">
|
||||
@@ -155,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>
|
||||
@@ -168,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">
|
||||
@@ -177,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={`/${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,
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -9,12 +9,13 @@ import Reveal from '@/components/Reveal';
|
||||
import Gallery from '@/components/team/Gallery';
|
||||
|
||||
interface TeamPageProps {
|
||||
params: {
|
||||
params: Promise<{
|
||||
locale: string;
|
||||
};
|
||||
}>;
|
||||
}
|
||||
|
||||
export async function generateMetadata({ params: { locale } }: TeamPageProps): Promise<Metadata> {
|
||||
export async function generateMetadata({ params }: TeamPageProps): Promise<Metadata> {
|
||||
const { locale } = await params;
|
||||
const t = await getTranslations({ locale, namespace: 'Team' });
|
||||
const title = t('meta.title') || t('hero.subtitle');
|
||||
const description = t('meta.description') || t('hero.title');
|
||||
@@ -24,15 +25,15 @@ 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: {
|
||||
@@ -43,16 +44,15 @@ export async function generateMetadata({ params: { locale } }: TeamPageProps): P
|
||||
};
|
||||
}
|
||||
|
||||
export default async function TeamPage({ params: { locale } }: TeamPageProps) {
|
||||
export default async function TeamPage({ params }: TeamPageProps) {
|
||||
const { locale } = await params;
|
||||
const t = await getTranslations({ locale, namespace: 'Team' });
|
||||
|
||||
return (
|
||||
<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"
|
||||
@@ -65,10 +65,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
|
||||
@@ -82,10 +80,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 */}
|
||||
@@ -101,9 +97,11 @@ 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>
|
||||
<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')}
|
||||
</Heading>
|
||||
@@ -120,7 +118,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>
|
||||
@@ -133,9 +133,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"
|
||||
>
|
||||
@@ -173,26 +173,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>
|
||||
@@ -216,7 +226,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>
|
||||
@@ -229,9 +241,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"
|
||||
>
|
||||
@@ -255,11 +267,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>
|
||||
))}
|
||||
@@ -268,12 +283,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,153 @@
|
||||
"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;
|
||||
|
||||
// Set analytics context from request headers for high-fidelity server-side tracking
|
||||
const { headers } = await import('next/headers');
|
||||
const requestHeaders = await headers();
|
||||
|
||||
if ('setServerContext' in services.analytics) {
|
||||
(services.analytics as any).setServerContext({
|
||||
userAgent: requestHeaders.get('user-agent') || undefined,
|
||||
language: requestHeaders.get('accept-language')?.split(',')[0] || undefined,
|
||||
referrer: requestHeaders.get('referer') || undefined,
|
||||
ip: requestHeaders.get('x-forwarded-for')?.split(',')[0] || undefined,
|
||||
});
|
||||
}
|
||||
|
||||
// Track attempt
|
||||
services.analytics.track('contact-form-attempt');
|
||||
|
||||
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,
|
||||
});
|
||||
|
||||
// Track success
|
||||
services.analytics.track('contact-form-success', {
|
||||
is_product_request: !!productName,
|
||||
});
|
||||
|
||||
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 };
|
||||
}
|
||||
}
|
||||
|
||||
17
app/api/feedback/route.ts
Normal file
17
app/api/feedback/route.ts
Normal file
@@ -0,0 +1,17 @@
|
||||
import { NextRequest } from 'next/server';
|
||||
import { handleFeedbackRequest } from '@mintel/next-feedback';
|
||||
import { config } from '@/lib/config';
|
||||
|
||||
export async function GET(req: NextRequest) {
|
||||
return handleFeedbackRequest(req as any, {
|
||||
url: config.infraCMS.url,
|
||||
token: config.infraCMS.token,
|
||||
});
|
||||
}
|
||||
|
||||
export async function POST(req: NextRequest) {
|
||||
return handleFeedbackRequest(req as any, {
|
||||
url: config.infraCMS.url,
|
||||
token: config.infraCMS.token,
|
||||
});
|
||||
}
|
||||
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 });
|
||||
}
|
||||
7
app/api/whoami/route.ts
Normal file
7
app/api/whoami/route.ts
Normal file
@@ -0,0 +1,7 @@
|
||||
import { NextRequest } from 'next/server';
|
||||
import { handleWhoAmIRequest } from '@mintel/next-feedback';
|
||||
import { config } from '@/lib/config';
|
||||
|
||||
export async function GET(req: NextRequest) {
|
||||
return handleWhoAmIRequest(req, config.gatekeeperUrl);
|
||||
}
|
||||
69
app/errors/api/relay/route.ts
Normal file
69
app/errors/api/relay/route.ts
Normal file
@@ -0,0 +1,69 @@
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { getServerAppServices } from '@/lib/services/create-services.server';
|
||||
import { config } from '@/lib/config';
|
||||
|
||||
/**
|
||||
* Smart Proxy / Relay for Sentry/GlitchTip events.
|
||||
*
|
||||
* This Route Handler receives Sentry envelopes from the client,
|
||||
* injects the correct DSN if needed, and forwards them to the
|
||||
* internal GlitchTip/Sentry instance.
|
||||
*
|
||||
* This hides the real DSN from the client and bypasses ad-blockers
|
||||
* that target Sentry's default ingest endpoints.
|
||||
*/
|
||||
export async function POST(request: NextRequest) {
|
||||
const services = getServerAppServices();
|
||||
const logger = services.logger.child({ component: 'sentry-relay' });
|
||||
|
||||
try {
|
||||
const envelope = await request.text();
|
||||
|
||||
// Sentry envelopes can contain multiple parts separated by newlines
|
||||
const lines = envelope.split('\n');
|
||||
if (lines.length < 1) {
|
||||
return NextResponse.json({ error: 'Empty envelope' }, { status: 400 });
|
||||
}
|
||||
|
||||
const header = JSON.parse(lines[0]);
|
||||
const realDsn = config.errors.glitchtip.dsn;
|
||||
|
||||
if (!realDsn) {
|
||||
logger.warn('Sentry relay received but no SENTRY_DSN configured on server');
|
||||
return NextResponse.json({ status: 'ignored' }, { status: 200 });
|
||||
}
|
||||
|
||||
const dsnUrl = new URL(realDsn);
|
||||
const projectId = dsnUrl.pathname.replace('/', '');
|
||||
const relayUrl = `${dsnUrl.protocol}//${dsnUrl.host}/api/${projectId}/envelope/`;
|
||||
|
||||
logger.debug('Relaying Sentry envelope', {
|
||||
projectId,
|
||||
host: dsnUrl.host,
|
||||
});
|
||||
|
||||
const response = await fetch(relayUrl, {
|
||||
method: 'POST',
|
||||
body: envelope,
|
||||
headers: {
|
||||
'Content-Type': 'application/x-sentry-envelope',
|
||||
},
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const errorText = await response.text();
|
||||
logger.error('Sentry/GlitchTip API responded with error', {
|
||||
status: response.status,
|
||||
error: errorText.slice(0, 100),
|
||||
});
|
||||
return new NextResponse(errorText, { status: response.status });
|
||||
}
|
||||
|
||||
return NextResponse.json({ status: 'ok' });
|
||||
} catch (error) {
|
||||
logger.error('Failed to relay Sentry request', {
|
||||
error: (error as Error).message,
|
||||
});
|
||||
return NextResponse.json({ error: 'Internal Server Error' }, { status: 500 });
|
||||
}
|
||||
}
|
||||
@@ -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(),
|
||||
|
||||
73
app/stats/api/send/route.ts
Normal file
73
app/stats/api/send/route.ts
Normal file
@@ -0,0 +1,73 @@
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { getServerAppServices } from '@/lib/services/create-services.server';
|
||||
import { config } from '@/lib/config';
|
||||
|
||||
/**
|
||||
* Smart Proxy for Umami Analytics.
|
||||
*
|
||||
* This Route Handler receives tracking events from the browser,
|
||||
* injects the secret UMAMI_WEBSITE_ID, and forwards them to the
|
||||
* internal Umami API endpoint.
|
||||
*
|
||||
* This ensures:
|
||||
* 1. The Website ID is NOT leaked to the client bundle.
|
||||
* 2. The Umami API endpoint is hidden behind our domain.
|
||||
* 3. We have full control over the tracking data.
|
||||
*/
|
||||
export async function POST(request: NextRequest) {
|
||||
const services = getServerAppServices();
|
||||
const logger = services.logger.child({ component: 'umami-smart-proxy' });
|
||||
|
||||
try {
|
||||
const body = await request.json();
|
||||
const { type, payload } = body;
|
||||
|
||||
// Inject the secret websiteId from server config
|
||||
const websiteId = config.analytics.umami.websiteId;
|
||||
if (!websiteId) {
|
||||
logger.warn('Umami tracking received but no Website ID configured on server');
|
||||
return NextResponse.json({ status: 'ignored' }, { status: 200 });
|
||||
}
|
||||
|
||||
// Prepare the enhanced payload with the secret ID
|
||||
const enhancedPayload = {
|
||||
...payload,
|
||||
website: websiteId,
|
||||
};
|
||||
|
||||
const umamiEndpoint = config.analytics.umami.apiEndpoint;
|
||||
|
||||
// Log the event (internal only)
|
||||
logger.debug('Forwarding analytics event', {
|
||||
type,
|
||||
url: payload.url,
|
||||
website: websiteId.slice(0, 8) + '...',
|
||||
});
|
||||
|
||||
const response = await fetch(`${umamiEndpoint}/api/send`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'User-Agent': request.headers.get('user-agent') || 'KLZ-Smart-Proxy',
|
||||
'X-Forwarded-For': request.headers.get('x-forwarded-for') || '',
|
||||
},
|
||||
body: JSON.stringify({ type, payload: enhancedPayload }),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const errorText = await response.text();
|
||||
logger.error('Umami API responded with error', {
|
||||
status: response.status,
|
||||
error: errorText.slice(0, 100),
|
||||
});
|
||||
return new NextResponse(errorText, { status: response.status });
|
||||
}
|
||||
|
||||
return NextResponse.json({ status: 'ok' });
|
||||
} catch (error) {
|
||||
logger.error('Failed to proxy analytics request', {
|
||||
error: (error as Error).message,
|
||||
});
|
||||
return NextResponse.json({ error: 'Internal Server Error' }, { status: 500 });
|
||||
}
|
||||
}
|
||||
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>
|
||||
|
||||
23
components/ContactMap.tsx
Normal file
23
components/ContactMap.tsx
Normal file
@@ -0,0 +1,23 @@
|
||||
'use client';
|
||||
|
||||
import React from 'react';
|
||||
import dynamic from 'next/dynamic';
|
||||
|
||||
const LeafletMap = dynamic(() => import('@/components/LeafletMap'), {
|
||||
ssr: false,
|
||||
loading: () => (
|
||||
<div className="h-full w-full bg-neutral-medium flex items-center justify-center">
|
||||
<div className="animate-pulse text-primary font-medium">Loading Map...</div>
|
||||
</div>
|
||||
),
|
||||
});
|
||||
|
||||
interface ContactMapProps {
|
||||
address: string;
|
||||
lat: number;
|
||||
lng: number;
|
||||
}
|
||||
|
||||
export default function ContactMap({ address, lat, lng }: ContactMapProps) {
|
||||
return <LeafletMap address={address} lat={lat} lng={lng} />;
|
||||
}
|
||||
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,7 +39,10 @@ export function OGImageTemplate({
|
||||
<div
|
||||
style={{
|
||||
position: 'absolute',
|
||||
inset: 0,
|
||||
top: 0,
|
||||
left: 0,
|
||||
right: 0,
|
||||
bottom: 0,
|
||||
display: 'flex',
|
||||
}}
|
||||
>
|
||||
@@ -57,23 +60,26 @@ export function OGImageTemplate({
|
||||
<div
|
||||
style={{
|
||||
position: 'absolute',
|
||||
inset: 0,
|
||||
background: 'linear-gradient(to right, rgba(0,26,77,0.9) 0%, rgba(0,26,77,0.4) 100%)',
|
||||
top: 0,
|
||||
left: 0,
|
||||
right: 0,
|
||||
bottom: 0,
|
||||
background: 'linear-gradient(to right, rgba(0,26,77,0.95), rgba(0,26,77,0.6))',
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Decorative Scribble Circle (Simplified for Satori) */}
|
||||
{/* Decorative Brand Accent (Top Right) */}
|
||||
<div
|
||||
style={{
|
||||
position: 'absolute',
|
||||
top: '-100px',
|
||||
right: '-100px',
|
||||
top: '-150px',
|
||||
right: '-150px',
|
||||
width: '600px',
|
||||
height: '600px',
|
||||
borderRadius: '50%',
|
||||
background: `radial-gradient(circle, ${accentGreen}1a 0%, transparent 70%)`,
|
||||
borderRadius: '300px',
|
||||
backgroundColor: `${accentGreen}15`,
|
||||
display: 'flex',
|
||||
}}
|
||||
/>
|
||||
@@ -84,11 +90,11 @@ export function OGImageTemplate({
|
||||
<div
|
||||
style={{
|
||||
fontSize: '24px',
|
||||
fontWeight: 'bold',
|
||||
fontWeight: 700,
|
||||
color: accentGreen,
|
||||
textTransform: 'uppercase',
|
||||
letterSpacing: '0.2em',
|
||||
marginBottom: '24px',
|
||||
letterSpacing: '0.3em',
|
||||
marginBottom: '32px',
|
||||
display: 'flex',
|
||||
}}
|
||||
>
|
||||
@@ -99,13 +105,14 @@ export function OGImageTemplate({
|
||||
{/* Title */}
|
||||
<div
|
||||
style={{
|
||||
fontSize: '72px',
|
||||
fontWeight: '900',
|
||||
fontSize: title.length > 40 ? '64px' : '82px',
|
||||
fontWeight: 700,
|
||||
color: 'white',
|
||||
lineHeight: '1.1',
|
||||
maxWidth: '900px',
|
||||
marginBottom: '32px',
|
||||
lineHeight: '1.05',
|
||||
maxWidth: '950px',
|
||||
marginBottom: '40px',
|
||||
display: 'flex',
|
||||
letterSpacing: '-0.02em',
|
||||
}}
|
||||
>
|
||||
{title}
|
||||
@@ -116,13 +123,14 @@ export function OGImageTemplate({
|
||||
<div
|
||||
style={{
|
||||
fontSize: '32px',
|
||||
color: 'rgba(255,255,255,0.8)',
|
||||
maxWidth: '800px',
|
||||
color: 'rgba(255,255,255,0.7)',
|
||||
maxWidth: '850px',
|
||||
lineHeight: '1.4',
|
||||
display: 'flex',
|
||||
fontWeight: 400,
|
||||
}}
|
||||
>
|
||||
{description}
|
||||
{description.length > 160 ? description.substring(0, 157) + '...' : description}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
@@ -139,33 +147,34 @@ export function OGImageTemplate({
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
width: '120px',
|
||||
height: '8px',
|
||||
width: '80px',
|
||||
height: '6px',
|
||||
backgroundColor: accentGreen,
|
||||
borderRadius: '4px',
|
||||
borderRadius: '3px',
|
||||
marginRight: '24px',
|
||||
}}
|
||||
/>
|
||||
<div
|
||||
style={{
|
||||
fontSize: '24px',
|
||||
fontWeight: 'bold',
|
||||
fontWeight: 700,
|
||||
color: 'white',
|
||||
textTransform: 'uppercase',
|
||||
letterSpacing: '0.1em',
|
||||
letterSpacing: '0.15em',
|
||||
display: 'flex',
|
||||
}}
|
||||
>
|
||||
KLZ Cables
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Saturated Blue Accent */}
|
||||
{/* Saturated Blue Brand Strip */}
|
||||
<div
|
||||
style={{
|
||||
position: 'absolute',
|
||||
top: 0,
|
||||
right: 0,
|
||||
width: '10px',
|
||||
width: '12px',
|
||||
height: '100%',
|
||||
backgroundColor: saturatedBlue,
|
||||
}}
|
||||
@@ -173,3 +182,4 @@ export function OGImageTemplate({
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -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,25 +3,15 @@
|
||||
import { useEffect } from 'react';
|
||||
import { usePathname, useSearchParams } from 'next/navigation';
|
||||
import { getAppServices } from '@/lib/services/create-services';
|
||||
import Script from 'next/script';
|
||||
|
||||
/**
|
||||
* AnalyticsProvider Component
|
||||
*
|
||||
* Automatically tracks pageviews on client-side route changes.
|
||||
* This component should be placed inside your layout to handle navigation events.
|
||||
* This component handles navigation events for the Umami analytics service.
|
||||
*
|
||||
* @example
|
||||
* ```tsx
|
||||
* // In your layout.tsx
|
||||
* <NextIntlClientProvider messages={messages} locale={locale}>
|
||||
* <UmamiScript />
|
||||
* <Header />
|
||||
* <main>{children}</main>
|
||||
* <Footer />
|
||||
* <AnalyticsProvider />
|
||||
* </NextIntlClientProvider>
|
||||
* ```
|
||||
* Note: Website ID is now centrally managed on the server side via a proxy,
|
||||
* so it's no longer needed as a prop here.
|
||||
*/
|
||||
export default function AnalyticsProvider() {
|
||||
const pathname = usePathname();
|
||||
@@ -29,31 +19,17 @@ export default function AnalyticsProvider() {
|
||||
|
||||
useEffect(() => {
|
||||
if (!pathname) return;
|
||||
|
||||
|
||||
const services = getAppServices();
|
||||
const url = `${pathname}${searchParams?.size ? `?${searchParams.toString()}` : ''}`;
|
||||
|
||||
|
||||
// Track pageview with the full URL
|
||||
// The service will relay this to our internal proxy which injects the Website ID
|
||||
services.analytics.trackPageview(url);
|
||||
|
||||
if (process.env.NODE_ENV === 'development') {
|
||||
console.log('[Umami] Tracked pageview:', url);
|
||||
}
|
||||
|
||||
// Services like logger are already sub-initialized in getAppServices()
|
||||
// so we don't need to log here manually.
|
||||
}, [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}
|
||||
data-host-url="/stats"
|
||||
strategy="afterInteractive"
|
||||
data-domains="klz-cables.com"
|
||||
defer
|
||||
/>
|
||||
);
|
||||
return null;
|
||||
}
|
||||
|
||||
|
||||
@@ -72,7 +72,7 @@ export default function Hero() {
|
||||
>
|
||||
<HeroIllustration />
|
||||
</motion.div>
|
||||
|
||||
|
||||
<motion.div
|
||||
className="absolute bottom-6 md:bottom-10 left-1/2 -translate-x-1/2 hidden sm:block"
|
||||
initial={{ opacity: 0, y: 16 }}
|
||||
|
||||
@@ -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],
|
||||
)}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
|
||||
|
||||
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)
|
||||
|
||||
@@ -398,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"
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
@@ -737,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"
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
@@ -1076,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"
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
@@ -398,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"
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
@@ -737,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"
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
@@ -1076,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"
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
0
directus/migrations/.keep
Normal file
0
directus/migrations/.keep
Normal file
590
directus/schema/feedback_schema.yaml
Normal file
590
directus/schema/feedback_schema.yaml
Normal file
@@ -0,0 +1,590 @@
|
||||
version: 1
|
||||
directus: 11.14.1
|
||||
vendor: postgres
|
||||
collections:
|
||||
- collection: contact_submissions
|
||||
meta:
|
||||
accountability: all
|
||||
archive_app_filter: true
|
||||
archive_field: null
|
||||
archive_value: null
|
||||
collapse: open
|
||||
collection: contact_submissions
|
||||
color: '#002b49'
|
||||
display_template: '{{first_name}} {{last_name}} | {{subject}}'
|
||||
group: null
|
||||
hidden: false
|
||||
icon: contact_mail
|
||||
item_duplication_fields: null
|
||||
note: null
|
||||
preview_url: null
|
||||
singleton: false
|
||||
sort: null
|
||||
sort_field: null
|
||||
translations: null
|
||||
unarchive_value: null
|
||||
versioning: false
|
||||
schema:
|
||||
name: contact_submissions
|
||||
- collection: product_requests
|
||||
meta:
|
||||
accountability: all
|
||||
archive_app_filter: true
|
||||
archive_field: null
|
||||
archive_value: null
|
||||
collapse: open
|
||||
collection: product_requests
|
||||
color: '#002b49'
|
||||
display_template: null
|
||||
group: null
|
||||
hidden: false
|
||||
icon: inventory
|
||||
item_duplication_fields: null
|
||||
note: null
|
||||
preview_url: null
|
||||
singleton: false
|
||||
sort: null
|
||||
sort_field: null
|
||||
translations: null
|
||||
unarchive_value: null
|
||||
versioning: false
|
||||
schema:
|
||||
name: product_requests
|
||||
- collection: visual_feedback
|
||||
meta:
|
||||
accountability: all
|
||||
archive_app_filter: true
|
||||
archive_field: null
|
||||
archive_value: null
|
||||
collapse: open
|
||||
collection: visual_feedback
|
||||
color: '#002b49'
|
||||
display_template: '{{user_name}} | {{type}}: {{text}}'
|
||||
group: null
|
||||
hidden: false
|
||||
icon: feedback
|
||||
item_duplication_fields: null
|
||||
note: null
|
||||
preview_url: null
|
||||
singleton: false
|
||||
sort: null
|
||||
sort_field: null
|
||||
translations: null
|
||||
unarchive_value: null
|
||||
versioning: false
|
||||
schema:
|
||||
name: visual_feedback
|
||||
- collection: visual_feedback_comments
|
||||
meta:
|
||||
accountability: all
|
||||
archive_app_filter: true
|
||||
archive_field: null
|
||||
archive_value: null
|
||||
collapse: open
|
||||
collection: visual_feedback_comments
|
||||
color: '#002b49'
|
||||
display_template: '{{user_name}}: {{text}}'
|
||||
group: null
|
||||
hidden: false
|
||||
icon: comment
|
||||
item_duplication_fields: null
|
||||
note: null
|
||||
preview_url: null
|
||||
singleton: false
|
||||
sort: null
|
||||
sort_field: null
|
||||
translations: null
|
||||
unarchive_value: null
|
||||
versioning: false
|
||||
schema:
|
||||
name: visual_feedback_comments
|
||||
fields:
|
||||
- collection: visual_feedback
|
||||
field: id
|
||||
type: uuid
|
||||
meta:
|
||||
collection: visual_feedback
|
||||
conditions: null
|
||||
display: null
|
||||
display_options: null
|
||||
field: id
|
||||
group: null
|
||||
hidden: true
|
||||
interface: null
|
||||
note: null
|
||||
options: null
|
||||
readonly: false
|
||||
required: false
|
||||
searchable: true
|
||||
sort: 1
|
||||
special: null
|
||||
translations: null
|
||||
validation: null
|
||||
validation_message: null
|
||||
width: full
|
||||
schema:
|
||||
name: id
|
||||
table: visual_feedback
|
||||
data_type: uuid
|
||||
default_value: null
|
||||
max_length: null
|
||||
numeric_precision: null
|
||||
numeric_scale: null
|
||||
is_nullable: false
|
||||
is_unique: true
|
||||
is_indexed: false
|
||||
is_primary_key: true
|
||||
is_generated: false
|
||||
generation_expression: null
|
||||
has_auto_increment: false
|
||||
foreign_key_table: null
|
||||
foreign_key_column: null
|
||||
- collection: visual_feedback
|
||||
field: status
|
||||
type: string
|
||||
meta:
|
||||
collection: visual_feedback
|
||||
conditions: null
|
||||
display: labels
|
||||
display_options:
|
||||
choices:
|
||||
- background: '#e1f5fe'
|
||||
color: '#01579b'
|
||||
text: Open
|
||||
value: open
|
||||
- background: '#e8f5e9'
|
||||
color: '#1b5e20'
|
||||
text: Resolved
|
||||
value: resolved
|
||||
- background: '#fafafa'
|
||||
color: '#212121'
|
||||
text: Closed
|
||||
value: closed
|
||||
show_as_dot: true
|
||||
field: status
|
||||
group: null
|
||||
hidden: false
|
||||
interface: select-dropdown
|
||||
note: null
|
||||
options:
|
||||
choices:
|
||||
- text: Open
|
||||
value: open
|
||||
- text: Resolved
|
||||
value: resolved
|
||||
- text: Closed
|
||||
value: closed
|
||||
readonly: false
|
||||
required: false
|
||||
searchable: true
|
||||
sort: 2
|
||||
special: null
|
||||
translations: null
|
||||
validation: null
|
||||
validation_message: null
|
||||
width: half
|
||||
schema:
|
||||
name: status
|
||||
table: visual_feedback
|
||||
data_type: character varying
|
||||
default_value: open
|
||||
max_length: 255
|
||||
numeric_precision: null
|
||||
numeric_scale: null
|
||||
is_nullable: true
|
||||
is_unique: false
|
||||
is_indexed: false
|
||||
is_primary_key: false
|
||||
is_generated: false
|
||||
generation_expression: null
|
||||
has_auto_increment: false
|
||||
foreign_key_table: null
|
||||
foreign_key_column: null
|
||||
- collection: visual_feedback
|
||||
field: type
|
||||
type: string
|
||||
meta:
|
||||
collection: visual_feedback
|
||||
conditions: null
|
||||
display: labels
|
||||
display_options:
|
||||
choices:
|
||||
- background: '#fff9c4'
|
||||
color: '#fbc02d'
|
||||
text: Design
|
||||
value: design
|
||||
- background: '#f3e5f5'
|
||||
color: '#7b1fa2'
|
||||
text: Content
|
||||
value: content
|
||||
show_as_dot: true
|
||||
field: type
|
||||
group: null
|
||||
hidden: false
|
||||
interface: select-dropdown
|
||||
note: null
|
||||
options:
|
||||
choices:
|
||||
- text: Design
|
||||
value: design
|
||||
- text: Content
|
||||
value: content
|
||||
readonly: false
|
||||
required: false
|
||||
searchable: true
|
||||
sort: 3
|
||||
special: null
|
||||
translations: null
|
||||
validation: null
|
||||
validation_message: null
|
||||
width: half
|
||||
schema:
|
||||
name: type
|
||||
table: visual_feedback
|
||||
data_type: character varying
|
||||
default_value: null
|
||||
max_length: 255
|
||||
numeric_precision: null
|
||||
numeric_scale: null
|
||||
is_nullable: true
|
||||
is_unique: false
|
||||
is_indexed: false
|
||||
is_primary_key: false
|
||||
is_generated: false
|
||||
generation_expression: null
|
||||
has_auto_increment: false
|
||||
foreign_key_table: null
|
||||
foreign_key_column: null
|
||||
- collection: visual_feedback
|
||||
field: text
|
||||
type: text
|
||||
meta:
|
||||
collection: visual_feedback
|
||||
conditions: null
|
||||
display: formatted-text
|
||||
display_options:
|
||||
soft_limit: 100
|
||||
field: text
|
||||
group: null
|
||||
hidden: false
|
||||
interface: input-multiline
|
||||
note: null
|
||||
options: null
|
||||
readonly: false
|
||||
required: false
|
||||
searchable: true
|
||||
sort: 4
|
||||
special: null
|
||||
translations: null
|
||||
validation: null
|
||||
validation_message: null
|
||||
width: full
|
||||
schema:
|
||||
name: text
|
||||
table: visual_feedback
|
||||
data_type: text
|
||||
default_value: null
|
||||
max_length: null
|
||||
numeric_precision: null
|
||||
numeric_scale: null
|
||||
is_nullable: true
|
||||
is_unique: false
|
||||
is_indexed: false
|
||||
is_primary_key: false
|
||||
is_generated: false
|
||||
generation_expression: null
|
||||
has_auto_increment: false
|
||||
foreign_key_table: null
|
||||
foreign_key_column: null
|
||||
- collection: visual_feedback
|
||||
field: url
|
||||
type: string
|
||||
meta:
|
||||
collection: visual_feedback
|
||||
conditions: null
|
||||
display: link
|
||||
display_options:
|
||||
url: '{{url}}'
|
||||
target: _blank
|
||||
icon: open_in_new
|
||||
field: url
|
||||
group: null
|
||||
hidden: false
|
||||
interface: input
|
||||
note: null
|
||||
options: null
|
||||
readonly: true
|
||||
required: false
|
||||
searchable: true
|
||||
sort: 5
|
||||
special: null
|
||||
translations: null
|
||||
validation: null
|
||||
validation_message: null
|
||||
width: full
|
||||
schema:
|
||||
name: url
|
||||
table: visual_feedback
|
||||
data_type: character varying
|
||||
default_value: null
|
||||
max_length: 255
|
||||
numeric_precision: null
|
||||
numeric_scale: null
|
||||
is_nullable: true
|
||||
is_unique: false
|
||||
is_indexed: false
|
||||
is_primary_key: false
|
||||
is_generated: false
|
||||
generation_expression: null
|
||||
has_auto_increment: false
|
||||
foreign_key_table: null
|
||||
foreign_key_column: null
|
||||
- collection: visual_feedback
|
||||
field: user_info_group
|
||||
type: alias
|
||||
meta:
|
||||
collection: visual_feedback
|
||||
field: user_info_group
|
||||
group: null
|
||||
hidden: false
|
||||
interface: group-detail
|
||||
options:
|
||||
header_icon: person
|
||||
header_text: User Information
|
||||
sort: 6
|
||||
special:
|
||||
- alias
|
||||
- no-data
|
||||
- group
|
||||
width: full
|
||||
- collection: visual_feedback
|
||||
field: user_name
|
||||
type: string
|
||||
meta:
|
||||
collection: visual_feedback
|
||||
field: user_name
|
||||
group: user_info_group
|
||||
hidden: false
|
||||
interface: input
|
||||
sort: 1
|
||||
width: half
|
||||
schema:
|
||||
name: user_name
|
||||
table: visual_feedback
|
||||
data_type: character varying
|
||||
is_nullable: true
|
||||
- collection: visual_feedback
|
||||
field: user_identity
|
||||
type: string
|
||||
meta:
|
||||
collection: visual_feedback
|
||||
field: user_identity
|
||||
group: user_info_group
|
||||
hidden: false
|
||||
interface: input
|
||||
readonly: true
|
||||
sort: 2
|
||||
width: half
|
||||
schema:
|
||||
name: user_identity
|
||||
table: visual_feedback
|
||||
data_type: character varying
|
||||
is_nullable: true
|
||||
- collection: visual_feedback
|
||||
field: technical_details_group
|
||||
type: alias
|
||||
meta:
|
||||
collection: visual_feedback
|
||||
field: technical_details_group
|
||||
group: null
|
||||
hidden: false
|
||||
interface: group-detail
|
||||
options:
|
||||
header_icon: psychology
|
||||
header_text: Technical Context
|
||||
sort: 7
|
||||
special:
|
||||
- alias
|
||||
- no-data
|
||||
- group
|
||||
width: full
|
||||
- collection: visual_feedback
|
||||
field: selector
|
||||
type: string
|
||||
meta:
|
||||
collection: visual_feedback
|
||||
field: selector
|
||||
group: technical_details_group
|
||||
hidden: false
|
||||
interface: input
|
||||
readonly: true
|
||||
sort: 1
|
||||
width: full
|
||||
schema:
|
||||
name: selector
|
||||
table: visual_feedback
|
||||
data_type: character varying
|
||||
is_nullable: true
|
||||
- collection: visual_feedback
|
||||
field: x
|
||||
type: float
|
||||
meta:
|
||||
collection: visual_feedback
|
||||
field: x
|
||||
group: technical_details_group
|
||||
hidden: false
|
||||
interface: input
|
||||
sort: 2
|
||||
width: half
|
||||
schema:
|
||||
name: x
|
||||
table: visual_feedback
|
||||
data_type: real
|
||||
is_nullable: true
|
||||
- collection: visual_feedback
|
||||
field: 'y'
|
||||
type: float
|
||||
meta:
|
||||
collection: visual_feedback
|
||||
field: 'y'
|
||||
group: technical_details_group
|
||||
hidden: false
|
||||
interface: input
|
||||
sort: 3
|
||||
width: half
|
||||
schema:
|
||||
name: 'y'
|
||||
table: visual_feedback
|
||||
data_type: real
|
||||
is_nullable: true
|
||||
- collection: visual_feedback
|
||||
field: date_created
|
||||
type: timestamp
|
||||
meta:
|
||||
collection: visual_feedback
|
||||
conditions: null
|
||||
display: datetime
|
||||
display_options:
|
||||
relative: true
|
||||
field: date_created
|
||||
group: null
|
||||
hidden: false
|
||||
interface: datetime
|
||||
note: null
|
||||
options: null
|
||||
readonly: true
|
||||
required: false
|
||||
searchable: true
|
||||
sort: 8
|
||||
special: null
|
||||
translations: null
|
||||
validation: null
|
||||
validation_message: null
|
||||
width: full
|
||||
schema:
|
||||
name: date_created
|
||||
table: visual_feedback
|
||||
data_type: timestamp with time zone
|
||||
default_value: CURRENT_TIMESTAMP
|
||||
is_nullable: true
|
||||
- collection: visual_feedback_comments
|
||||
field: id
|
||||
type: uuid
|
||||
meta:
|
||||
collection: visual_feedback_comments
|
||||
field: id
|
||||
hidden: true
|
||||
schema:
|
||||
name: id
|
||||
table: visual_feedback_comments
|
||||
data_type: uuid
|
||||
is_primary_key: true
|
||||
- collection: visual_feedback_comments
|
||||
field: feedback_id
|
||||
type: uuid
|
||||
meta:
|
||||
collection: visual_feedback_comments
|
||||
display: null
|
||||
field: feedback_id
|
||||
interface: select-relational
|
||||
sort: 2
|
||||
width: full
|
||||
schema:
|
||||
name: feedback_id
|
||||
table: visual_feedback_comments
|
||||
data_type: uuid
|
||||
- collection: visual_feedback_comments
|
||||
field: user_name
|
||||
type: string
|
||||
meta:
|
||||
collection: visual_feedback_comments
|
||||
field: user_name
|
||||
interface: input
|
||||
sort: 3
|
||||
width: half
|
||||
schema:
|
||||
name: user_name
|
||||
table: visual_feedback_comments
|
||||
data_type: character varying
|
||||
- collection: visual_feedback_comments
|
||||
field: text
|
||||
type: text
|
||||
meta:
|
||||
collection: visual_feedback_comments
|
||||
field: text
|
||||
interface: input-multiline
|
||||
sort: 4
|
||||
width: full
|
||||
schema:
|
||||
name: text
|
||||
table: visual_feedback_comments
|
||||
data_type: text
|
||||
- collection: visual_feedback_comments
|
||||
field: date_created
|
||||
type: timestamp
|
||||
meta:
|
||||
collection: visual_feedback_comments
|
||||
display: datetime
|
||||
display_options:
|
||||
relative: true
|
||||
field: date_created
|
||||
interface: datetime
|
||||
readonly: true
|
||||
sort: 5
|
||||
width: full
|
||||
schema:
|
||||
name: date_created
|
||||
table: visual_feedback_comments
|
||||
data_type: timestamp with time zone
|
||||
default_value: CURRENT_TIMESTAMP
|
||||
systemFields:
|
||||
- collection: directus_activity
|
||||
field: timestamp
|
||||
schema:
|
||||
is_indexed: true
|
||||
- collection: directus_revisions
|
||||
field: activity
|
||||
schema:
|
||||
is_indexed: true
|
||||
- collection: directus_revisions
|
||||
field: parent
|
||||
schema:
|
||||
is_indexed: true
|
||||
relations:
|
||||
- collection: visual_feedback_comments
|
||||
field: feedback_id
|
||||
related_collection: visual_feedback
|
||||
schema:
|
||||
column: feedback_id
|
||||
foreign_key_column: id
|
||||
foreign_key_table: visual_feedback
|
||||
table: visual_feedback_comments
|
||||
meta:
|
||||
junction_field: null
|
||||
many_collection: visual_feedback_comments
|
||||
many_field: feedback_id
|
||||
one_allowed_m2m: false
|
||||
one_collection: visual_feedback
|
||||
one_deselect_action: nullify
|
||||
one_field: null
|
||||
sort_field: null
|
||||
590
directus/schema/snapshot.yaml
Normal file
590
directus/schema/snapshot.yaml
Normal file
@@ -0,0 +1,590 @@
|
||||
version: 1
|
||||
directus: 11.14.1
|
||||
vendor: postgres
|
||||
collections:
|
||||
- collection: contact_submissions
|
||||
meta:
|
||||
accountability: all
|
||||
archive_app_filter: true
|
||||
archive_field: null
|
||||
archive_value: null
|
||||
collapse: open
|
||||
collection: contact_submissions
|
||||
color: '#002b49'
|
||||
display_template: '{{first_name}} {{last_name}} | {{subject}}'
|
||||
group: null
|
||||
hidden: false
|
||||
icon: contact_mail
|
||||
item_duplication_fields: null
|
||||
note: null
|
||||
preview_url: null
|
||||
singleton: false
|
||||
sort: null
|
||||
sort_field: null
|
||||
translations: null
|
||||
unarchive_value: null
|
||||
versioning: false
|
||||
schema:
|
||||
name: contact_submissions
|
||||
- collection: product_requests
|
||||
meta:
|
||||
accountability: all
|
||||
archive_app_filter: true
|
||||
archive_field: null
|
||||
archive_value: null
|
||||
collapse: open
|
||||
collection: product_requests
|
||||
color: '#002b49'
|
||||
display_template: null
|
||||
group: null
|
||||
hidden: false
|
||||
icon: inventory
|
||||
item_duplication_fields: null
|
||||
note: null
|
||||
preview_url: null
|
||||
singleton: false
|
||||
sort: null
|
||||
sort_field: null
|
||||
translations: null
|
||||
unarchive_value: null
|
||||
versioning: false
|
||||
schema:
|
||||
name: product_requests
|
||||
- collection: visual_feedback
|
||||
meta:
|
||||
accountability: all
|
||||
archive_app_filter: true
|
||||
archive_field: null
|
||||
archive_value: null
|
||||
collapse: open
|
||||
collection: visual_feedback
|
||||
color: '#002b49'
|
||||
display_template: '{{user_name}} | {{type}}: {{text}}'
|
||||
group: null
|
||||
hidden: false
|
||||
icon: feedback
|
||||
item_duplication_fields: null
|
||||
note: null
|
||||
preview_url: null
|
||||
singleton: false
|
||||
sort: null
|
||||
sort_field: null
|
||||
translations: null
|
||||
unarchive_value: null
|
||||
versioning: false
|
||||
schema:
|
||||
name: visual_feedback
|
||||
- collection: visual_feedback_comments
|
||||
meta:
|
||||
accountability: all
|
||||
archive_app_filter: true
|
||||
archive_field: null
|
||||
archive_value: null
|
||||
collapse: open
|
||||
collection: visual_feedback_comments
|
||||
color: '#002b49'
|
||||
display_template: '{{user_name}}: {{text}}'
|
||||
group: null
|
||||
hidden: false
|
||||
icon: comment
|
||||
item_duplication_fields: null
|
||||
note: null
|
||||
preview_url: null
|
||||
singleton: false
|
||||
sort: null
|
||||
sort_field: null
|
||||
translations: null
|
||||
unarchive_value: null
|
||||
versioning: false
|
||||
schema:
|
||||
name: visual_feedback_comments
|
||||
fields:
|
||||
- collection: visual_feedback
|
||||
field: id
|
||||
type: uuid
|
||||
meta:
|
||||
collection: visual_feedback
|
||||
conditions: null
|
||||
display: null
|
||||
display_options: null
|
||||
field: id
|
||||
group: null
|
||||
hidden: true
|
||||
interface: null
|
||||
note: null
|
||||
options: null
|
||||
readonly: false
|
||||
required: false
|
||||
searchable: true
|
||||
sort: 1
|
||||
special: null
|
||||
translations: null
|
||||
validation: null
|
||||
validation_message: null
|
||||
width: full
|
||||
schema:
|
||||
name: id
|
||||
table: visual_feedback
|
||||
data_type: uuid
|
||||
default_value: null
|
||||
max_length: null
|
||||
numeric_precision: null
|
||||
numeric_scale: null
|
||||
is_nullable: false
|
||||
is_unique: true
|
||||
is_indexed: false
|
||||
is_primary_key: true
|
||||
is_generated: false
|
||||
generation_expression: null
|
||||
has_auto_increment: false
|
||||
foreign_key_table: null
|
||||
foreign_key_column: null
|
||||
- collection: visual_feedback
|
||||
field: status
|
||||
type: string
|
||||
meta:
|
||||
collection: visual_feedback
|
||||
conditions: null
|
||||
display: labels
|
||||
display_options:
|
||||
choices:
|
||||
- background: '#e1f5fe'
|
||||
color: '#01579b'
|
||||
text: Open
|
||||
value: open
|
||||
- background: '#e8f5e9'
|
||||
color: '#1b5e20'
|
||||
text: Resolved
|
||||
value: resolved
|
||||
- background: '#fafafa'
|
||||
color: '#212121'
|
||||
text: Closed
|
||||
value: closed
|
||||
show_as_dot: true
|
||||
field: status
|
||||
group: null
|
||||
hidden: false
|
||||
interface: select-dropdown
|
||||
note: null
|
||||
options:
|
||||
choices:
|
||||
- text: Open
|
||||
value: open
|
||||
- text: Resolved
|
||||
value: resolved
|
||||
- text: Closed
|
||||
value: closed
|
||||
readonly: false
|
||||
required: false
|
||||
searchable: true
|
||||
sort: 2
|
||||
special: null
|
||||
translations: null
|
||||
validation: null
|
||||
validation_message: null
|
||||
width: half
|
||||
schema:
|
||||
name: status
|
||||
table: visual_feedback
|
||||
data_type: character varying
|
||||
default_value: open
|
||||
max_length: 255
|
||||
numeric_precision: null
|
||||
numeric_scale: null
|
||||
is_nullable: true
|
||||
is_unique: false
|
||||
is_indexed: false
|
||||
is_primary_key: false
|
||||
is_generated: false
|
||||
generation_expression: null
|
||||
has_auto_increment: false
|
||||
foreign_key_table: null
|
||||
foreign_key_column: null
|
||||
- collection: visual_feedback
|
||||
field: type
|
||||
type: string
|
||||
meta:
|
||||
collection: visual_feedback
|
||||
conditions: null
|
||||
display: labels
|
||||
display_options:
|
||||
choices:
|
||||
- background: '#fff9c4'
|
||||
color: '#fbc02d'
|
||||
text: Design
|
||||
value: design
|
||||
- background: '#f3e5f5'
|
||||
color: '#7b1fa2'
|
||||
text: Content
|
||||
value: content
|
||||
show_as_dot: true
|
||||
field: type
|
||||
group: null
|
||||
hidden: false
|
||||
interface: select-dropdown
|
||||
note: null
|
||||
options:
|
||||
choices:
|
||||
- text: Design
|
||||
value: design
|
||||
- text: Content
|
||||
value: content
|
||||
readonly: false
|
||||
required: false
|
||||
searchable: true
|
||||
sort: 3
|
||||
special: null
|
||||
translations: null
|
||||
validation: null
|
||||
validation_message: null
|
||||
width: half
|
||||
schema:
|
||||
name: type
|
||||
table: visual_feedback
|
||||
data_type: character varying
|
||||
default_value: null
|
||||
max_length: 255
|
||||
numeric_precision: null
|
||||
numeric_scale: null
|
||||
is_nullable: true
|
||||
is_unique: false
|
||||
is_indexed: false
|
||||
is_primary_key: false
|
||||
is_generated: false
|
||||
generation_expression: null
|
||||
has_auto_increment: false
|
||||
foreign_key_table: null
|
||||
foreign_key_column: null
|
||||
- collection: visual_feedback
|
||||
field: text
|
||||
type: text
|
||||
meta:
|
||||
collection: visual_feedback
|
||||
conditions: null
|
||||
display: formatted-text
|
||||
display_options:
|
||||
soft_limit: 100
|
||||
field: text
|
||||
group: null
|
||||
hidden: false
|
||||
interface: input-multiline
|
||||
note: null
|
||||
options: null
|
||||
readonly: false
|
||||
required: false
|
||||
searchable: true
|
||||
sort: 4
|
||||
special: null
|
||||
translations: null
|
||||
validation: null
|
||||
validation_message: null
|
||||
width: full
|
||||
schema:
|
||||
name: text
|
||||
table: visual_feedback
|
||||
data_type: text
|
||||
default_value: null
|
||||
max_length: null
|
||||
numeric_precision: null
|
||||
numeric_scale: null
|
||||
is_nullable: true
|
||||
is_unique: false
|
||||
is_indexed: false
|
||||
is_primary_key: false
|
||||
is_generated: false
|
||||
generation_expression: null
|
||||
has_auto_increment: false
|
||||
foreign_key_table: null
|
||||
foreign_key_column: null
|
||||
- collection: visual_feedback
|
||||
field: url
|
||||
type: string
|
||||
meta:
|
||||
collection: visual_feedback
|
||||
conditions: null
|
||||
display: link
|
||||
display_options:
|
||||
url: '{{url}}'
|
||||
target: _blank
|
||||
icon: open_in_new
|
||||
field: url
|
||||
group: null
|
||||
hidden: false
|
||||
interface: input
|
||||
note: null
|
||||
options: null
|
||||
readonly: true
|
||||
required: false
|
||||
searchable: true
|
||||
sort: 5
|
||||
special: null
|
||||
translations: null
|
||||
validation: null
|
||||
validation_message: null
|
||||
width: full
|
||||
schema:
|
||||
name: url
|
||||
table: visual_feedback
|
||||
data_type: character varying
|
||||
default_value: null
|
||||
max_length: 255
|
||||
numeric_precision: null
|
||||
numeric_scale: null
|
||||
is_nullable: true
|
||||
is_unique: false
|
||||
is_indexed: false
|
||||
is_primary_key: false
|
||||
is_generated: false
|
||||
generation_expression: null
|
||||
has_auto_increment: false
|
||||
foreign_key_table: null
|
||||
foreign_key_column: null
|
||||
- collection: visual_feedback
|
||||
field: user_info_group
|
||||
type: alias
|
||||
meta:
|
||||
collection: visual_feedback
|
||||
field: user_info_group
|
||||
group: null
|
||||
hidden: false
|
||||
interface: group-detail
|
||||
options:
|
||||
header_icon: person
|
||||
header_text: User Information
|
||||
sort: 6
|
||||
special:
|
||||
- alias
|
||||
- no-data
|
||||
- group
|
||||
width: full
|
||||
- collection: visual_feedback
|
||||
field: user_name
|
||||
type: string
|
||||
meta:
|
||||
collection: visual_feedback
|
||||
field: user_name
|
||||
group: user_info_group
|
||||
hidden: false
|
||||
interface: input
|
||||
sort: 1
|
||||
width: half
|
||||
schema:
|
||||
name: user_name
|
||||
table: visual_feedback
|
||||
data_type: character varying
|
||||
is_nullable: true
|
||||
- collection: visual_feedback
|
||||
field: user_identity
|
||||
type: string
|
||||
meta:
|
||||
collection: visual_feedback
|
||||
field: user_identity
|
||||
group: user_info_group
|
||||
hidden: false
|
||||
interface: input
|
||||
readonly: true
|
||||
sort: 2
|
||||
width: half
|
||||
schema:
|
||||
name: user_identity
|
||||
table: visual_feedback
|
||||
data_type: character varying
|
||||
is_nullable: true
|
||||
- collection: visual_feedback
|
||||
field: technical_details_group
|
||||
type: alias
|
||||
meta:
|
||||
collection: visual_feedback
|
||||
field: technical_details_group
|
||||
group: null
|
||||
hidden: false
|
||||
interface: group-detail
|
||||
options:
|
||||
header_icon: psychology
|
||||
header_text: Technical Context
|
||||
sort: 7
|
||||
special:
|
||||
- alias
|
||||
- no-data
|
||||
- group
|
||||
width: full
|
||||
- collection: visual_feedback
|
||||
field: selector
|
||||
type: string
|
||||
meta:
|
||||
collection: visual_feedback
|
||||
field: selector
|
||||
group: technical_details_group
|
||||
hidden: false
|
||||
interface: input
|
||||
readonly: true
|
||||
sort: 1
|
||||
width: full
|
||||
schema:
|
||||
name: selector
|
||||
table: visual_feedback
|
||||
data_type: character varying
|
||||
is_nullable: true
|
||||
- collection: visual_feedback
|
||||
field: x
|
||||
type: float
|
||||
meta:
|
||||
collection: visual_feedback
|
||||
field: x
|
||||
group: technical_details_group
|
||||
hidden: false
|
||||
interface: input
|
||||
sort: 2
|
||||
width: half
|
||||
schema:
|
||||
name: x
|
||||
table: visual_feedback
|
||||
data_type: real
|
||||
is_nullable: true
|
||||
- collection: visual_feedback
|
||||
field: 'y'
|
||||
type: float
|
||||
meta:
|
||||
collection: visual_feedback
|
||||
field: 'y'
|
||||
group: technical_details_group
|
||||
hidden: false
|
||||
interface: input
|
||||
sort: 3
|
||||
width: half
|
||||
schema:
|
||||
name: 'y'
|
||||
table: visual_feedback
|
||||
data_type: real
|
||||
is_nullable: true
|
||||
- collection: visual_feedback
|
||||
field: date_created
|
||||
type: timestamp
|
||||
meta:
|
||||
collection: visual_feedback
|
||||
conditions: null
|
||||
display: datetime
|
||||
display_options:
|
||||
relative: true
|
||||
field: date_created
|
||||
group: null
|
||||
hidden: false
|
||||
interface: datetime
|
||||
note: null
|
||||
options: null
|
||||
readonly: true
|
||||
required: false
|
||||
searchable: true
|
||||
sort: 8
|
||||
special: null
|
||||
translations: null
|
||||
validation: null
|
||||
validation_message: null
|
||||
width: full
|
||||
schema:
|
||||
name: date_created
|
||||
table: visual_feedback
|
||||
data_type: timestamp with time zone
|
||||
default_value: CURRENT_TIMESTAMP
|
||||
is_nullable: true
|
||||
- collection: visual_feedback_comments
|
||||
field: id
|
||||
type: uuid
|
||||
meta:
|
||||
collection: visual_feedback_comments
|
||||
field: id
|
||||
hidden: true
|
||||
schema:
|
||||
name: id
|
||||
table: visual_feedback_comments
|
||||
data_type: uuid
|
||||
is_primary_key: true
|
||||
- collection: visual_feedback_comments
|
||||
field: feedback_id
|
||||
type: uuid
|
||||
meta:
|
||||
collection: visual_feedback_comments
|
||||
display: null
|
||||
field: feedback_id
|
||||
interface: select-relational
|
||||
sort: 2
|
||||
width: full
|
||||
schema:
|
||||
name: feedback_id
|
||||
table: visual_feedback_comments
|
||||
data_type: uuid
|
||||
- collection: visual_feedback_comments
|
||||
field: user_name
|
||||
type: string
|
||||
meta:
|
||||
collection: visual_feedback_comments
|
||||
field: user_name
|
||||
interface: input
|
||||
sort: 3
|
||||
width: half
|
||||
schema:
|
||||
name: user_name
|
||||
table: visual_feedback_comments
|
||||
data_type: character varying
|
||||
- collection: visual_feedback_comments
|
||||
field: text
|
||||
type: text
|
||||
meta:
|
||||
collection: visual_feedback_comments
|
||||
field: text
|
||||
interface: input-multiline
|
||||
sort: 4
|
||||
width: full
|
||||
schema:
|
||||
name: text
|
||||
table: visual_feedback_comments
|
||||
data_type: text
|
||||
- collection: visual_feedback_comments
|
||||
field: date_created
|
||||
type: timestamp
|
||||
meta:
|
||||
collection: visual_feedback_comments
|
||||
display: datetime
|
||||
display_options:
|
||||
relative: true
|
||||
field: date_created
|
||||
interface: datetime
|
||||
readonly: true
|
||||
sort: 5
|
||||
width: full
|
||||
schema:
|
||||
name: date_created
|
||||
table: visual_feedback_comments
|
||||
data_type: timestamp with time zone
|
||||
default_value: CURRENT_TIMESTAMP
|
||||
systemFields:
|
||||
- collection: directus_activity
|
||||
field: timestamp
|
||||
schema:
|
||||
is_indexed: true
|
||||
- collection: directus_revisions
|
||||
field: activity
|
||||
schema:
|
||||
is_indexed: true
|
||||
- collection: directus_revisions
|
||||
field: parent
|
||||
schema:
|
||||
is_indexed: true
|
||||
relations:
|
||||
- collection: visual_feedback_comments
|
||||
field: feedback_id
|
||||
related_collection: visual_feedback
|
||||
schema:
|
||||
column: feedback_id
|
||||
foreign_key_column: id
|
||||
foreign_key_table: visual_feedback
|
||||
table: visual_feedback_comments
|
||||
meta:
|
||||
junction_field: null
|
||||
many_collection: visual_feedback_comments
|
||||
many_field: feedback_id
|
||||
one_allowed_m2m: false
|
||||
one_collection: visual_feedback
|
||||
one_deselect_action: nullify
|
||||
one_field: null
|
||||
sort_field: null
|
||||
@@ -1,4 +1,83 @@
|
||||
services:
|
||||
app:
|
||||
env_file:
|
||||
- .env
|
||||
klz-app:
|
||||
image: node:20-alpine
|
||||
working_dir: /app
|
||||
command: sh -c "npm install --legacy-peer-deps && npx next dev"
|
||||
networks:
|
||||
- default
|
||||
- infra
|
||||
volumes:
|
||||
- .:/app
|
||||
environment:
|
||||
NODE_ENV: development
|
||||
# Docker Internal Communication
|
||||
DIRECTUS_URL: http://directus:8055
|
||||
INTERNAL_DIRECTUS_URL: http://directus:8055
|
||||
INFRA_DIRECTUS_URL: http://cms-infra-infra-cms-1:8055
|
||||
GATEKEEPER_URL: http://gatekeeper:3000
|
||||
DIRECTUS_API_TOKEN: ${DIRECTUS_API_TOKEN}
|
||||
INFRA_DIRECTUS_TOKEN: ${INFRA_DIRECTUS_TOKEN}
|
||||
NEXT_PUBLIC_FEEDBACK_ENABLED: ${NEXT_PUBLIC_FEEDBACK_ENABLED}
|
||||
GATEKEEPER_BYPASS_ENABLED: ${GATEKEEPER_BYPASS_ENABLED}
|
||||
ports:
|
||||
- "3000:3000"
|
||||
labels:
|
||||
- "traefik.enable=true"
|
||||
# Global local settings
|
||||
- "traefik.http.routers.klz-cables-local.entrypoints=web"
|
||||
- "traefik.http.routers.klz-cables-local.rule=Host(`klz.localhost`)"
|
||||
- "traefik.http.routers.klz-cables-local.tls=false"
|
||||
- "traefik.http.routers.klz-cables-local.middlewares="
|
||||
- "traefik.http.routers.klz-cables-local.service=klz-cables-local"
|
||||
- "traefik.http.services.klz-cables-local.loadbalancer.server.port=3000"
|
||||
- "traefik.docker.network=infra"
|
||||
|
||||
# Web direct router
|
||||
- "traefik.http.routers.klz-cables-local-web.entrypoints=web"
|
||||
- "traefik.http.routers.klz-cables-local-web.rule=Host(`klz.localhost`)"
|
||||
- "traefik.http.routers.klz-cables-local-web.tls=false"
|
||||
- "traefik.http.routers.klz-cables-local-web.middlewares="
|
||||
- "traefik.http.routers.klz-cables-local-web.service=klz-cables-local"
|
||||
|
||||
directus:
|
||||
networks:
|
||||
- default
|
||||
- infra
|
||||
labels:
|
||||
- "traefik.enable=true"
|
||||
- "traefik.http.routers.klz-cables-directus-local.entrypoints=web"
|
||||
- "traefik.http.routers.klz-cables-directus-local.rule=Host(`cms.klz.localhost`)"
|
||||
- "traefik.http.routers.klz-cables-directus-local.tls=false"
|
||||
- "traefik.http.routers.klz-cables-directus-local.middlewares="
|
||||
- "traefik.http.routers.klz-cables-directus-local.service=klz-cables-directus-local"
|
||||
- "traefik.http.services.klz-cables-directus-local.loadbalancer.server.port=8055"
|
||||
- "traefik.docker.network=infra"
|
||||
ports:
|
||||
- "${DIRECTUS_PORT:-8055}:8055"
|
||||
environment:
|
||||
PUBLIC_URL: http://cms.klz.localhost
|
||||
|
||||
gatekeeper:
|
||||
image: node:20-alpine
|
||||
working_dir: /app/packages/gatekeeper
|
||||
command: sh -c "corepack enable && CI=true NPM_TOKEN=dummy pnpm install --no-frozen-lockfile && pnpm dev"
|
||||
volumes:
|
||||
- /Users/marcmintel/Projects/at-mintel:/app
|
||||
networks:
|
||||
- default
|
||||
- infra
|
||||
environment:
|
||||
DIRECTUS_URL: http://directus:8055
|
||||
NEXT_PUBLIC_BASE_URL: http://gatekeeper.klz.localhost
|
||||
DIRECTUS_ADMIN_EMAIL: ${DIRECTUS_ADMIN_EMAIL}
|
||||
DIRECTUS_ADMIN_PASSWORD: ${DIRECTUS_ADMIN_PASSWORD}
|
||||
COOKIE_DOMAIN: localhost
|
||||
NODE_ENV: development
|
||||
labels:
|
||||
- "traefik.enable=true"
|
||||
- "traefik.http.routers.klz-cables-gatekeeper-local.entrypoints=web"
|
||||
- "traefik.http.routers.klz-cables-gatekeeper-local.rule=Host(`gatekeeper.klz.localhost`)"
|
||||
- "traefik.http.routers.klz-cables-gatekeeper-local.tls=false"
|
||||
- "traefik.http.routers.klz-cables-gatekeeper-local.service=klz-cables-gatekeeper-local"
|
||||
- "traefik.http.services.klz-cables-gatekeeper-local.loadbalancer.server.port=3000"
|
||||
- "traefik.docker.network=infra"
|
||||
|
||||
@@ -1,33 +1,156 @@
|
||||
services:
|
||||
app:
|
||||
image: registry.infra.mintel.me/mintel/klz-cables.com:latest
|
||||
klz-app:
|
||||
image: registry.infra.mintel.me/mintel/klz-cables.com:${IMAGE_TAG:-latest}
|
||||
restart: always
|
||||
networks:
|
||||
- infra
|
||||
ports:
|
||||
- "3000:3000"
|
||||
- default
|
||||
env_file:
|
||||
- .env
|
||||
- ${ENV_FILE:-.env}
|
||||
labels:
|
||||
- "traefik.enable=false"
|
||||
|
||||
varnish:
|
||||
image: varnish:7
|
||||
restart: always
|
||||
networks:
|
||||
- default
|
||||
- infra
|
||||
volumes:
|
||||
- ./varnish/default.vcl:/etc/varnish/default.vcl:ro
|
||||
tmpfs:
|
||||
- /var/lib/varnish:exec,mode=1777
|
||||
environment:
|
||||
VARNISH_SIZE: ${VARNISH_CACHE_SIZE:-256M}
|
||||
APP_VERSION: ${IMAGE_TAG:-latest}
|
||||
labels:
|
||||
- "traefik.enable=true"
|
||||
# HTTP ⇒ HTTPS redirect
|
||||
- "traefik.http.routers.klz-cables-web.rule=(Host(`klz-cables.com`) || Host(`www.klz-cables.com`) || Host(`staging.klz-cables.com`)) && !PathPrefix(`/.well-known/acme-challenge/`)"
|
||||
- "traefik.http.routers.klz-cables-web.entrypoints=web"
|
||||
- "traefik.http.routers.klz-cables-web.middlewares=redirect-https"
|
||||
# HTTPS router
|
||||
- "traefik.http.routers.klz-cables.rule=Host(`klz-cables.com`) || Host(`www.klz-cables.com`) || Host(`staging.klz-cables.com`)"
|
||||
- "traefik.http.routers.klz-cables.entrypoints=websecure"
|
||||
- "traefik.http.routers.klz-cables.tls.certresolver=le"
|
||||
- "traefik.http.routers.klz-cables.tls=true"
|
||||
- "traefik.http.routers.klz-cables.service=klz-cables"
|
||||
- "traefik.http.services.klz-cables.loadbalancer.server.port=3000"
|
||||
- "traefik.http.services.klz-cables.loadbalancer.server.scheme=http"
|
||||
- "traefik.http.routers.${PROJECT_NAME:-klz-cables}-web.rule=${TRAEFIK_HOST_RULE:-Host(`klz-cables.com`)}"
|
||||
- "traefik.http.routers.${PROJECT_NAME:-klz-cables}-web.entrypoints=web"
|
||||
- "traefik.http.routers.${PROJECT_NAME:-klz-cables}-web.middlewares=redirect-https"
|
||||
# HTTPS router (Protected)
|
||||
- "traefik.http.routers.${PROJECT_NAME:-klz-cables}.rule=${TRAEFIK_HOST_RULE:-Host(`klz-cables.com`)}"
|
||||
- "traefik.http.routers.${PROJECT_NAME:-klz-cables}.entrypoints=websecure"
|
||||
- "traefik.http.routers.${PROJECT_NAME:-klz-cables}.tls.certresolver=le"
|
||||
- "traefik.http.routers.${PROJECT_NAME:-klz-cables}.tls=true"
|
||||
- "traefik.http.routers.${PROJECT_NAME:-klz-cables}.service=${PROJECT_NAME:-klz-cables}"
|
||||
- "traefik.http.routers.${PROJECT_NAME:-klz-cables}.middlewares=${PROJECT_NAME:-klz-cables}-ratelimit,${PROJECT_NAME:-klz-cables}-forward,${AUTH_MIDDLEWARE:-${PROJECT_NAME:-klz-cables}-compress}"
|
||||
|
||||
# HTTPS router (Unprotected - for Analytics & Errors)
|
||||
- "traefik.http.routers.${PROJECT_NAME:-klz-cables}-unprotected.rule=${TRAEFIK_HOST_RULE:-Host(`klz-cables.com`)} && PathPrefix(`/stats`, `/errors`)"
|
||||
- "traefik.http.routers.${PROJECT_NAME:-klz-cables}-unprotected.entrypoints=websecure"
|
||||
- "traefik.http.routers.${PROJECT_NAME:-klz-cables}-unprotected.tls.certresolver=le"
|
||||
- "traefik.http.routers.${PROJECT_NAME:-klz-cables}-unprotected.tls=true"
|
||||
- "traefik.http.routers.${PROJECT_NAME:-klz-cables}-unprotected.service=${PROJECT_NAME:-klz-cables}"
|
||||
- "traefik.http.routers.${PROJECT_NAME:-klz-cables}-unprotected.middlewares=${PROJECT_NAME:-klz-cables}-ratelimit,${PROJECT_NAME:-klz-cables}-forward,${PROJECT_NAME:-klz-cables}-compress"
|
||||
|
||||
- "traefik.http.services.${PROJECT_NAME:-klz-cables}.loadbalancer.server.port=80"
|
||||
- "traefik.http.services.${PROJECT_NAME:-klz-cables}.loadbalancer.server.scheme=http"
|
||||
- "traefik.docker.network=infra"
|
||||
|
||||
# Middleware Definitions
|
||||
- "traefik.http.middlewares.${PROJECT_NAME:-klz-cables}-compress.compress=true"
|
||||
|
||||
# Gatekeeper Router (to show the login page)
|
||||
- "traefik.http.routers.${PROJECT_NAME:-klz-cables}-gatekeeper.rule=Host(`gatekeeper.${TRAEFIK_HOST:-klz-cables.com}`)"
|
||||
- "traefik.http.routers.${PROJECT_NAME:-klz-cables}-gatekeeper.entrypoints=websecure"
|
||||
- "traefik.http.routers.${PROJECT_NAME:-klz-cables}-gatekeeper.tls.certresolver=le"
|
||||
- "traefik.http.routers.${PROJECT_NAME:-klz-cables}-gatekeeper.tls=true"
|
||||
- "traefik.http.routers.${PROJECT_NAME:-klz-cables}-gatekeeper.service=${PROJECT_NAME:-klz-cables}-gatekeeper"
|
||||
|
||||
# Forwarded Headers
|
||||
- "traefik.http.middlewares.klz-forward.headers.customrequestheaders.X-Forwarded-Proto=https"
|
||||
- "traefik.http.middlewares.klz-forward.headers.customrequestheaders.X-Forwarded-Ssl=on"
|
||||
# Middlewares
|
||||
- "traefik.http.routers.klz-cables.middlewares=klz-forward,compress"
|
||||
- "traefik.http.middlewares.${PROJECT_NAME:-klz-cables}-forward.headers.customrequestheaders.X-Forwarded-Proto=https"
|
||||
- "traefik.http.middlewares.${PROJECT_NAME:-klz-cables}-forward.headers.customrequestheaders.X-Forwarded-Ssl=on"
|
||||
|
||||
# Middleware Definitions
|
||||
- "traefik.http.middlewares.${PROJECT_NAME:-klz-cables}-ratelimit.ratelimit.average=100"
|
||||
- "traefik.http.middlewares.${PROJECT_NAME:-klz-cables}-ratelimit.ratelimit.burst=50"
|
||||
- "traefik.http.middlewares.${PROJECT_NAME:-klz-cables}-auth.forwardauth.address=http://${PROJECT_NAME}-gatekeeper:3000/api/verify"
|
||||
- "traefik.http.middlewares.${PROJECT_NAME:-klz-cables}-auth.forwardauth.trustForwardHeader=true"
|
||||
- "traefik.http.middlewares.${PROJECT_NAME:-klz-cables}-auth.forwardauth.authResponseHeaders=X-Auth-User"
|
||||
|
||||
gatekeeper:
|
||||
image: registry.infra.mintel.me/mintel/gatekeeper:1.4.0
|
||||
container_name: ${PROJECT_NAME:-klz-cables}-gatekeeper
|
||||
restart: always
|
||||
networks:
|
||||
- default
|
||||
- infra
|
||||
env_file:
|
||||
- ${ENV_FILE:-.env}
|
||||
environment:
|
||||
PORT: 3000
|
||||
COOKIE_DOMAIN: ${COOKIE_DOMAIN}
|
||||
AUTH_COOKIE_NAME: klz_gatekeeper_session
|
||||
NEXT_PUBLIC_BASE_URL: https://gatekeeper.${TRAEFIK_HOST:-klz-cables.com}
|
||||
GATEKEEPER_PASSWORD: ${GATEKEEPER_PASSWORD:-klz2026}
|
||||
DIRECTUS_URL: ${DIRECTUS_URL}
|
||||
DIRECTUS_ADMIN_EMAIL: ${DIRECTUS_ADMIN_EMAIL}
|
||||
DIRECTUS_ADMIN_PASSWORD: ${DIRECTUS_ADMIN_PASSWORD}
|
||||
labels:
|
||||
- "traefik.enable=true"
|
||||
- "traefik.http.services.${PROJECT_NAME:-klz-cables}-gatekeeper.loadbalancer.server.port=3000"
|
||||
- "traefik.docker.network=infra"
|
||||
|
||||
directus:
|
||||
image: directus/directus:11
|
||||
restart: always
|
||||
networks:
|
||||
- default
|
||||
- infra
|
||||
env_file:
|
||||
- ${ENV_FILE:-.env}
|
||||
environment:
|
||||
KEY: ${DIRECTUS_KEY}
|
||||
SECRET: ${DIRECTUS_SECRET}
|
||||
ADMIN_EMAIL: ${DIRECTUS_ADMIN_EMAIL}
|
||||
ADMIN_PASSWORD: ${DIRECTUS_ADMIN_PASSWORD}
|
||||
DB_CLIENT: 'pg'
|
||||
DB_HOST: 'directus-db'
|
||||
DB_PORT: '5432'
|
||||
DB_DATABASE: ${DIRECTUS_DB_NAME:-directus}
|
||||
DB_USER: ${DIRECTUS_DB_USER:-directus}
|
||||
DB_PASSWORD: ${DIRECTUS_DB_PASSWORD:-directus}
|
||||
WEBSOCKETS_ENABLED: 'true'
|
||||
PUBLIC_URL: ${DIRECTUS_URL:-https://cms.klz-cables.com}
|
||||
# Error Tracking
|
||||
SENTRY_DSN: ${SENTRY_DSN}
|
||||
SENTRY_ENVIRONMENT: ${TARGET:-development}
|
||||
LOGGER_LEVEL: ${LOG_LEVEL:-info}
|
||||
volumes:
|
||||
- ./directus/uploads:/directus/uploads
|
||||
- ./directus/extensions:/directus/extensions
|
||||
- ./directus/schema:/directus/schema
|
||||
- ./directus/migrations:/directus/migrations
|
||||
labels:
|
||||
- "traefik.enable=true"
|
||||
- "traefik.http.routers.${PROJECT_NAME:-klz-cables}-directus.rule=Host(`${DIRECTUS_HOST}`)"
|
||||
- "traefik.http.routers.${PROJECT_NAME:-klz-cables}-directus.entrypoints=websecure"
|
||||
- "traefik.http.routers.${PROJECT_NAME:-klz-cables}-directus.tls.certresolver=le"
|
||||
- "traefik.http.routers.${PROJECT_NAME:-klz-cables}-directus.tls=true"
|
||||
- "traefik.http.routers.${PROJECT_NAME:-klz-cables}-directus.middlewares=${PROJECT_NAME:-klz-cables}-forward,compress"
|
||||
- "traefik.http.services.${PROJECT_NAME:-klz-cables}-directus.loadbalancer.server.port=8055"
|
||||
- "traefik.docker.network=infra"
|
||||
|
||||
directus-db:
|
||||
image: postgres:15-alpine
|
||||
restart: always
|
||||
networks:
|
||||
- default
|
||||
env_file:
|
||||
- ${ENV_FILE:-.env}
|
||||
environment:
|
||||
POSTGRES_DB: ${DIRECTUS_DB_NAME:-directus}
|
||||
POSTGRES_USER: ${DIRECTUS_DB_USER:-directus}
|
||||
POSTGRES_PASSWORD: ${DIRECTUS_DB_PASSWORD:-directus}
|
||||
volumes:
|
||||
- directus-db-data:/var/lib/postgresql/data
|
||||
|
||||
networks:
|
||||
default:
|
||||
name: ${PROJECT_NAME:-klz-cables}-internal
|
||||
infra:
|
||||
external: true
|
||||
|
||||
volumes:
|
||||
directus-db-data:
|
||||
|
||||
@@ -42,15 +42,15 @@ The application uses a clean, robust, **fully automated** environment variable s
|
||||
|
||||
## Environment Variables
|
||||
|
||||
### Build-Time Variables (NEXT_PUBLIC_*)
|
||||
### Build-Time Variables (NEXT*PUBLIC*\*)
|
||||
|
||||
These are embedded into the JavaScript bundle during build and are visible to the client:
|
||||
|
||||
| Variable | Required | Description |
|
||||
|----------|----------|-------------|
|
||||
| `NEXT_PUBLIC_BASE_URL` | ✅ Yes | Base URL of the application (e.g., `https://klz-cables.com`) |
|
||||
| `NEXT_PUBLIC_UMAMI_WEBSITE_ID` | ❌ No | Umami analytics website ID |
|
||||
| `NEXT_PUBLIC_UMAMI_SCRIPT_URL` | ❌ No | Umami analytics script URL (default: `https://analytics.infra.mintel.me/script.js`) |
|
||||
| Variable | Required | Description |
|
||||
| ---------------------- | -------- | ------------------------------------------------------------ |
|
||||
| `NEXT_PUBLIC_BASE_URL` | ✅ Yes | Base URL of the application (e.g., `https://klz-cables.com`) |
|
||||
| `UMAMI_WEBSITE_ID` | ❌ No | Umami analytics website ID (passed as prop) |
|
||||
| `UMAMI_API_ENDPOINT` | ❌ No | Backend-only Umami analytics API target (internal) |
|
||||
|
||||
**Important**: These must be provided as `--build-arg` when building the Docker image.
|
||||
|
||||
@@ -58,29 +58,40 @@ These are embedded into the JavaScript bundle during build and are visible to th
|
||||
|
||||
These are loaded from the `.env` file at runtime and are only available on the server:
|
||||
|
||||
| Variable | Required | Description |
|
||||
|----------|----------|-------------|
|
||||
| `NODE_ENV` | ✅ Yes | Environment mode (`production`, `development`, `test`) |
|
||||
| `SENTRY_DSN` | ❌ No | GlitchTip/Sentry error tracking DSN |
|
||||
| `MAIL_HOST` | ❌ No | SMTP server hostname |
|
||||
| `MAIL_PORT` | ❌ No | SMTP server port (default: `587`) |
|
||||
| `MAIL_USERNAME` | ❌ No | SMTP authentication username |
|
||||
| `MAIL_PASSWORD` | ❌ No | SMTP authentication password |
|
||||
| `MAIL_FROM` | ❌ No | Email sender address |
|
||||
| `MAIL_RECIPIENTS` | ❌ No | Comma-separated list of recipient emails |
|
||||
| `REDIS_URL` | ❌ No | Redis connection URL (e.g., `redis://redis:6379/2`) |
|
||||
| `REDIS_KEY_PREFIX` | ❌ No | Redis key prefix (default: `klz:`) |
|
||||
| Variable | Required | Description |
|
||||
| -------------------------- | -------- | ------------------------------------------------------ |
|
||||
| `NODE_ENV` | ✅ Yes | Environment mode (`production`, `development`, `test`) |
|
||||
| `SENTRY_DSN` | ❌ No | GlitchTip/Sentry error tracking DSN |
|
||||
| `MAIL_HOST` | ❌ No | SMTP server hostname |
|
||||
| `MAIL_PORT` | ❌ No | SMTP server port (default: `587`) |
|
||||
| `MAIL_USERNAME` | ❌ No | SMTP authentication username |
|
||||
| `MAIL_PASSWORD` | ❌ No | SMTP authentication password |
|
||||
| `MAIL_FROM` | ❌ No | Email sender address |
|
||||
| `MAIL_RECIPIENTS` | ❌ No | Comma-separated list of recipient emails |
|
||||
| `REDIS_URL` | ❌ No | Redis connection URL (e.g., `redis://redis:6379/2`) |
|
||||
| `REDIS_KEY_PREFIX` | ❌ No | Redis key prefix (default: `klz:`) |
|
||||
| `STRAPI_DATABASE_NAME` | ✅ Yes | Strapi database name |
|
||||
| `STRAPI_DATABASE_USERNAME` | ✅ Yes | Strapi database username |
|
||||
| `STRAPI_DATABASE_PASSWORD` | ✅ Yes | Strapi database password |
|
||||
| `STRAPI_URL` | ✅ Yes | URL of the Strapi CMS |
|
||||
| `APP_KEYS` | ✅ Yes | Strapi application keys (comma-separated) |
|
||||
| `API_TOKEN_SALT` | ✅ Yes | Strapi API token salt |
|
||||
| `ADMIN_JWT_SECRET` | ✅ Yes | Strapi admin JWT secret |
|
||||
| `TRANSFER_TOKEN_SALT` | ✅ Yes | Strapi transfer token salt |
|
||||
| `JWT_SECRET` | ✅ Yes | Strapi JWT secret |
|
||||
|
||||
## Local Development
|
||||
|
||||
### Setup
|
||||
|
||||
1. Copy the example environment file:
|
||||
|
||||
```bash
|
||||
cp .env.example .env
|
||||
```
|
||||
|
||||
2. Edit `.env` and fill in your local configuration:
|
||||
|
||||
```bash
|
||||
NODE_ENV=development
|
||||
NEXT_PUBLIC_BASE_URL=http://localhost:3000
|
||||
@@ -88,6 +99,7 @@ These are loaded from the `.env` file at runtime and are only available on the s
|
||||
```
|
||||
|
||||
3. Install dependencies:
|
||||
|
||||
```bash
|
||||
npm install
|
||||
```
|
||||
@@ -103,8 +115,8 @@ These are loaded from the `.env` file at runtime and are only available on the s
|
||||
# Build with build-time arguments
|
||||
docker build \
|
||||
--build-arg NEXT_PUBLIC_BASE_URL=http://localhost:3000 \
|
||||
--build-arg NEXT_PUBLIC_UMAMI_WEBSITE_ID=your-id \
|
||||
--build-arg NEXT_PUBLIC_UMAMI_SCRIPT_URL=https://analytics.infra.mintel.me/script.js \
|
||||
--build-arg UMAMI_WEBSITE_ID=your-id \
|
||||
--build-arg UMAMI_API_ENDPOINT=https://analytics.infra.mintel.me \
|
||||
-t klz-cables:local .
|
||||
|
||||
# Run with runtime environment file
|
||||
@@ -129,8 +141,8 @@ docker run --env-file .env -p 3000:3000 klz-cables:local
|
||||
|
||||
**Build-Time Variables:**
|
||||
- `NEXT_PUBLIC_BASE_URL` - Production URL (e.g., `https://klz-cables.com`)
|
||||
- `NEXT_PUBLIC_UMAMI_WEBSITE_ID` - Umami analytics ID
|
||||
- `NEXT_PUBLIC_UMAMI_SCRIPT_URL` - Umami script URL
|
||||
- `UMAMI_WEBSITE_ID` - Umami analytics ID
|
||||
- `UMAMI_API_ENDPOINT` - Umami API endpoint
|
||||
|
||||
**Runtime Variables:**
|
||||
- `SENTRY_DSN` - Error tracking DSN
|
||||
@@ -200,11 +212,12 @@ docker-compose logs -f app
|
||||
**Problem**: Build fails with "Environment validation failed"
|
||||
|
||||
**Solution**: Ensure all required `NEXT_PUBLIC_*` variables are provided as build arguments:
|
||||
|
||||
```bash
|
||||
docker build \
|
||||
--build-arg NEXT_PUBLIC_BASE_URL=https://klz-cables.com \
|
||||
--build-arg NEXT_PUBLIC_UMAMI_WEBSITE_ID=your-id \
|
||||
--build-arg NEXT_PUBLIC_UMAMI_SCRIPT_URL=https://analytics.infra.mintel.me/script.js \
|
||||
--build-arg UMAMI_WEBSITE_ID=your-id \
|
||||
--build-arg UMAMI_API_ENDPOINT=https://analytics.infra.mintel.me \
|
||||
-t klz-cables .
|
||||
```
|
||||
|
||||
@@ -213,6 +226,7 @@ docker build \
|
||||
**Problem**: Container starts but application crashes
|
||||
|
||||
**Solution**: Check that the `.env` file exists and contains all required runtime variables:
|
||||
|
||||
```bash
|
||||
# On the server
|
||||
cat /home/deploy/sites/klz-cables.com/.env
|
||||
@@ -226,9 +240,11 @@ docker-compose logs app
|
||||
**Problem**: Features not working (email, analytics, etc.)
|
||||
|
||||
**Solution**:
|
||||
|
||||
1. Check that the secret is configured in Gitea
|
||||
2. Verify the workflow includes it in the `.env` generation (see `.gitea/workflows/deploy.yml`)
|
||||
3. Redeploy to regenerate the `.env` file:
|
||||
|
||||
```bash
|
||||
git commit --allow-empty -m "Trigger redeploy"
|
||||
git push origin main
|
||||
@@ -246,6 +262,7 @@ docker-compose logs app
|
||||
**Problem**: `docker-compose up` fails with "env file not found"
|
||||
|
||||
**Solution**: The `.env` file should be automatically created by the workflow. If it's missing:
|
||||
|
||||
1. Check the workflow logs for errors in the "📝 Preparing environment configuration" step
|
||||
2. Manually trigger a deployment by pushing to main
|
||||
3. If still missing, check server permissions and disk space
|
||||
@@ -255,6 +272,7 @@ docker-compose logs app
|
||||
**Problem**: Container can't connect to Traefik
|
||||
|
||||
**Solution**: Verify the `infra` network exists:
|
||||
|
||||
```bash
|
||||
docker network ls | grep infra
|
||||
docker network inspect infra
|
||||
|
||||
@@ -7,29 +7,31 @@ This guide helps you migrate from the old fragile environment variable setup to
|
||||
### Before (Fragile & Overkill)
|
||||
|
||||
❌ **Problems:**
|
||||
|
||||
- Environment variables passed individually via SSH (12+ vars)
|
||||
- Duplicate definitions in Dockerfile, docker-compose.yml, and deploy.yml
|
||||
- Build args included runtime-only variables (SENTRY_DSN, MAIL_*, REDIS_*)
|
||||
- Build args included runtime-only variables (SENTRY*DSN, MAIL*_, REDIS\__)
|
||||
- No single source of truth
|
||||
- Difficult to maintain and error-prone
|
||||
|
||||
```yaml
|
||||
# Old deploy.yml - FRAGILE!
|
||||
ssh root@alpha.mintel.me \
|
||||
"MAIL_FROM='${{ secrets.MAIL_FROM }}' \
|
||||
MAIL_HOST='${{ secrets.MAIL_HOST }}' \
|
||||
MAIL_PASSWORD='${{ secrets.MAIL_PASSWORD }}' \
|
||||
MAIL_PORT='${{ secrets.MAIL_PORT }}' \
|
||||
MAIL_RECIPIENTS='${{ secrets.MAIL_RECIPIENTS }}' \
|
||||
MAIL_USERNAME='${{ secrets.MAIL_USERNAME }}' \
|
||||
NEXT_PUBLIC_BASE_URL='${{ secrets.NEXT_PUBLIC_BASE_URL }}' \
|
||||
... (12+ variables) \
|
||||
/home/deploy/deploy.sh"
|
||||
"MAIL_FROM='${{ secrets.MAIL_FROM }}' \
|
||||
MAIL_HOST='${{ secrets.MAIL_HOST }}' \
|
||||
MAIL_PASSWORD='${{ secrets.MAIL_PASSWORD }}' \
|
||||
MAIL_PORT='${{ secrets.MAIL_PORT }}' \
|
||||
MAIL_RECIPIENTS='${{ secrets.MAIL_RECIPIENTS }}' \
|
||||
MAIL_USERNAME='${{ secrets.MAIL_USERNAME }}' \
|
||||
NEXT_PUBLIC_BASE_URL='${{ secrets.NEXT_PUBLIC_BASE_URL }}' \
|
||||
... (12+ variables) \
|
||||
/home/deploy/deploy.sh"
|
||||
```
|
||||
|
||||
### After (Clean & Robust)
|
||||
|
||||
✅ **Benefits:**
|
||||
|
||||
- Single `.env` file on server contains all runtime variables
|
||||
- Only `NEXT_PUBLIC_*` variables passed as build args (3 vars)
|
||||
- Clear separation: build-time vs runtime
|
||||
@@ -46,6 +48,7 @@ ssh root@alpha.mintel.me "/home/deploy/deploy.sh"
|
||||
### Step 1: Update Gitea Secrets
|
||||
|
||||
**Remove these secrets** (no longer needed in CI/CD):
|
||||
|
||||
- ❌ `MAIL_FROM`
|
||||
- ❌ `MAIL_HOST`
|
||||
- ❌ `MAIL_PASSWORD`
|
||||
@@ -58,9 +61,11 @@ ssh root@alpha.mintel.me "/home/deploy/deploy.sh"
|
||||
- ❌ `SENTRY_DSN` (from build args)
|
||||
|
||||
**Keep these secrets** (still needed for build):
|
||||
|
||||
- ✅ `NEXT_PUBLIC_BASE_URL`
|
||||
- ✅ `NEXT_PUBLIC_UMAMI_WEBSITE_ID`
|
||||
- ✅ `NEXT_PUBLIC_UMAMI_SCRIPT_URL`
|
||||
- ✅ `NEXT_PUBLIC_BASE_URL`
|
||||
- ✅ `UMAMI_WEBSITE_ID`
|
||||
- ✅ `UMAMI_API_ENDPOINT`
|
||||
- ✅ `REGISTRY_USER`
|
||||
- ✅ `REGISTRY_PASS`
|
||||
- ✅ `ALPHA_SSH_KEY`
|
||||
@@ -81,8 +86,8 @@ NODE_ENV=production
|
||||
NEXT_PUBLIC_BASE_URL=https://klz-cables.com
|
||||
|
||||
# Analytics
|
||||
NEXT_PUBLIC_UMAMI_WEBSITE_ID=your-actual-id
|
||||
NEXT_PUBLIC_UMAMI_SCRIPT_URL=https://analytics.infra.mintel.me/script.js
|
||||
UMAMI_WEBSITE_ID=your-actual-id
|
||||
UMAMI_API_ENDPOINT=https://analytics.infra.mintel.me
|
||||
|
||||
# Error Tracking
|
||||
SENTRY_DSN=your-actual-dsn
|
||||
@@ -168,6 +173,7 @@ git push origin main
|
||||
```
|
||||
|
||||
The CI/CD workflow will:
|
||||
|
||||
1. Build with only `NEXT_PUBLIC_*` build args
|
||||
2. Push to registry
|
||||
3. SSH to server and run deploy.sh
|
||||
@@ -197,21 +203,22 @@ curl -I https://klz-cables.com
|
||||
|
||||
## Comparison Table
|
||||
|
||||
| Aspect | Before | After |
|
||||
|--------|--------|-------|
|
||||
| **Gitea Secrets** | 15+ secrets | 8 secrets |
|
||||
| **Build Args** | 4 vars (including runtime-only) | 3 vars (NEXT_PUBLIC_* only) |
|
||||
| **Runtime Vars** | Passed via SSH command | Loaded from .env file |
|
||||
| **Maintenance** | Update in 3 places | Update in 1 place |
|
||||
| **Security** | Secrets in CI logs | Secrets only on server |
|
||||
| **Clarity** | Confusing duplication | Clear separation |
|
||||
| **Robustness** | Fragile SSH command | Robust file-based config |
|
||||
| Aspect | Before | After |
|
||||
| ----------------- | ------------------------------- | ---------------------------- |
|
||||
| **Gitea Secrets** | 15+ secrets | 8 secrets |
|
||||
| **Build Args** | 4 vars (including runtime-only) | 3 vars (NEXT*PUBLIC*\* only) |
|
||||
| **Runtime Vars** | Passed via SSH command | Loaded from .env file |
|
||||
| **Maintenance** | Update in 3 places | Update in 1 place |
|
||||
| **Security** | Secrets in CI logs | Secrets only on server |
|
||||
| **Clarity** | Confusing duplication | Clear separation |
|
||||
| **Robustness** | Fragile SSH command | Robust file-based config |
|
||||
|
||||
## Rollback Plan
|
||||
|
||||
If you need to rollback to the old system:
|
||||
|
||||
1. Revert the changes in git:
|
||||
|
||||
```bash
|
||||
git revert HEAD
|
||||
git push origin main
|
||||
@@ -229,7 +236,8 @@ A: `NEXT_PUBLIC_*` variables are special in Next.js - they're embedded into the
|
||||
|
||||
**Q: Can I update environment variables without rebuilding?**
|
||||
|
||||
A: Yes, for runtime-only variables (MAIL_*, REDIS_*, SENTRY_DSN, etc.). Just edit the `.env` file on the server and restart containers:
|
||||
A: Yes, for runtime-only variables (MAIL*\*, REDIS*\*, SENTRY_DSN, etc.). Just edit the `.env` file on the server and restart containers:
|
||||
|
||||
```bash
|
||||
nano /home/deploy/sites/klz-cables.com/.env
|
||||
docker-compose down && docker-compose up -d
|
||||
@@ -240,6 +248,7 @@ For `NEXT_PUBLIC_*` variables, you need to rebuild the Docker image since they'r
|
||||
**Q: Where should I store the .env file backup?**
|
||||
|
||||
A: Keep a secure backup outside the server:
|
||||
|
||||
```bash
|
||||
# Download from server
|
||||
scp root@alpha.mintel.me:/home/deploy/sites/klz-cables.com/.env \
|
||||
@@ -250,7 +259,8 @@ scp root@alpha.mintel.me:/home/deploy/sites/klz-cables.com/.env \
|
||||
|
||||
**Q: What if I accidentally commit .env to git?**
|
||||
|
||||
A:
|
||||
A:
|
||||
|
||||
1. Remove it immediately: `git rm .env && git commit -m "Remove .env"`
|
||||
2. Rotate all credentials in the file
|
||||
3. Update the `.gitignore` to ensure it doesn't happen again (already done)
|
||||
@@ -267,6 +277,7 @@ If you encounter issues during migration:
|
||||
## Summary
|
||||
|
||||
The new system is:
|
||||
|
||||
- ✅ **Simpler**: One .env file instead of scattered variables
|
||||
- ✅ **Cleaner**: Clear separation of build vs runtime
|
||||
- ✅ **Robust**: File-based config instead of fragile SSH commands
|
||||
|
||||
@@ -36,6 +36,31 @@ https://logs.infra.mintel.me
|
||||
|
||||
---
|
||||
|
||||
## SMTP
|
||||
|
||||
# SMTP Config
|
||||
SMTP_HOST=
|
||||
SMTP_PORT=587
|
||||
SMTP_SECURE=false
|
||||
SMTP_USER=
|
||||
SMTP_PASS=
|
||||
SMTP_FROM= # muss im projekt gesetzt werden
|
||||
|
||||
---
|
||||
|
||||
## Shared Image Optimization (imgproxy)
|
||||
|
||||
Alle Bilder werden zentral über **imgproxy** optimiert, resized und in moderne Formate (WebP, AVIF) konvertiert.
|
||||
|
||||
**Basis-URL**
|
||||
https://img.infra.mintel.me
|
||||
|
||||
```text
|
||||
https://img.infra.mintel.me/unsafe/rs:800x600/plain/https://example.com/bild.jpg
|
||||
https://img.infra.mintel.me/rs:400x/plain/https://picsum.photos/2000/1333
|
||||
|
||||
---
|
||||
|
||||
## Production Platform (Alpha)
|
||||
|
||||
Alpha runs all customer websites and is publicly reachable.
|
||||
|
||||
45
eslint.config.mjs
Normal file
45
eslint.config.mjs
Normal file
@@ -0,0 +1,45 @@
|
||||
import baseConfig from "@mintel/eslint-config";
|
||||
import { nextConfig } from "@mintel/eslint-config/next";
|
||||
|
||||
export default [
|
||||
{
|
||||
ignores: [
|
||||
"**/node_modules/**",
|
||||
"node_modules/**",
|
||||
"**/.next/**",
|
||||
".next/**",
|
||||
"**/dist/**",
|
||||
"dist/**",
|
||||
"**/out/**",
|
||||
"out/**",
|
||||
"**/.pnpm-store/**",
|
||||
"**/at-mintel/**",
|
||||
"at-mintel/**",
|
||||
"**/.git/**",
|
||||
"*.js",
|
||||
"*.mjs",
|
||||
"scripts/**",
|
||||
"tests/**",
|
||||
"next-env.d.ts"
|
||||
],
|
||||
},
|
||||
...baseConfig,
|
||||
...nextConfig.map((config) => ({
|
||||
...config,
|
||||
files: ["**/*.{ts,tsx}"],
|
||||
rules: {
|
||||
...config.rules,
|
||||
"@typescript-eslint/no-explicit-any": "off",
|
||||
"@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"
|
||||
}
|
||||
})),
|
||||
];
|
||||
@@ -1,171 +0,0 @@
|
||||
[
|
||||
{
|
||||
"url": "https://shop.faberkabel.de/Starkstromkabel-1-30-kV/Mittelspannungskabel/Mittelspannungskabel-N2XS-FL-2Y/",
|
||||
"verwendung": "Zur Verlegung in Erde, in Wasser, im Freien, in Innenräumen und Kabelkanälen für EVU-Netze, Industrie- und Verteilernetze. Bei Verlegung in Kabelkanälen und Innenräumen muss berücksichtigt werden, dass der PE-Mantel nach DIN VDE 0482-332-1 nicht flammwidrig ist. Das Kabel ist für ungünstige Einsatzbedingungen geeignet, insbesondere wenn nach mechanischen Beschädigungen das Eindringen von Wasser in Quer- und Längsrichtung vermieden werden soll.",
|
||||
"technischeDaten": {
|
||||
"Zolltarifnummer (Warennummer)": "85446010900000000",
|
||||
"Norm": "VDE 0276-620",
|
||||
"Leitermaterial": "Cu, blank",
|
||||
"Leiterklasse": "Kl.2 = mehrdrähtig",
|
||||
"Aderisolation": "VPE DIX8",
|
||||
"Feldsteuerung": "innere und äußere Leitschicht aus halbleitendem Kunststoff (Dreifachextrusion)",
|
||||
"Schirm": "Cu-Drahtumspinnung + Querleitwendel",
|
||||
"Mantelmaterial": "Polyethylen DMP2",
|
||||
"Schichtenmantel": "ja",
|
||||
"Kabel querwasserdicht": "ja",
|
||||
"Kabel längswasserdicht": "ja",
|
||||
"Mantelfarbe": "schwarz",
|
||||
"UV-beständig": "ja",
|
||||
"Als Außenkabel zulässig": "ja",
|
||||
"Max. zulässige Leitertemperatur, °C": "90 °C",
|
||||
"Zul. Kabelaußentemperatur, fest verlegt, °C": "70 °C",
|
||||
"Zul. Kabelaußentemperatur, in Bewegung, °C": "-20 - +70 °C",
|
||||
"Min. Biegeradius, fest verlegt": "15 x Ø",
|
||||
"Aderzahl": "1",
|
||||
"Mantelwanddicke": "2.1 mm",
|
||||
"Metallbasis Cu (de)": "0 EUR/100 kg",
|
||||
"Maßeinheit": "Meter"
|
||||
}
|
||||
},
|
||||
{
|
||||
"url": "https://shop.faberkabel.de/Starkstromkabel-1-30-kV/Mittelspannungskabel/Mittelspannungskabel-N2XS2Y/",
|
||||
"verwendung": "Zur Verlegung in Erde, in Wasser, im Freien, in Innenräumen und Kabelkanälen für Kraftwerks-, Industrie- und Verteilernetze. Bei Verlegung in Kabelkanälen und Innenräumen muss berücksichtigt werden, dass der PE-Mantel halogenfrei ist, jedoch nicht flammwidrig nach DIN VDE 0482-332-1. Das Kabel kann infolge des widerstandsfähigen PE-Mantels bei der Verlegung und im Betrieb stark mechanisch beansprucht werden.",
|
||||
"technischeDaten": {
|
||||
"Norm": "VDE 0276-620",
|
||||
"Leitermaterial": "Cu, blank",
|
||||
"Leiterklasse": "Kl.2 = mehrdrähtig",
|
||||
"Aderisolation": "VPE DIX8",
|
||||
"Feldsteuerung": "innere und äußere Leitschicht aus halbleitendem Kunststoff (Dreifachextrusion)",
|
||||
"Schirm": "Cu-Drahtumspinnung + Querleitwendel",
|
||||
"Mantelmaterial": "Polyethylen DMP2",
|
||||
"Mantelfarbe": "schwarz",
|
||||
"Flammwidrigkeit": "keine",
|
||||
"UV-beständig": "ja",
|
||||
"Als Außenkabel zulässig": "ja",
|
||||
"Max. zulässige Leitertemperatur, °C": "90 °C",
|
||||
"Zul. Kabelaußentemperatur, fest verlegt, °C": "70 °C",
|
||||
"Zul. Kabelaußentemperatur, in Bewegung, °C": "-20 - +70 °C",
|
||||
"Min. Biegeradius, fest verlegt": "15 x Ø",
|
||||
"Leiterform": "rund",
|
||||
"Aderzahl": "1",
|
||||
"Mantelwanddicke": "2.1 mm",
|
||||
"Metallbasis Cu (de)": "0 EUR/100 kg",
|
||||
"Maßeinheit": "Meter"
|
||||
}
|
||||
},
|
||||
{
|
||||
"url": "https://shop.faberkabel.de/Starkstromkabel-1-30-kV/Mittelspannungskabel/Mittelspannungskabel-N2XSF2Y/",
|
||||
"verwendung": "",
|
||||
"technischeDaten": {}
|
||||
},
|
||||
{
|
||||
"url": "https://shop.faberkabel.de/Starkstromkabel-1-30-kV/Mittelspannungskabel/Mittelspannungskabel-N2XSY/",
|
||||
"verwendung": "Zur Verlegung in Erde, in Wasser, im Freien, in Innenräumen und Kabelkanälen für Kraftwerks-, Industrie- und Verteilernetze. Das Kabel lässt sich aufgrund der guten Verlegeeigenschaften auch bei schwieriger Trassenführung leicht verlegen. Gemäß VDE 0276 müssen die Kabel vor direkter Sonneneinstrahlung geschützt sein.",
|
||||
"technischeDaten": {
|
||||
"Zolltarifnummer (Warennummer)": "85446010900000000",
|
||||
"Norm": "VDE 0276-620",
|
||||
"Leitermaterial": "Cu, blank",
|
||||
"Leiterklasse": "Kl.2 = mehrdrähtig",
|
||||
"Aderisolation": "VPE DIX8",
|
||||
"Feldsteuerung": "innere und äußere Leitschicht aus halbleitendem Kunststoff (Dreifachextrusion)",
|
||||
"Schirm": "Cu-Drahtumspinnung + Querleitwendel",
|
||||
"Mantelmaterial": "PVC DMV6",
|
||||
"Mantelfarbe": "rot",
|
||||
"Flammwidrigkeit": "VDE 0482-332-1-2/IEC 60332-1-2",
|
||||
"Als Außenkabel zulässig": "ja",
|
||||
"Max. zulässige Leitertemperatur, °C": "90 °C",
|
||||
"Zul. Kabelaußentemperatur, fest verlegt, °C": "70 °C",
|
||||
"Zul. Kabelaußentemperatur, in Bewegung, °C": "-5 - +70 °C",
|
||||
"Min. Biegeradius, fest verlegt": "15 x Ø",
|
||||
"Leiterform": "rund",
|
||||
"Aderzahl": "1",
|
||||
"Metallbasis Cu (de)": "0 EUR/100 kg",
|
||||
"Maßeinheit": "Meter"
|
||||
}
|
||||
},
|
||||
{
|
||||
"url": "https://shop.faberkabel.de/Starkstromkabel-1-30-kV/Mittelspannungskabel/Mittelspannungskabel-NA2XS2Y/",
|
||||
"verwendung": "Zur Verlegung in Erde, in Wasser, im Freien, in Innenräumen und Kabelkanälen für Kraftwerks-, Industrie- und Verteilernetze. Bei Verlegung in Kabelkanälen und Innenräumen muss berücksichtigt werden, dass der PE-Mantel halogenfrei ist, jedoch nicht flammwidrig nach DIN VDE 0482-332-1. Das Kabel kann infolge des widerstandsfähigen PE-Mantels bei der Verlegung und im Betrieb stark mechanisch beansprucht werden.",
|
||||
"technischeDaten": {
|
||||
"Zolltarifnummer (Warennummer)": "85446090000000000",
|
||||
"Norm": "VDE 0276-620",
|
||||
"Leitermaterial": "Aluminium",
|
||||
"Leiterklasse": "Kl.2 = mehrdrähtig",
|
||||
"Aderisolation": "VPE DIX8",
|
||||
"Feldsteuerung": "innere und äußere Leitschicht aus halbleitendem Kunststoff (Dreifachextrusion)",
|
||||
"Schirm": "Cu-Drahtumspinnung + Querleitwendel",
|
||||
"Mantelmaterial": "Polyethylen DMP2",
|
||||
"Mantelfarbe": "schwarz",
|
||||
"Flammwidrigkeit": "keine",
|
||||
"UV-beständig": "ja",
|
||||
"Als Außenkabel zulässig": "ja",
|
||||
"Max. zulässige Leitertemperatur, °C": "90 °C",
|
||||
"Zul. Kabelaußentemperatur, fest verlegt, °C": "70 °C",
|
||||
"Zul. Kabelaußentemperatur, in Bewegung, °C": "-20 - +70 °C",
|
||||
"Min. Biegeradius, fest verlegt": "15 x Ø",
|
||||
"Aderzahl": "1",
|
||||
"Metallbasis Al (de)": "0 EUR/100 kg",
|
||||
"Metallbasis Cu (de)": "0 EUR/100 kg",
|
||||
"Maßeinheit": "Meter"
|
||||
}
|
||||
},
|
||||
{
|
||||
"url": "https://shop.faberkabel.de/Starkstromkabel-1-30-kV/Mittelspannungskabel/Mittelspannungskabel-NA2XSF2Y/",
|
||||
"verwendung": "",
|
||||
"technischeDaten": {}
|
||||
},
|
||||
{
|
||||
"url": "https://shop.faberkabel.de/Starkstromkabel-1-30-kV/Mittelspannungskabel/Mittelspannungskabel-NA2XS-FL-2Y/",
|
||||
"verwendung": "Zur Verlegung in Erde, in Wasser, im Freien, in Innenräumen und Kabelkanälen für EVU-Netze, Industrie- und Verteilernetze. Bei Verlegung in Kabelkanälen und Innenräumen muss berücksichtigt werden, dass der PE-Mantel nach DIN VDE 0482-332-1 nicht flammwidrig ist. Das Kabel ist für ungünstige Einsatzbedingungen geeignet, insbesondere wenn nach mechanischen Beschädigungen das Eindringen von Wasser in Quer- und Längsrichtung vermieden werden soll.",
|
||||
"technischeDaten": {
|
||||
"Zolltarifnummer (Warennummer)": "85446090000000000",
|
||||
"Norm": "VDE 0276-620",
|
||||
"Leitermaterial": "Aluminium",
|
||||
"Leiterklasse": "Kl.2 = mehrdrähtig",
|
||||
"Aderisolation": "VPE DIX8",
|
||||
"Mantelmaterial": "Polyethylen DMP2",
|
||||
"Schichtenmantel": "ja",
|
||||
"Kabel querwasserdicht": "ja",
|
||||
"Kabel längswasserdicht": "ja",
|
||||
"Mantelfarbe": "schwarz",
|
||||
"Flammwidrigkeit": "keine",
|
||||
"UV-beständig": "ja",
|
||||
"Als Außenkabel zulässig": "ja",
|
||||
"Max. zulässige Leitertemperatur, °C": "90 °C",
|
||||
"Zul. Kabelaußentemperatur, fest verlegt, °C": "70 °C",
|
||||
"Zul. Kabelaußentemperatur, in Bewegung, °C": "-20 - +70 °C",
|
||||
"Min. Biegeradius, fest verlegt": "15 x Ø",
|
||||
"Leiterform (Faber)": "RMv",
|
||||
"Aderzahl": "1",
|
||||
"Metallbasis Al (de)": "0 EUR/100 kg",
|
||||
"Metallbasis Cu (de)": "0 EUR/100 kg",
|
||||
"Maßeinheit": "Meter"
|
||||
}
|
||||
},
|
||||
{
|
||||
"url": "https://shop.faberkabel.de/Starkstromkabel-1-30-kV/Mittelspannungskabel/Mittelspannungskabel-NA2XSY/",
|
||||
"verwendung": "Zur Verlegung in Erde, in Wasser, im Freien, in Innenräumen und Kabelkanälen für Kraftwerks-, Industrie- und Verteilernetze. Das Kabel lässt sich aufgrund der guten Verlegeeigenschaften auch bei schwieriger Trassenführung leicht verlegen. Gemäß VDE 0276 müssen die Kabel vor direkter Sonneneinstrahlung geschützt sein.",
|
||||
"technischeDaten": {
|
||||
"Zolltarifnummer (Warennummer)": "85446090000000000",
|
||||
"Norm": "VDE 0276-620",
|
||||
"Leitermaterial": "Aluminium",
|
||||
"Leiterklasse": "Kl.2 = mehrdrähtig",
|
||||
"Aderisolation": "VPE DIX8",
|
||||
"Feldsteuerung": "innere und äußere Leitschicht aus halbleitendem Kunststoff (Dreifachextrusion)",
|
||||
"Schirm": "Cu-Drahtumspinnung + Querleitwendel",
|
||||
"Mantelmaterial": "PVC DMV6",
|
||||
"Mantelfarbe": "rot",
|
||||
"Flammwidrigkeit": "VDE 0482-332-1-2/IEC 60332-1-2",
|
||||
"Als Außenkabel zulässig": "ja",
|
||||
"Max. zulässige Leitertemperatur, °C": "90 °C",
|
||||
"Zul. Kabelaußentemperatur, fest verlegt, °C": "70 °C",
|
||||
"Zul. Kabelaußentemperatur, in Bewegung, °C": "-5 - +70 °C",
|
||||
"Min. Biegeradius, fest verlegt": "15 x Ø",
|
||||
"Leiterform (Faber)": "RMv",
|
||||
"Aderzahl": "1",
|
||||
"Metallbasis Al (de)": "0 EUR/100 kg",
|
||||
"Metallbasis Cu (de)": "0 EUR/100 kg",
|
||||
"Maßeinheit": "Meter"
|
||||
}
|
||||
}
|
||||
]
|
||||
@@ -1,15 +1,14 @@
|
||||
import {getRequestConfig} from 'next-intl/server';
|
||||
import { getRequestConfig } from 'next-intl/server';
|
||||
import * as Sentry from '@sentry/nextjs';
|
||||
|
||||
export default getRequestConfig(async ({requestLocale}) => {
|
||||
// This typically corresponds to the `[locale]` segment
|
||||
|
||||
export default getRequestConfig(async ({ requestLocale }) => {
|
||||
let locale = await requestLocale;
|
||||
|
||||
|
||||
// Ensure that a valid locale is used
|
||||
if (!locale || !['en', 'de'].includes(locale)) {
|
||||
locale = 'en';
|
||||
}
|
||||
|
||||
|
||||
return {
|
||||
locale,
|
||||
messages: (await import(`../messages/${locale}.json`)).default,
|
||||
@@ -21,12 +20,12 @@ export default getRequestConfig(async ({requestLocale}) => {
|
||||
}
|
||||
Sentry.captureException(error);
|
||||
},
|
||||
getMessageFallback({namespace, key, error}) {
|
||||
getMessageFallback({ namespace, key, error }) {
|
||||
const path = [namespace, key].filter((part) => part != null).join('.');
|
||||
if (error.code === 'MISSING_MESSAGE') {
|
||||
return path;
|
||||
}
|
||||
return 'fallback';
|
||||
}
|
||||
};
|
||||
} as any;
|
||||
});
|
||||
|
||||
40
lib/blog.ts
40
lib/blog.ts
@@ -41,11 +41,11 @@ export async function getPostBySlug(slug: string, locale: string): Promise<PostM
|
||||
export async function getAllPosts(locale: string): Promise<PostMdx[]> {
|
||||
const postsDir = path.join(process.cwd(), 'data', 'blog', locale);
|
||||
if (!fs.existsSync(postsDir)) return [];
|
||||
|
||||
|
||||
const files = fs.readdirSync(postsDir);
|
||||
const posts = files
|
||||
.filter(file => file.endsWith('.mdx'))
|
||||
.map(file => {
|
||||
.filter((file) => file.endsWith('.mdx'))
|
||||
.map((file) => {
|
||||
const filePath = path.join(postsDir, file);
|
||||
const fileContent = fs.readFileSync(filePath, 'utf8');
|
||||
const { data, content } = matter(fileContent);
|
||||
@@ -55,14 +55,42 @@ export async function getAllPosts(locale: string): Promise<PostMdx[]> {
|
||||
content,
|
||||
};
|
||||
})
|
||||
.sort((a, b) => new Date(b.frontmatter.date).getTime() - new Date(a.frontmatter.date).getTime());
|
||||
.sort(
|
||||
(a, b) => new Date(b.frontmatter.date).getTime() - new Date(a.frontmatter.date).getTime(),
|
||||
);
|
||||
|
||||
return posts;
|
||||
}
|
||||
|
||||
export async function getAdjacentPosts(slug: string, locale: string): Promise<{ prev: PostMdx | null; next: PostMdx | null }> {
|
||||
export async function getAllPostsMetadata(locale: string): Promise<Partial<PostMdx>[]> {
|
||||
const postsDir = path.join(process.cwd(), 'data', 'blog', locale);
|
||||
if (!fs.existsSync(postsDir)) return [];
|
||||
|
||||
const files = fs.readdirSync(postsDir);
|
||||
return files
|
||||
.filter((file) => file.endsWith('.mdx'))
|
||||
.map((file) => {
|
||||
const filePath = path.join(postsDir, file);
|
||||
const fileContent = fs.readFileSync(filePath, 'utf8');
|
||||
const { data } = matter(fileContent);
|
||||
return {
|
||||
slug: file.replace(/\.mdx$/, ''),
|
||||
frontmatter: data as PostFrontmatter,
|
||||
};
|
||||
})
|
||||
.sort(
|
||||
(a, b) =>
|
||||
new Date(b.frontmatter.date as string).getTime() -
|
||||
new Date(a.frontmatter.date as string).getTime(),
|
||||
);
|
||||
}
|
||||
|
||||
export async function getAdjacentPosts(
|
||||
slug: string,
|
||||
locale: string,
|
||||
): Promise<{ prev: PostMdx | null; next: PostMdx | null }> {
|
||||
const posts = await getAllPosts(locale);
|
||||
const currentIndex = posts.findIndex(post => post.slug === slug);
|
||||
const currentIndex = posts.findIndex((post) => post.slug === slug);
|
||||
|
||||
if (currentIndex === -1) {
|
||||
return { prev: null, next: null };
|
||||
|
||||
121
lib/config.ts
121
lib/config.ts
@@ -13,31 +13,35 @@ let memoizedConfig: ReturnType<typeof createConfig> | undefined;
|
||||
function createConfig() {
|
||||
const env = envSchema.parse(getRawEnv());
|
||||
|
||||
const target = env.NEXT_PUBLIC_TARGET || env.TARGET;
|
||||
|
||||
return {
|
||||
env: env.NODE_ENV,
|
||||
isProduction: env.NODE_ENV === 'production',
|
||||
isDevelopment: env.NODE_ENV === 'development',
|
||||
isTest: env.NODE_ENV === 'test',
|
||||
target,
|
||||
isProduction: target === 'production' || !target,
|
||||
isStaging: target === 'staging',
|
||||
isTesting: target === 'testing',
|
||||
isDevelopment: target === 'development',
|
||||
feedbackEnabled: env.NEXT_PUBLIC_FEEDBACK_ENABLED,
|
||||
gatekeeperUrl: env.GATEKEEPER_URL,
|
||||
|
||||
baseUrl: env.NEXT_PUBLIC_BASE_URL,
|
||||
|
||||
analytics: {
|
||||
umami: {
|
||||
websiteId: env.NEXT_PUBLIC_UMAMI_WEBSITE_ID,
|
||||
scriptUrl: env.NEXT_PUBLIC_UMAMI_SCRIPT_URL,
|
||||
// The proxied path used in the frontend
|
||||
proxyPath: '/stats/script.js',
|
||||
enabled: Boolean(env.NEXT_PUBLIC_UMAMI_WEBSITE_ID),
|
||||
websiteId: env.UMAMI_WEBSITE_ID,
|
||||
apiEndpoint: env.UMAMI_API_ENDPOINT,
|
||||
enabled: Boolean(env.UMAMI_WEBSITE_ID),
|
||||
},
|
||||
},
|
||||
|
||||
errors: {
|
||||
glitchtip: {
|
||||
// Use SENTRY_DSN for both server and client (proxied)
|
||||
dsn: env.SENTRY_DSN,
|
||||
// The proxied origin used in the frontend
|
||||
proxyPath: '/errors',
|
||||
enabled: Boolean(env.SENTRY_DSN),
|
||||
// On the client, we always enable it (it uses the tunnel / proxy defined in sentry.client.config.ts)
|
||||
// On the server, we only enable it if the DSN is provided.
|
||||
enabled: typeof window !== 'undefined' || Boolean(env.SENTRY_DSN),
|
||||
},
|
||||
},
|
||||
|
||||
@@ -57,6 +61,25 @@ function createConfig() {
|
||||
from: env.MAIL_FROM,
|
||||
recipients: env.MAIL_RECIPIENTS,
|
||||
},
|
||||
directus: {
|
||||
url: env.DIRECTUS_URL,
|
||||
adminEmail: env.DIRECTUS_ADMIN_EMAIL,
|
||||
password: env.DIRECTUS_ADMIN_PASSWORD,
|
||||
token: env.DIRECTUS_API_TOKEN,
|
||||
internalUrl: env.INTERNAL_DIRECTUS_URL,
|
||||
proxyPath: '/cms',
|
||||
},
|
||||
infraCMS: {
|
||||
url: env.INFRA_DIRECTUS_URL || env.DIRECTUS_URL,
|
||||
token: env.INFRA_DIRECTUS_TOKEN || env.DIRECTUS_API_TOKEN,
|
||||
},
|
||||
notifications: {
|
||||
gotify: {
|
||||
url: env.GOTIFY_URL,
|
||||
token: env.GOTIFY_TOKEN,
|
||||
enabled: Boolean(env.GOTIFY_URL && env.GOTIFY_TOKEN),
|
||||
},
|
||||
},
|
||||
} as const;
|
||||
}
|
||||
|
||||
@@ -76,16 +99,57 @@ export function getConfig() {
|
||||
* Uses getters to ensure it's only initialized when accessed.
|
||||
*/
|
||||
export const config = {
|
||||
get env() { return getConfig().env; },
|
||||
get isProduction() { return getConfig().isProduction; },
|
||||
get isDevelopment() { return getConfig().isDevelopment; },
|
||||
get isTest() { return getConfig().isTest; },
|
||||
get baseUrl() { return getConfig().baseUrl; },
|
||||
get analytics() { return getConfig().analytics; },
|
||||
get errors() { return getConfig().errors; },
|
||||
get cache() { return getConfig().cache; },
|
||||
get logging() { return getConfig().logging; },
|
||||
get mail() { return getConfig().mail; },
|
||||
get env() {
|
||||
return getConfig().env;
|
||||
},
|
||||
get target() {
|
||||
return getConfig().target;
|
||||
},
|
||||
get isProduction() {
|
||||
return getConfig().isProduction;
|
||||
},
|
||||
get isStaging() {
|
||||
return getConfig().isStaging;
|
||||
},
|
||||
get isTesting() {
|
||||
return getConfig().isTesting;
|
||||
},
|
||||
get isDevelopment() {
|
||||
return getConfig().isDevelopment;
|
||||
},
|
||||
get baseUrl() {
|
||||
return getConfig().baseUrl;
|
||||
},
|
||||
get analytics() {
|
||||
return getConfig().analytics;
|
||||
},
|
||||
get errors() {
|
||||
return getConfig().errors;
|
||||
},
|
||||
get cache() {
|
||||
return getConfig().cache;
|
||||
},
|
||||
get logging() {
|
||||
return getConfig().logging;
|
||||
},
|
||||
get mail() {
|
||||
return getConfig().mail;
|
||||
},
|
||||
get directus() {
|
||||
return getConfig().directus;
|
||||
},
|
||||
get notifications() {
|
||||
return getConfig().notifications;
|
||||
},
|
||||
get feedbackEnabled() {
|
||||
return getConfig().feedbackEnabled;
|
||||
},
|
||||
get infraCMS() {
|
||||
return getConfig().infraCMS;
|
||||
},
|
||||
get gatekeeperUrl() {
|
||||
return getConfig().gatekeeperUrl;
|
||||
},
|
||||
};
|
||||
|
||||
/**
|
||||
@@ -101,7 +165,7 @@ export function getMaskedConfig() {
|
||||
analytics: {
|
||||
umami: {
|
||||
websiteId: mask(c.analytics.umami.websiteId),
|
||||
scriptUrl: c.analytics.umami.scriptUrl,
|
||||
apiEndpoint: c.analytics.umami.apiEndpoint,
|
||||
enabled: c.analytics.umami.enabled,
|
||||
},
|
||||
},
|
||||
@@ -124,5 +188,18 @@ export function getMaskedConfig() {
|
||||
from: c.mail.from,
|
||||
recipients: c.mail.recipients,
|
||||
},
|
||||
directus: {
|
||||
url: c.directus.url,
|
||||
adminEmail: mask(c.directus.adminEmail),
|
||||
password: mask(c.directus.password),
|
||||
token: mask(c.directus.token),
|
||||
},
|
||||
notifications: {
|
||||
gotify: {
|
||||
url: c.notifications.gotify.url,
|
||||
token: mask(c.notifications.gotify.token),
|
||||
enabled: c.notifications.gotify.enabled,
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
@@ -15,6 +15,9 @@ export function getDatasheetPath(slug: string, locale: string): string | null {
|
||||
// Normalize slug: remove common suffixes that might not be in the PDF filename
|
||||
const normalizedSlug = slug.replace(/-hv$|-mv$/, '');
|
||||
|
||||
// Subdirectories to search in
|
||||
const subdirs = ['', 'low-voltage', 'medium-voltage', 'high-voltage', 'solar'];
|
||||
|
||||
// List of patterns to try for the current locale
|
||||
const patterns = [
|
||||
`${slug}-${locale}.pdf`,
|
||||
@@ -25,10 +28,13 @@ export function getDatasheetPath(slug: string, locale: string): string | null {
|
||||
`${normalizedSlug}-3-${locale}.pdf`,
|
||||
];
|
||||
|
||||
for (const pattern of patterns) {
|
||||
const filePath = path.join(datasheetsDir, pattern);
|
||||
if (fs.existsSync(filePath)) {
|
||||
return `/datasheets/${pattern}`;
|
||||
for (const subdir of subdirs) {
|
||||
for (const pattern of patterns) {
|
||||
const relativePath = path.join(subdir, pattern);
|
||||
const filePath = path.join(datasheetsDir, relativePath);
|
||||
if (fs.existsSync(filePath)) {
|
||||
return `/datasheets/${relativePath}`;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -42,10 +48,13 @@ export function getDatasheetPath(slug: string, locale: string): string | null {
|
||||
`${normalizedSlug}-2-en.pdf`,
|
||||
`${normalizedSlug}-3-en.pdf`,
|
||||
];
|
||||
for (const pattern of enPatterns) {
|
||||
const filePath = path.join(datasheetsDir, pattern);
|
||||
if (fs.existsSync(filePath)) {
|
||||
return `/datasheets/${pattern}`;
|
||||
for (const subdir of subdirs) {
|
||||
for (const pattern of enPatterns) {
|
||||
const relativePath = path.join(subdir, pattern);
|
||||
const filePath = path.join(datasheetsDir, relativePath);
|
||||
if (fs.existsSync(filePath)) {
|
||||
return `/datasheets/${relativePath}`;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
221
lib/directus.ts
Normal file
221
lib/directus.ts
Normal file
@@ -0,0 +1,221 @@
|
||||
import { createDirectus, rest, authentication, staticToken, readItems, readCollections } from '@directus/sdk';
|
||||
import { config } from './config';
|
||||
import { getServerAppServices } from './services/create-services.server';
|
||||
|
||||
const { url, adminEmail, password, token, proxyPath, internalUrl } = config.directus;
|
||||
|
||||
// Use internal URL if on server to bypass Gatekeeper/Auth
|
||||
// Use proxy path in browser to stay on the same origin
|
||||
const effectiveUrl =
|
||||
typeof window === 'undefined'
|
||||
? internalUrl || url
|
||||
: typeof window !== 'undefined'
|
||||
? `${window.location.origin}${proxyPath}`
|
||||
: proxyPath;
|
||||
|
||||
// Initialize client with authentication plugin
|
||||
const client = createDirectus(effectiveUrl).with(rest()).with(authentication());
|
||||
|
||||
/**
|
||||
* Helper to determine if we should show detailed errors
|
||||
*/
|
||||
const shouldShowDevErrors = config.isTesting || config.isDevelopment;
|
||||
|
||||
/**
|
||||
* Genericizes error messages for production/staging
|
||||
*/
|
||||
function formatError(error: any) {
|
||||
if (shouldShowDevErrors) {
|
||||
return error.errors?.[0]?.message || error.message || 'An unexpected error occurred.';
|
||||
}
|
||||
return 'A system error occurred. Our team has been notified.';
|
||||
}
|
||||
|
||||
let authPromise: Promise<void> | null = null;
|
||||
|
||||
export async function ensureAuthenticated() {
|
||||
if (token) {
|
||||
(client as any).setToken(token);
|
||||
return;
|
||||
}
|
||||
|
||||
// Check if we already have a valid session token in memory (for login flow)
|
||||
const existingToken = await (client as any).getToken();
|
||||
if (existingToken) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (adminEmail && password) {
|
||||
if (authPromise) {
|
||||
return authPromise;
|
||||
}
|
||||
|
||||
authPromise = (async () => {
|
||||
try {
|
||||
client.setToken(null as any);
|
||||
await client.login(adminEmail, password);
|
||||
console.log(`✅ Directus: Authenticated successfully as ${adminEmail}`);
|
||||
} catch (e: any) {
|
||||
if (typeof window === 'undefined') {
|
||||
getServerAppServices().errors.captureException(e, { part: 'directus_auth' });
|
||||
}
|
||||
console.error(`Failed to authenticate with Directus (${adminEmail}):`, e.message);
|
||||
if (shouldShowDevErrors && e.errors) {
|
||||
console.error('Directus Auth Details:', JSON.stringify(e.errors, null, 2));
|
||||
}
|
||||
// Clear the promise on failure (especially on invalid credentials)
|
||||
// so we can retry on next request if credentials were updated
|
||||
authPromise = null;
|
||||
throw e;
|
||||
}
|
||||
})();
|
||||
|
||||
return authPromise;
|
||||
} else if (shouldShowDevErrors && !adminEmail && !password && !token) {
|
||||
console.warn('Directus: No token or admin credentials provided.');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Maps the new translation-based schema back to the application's Product interface
|
||||
*/
|
||||
function mapDirectusProduct(item: any, locale: string): any {
|
||||
const langCode = locale === 'en' ? 'en-US' : 'de-DE';
|
||||
const translation =
|
||||
item.translations?.find((t: any) => t.languages_code === langCode) ||
|
||||
item.translations?.[0] ||
|
||||
{};
|
||||
|
||||
return {
|
||||
id: item.id,
|
||||
sku: item.sku,
|
||||
title: translation.name || '',
|
||||
description: translation.description || '',
|
||||
content: translation.content || '',
|
||||
technicalData: {
|
||||
technicalItems: translation.technical_items || [],
|
||||
voltageTables: translation.voltage_tables || [],
|
||||
},
|
||||
locale: locale,
|
||||
// Use proxy URL for assets to avoid CORS and handle internal/external issues
|
||||
data_sheet_url: item.data_sheet ? `${proxyPath}/assets/${item.data_sheet}` : null,
|
||||
categories: (item.categories_link || [])
|
||||
.map((c: any) => c.categories_id?.translations?.[0]?.name)
|
||||
.filter(Boolean),
|
||||
};
|
||||
}
|
||||
|
||||
export async function getProducts(locale: string = 'de') {
|
||||
await ensureAuthenticated();
|
||||
try {
|
||||
const items = await client.request(
|
||||
readItems('products', {
|
||||
fields: ['*', 'translations.*', 'categories_link.categories_id.translations.name'],
|
||||
}),
|
||||
);
|
||||
return items.map((item) => mapDirectusProduct(item, locale));
|
||||
} catch (error) {
|
||||
if (typeof window === 'undefined') {
|
||||
getServerAppServices().errors.captureException(error, { part: 'directus_get_products' });
|
||||
}
|
||||
console.error('Error fetching products:', error);
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
export async function getProductBySlug(slug: string, locale: string = 'de') {
|
||||
await ensureAuthenticated();
|
||||
const langCode = locale === 'en' ? 'en-US' : 'de-DE';
|
||||
try {
|
||||
const items = await client.request(
|
||||
readItems('products', {
|
||||
filter: {
|
||||
translations: {
|
||||
slug: { _eq: slug },
|
||||
languages_code: { _eq: langCode },
|
||||
},
|
||||
},
|
||||
fields: ['*', 'translations.*', 'categories_link.categories_id.translations.name'],
|
||||
limit: 1,
|
||||
}),
|
||||
);
|
||||
|
||||
if (!items || items.length === 0) return null;
|
||||
return mapDirectusProduct(items[0], locale);
|
||||
} catch (error) {
|
||||
if (typeof window === 'undefined') {
|
||||
getServerAppServices().errors.captureException(error, {
|
||||
part: 'directus_get_product_by_slug',
|
||||
slug,
|
||||
});
|
||||
}
|
||||
console.error(`Error fetching product ${slug}:`, error);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export async function checkHealth() {
|
||||
try {
|
||||
// 1. Connectivity & Auth Check
|
||||
try {
|
||||
await ensureAuthenticated();
|
||||
await client.request(readCollections());
|
||||
} catch (e: any) {
|
||||
if (typeof window === 'undefined') {
|
||||
getServerAppServices().errors.captureException(e, { part: 'directus_health_auth' });
|
||||
}
|
||||
console.error('Directus authentication failed during health check:', e);
|
||||
return {
|
||||
status: 'error',
|
||||
message: shouldShowDevErrors
|
||||
? 'Authentication failed. Check your DIRECTUS_ADMIN_EMAIL and DIRECTUS_ADMIN_PASSWORD.'
|
||||
: 'CMS is currently unavailable due to an internal authentication error.',
|
||||
code: 'AUTH_FAILED',
|
||||
details: shouldShowDevErrors ? e.message : undefined,
|
||||
};
|
||||
}
|
||||
|
||||
// 2. Schema check (does the contact_submissions table exist?)
|
||||
try {
|
||||
await client.request(readItems('contact_submissions', { limit: 1 }));
|
||||
} catch (e: any) {
|
||||
if (typeof window === 'undefined') {
|
||||
getServerAppServices().errors.captureException(e, { part: 'directus_health_schema' });
|
||||
}
|
||||
if (
|
||||
e.message?.includes('does not exist') ||
|
||||
e.code === 'INVALID_PAYLOAD' ||
|
||||
e.status === 404
|
||||
) {
|
||||
return {
|
||||
status: 'error',
|
||||
message: shouldShowDevErrors
|
||||
? `The "contact_submissions" collection is missing or inaccessible. Error: ${e.message || 'Unknown'}`
|
||||
: 'Required data structures are currently unavailable.',
|
||||
code: 'SCHEMA_MISSING',
|
||||
};
|
||||
}
|
||||
return {
|
||||
status: 'error',
|
||||
message: shouldShowDevErrors
|
||||
? `Schema error: ${e.errors?.[0]?.message || e.message || 'Unknown error'}`
|
||||
: 'The data schema is currently misconfigured.',
|
||||
code: 'SCHEMA_ERROR',
|
||||
};
|
||||
}
|
||||
|
||||
return { status: 'ok', message: 'Directus is reachable and responding.' };
|
||||
} catch (error: any) {
|
||||
if (typeof window === 'undefined') {
|
||||
getServerAppServices().errors.captureException(error, { part: 'directus_health_critical' });
|
||||
}
|
||||
console.error('Directus health check failed with unexpected error:', error);
|
||||
return {
|
||||
status: 'error',
|
||||
message: formatError(error),
|
||||
code: error.code || 'UNKNOWN',
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
export default client;
|
||||
70
lib/env.test.ts
Normal file
70
lib/env.test.ts
Normal file
@@ -0,0 +1,70 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { envSchema } from './env';
|
||||
|
||||
describe('envSchema', () => {
|
||||
it('should allow missing MAIL_HOST in development', () => {
|
||||
const result = envSchema.safeParse({
|
||||
NEXT_PUBLIC_BASE_URL: 'http://localhost:3000',
|
||||
TARGET: 'development',
|
||||
});
|
||||
expect(result.success).toBe(true);
|
||||
});
|
||||
|
||||
it('should require MAIL_HOST in production', () => {
|
||||
const result = envSchema.safeParse({
|
||||
NEXT_PUBLIC_BASE_URL: 'https://example.com',
|
||||
TARGET: 'production',
|
||||
});
|
||||
expect(result.success).toBe(false);
|
||||
if (!result.success) {
|
||||
expect(result.error.issues[0].message).toBe(
|
||||
'MAIL_HOST is required in non-development environments',
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
it('should require MAIL_HOST in testing', () => {
|
||||
const result = envSchema.safeParse({
|
||||
NEXT_PUBLIC_BASE_URL: 'https://testing.example.com',
|
||||
TARGET: 'testing',
|
||||
});
|
||||
expect(result.success).toBe(false);
|
||||
if (!result.success) {
|
||||
expect(result.error.issues[0].message).toBe(
|
||||
'MAIL_HOST is required in non-development environments',
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
it('should require MAIL_HOST in staging', () => {
|
||||
const result = envSchema.safeParse({
|
||||
NEXT_PUBLIC_BASE_URL: 'https://staging.example.com',
|
||||
TARGET: 'staging',
|
||||
});
|
||||
expect(result.success).toBe(false);
|
||||
if (!result.success) {
|
||||
expect(result.error.issues[0].message).toBe(
|
||||
'MAIL_HOST is required in non-development environments',
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
it('should pass if MAIL_HOST is provided in production', () => {
|
||||
const result = envSchema.safeParse({
|
||||
NEXT_PUBLIC_BASE_URL: 'https://example.com',
|
||||
TARGET: 'production',
|
||||
MAIL_HOST: 'smtp.example.com',
|
||||
});
|
||||
expect(result.success).toBe(true);
|
||||
});
|
||||
|
||||
it('should skip MAIL_HOST requirement if SKIP_RUNTIME_ENV_VALIDATION is true', () => {
|
||||
process.env.SKIP_RUNTIME_ENV_VALIDATION = 'true';
|
||||
const result = envSchema.safeParse({
|
||||
NEXT_PUBLIC_BASE_URL: 'https://example.com',
|
||||
TARGET: 'production',
|
||||
});
|
||||
expect(result.success).toBe(true);
|
||||
delete process.env.SKIP_RUNTIME_ENV_VALIDATION;
|
||||
});
|
||||
});
|
||||
120
lib/env.ts
120
lib/env.ts
@@ -8,31 +8,83 @@ const preprocessEmptyString = (val: unknown) => (val === '' ? undefined : val);
|
||||
/**
|
||||
* Environment variable schema.
|
||||
*/
|
||||
export const envSchema = z.object({
|
||||
NODE_ENV: z.enum(['development', 'production', 'test']).default('development'),
|
||||
NEXT_PUBLIC_BASE_URL: z.preprocess(preprocessEmptyString, z.string().url()),
|
||||
|
||||
// Analytics
|
||||
NEXT_PUBLIC_UMAMI_WEBSITE_ID: z.preprocess(preprocessEmptyString, z.string().optional()),
|
||||
NEXT_PUBLIC_UMAMI_SCRIPT_URL: z.preprocess(preprocessEmptyString, z.string().url().default('https://analytics.infra.mintel.me/script.js')),
|
||||
|
||||
// Error Tracking
|
||||
SENTRY_DSN: z.preprocess(preprocessEmptyString, z.string().optional()),
|
||||
|
||||
// Logging
|
||||
LOG_LEVEL: z.enum(['debug', 'info', 'warn', 'error']).default('info'),
|
||||
|
||||
// Mail
|
||||
MAIL_HOST: z.preprocess(preprocessEmptyString, z.string().optional()),
|
||||
MAIL_PORT: z.preprocess(preprocessEmptyString, z.coerce.number().default(587)),
|
||||
MAIL_USERNAME: z.preprocess(preprocessEmptyString, z.string().optional()),
|
||||
MAIL_PASSWORD: z.preprocess(preprocessEmptyString, z.string().optional()),
|
||||
MAIL_FROM: z.preprocess(preprocessEmptyString, z.string().optional()),
|
||||
MAIL_RECIPIENTS: z.preprocess(
|
||||
(val) => (typeof val === 'string' ? val.split(',').filter(Boolean) : val),
|
||||
z.array(z.string()).default([])
|
||||
),
|
||||
});
|
||||
export const envSchema = z
|
||||
.object({
|
||||
NODE_ENV: z.enum(['development', 'production', 'test']).default('development'),
|
||||
NEXT_PUBLIC_BASE_URL: z.preprocess(preprocessEmptyString, z.string().url()),
|
||||
NEXT_PUBLIC_TARGET: z.enum(['development', 'testing', 'staging', 'production']).optional(),
|
||||
|
||||
// Analytics
|
||||
UMAMI_WEBSITE_ID: z.preprocess(preprocessEmptyString, z.string().optional()),
|
||||
UMAMI_API_ENDPOINT: z.preprocess(
|
||||
preprocessEmptyString,
|
||||
z.string().url().default('https://analytics.infra.mintel.me'),
|
||||
),
|
||||
|
||||
// Error Tracking
|
||||
SENTRY_DSN: z.preprocess(preprocessEmptyString, z.string().optional()),
|
||||
|
||||
// Logging
|
||||
LOG_LEVEL: z.enum(['debug', 'info', 'warn', 'error']).default('info'),
|
||||
|
||||
// Mail
|
||||
MAIL_HOST: z.preprocess(preprocessEmptyString, z.string().optional()),
|
||||
MAIL_PORT: z.preprocess(preprocessEmptyString, z.coerce.number().default(587)),
|
||||
MAIL_USERNAME: z.preprocess(preprocessEmptyString, z.string().optional()),
|
||||
MAIL_PASSWORD: z.preprocess(preprocessEmptyString, z.string().optional()),
|
||||
MAIL_FROM: z.preprocess(preprocessEmptyString, z.string().optional()),
|
||||
MAIL_RECIPIENTS: z.preprocess(
|
||||
(val) => (typeof val === 'string' ? val.split(',').filter(Boolean) : val),
|
||||
z.array(z.string()).default([]),
|
||||
),
|
||||
|
||||
// Directus
|
||||
DIRECTUS_URL: z.preprocess(
|
||||
preprocessEmptyString,
|
||||
z.string().url().default('http://localhost:8055'),
|
||||
),
|
||||
DIRECTUS_ADMIN_EMAIL: z.preprocess(preprocessEmptyString, z.string().optional()),
|
||||
DIRECTUS_ADMIN_PASSWORD: z.preprocess(preprocessEmptyString, z.string().optional()),
|
||||
DIRECTUS_API_TOKEN: z.preprocess(preprocessEmptyString, z.string().optional()),
|
||||
INTERNAL_DIRECTUS_URL: z.preprocess(preprocessEmptyString, z.string().url().optional()),
|
||||
|
||||
// Deploy Target
|
||||
TARGET: z.enum(['development', 'testing', 'staging', 'production']).optional(),
|
||||
// Gotify
|
||||
GOTIFY_URL: z.preprocess(preprocessEmptyString, z.string().url().optional()),
|
||||
GOTIFY_TOKEN: z.preprocess(preprocessEmptyString, z.string().optional()),
|
||||
// Gatekeeper
|
||||
GATEKEEPER_URL: z.preprocess(
|
||||
preprocessEmptyString,
|
||||
z.string().url().default('http://gatekeeper:3000'),
|
||||
),
|
||||
NEXT_PUBLIC_FEEDBACK_ENABLED: z.preprocess(
|
||||
(val) => val === 'true' || val === true,
|
||||
z.boolean().default(false)
|
||||
),
|
||||
GATEKEEPER_BYPASS_ENABLED: z.preprocess(
|
||||
(val) => val === 'true' || val === true,
|
||||
z.boolean().default(false)
|
||||
),
|
||||
INFRA_DIRECTUS_URL: z.preprocess(preprocessEmptyString, z.string().url().optional()),
|
||||
INFRA_DIRECTUS_TOKEN: z.preprocess(preprocessEmptyString, z.string().optional()),
|
||||
})
|
||||
.superRefine((data, ctx) => {
|
||||
const target = data.NEXT_PUBLIC_TARGET || data.TARGET;
|
||||
const isDev = target === 'development' || !target;
|
||||
const isBuildTimeValidation = process.env.SKIP_RUNTIME_ENV_VALIDATION === 'true';
|
||||
const isServer = typeof window === 'undefined';
|
||||
|
||||
// Only enforce server-only variables when running on the server.
|
||||
// In the browser, non-NEXT_PUBLIC_ variables are undefined and should not trigger validation errors.
|
||||
if (isServer && !isDev && !isBuildTimeValidation && !data.MAIL_HOST) {
|
||||
ctx.addIssue({
|
||||
code: z.ZodIssueCode.custom,
|
||||
message: 'MAIL_HOST is required in non-development environments',
|
||||
path: ['MAIL_HOST'],
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
export type Env = z.infer<typeof envSchema>;
|
||||
|
||||
@@ -44,8 +96,9 @@ export function getRawEnv() {
|
||||
return {
|
||||
NODE_ENV: process.env.NODE_ENV,
|
||||
NEXT_PUBLIC_BASE_URL: process.env.NEXT_PUBLIC_BASE_URL,
|
||||
NEXT_PUBLIC_UMAMI_WEBSITE_ID: process.env.NEXT_PUBLIC_UMAMI_WEBSITE_ID,
|
||||
NEXT_PUBLIC_UMAMI_SCRIPT_URL: process.env.NEXT_PUBLIC_UMAMI_SCRIPT_URL,
|
||||
NEXT_PUBLIC_TARGET: process.env.NEXT_PUBLIC_TARGET,
|
||||
UMAMI_WEBSITE_ID: process.env.UMAMI_WEBSITE_ID,
|
||||
UMAMI_API_ENDPOINT: process.env.UMAMI_API_ENDPOINT,
|
||||
SENTRY_DSN: process.env.SENTRY_DSN,
|
||||
LOG_LEVEL: process.env.LOG_LEVEL,
|
||||
MAIL_HOST: process.env.MAIL_HOST,
|
||||
@@ -54,5 +107,18 @@ export function getRawEnv() {
|
||||
MAIL_PASSWORD: process.env.MAIL_PASSWORD,
|
||||
MAIL_FROM: process.env.MAIL_FROM,
|
||||
MAIL_RECIPIENTS: process.env.MAIL_RECIPIENTS,
|
||||
DIRECTUS_URL: process.env.DIRECTUS_URL,
|
||||
DIRECTUS_ADMIN_EMAIL: process.env.DIRECTUS_ADMIN_EMAIL,
|
||||
DIRECTUS_ADMIN_PASSWORD: process.env.DIRECTUS_ADMIN_PASSWORD,
|
||||
DIRECTUS_API_TOKEN: process.env.DIRECTUS_API_TOKEN,
|
||||
INTERNAL_DIRECTUS_URL: process.env.INTERNAL_DIRECTUS_URL,
|
||||
TARGET: process.env.TARGET,
|
||||
GOTIFY_URL: process.env.GOTIFY_URL,
|
||||
GOTIFY_TOKEN: process.env.GOTIFY_TOKEN,
|
||||
GATEKEEPER_URL: process.env.GATEKEEPER_URL,
|
||||
NEXT_PUBLIC_FEEDBACK_ENABLED: process.env.NEXT_PUBLIC_FEEDBACK_ENABLED,
|
||||
GATEKEEPER_BYPASS_ENABLED: process.env.GATEKEEPER_BYPASS_ENABLED,
|
||||
INFRA_DIRECTUS_URL: process.env.INFRA_DIRECTUS_URL,
|
||||
INFRA_DIRECTUS_TOKEN: process.env.INFRA_DIRECTUS_TOKEN,
|
||||
};
|
||||
}
|
||||
|
||||
59
lib/mail/mailer.test.ts
Normal file
59
lib/mail/mailer.test.ts
Normal file
@@ -0,0 +1,59 @@
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
import { sendEmail } from './mailer';
|
||||
import { config } from '../config';
|
||||
|
||||
// Mock getServerAppServices to avoid full app initialization
|
||||
vi.mock('@/lib/services/create-services.server', () => ({
|
||||
getServerAppServices: () => ({
|
||||
logger: {
|
||||
child: () => ({
|
||||
info: vi.fn(),
|
||||
error: vi.fn(),
|
||||
warn: vi.fn(),
|
||||
}),
|
||||
},
|
||||
}),
|
||||
}));
|
||||
|
||||
// Mock config
|
||||
vi.mock('../config', () => ({
|
||||
config: {
|
||||
mail: {
|
||||
host: 'smtp.example.com',
|
||||
port: 587,
|
||||
user: 'user',
|
||||
pass: 'pass',
|
||||
from: 'from@example.com',
|
||||
recipients: ['to@example.com'],
|
||||
},
|
||||
},
|
||||
getConfig: vi.fn(),
|
||||
}));
|
||||
|
||||
describe('mailer', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
describe('sendEmail', () => {
|
||||
it('should throw error if MAIL_HOST is missing', async () => {
|
||||
// Temporarily nullify host
|
||||
const originalHost = config.mail.host;
|
||||
(config.mail as any).host = '';
|
||||
|
||||
const result = await sendEmail({
|
||||
subject: 'Test',
|
||||
html: '<p>Test</p>',
|
||||
});
|
||||
|
||||
expect(result.success).toBe(false);
|
||||
expect(result.error).toContain('MAIL_HOST is not configured');
|
||||
|
||||
// Restore host
|
||||
(config.mail as any).host = originalHost;
|
||||
});
|
||||
|
||||
// In a real environment, we'd mock nodemailer, but for now we focus on the validation logic
|
||||
// we added. Full SMTP integration tests are usually out of scope for unit tests.
|
||||
});
|
||||
});
|
||||
@@ -1,33 +1,44 @@
|
||||
import nodemailer from "nodemailer";
|
||||
import { render } from "@react-email/components";
|
||||
import { ReactElement } from "react";
|
||||
import { getServerAppServices } from "@/lib/services/create-services.server";
|
||||
import { config } from "../config";
|
||||
import nodemailer from 'nodemailer';
|
||||
import { getServerAppServices } from '@/lib/services/create-services.server';
|
||||
import { config } from '../config';
|
||||
import { ReactElement } from 'react';
|
||||
|
||||
const transporter = nodemailer.createTransport({
|
||||
host: config.mail.host,
|
||||
port: config.mail.port,
|
||||
secure: config.mail.port === 465,
|
||||
auth: {
|
||||
user: config.mail.user,
|
||||
pass: config.mail.pass,
|
||||
},
|
||||
});
|
||||
let transporterInstance: nodemailer.Transporter | null = null;
|
||||
|
||||
function getTransporter() {
|
||||
if (transporterInstance) return transporterInstance;
|
||||
|
||||
if (!config.mail.host) {
|
||||
throw new Error('MAIL_HOST is not configured. Please check your environment variables.');
|
||||
}
|
||||
|
||||
transporterInstance = nodemailer.createTransport({
|
||||
host: config.mail.host,
|
||||
port: config.mail.port,
|
||||
secure: config.mail.port === 465,
|
||||
auth: {
|
||||
user: config.mail.user,
|
||||
pass: config.mail.pass,
|
||||
},
|
||||
});
|
||||
|
||||
return transporterInstance;
|
||||
}
|
||||
|
||||
interface SendEmailOptions {
|
||||
to?: string | string[];
|
||||
replyTo?: string;
|
||||
subject: string;
|
||||
template: ReactElement;
|
||||
html: string;
|
||||
}
|
||||
|
||||
export async function sendEmail({ to, subject, template }: SendEmailOptions) {
|
||||
const html = await render(template);
|
||||
|
||||
export async function sendEmail({ to, replyTo, subject, html }: SendEmailOptions) {
|
||||
const recipients = to || config.mail.recipients;
|
||||
|
||||
const mailOptions = {
|
||||
from: config.mail.from,
|
||||
to: recipients,
|
||||
replyTo,
|
||||
subject,
|
||||
html,
|
||||
};
|
||||
@@ -35,11 +46,12 @@ export async function sendEmail({ to, subject, template }: SendEmailOptions) {
|
||||
const logger = getServerAppServices().logger.child({ component: 'mailer' });
|
||||
|
||||
try {
|
||||
const info = await transporter.sendMail(mailOptions);
|
||||
logger.info("Email sent successfully", { messageId: info.messageId, subject, recipients });
|
||||
const info = await getTransporter().sendMail(mailOptions);
|
||||
logger.info('Email sent successfully', { messageId: info.messageId, subject, recipients });
|
||||
return { success: true, messageId: info.messageId };
|
||||
} catch (error) {
|
||||
logger.error("Error sending email", { error, subject, recipients });
|
||||
return { success: false, error };
|
||||
const errorMsg = error instanceof Error ? error.message : String(error);
|
||||
logger.error('Error sending email', { error: errorMsg, subject, recipients });
|
||||
return { success: false, error: errorMsg };
|
||||
}
|
||||
}
|
||||
|
||||
82
lib/mdx.ts
82
lib/mdx.ts
@@ -18,11 +18,61 @@ export interface ProductMdx {
|
||||
content: string;
|
||||
}
|
||||
|
||||
export async function getProductMetadata(
|
||||
slug: string,
|
||||
locale: string,
|
||||
): Promise<Partial<ProductMdx> | null> {
|
||||
// Map translated slug to file slug
|
||||
const fileSlug = await mapSlugToFileSlug(slug, locale);
|
||||
const productsDir = path.join(process.cwd(), 'data', 'products', locale);
|
||||
|
||||
// Try exact slug first
|
||||
let filePath = path.join(productsDir, `${fileSlug}.mdx`);
|
||||
|
||||
if (!fs.existsSync(filePath)) {
|
||||
// Try with -2 suffix (common in the dumped files)
|
||||
filePath = path.join(productsDir, `${fileSlug}-2.mdx`);
|
||||
}
|
||||
|
||||
if (!fs.existsSync(filePath)) {
|
||||
// Fallback to English if locale is not 'en'
|
||||
if (locale !== 'en') {
|
||||
const enProductsDir = path.join(process.cwd(), 'data', 'products', 'en');
|
||||
let enFilePath = path.join(enProductsDir, `${fileSlug}.mdx`);
|
||||
if (!fs.existsSync(enFilePath)) {
|
||||
enFilePath = path.join(enProductsDir, `${fileSlug}-2.mdx`);
|
||||
}
|
||||
|
||||
if (fs.existsSync(enFilePath)) {
|
||||
const fileContent = fs.readFileSync(enFilePath, 'utf8');
|
||||
const { data } = matter(fileContent);
|
||||
return {
|
||||
slug: fileSlug,
|
||||
frontmatter: {
|
||||
...data,
|
||||
isFallback: true,
|
||||
} as ProductFrontmatter & { isFallback?: boolean },
|
||||
};
|
||||
}
|
||||
}
|
||||
} else {
|
||||
const fileContent = fs.readFileSync(filePath, 'utf8');
|
||||
const { data } = matter(fileContent);
|
||||
|
||||
return {
|
||||
slug: fileSlug,
|
||||
frontmatter: data as ProductFrontmatter,
|
||||
};
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
export async function getProductBySlug(slug: string, locale: string): Promise<ProductMdx | null> {
|
||||
// Map translated slug to file slug
|
||||
const fileSlug = await mapSlugToFileSlug(slug, locale);
|
||||
const productsDir = path.join(process.cwd(), 'data', 'products', locale);
|
||||
|
||||
|
||||
// Try exact slug first
|
||||
let filePath = path.join(productsDir, `${fileSlug}.mdx`);
|
||||
|
||||
@@ -41,7 +91,7 @@ export async function getProductBySlug(slug: string, locale: string): Promise<Pr
|
||||
if (!fs.existsSync(enFilePath)) {
|
||||
enFilePath = path.join(enProductsDir, `${fileSlug}-2.mdx`);
|
||||
}
|
||||
|
||||
|
||||
if (fs.existsSync(enFilePath)) {
|
||||
const fileContent = fs.readFileSync(enFilePath, 'utf8');
|
||||
const { data, content } = matter(fileContent);
|
||||
@@ -49,7 +99,7 @@ export async function getProductBySlug(slug: string, locale: string): Promise<Pr
|
||||
slug: fileSlug,
|
||||
frontmatter: {
|
||||
...data,
|
||||
isFallback: true
|
||||
isFallback: true,
|
||||
} as ProductFrontmatter & { isFallback?: boolean },
|
||||
content,
|
||||
};
|
||||
@@ -67,7 +117,12 @@ export async function getProductBySlug(slug: string, locale: string): Promise<Pr
|
||||
}
|
||||
|
||||
// Filter out products without images
|
||||
if (product && (!product.frontmatter.images || product.frontmatter.images.length === 0 || !product.frontmatter.images[0])) {
|
||||
if (
|
||||
product &&
|
||||
(!product.frontmatter.images ||
|
||||
product.frontmatter.images.length === 0 ||
|
||||
!product.frontmatter.images[0])
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
|
||||
@@ -77,9 +132,9 @@ export async function getProductBySlug(slug: string, locale: string): Promise<Pr
|
||||
export async function getAllProductSlugs(locale: string): Promise<string[]> {
|
||||
const productsDir = path.join(process.cwd(), 'data', 'products', locale);
|
||||
if (!fs.existsSync(productsDir)) return [];
|
||||
|
||||
|
||||
const files = fs.readdirSync(productsDir);
|
||||
return files.filter(file => file.endsWith('.mdx')).map(file => file.replace(/\.mdx$/, ''));
|
||||
return files.filter((file) => file.endsWith('.mdx')).map((file) => file.replace(/\.mdx$/, ''));
|
||||
}
|
||||
|
||||
export async function getAllProducts(locale: string): Promise<ProductMdx[]> {
|
||||
@@ -91,6 +146,19 @@ export async function getAllProducts(locale: string): Promise<ProductMdx[]> {
|
||||
allSlugs = Array.from(new Set([...slugs, ...enSlugs]));
|
||||
}
|
||||
|
||||
const products = await Promise.all(allSlugs.map(slug => getProductBySlug(slug, locale)));
|
||||
const products = await Promise.all(allSlugs.map((slug) => getProductBySlug(slug, locale)));
|
||||
return products.filter((p): p is ProductMdx => p !== null);
|
||||
}
|
||||
|
||||
export async function getAllProductsMetadata(locale: string): Promise<Partial<ProductMdx>[]> {
|
||||
const slugs = await getAllProductSlugs(locale);
|
||||
let allSlugs = slugs;
|
||||
|
||||
if (locale !== 'en') {
|
||||
const enSlugs = await getAllProductSlugs('en');
|
||||
allSlugs = Array.from(new Set([...slugs, ...enSlugs]));
|
||||
}
|
||||
|
||||
const metadata = await Promise.all(allSlugs.map((slug) => getProductMetadata(slug, locale)));
|
||||
return metadata.filter((m): m is Partial<ProductMdx> => m !== null);
|
||||
}
|
||||
|
||||
@@ -2,9 +2,10 @@ import { Metadata } from 'next';
|
||||
import { SITE_URL } from './schema';
|
||||
|
||||
export function getOGImageMetadata(path: string, title: string, locale: string): NonNullable<Metadata['openGraph']>['images'] {
|
||||
const cleanPath = path ? (path.startsWith('/') ? path : `/${path}`) : '';
|
||||
return [
|
||||
{
|
||||
url: `${SITE_URL}/${locale}/${path}/opengraph-image`,
|
||||
url: `${SITE_URL}/${locale}${cleanPath}/opengraph-image`,
|
||||
width: 1200,
|
||||
height: 630,
|
||||
alt: title,
|
||||
|
||||
42
lib/og-helper.tsx
Normal file
42
lib/og-helper.tsx
Normal file
@@ -0,0 +1,42 @@
|
||||
import { readFileSync } from 'fs';
|
||||
import { join } from 'path';
|
||||
|
||||
/**
|
||||
* Loads the Inter fonts for use in Satori (Next.js OG Image generation).
|
||||
* Since we are using runtime = 'nodejs', we can read them from the filesystem.
|
||||
*/
|
||||
export async function getOgFonts() {
|
||||
const boldFontPath = join(process.cwd(), 'public/fonts/Inter-Bold.ttf');
|
||||
const regularFontPath = join(process.cwd(), 'public/fonts/Inter-Regular.ttf');
|
||||
|
||||
try {
|
||||
const boldFont = readFileSync(boldFontPath);
|
||||
const regularFont = readFileSync(regularFontPath);
|
||||
|
||||
return [
|
||||
{
|
||||
name: 'Inter',
|
||||
data: boldFont,
|
||||
weight: 700 as const,
|
||||
style: 'normal' as const,
|
||||
},
|
||||
{
|
||||
name: 'Inter',
|
||||
data: regularFont,
|
||||
weight: 400 as const,
|
||||
style: 'normal' as const,
|
||||
},
|
||||
];
|
||||
} catch (error) {
|
||||
console.error('Failed to load OG fonts from filesystem, falling back to system fonts:', error);
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Common configuration for OG images
|
||||
*/
|
||||
export const OG_IMAGE_SIZE = {
|
||||
width: 1200,
|
||||
height: 630,
|
||||
};
|
||||
31
lib/pages.ts
31
lib/pages.ts
@@ -39,23 +39,42 @@ export async function getPageBySlug(slug: string, locale: string): Promise<PageM
|
||||
export async function getAllPages(locale: string): Promise<PageMdx[]> {
|
||||
const pagesDir = path.join(process.cwd(), 'data', 'pages', locale);
|
||||
if (!fs.existsSync(pagesDir)) return [];
|
||||
|
||||
|
||||
const files = fs.readdirSync(pagesDir);
|
||||
const pages = await Promise.all(
|
||||
files
|
||||
.filter(file => file.endsWith('.mdx'))
|
||||
.map(file => {
|
||||
.filter((file) => file.endsWith('.mdx'))
|
||||
.map((file) => {
|
||||
const fileSlug = file.replace(/\.mdx$/, '');
|
||||
const filePath = path.join(pagesDir, file);
|
||||
const fileContent = { content: fs.readFileSync(filePath, 'utf8') };
|
||||
const { data, content } = matter(fileContent.content);
|
||||
const fileContent = fs.readFileSync(filePath, 'utf8');
|
||||
const { data, content } = matter(fileContent);
|
||||
return {
|
||||
slug: fileSlug,
|
||||
frontmatter: data as PageFrontmatter,
|
||||
content,
|
||||
};
|
||||
})
|
||||
}),
|
||||
);
|
||||
|
||||
return pages.filter((p): p is PageMdx => p !== null);
|
||||
}
|
||||
|
||||
export async function getAllPagesMetadata(locale: string): Promise<Partial<PageMdx>[]> {
|
||||
const pagesDir = path.join(process.cwd(), 'data', 'pages', locale);
|
||||
if (!fs.existsSync(pagesDir)) return [];
|
||||
|
||||
const files = fs.readdirSync(pagesDir);
|
||||
return files
|
||||
.filter((file) => file.endsWith('.mdx'))
|
||||
.map((file) => {
|
||||
const fileSlug = file.replace(/\.mdx$/, '');
|
||||
const filePath = path.join(pagesDir, file);
|
||||
const fileContent = fs.readFileSync(filePath, 'utf8');
|
||||
const { data } = matter(fileContent);
|
||||
return {
|
||||
slug: fileSlug,
|
||||
frontmatter: data as PageFrontmatter,
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
@@ -21,234 +21,149 @@ Font.register({
|
||||
// Industrial/technical/restrained design - STYLEGUIDE.md compliant
|
||||
const styles = StyleSheet.create({
|
||||
page: {
|
||||
// Large margins for engineering documentation feel.
|
||||
// Extra bottom padding reserves space for the fixed footer so content
|
||||
// (esp. long descriptions) doesn't render underneath it.
|
||||
paddingTop: 72,
|
||||
paddingLeft: 72,
|
||||
paddingRight: 72,
|
||||
paddingBottom: 140,
|
||||
color: '#111827', // Text Primary
|
||||
lineHeight: 1.5,
|
||||
backgroundColor: '#FFFFFF',
|
||||
paddingTop: 0,
|
||||
paddingBottom: 100,
|
||||
fontFamily: 'Helvetica',
|
||||
fontSize: 10,
|
||||
color: '#1F2933', // Dark gray text
|
||||
lineHeight: 1.5, // Generous line height
|
||||
backgroundColor: '#F8F9FA', // Almost white background
|
||||
},
|
||||
|
||||
// Engineering documentation header
|
||||
// Hero-style header
|
||||
hero: {
|
||||
backgroundColor: '#FFFFFF',
|
||||
paddingTop: 24,
|
||||
paddingBottom: 0,
|
||||
paddingHorizontal: 72,
|
||||
marginBottom: 20,
|
||||
position: 'relative',
|
||||
borderBottomWidth: 0,
|
||||
borderBottomColor: '#e5e7eb',
|
||||
},
|
||||
|
||||
header: {
|
||||
flexDirection: 'row',
|
||||
justifyContent: 'space-between',
|
||||
alignItems: 'flex-start',
|
||||
marginBottom: 48, // Large spacing
|
||||
paddingBottom: 24,
|
||||
borderBottom: '2px solid #E6E9ED', // Light gray separator
|
||||
},
|
||||
|
||||
// Logo area - industrial style
|
||||
logoArea: {
|
||||
flexDirection: 'column',
|
||||
alignItems: 'flex-start',
|
||||
},
|
||||
|
||||
// Optional image logo container (keeps header height stable)
|
||||
logoContainer: {
|
||||
width: 120,
|
||||
height: 32,
|
||||
justifyContent: 'center',
|
||||
},
|
||||
|
||||
// Image logo (preferred when available)
|
||||
logo: {
|
||||
width: 110,
|
||||
height: 28,
|
||||
objectFit: 'contain',
|
||||
alignItems: 'center',
|
||||
marginBottom: 16,
|
||||
},
|
||||
|
||||
logoText: {
|
||||
fontSize: 20,
|
||||
fontSize: 24,
|
||||
fontWeight: 700,
|
||||
color: '#0E2A47', // Dark navy
|
||||
color: '#000d26',
|
||||
letterSpacing: 1,
|
||||
textTransform: 'uppercase',
|
||||
},
|
||||
|
||||
logoSubtext: {
|
||||
fontSize: 10,
|
||||
fontWeight: 400,
|
||||
color: '#6B7280', // Medium gray
|
||||
letterSpacing: 0.5,
|
||||
marginTop: 2,
|
||||
},
|
||||
|
||||
// Document info - technical style
|
||||
docInfo: {
|
||||
textAlign: 'right',
|
||||
alignItems: 'flex-end',
|
||||
},
|
||||
|
||||
docTitle: {
|
||||
fontSize: 16,
|
||||
fontSize: 10,
|
||||
fontWeight: 700,
|
||||
color: '#0E2A47', // Dark navy
|
||||
marginBottom: 8,
|
||||
letterSpacing: 0.5,
|
||||
color: '#001a4d',
|
||||
letterSpacing: 2,
|
||||
textTransform: 'uppercase',
|
||||
},
|
||||
|
||||
skuContainer: {
|
||||
backgroundColor: '#E6E9ED', // Light gray background
|
||||
paddingHorizontal: 16,
|
||||
paddingVertical: 8,
|
||||
border: '1px solid #E6E9ED',
|
||||
productRow: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
gap: 20,
|
||||
},
|
||||
productInfoCol: {
|
||||
flex: 1,
|
||||
justifyContent: 'center',
|
||||
},
|
||||
productImageCol: {
|
||||
flex: 1,
|
||||
height: 120,
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
borderRadius: 8,
|
||||
borderWidth: 1,
|
||||
borderColor: '#e5e7eb',
|
||||
backgroundColor: '#FFFFFF',
|
||||
overflow: 'hidden',
|
||||
},
|
||||
|
||||
skuLabel: {
|
||||
fontSize: 8,
|
||||
color: '#6B7280', // Medium gray
|
||||
textTransform: 'uppercase',
|
||||
letterSpacing: 0.5,
|
||||
marginBottom: 4,
|
||||
},
|
||||
|
||||
skuValue: {
|
||||
fontSize: 14,
|
||||
fontWeight: 700,
|
||||
color: '#0E2A47', // Dark navy
|
||||
},
|
||||
|
||||
// Product section - technical specification style
|
||||
productSection: {
|
||||
marginBottom: 40,
|
||||
backgroundColor: '#FFFFFF', // White background for content blocks
|
||||
padding: 24,
|
||||
border: '1px solid #E6E9ED',
|
||||
// Product Hero Info
|
||||
productHero: {
|
||||
marginTop: 0,
|
||||
},
|
||||
|
||||
productName: {
|
||||
fontSize: 24,
|
||||
fontWeight: 700,
|
||||
color: '#0E2A47', // Dark navy
|
||||
marginBottom: 12,
|
||||
lineHeight: 1.2,
|
||||
color: '#000d26',
|
||||
marginBottom: 0,
|
||||
textTransform: 'uppercase',
|
||||
letterSpacing: 0.5,
|
||||
letterSpacing: -0.5,
|
||||
},
|
||||
|
||||
productMeta: {
|
||||
fontSize: 12,
|
||||
color: '#6B7280', // Medium gray
|
||||
fontWeight: 500,
|
||||
fontSize: 10,
|
||||
color: '#4b5563',
|
||||
fontWeight: 700,
|
||||
textTransform: 'uppercase',
|
||||
letterSpacing: 1,
|
||||
},
|
||||
|
||||
// Content sections - rectangular blocks
|
||||
heroImage: {
|
||||
width: '100%',
|
||||
height: '100%',
|
||||
objectFit: 'contain',
|
||||
},
|
||||
|
||||
noImage: {
|
||||
fontSize: 8,
|
||||
color: '#9ca3af',
|
||||
textAlign: 'center',
|
||||
},
|
||||
|
||||
// Content Area
|
||||
content: {
|
||||
paddingHorizontal: 72,
|
||||
},
|
||||
|
||||
// Content sections
|
||||
section: {
|
||||
marginBottom: 32,
|
||||
backgroundColor: '#FFFFFF',
|
||||
padding: 24,
|
||||
border: '1px solid #E6E9ED',
|
||||
marginBottom: 20,
|
||||
},
|
||||
|
||||
sectionTitle: {
|
||||
fontSize: 14,
|
||||
fontWeight: 700,
|
||||
color: '#0E2A47', // Dark navy
|
||||
marginBottom: 16,
|
||||
letterSpacing: 0.5,
|
||||
color: '#000d26', // Primary Dark
|
||||
marginBottom: 8,
|
||||
textTransform: 'uppercase',
|
||||
borderBottom: '1px solid #E6E9ED',
|
||||
paddingBottom: 8,
|
||||
letterSpacing: -0.2,
|
||||
},
|
||||
|
||||
sectionAccent: {
|
||||
width: 30,
|
||||
height: 3,
|
||||
backgroundColor: '#82ed20', // Accent Green
|
||||
marginBottom: 8,
|
||||
borderRadius: 1.5,
|
||||
},
|
||||
|
||||
// Description - technical documentation style
|
||||
description: {
|
||||
fontSize: 10,
|
||||
lineHeight: 1.6,
|
||||
color: '#1F2933', // Dark gray text
|
||||
marginBottom: 0,
|
||||
fontSize: 11,
|
||||
lineHeight: 1.7,
|
||||
color: '#4b5563', // Text Secondary
|
||||
},
|
||||
|
||||
// Cross-section table - engineering specification style
|
||||
table: {
|
||||
marginTop: 16,
|
||||
borderWidth: 1,
|
||||
borderColor: '#E6E9ED',
|
||||
},
|
||||
|
||||
tableHeader: {
|
||||
flexDirection: 'row',
|
||||
backgroundColor: '#E6E9ED',
|
||||
borderBottomWidth: 1,
|
||||
borderBottomColor: '#E6E9ED',
|
||||
},
|
||||
|
||||
tableHeaderCell: {
|
||||
flex: 1,
|
||||
padding: 8,
|
||||
fontSize: 10,
|
||||
fontWeight: 700,
|
||||
color: '#0E2A47',
|
||||
textTransform: 'uppercase',
|
||||
letterSpacing: 0.3,
|
||||
},
|
||||
|
||||
tableHeaderCellLast: {
|
||||
borderRightWidth: 0,
|
||||
},
|
||||
|
||||
tableHeaderCellWithDivider: {
|
||||
borderRightWidth: 1,
|
||||
borderRightColor: '#E6E9ED',
|
||||
},
|
||||
|
||||
tableRow: {
|
||||
flexDirection: 'row',
|
||||
borderBottomWidth: 1,
|
||||
borderBottomColor: '#E6E9ED',
|
||||
},
|
||||
|
||||
tableCell: {
|
||||
flex: 1,
|
||||
padding: 8,
|
||||
fontSize: 10,
|
||||
color: '#1F2933',
|
||||
},
|
||||
|
||||
tableCellLast: {
|
||||
borderRightWidth: 0,
|
||||
},
|
||||
|
||||
tableCellWithDivider: {
|
||||
borderRightWidth: 1,
|
||||
borderRightColor: '#E6E9ED',
|
||||
},
|
||||
|
||||
tableRowAlt: {
|
||||
backgroundColor: '#F8F9FA',
|
||||
},
|
||||
|
||||
// Specifications - technical data style
|
||||
specsContainer: {
|
||||
flexDirection: 'row',
|
||||
flexWrap: 'wrap',
|
||||
},
|
||||
|
||||
// Backwards-compatible alias used by the component markup
|
||||
specsGrid: {
|
||||
flexDirection: 'row',
|
||||
flexWrap: 'wrap',
|
||||
},
|
||||
|
||||
// Technical data table (used for the metagrid)
|
||||
// Technical data table
|
||||
specsTable: {
|
||||
borderWidth: 1,
|
||||
borderColor: '#E6E9ED',
|
||||
marginTop: 8,
|
||||
border: '1px solid #e5e7eb',
|
||||
borderRadius: 8,
|
||||
overflow: 'hidden',
|
||||
},
|
||||
|
||||
specsTableRow: {
|
||||
flexDirection: 'row',
|
||||
borderBottomWidth: 1,
|
||||
borderBottomColor: '#E6E9ED',
|
||||
borderBottomColor: '#e5e7eb',
|
||||
},
|
||||
|
||||
specsTableRowLast: {
|
||||
@@ -256,63 +171,35 @@ const styles = StyleSheet.create({
|
||||
},
|
||||
|
||||
specsTableLabelCell: {
|
||||
flex: 3,
|
||||
paddingVertical: 8,
|
||||
paddingHorizontal: 8,
|
||||
backgroundColor: '#F8F9FA',
|
||||
flex: 1,
|
||||
paddingVertical: 4,
|
||||
paddingHorizontal: 16,
|
||||
backgroundColor: '#f8f9fa',
|
||||
borderRightWidth: 1,
|
||||
borderRightColor: '#E6E9ED',
|
||||
justifyContent: 'center',
|
||||
borderRightColor: '#e5e7eb',
|
||||
},
|
||||
|
||||
specsTableValueCell: {
|
||||
flex: 4,
|
||||
paddingVertical: 8,
|
||||
paddingHorizontal: 8,
|
||||
justifyContent: 'center',
|
||||
flex: 1,
|
||||
paddingVertical: 4,
|
||||
paddingHorizontal: 16,
|
||||
},
|
||||
|
||||
specsTableLabelText: {
|
||||
fontSize: 9,
|
||||
fontWeight: 700,
|
||||
color: '#0E2A47',
|
||||
color: '#000d26',
|
||||
textTransform: 'uppercase',
|
||||
letterSpacing: 0.3,
|
||||
lineHeight: 1.2,
|
||||
letterSpacing: 0.5,
|
||||
},
|
||||
|
||||
specsTableValueText: {
|
||||
fontSize: 10,
|
||||
color: '#1F2933',
|
||||
lineHeight: 1.4,
|
||||
color: '#111827',
|
||||
fontWeight: 500,
|
||||
},
|
||||
|
||||
specColumn: {
|
||||
width: '48%',
|
||||
marginRight: '4%',
|
||||
marginBottom: 16,
|
||||
},
|
||||
|
||||
specItem: {
|
||||
marginBottom: 12,
|
||||
},
|
||||
|
||||
specLabel: {
|
||||
fontSize: 9,
|
||||
fontWeight: 700,
|
||||
color: '#0E2A47',
|
||||
marginBottom: 4,
|
||||
textTransform: 'uppercase',
|
||||
letterSpacing: 0.3,
|
||||
},
|
||||
|
||||
specValue: {
|
||||
fontSize: 10,
|
||||
color: '#1F2933',
|
||||
lineHeight: 1.4,
|
||||
},
|
||||
|
||||
// Categories - technical classification
|
||||
// Categories
|
||||
categories: {
|
||||
flexDirection: 'row',
|
||||
flexWrap: 'wrap',
|
||||
@@ -320,42 +207,48 @@ const styles = StyleSheet.create({
|
||||
},
|
||||
|
||||
categoryTag: {
|
||||
backgroundColor: '#E6E9ED',
|
||||
backgroundColor: '#f8f9fa',
|
||||
paddingHorizontal: 12,
|
||||
paddingVertical: 6,
|
||||
border: '1px solid #E6E9ED',
|
||||
border: '1px solid #e5e7eb',
|
||||
borderRadius: 100,
|
||||
},
|
||||
|
||||
categoryText: {
|
||||
fontSize: 9,
|
||||
color: '#6B7280',
|
||||
fontWeight: 500,
|
||||
fontSize: 8,
|
||||
color: '#4b5563',
|
||||
fontWeight: 700,
|
||||
textTransform: 'uppercase',
|
||||
letterSpacing: 0.3,
|
||||
letterSpacing: 0.5,
|
||||
},
|
||||
|
||||
// Engineering documentation footer
|
||||
// Footer
|
||||
footer: {
|
||||
position: 'absolute',
|
||||
bottom: 48,
|
||||
bottom: 40,
|
||||
left: 72,
|
||||
right: 72,
|
||||
flexDirection: 'row',
|
||||
justifyContent: 'space-between',
|
||||
alignItems: 'center',
|
||||
paddingTop: 24,
|
||||
borderTop: '2px solid #E6E9ED',
|
||||
fontSize: 9,
|
||||
color: '#6B7280',
|
||||
borderTop: '1px solid #e5e7eb',
|
||||
},
|
||||
|
||||
footerLeft: {
|
||||
footerText: {
|
||||
fontSize: 8,
|
||||
color: '#9ca3af',
|
||||
fontWeight: 500,
|
||||
textTransform: 'uppercase',
|
||||
letterSpacing: 1,
|
||||
},
|
||||
|
||||
footerBrand: {
|
||||
fontSize: 10,
|
||||
fontWeight: 700,
|
||||
color: '#0E2A47',
|
||||
},
|
||||
|
||||
footerRight: {
|
||||
color: '#6B7280',
|
||||
color: '#000d26',
|
||||
textTransform: 'uppercase',
|
||||
letterSpacing: 1,
|
||||
},
|
||||
});
|
||||
|
||||
@@ -364,6 +257,7 @@ interface ProductData {
|
||||
name: string;
|
||||
shortDescriptionHtml: string;
|
||||
descriptionHtml: string;
|
||||
applicationHtml?: string;
|
||||
images: string[];
|
||||
featuredImage: string | null;
|
||||
sku: string;
|
||||
@@ -389,19 +283,19 @@ const stripHtml = (html: string): string => {
|
||||
const getLabels = (locale: 'en' | 'de') => {
|
||||
const labels = {
|
||||
en: {
|
||||
productDatasheet: 'Product Datasheet',
|
||||
description: 'Description',
|
||||
specifications: 'Technical Specifications',
|
||||
categories: 'Categories',
|
||||
productDatasheet: 'Technical Datasheet',
|
||||
description: 'APPLICATION',
|
||||
specifications: 'TECHNICAL DATA',
|
||||
categories: 'CATEGORIES',
|
||||
sku: 'SKU',
|
||||
noImage: 'No image available',
|
||||
},
|
||||
de: {
|
||||
productDatasheet: 'Produktdatenblatt',
|
||||
description: 'Beschreibung',
|
||||
specifications: 'Technische Spezifikationen',
|
||||
categories: 'Kategorien',
|
||||
sku: 'Artikelnummer',
|
||||
productDatasheet: 'Technisches Datenblatt',
|
||||
description: 'ANWENDUNG',
|
||||
specifications: 'TECHNISCHE DATEN',
|
||||
categories: 'KATEGORIEN',
|
||||
sku: 'ARTIKELNUMMER',
|
||||
noImage: 'Kein Bild verfügbar',
|
||||
},
|
||||
};
|
||||
@@ -411,106 +305,111 @@ const getLabels = (locale: 'en' | 'de') => {
|
||||
export const PDFDatasheet: React.FC<PDFDatasheetProps> = ({
|
||||
product,
|
||||
locale,
|
||||
logoUrl = '/media/logo.svg',
|
||||
}) => {
|
||||
const labels = getLabels(locale);
|
||||
|
||||
return (
|
||||
<Document>
|
||||
<Page size="A4" style={styles.page}>
|
||||
{/* Clean, minimal header */}
|
||||
<View style={styles.header}>
|
||||
<View style={styles.logoArea}>
|
||||
<View style={styles.logoContainer}>
|
||||
{logoUrl ? (
|
||||
/* eslint-disable-next-line jsx-a11y/alt-text */
|
||||
<Image src={logoUrl} style={styles.logo} />
|
||||
) : (
|
||||
<View>
|
||||
<Text style={styles.logoText}>KLZ</Text>
|
||||
<Text style={styles.logoSubtext}>Cables</Text>
|
||||
{/* Hero Header */}
|
||||
<View style={styles.hero}>
|
||||
<View style={styles.header}>
|
||||
<View>
|
||||
<Text style={styles.logoText}>KLZ</Text>
|
||||
</View>
|
||||
<Text style={styles.docTitle}>
|
||||
{labels.productDatasheet}
|
||||
</Text>
|
||||
</View>
|
||||
|
||||
<View style={styles.productRow}>
|
||||
<View style={styles.productInfoCol}>
|
||||
<View style={styles.productHero}>
|
||||
<View style={styles.categories}>
|
||||
{product.categories.map((cat, index) => (
|
||||
<Text key={index} style={styles.productMeta}>
|
||||
{cat.name}{index < product.categories.length - 1 ? ' • ' : ''}
|
||||
</Text>
|
||||
))}
|
||||
</View>
|
||||
<Text style={styles.productName}>{product.name}</Text>
|
||||
</View>
|
||||
</View>
|
||||
<View style={styles.productImageCol}>
|
||||
{product.featuredImage ? (
|
||||
<Image
|
||||
src={product.featuredImage}
|
||||
style={styles.heroImage}
|
||||
/>
|
||||
) : (
|
||||
|
||||
<Text style={styles.noImage}>{labels.noImage}</Text>
|
||||
)}
|
||||
</View>
|
||||
</View>
|
||||
</View>
|
||||
|
||||
<View style={styles.docInfo}>
|
||||
<Text style={styles.docTitle}>
|
||||
{locale === 'en' ? 'Product Datasheet' : 'Produktdatenblatt'}
|
||||
</Text>
|
||||
<View style={styles.skuContainer}>
|
||||
<Text style={styles.skuLabel}>{labels.sku}</Text>
|
||||
<Text style={styles.skuValue}>{product.sku}</Text>
|
||||
<View style={styles.content}>
|
||||
{/* Description section */}
|
||||
{(product.applicationHtml || product.shortDescriptionHtml || product.descriptionHtml) && (
|
||||
<View style={styles.section}>
|
||||
<Text style={styles.sectionTitle}>{labels.description}</Text>
|
||||
<View style={styles.sectionAccent} />
|
||||
<Text style={styles.description}>
|
||||
{stripHtml(product.applicationHtml || product.shortDescriptionHtml || product.descriptionHtml)}
|
||||
</Text>
|
||||
</View>
|
||||
</View>
|
||||
</View>
|
||||
)}
|
||||
|
||||
{/* Product section - clean and prominent */}
|
||||
<View style={styles.productSection}>
|
||||
<Text style={styles.productName}>{product.name}</Text>
|
||||
<Text style={styles.productMeta}>
|
||||
{product.categories.map(cat => cat.name).join(' • ')}
|
||||
</Text>
|
||||
</View>
|
||||
|
||||
{/* Description section */}
|
||||
{(product.shortDescriptionHtml || product.descriptionHtml) && (
|
||||
<View style={styles.section}>
|
||||
<Text style={styles.sectionTitle}>{labels.description}</Text>
|
||||
<Text style={styles.description}>
|
||||
{stripHtml(product.shortDescriptionHtml || product.descriptionHtml)}
|
||||
</Text>
|
||||
</View>
|
||||
)}
|
||||
|
||||
{/* Technical specifications */}
|
||||
{product.attributes && product.attributes.length > 0 && (
|
||||
<View style={styles.section}>
|
||||
<Text style={styles.sectionTitle}>{labels.specifications}</Text>
|
||||
<View style={styles.specsTable}>
|
||||
{product.attributes.map((attr, index) => (
|
||||
<View
|
||||
key={index}
|
||||
style={[
|
||||
styles.specsTableRow,
|
||||
index === product.attributes.length - 1 &&
|
||||
{/* Technical specifications */}
|
||||
{product.attributes && product.attributes.length > 0 && (
|
||||
<View style={styles.section}>
|
||||
<Text style={styles.sectionTitle}>{labels.specifications}</Text>
|
||||
<View style={styles.sectionAccent} />
|
||||
<View style={styles.specsTable}>
|
||||
{product.attributes.map((attr, index) => (
|
||||
<View
|
||||
key={index}
|
||||
style={[
|
||||
styles.specsTableRow,
|
||||
index === product.attributes.length - 1 &&
|
||||
styles.specsTableRowLast,
|
||||
]}
|
||||
>
|
||||
<View style={styles.specsTableLabelCell}>
|
||||
<Text style={styles.specsTableLabelText}>{attr.name}</Text>
|
||||
]}
|
||||
>
|
||||
<View style={styles.specsTableLabelCell}>
|
||||
<Text style={styles.specsTableLabelText}>{attr.name}</Text>
|
||||
</View>
|
||||
<View style={styles.specsTableValueCell}>
|
||||
<Text style={styles.specsTableValueText}>
|
||||
{attr.options.join(', ')}
|
||||
</Text>
|
||||
</View>
|
||||
</View>
|
||||
<View style={styles.specsTableValueCell}>
|
||||
<Text style={styles.specsTableValueText}>
|
||||
{attr.options.join(', ')}
|
||||
</Text>
|
||||
</View>
|
||||
</View>
|
||||
))}
|
||||
))}
|
||||
</View>
|
||||
</View>
|
||||
</View>
|
||||
)}
|
||||
)}
|
||||
|
||||
{/* Categories as clean tags */}
|
||||
{product.categories && product.categories.length > 0 && (
|
||||
<View style={styles.section}>
|
||||
<Text style={styles.sectionTitle}>{labels.categories}</Text>
|
||||
<View style={styles.categories}>
|
||||
{product.categories.map((cat, index) => (
|
||||
<View key={index} style={styles.categoryTag}>
|
||||
<Text style={styles.categoryText}>{cat.name}</Text>
|
||||
</View>
|
||||
))}
|
||||
{/* Categories as clean tags */}
|
||||
{product.categories && product.categories.length > 0 && (
|
||||
<View style={styles.section}>
|
||||
<Text style={styles.sectionTitle}>{labels.categories}</Text>
|
||||
<View style={styles.sectionAccent} />
|
||||
<View style={styles.categories}>
|
||||
{product.categories.map((cat, index) => (
|
||||
<View key={index} style={styles.categoryTag}>
|
||||
<Text style={styles.categoryText}>{cat.name}</Text>
|
||||
</View>
|
||||
))}
|
||||
</View>
|
||||
</View>
|
||||
</View>
|
||||
)}
|
||||
)}
|
||||
</View>
|
||||
|
||||
{/* Minimal footer */}
|
||||
<View style={styles.footer} fixed>
|
||||
<Text style={styles.footerLeft}>
|
||||
{labels.sku}: {product.sku}
|
||||
</Text>
|
||||
<Text style={styles.footerRight}>
|
||||
<Text style={styles.footerBrand}>KLZ CABLES</Text>
|
||||
<Text style={styles.footerText}>
|
||||
{new Date().toLocaleDateString(locale === 'en' ? 'en-US' : 'de-DE', {
|
||||
year: 'numeric',
|
||||
month: 'long',
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { config } from './config';
|
||||
|
||||
export const SITE_URL = 'https://klz-cables.com';
|
||||
export const SITE_URL = config.baseUrl || 'https://klz-cables.com';
|
||||
export const LOGO_URL = `${SITE_URL}/logo.png`;
|
||||
|
||||
export const getOrganizationSchema = () => ({
|
||||
@@ -8,16 +9,14 @@ export const getOrganizationSchema = () => ({
|
||||
name: 'KLZ Cables',
|
||||
url: SITE_URL,
|
||||
logo: LOGO_URL,
|
||||
sameAs: [
|
||||
'https://www.linkedin.com/company/klz-cables',
|
||||
],
|
||||
sameAs: ['https://www.linkedin.com/company/klz-cables'],
|
||||
contactPoint: {
|
||||
'@type': 'ContactPoint' as const,
|
||||
telephone: '+49-881-92537298',
|
||||
contactType: 'customer service' as const,
|
||||
email: 'info@klz-cables.com',
|
||||
availableLanguage: ['German', 'English']
|
||||
}
|
||||
availableLanguage: ['German', 'English'],
|
||||
},
|
||||
});
|
||||
|
||||
export const getBreadcrumbSchema = (items: { name: string; item: string }[]) => ({
|
||||
|
||||
@@ -1,14 +1,6 @@
|
||||
import type { AnalyticsEventProperties, AnalyticsService } from './analytics-service';
|
||||
|
||||
/**
|
||||
* Type definition for the Umami global object.
|
||||
*
|
||||
* This represents the `window.umami` object that the Umami script exposes.
|
||||
* The `track` function can accept either an event name or a URL.
|
||||
*/
|
||||
type UmamiGlobal = {
|
||||
track?: (eventOrUrl: string, props?: AnalyticsEventProperties) => void;
|
||||
};
|
||||
import { config } from '../../config';
|
||||
import type { LoggerService } from '../logging/logger-service';
|
||||
|
||||
/**
|
||||
* Configuration options for UmamiAnalyticsService.
|
||||
@@ -20,133 +12,162 @@ export type UmamiAnalyticsServiceOptions = {
|
||||
};
|
||||
|
||||
/**
|
||||
* Umami Analytics Service Implementation.
|
||||
* Umami Analytics Service Implementation (Script-less/Proxy edition).
|
||||
*
|
||||
* This service implements the AnalyticsService interface for Umami analytics.
|
||||
* It provides type-safe event tracking and pageview tracking.
|
||||
* This version implements the Umami tracking protocol directly via fetch,
|
||||
* eliminating the need to load an external script.js file.
|
||||
*
|
||||
* @example
|
||||
* ```typescript
|
||||
* // Service creation (usually done by create-services.ts)
|
||||
* const service = new UmamiAnalyticsService({ enabled: true });
|
||||
*
|
||||
* // Track events
|
||||
* service.track('button_click', { button_id: 'cta' });
|
||||
* service.trackPageview('/products/123');
|
||||
* ```
|
||||
*
|
||||
* @example
|
||||
* ```typescript
|
||||
* // Using through the service layer (recommended)
|
||||
* import { getAppServices } from '@/lib/services/create-services';
|
||||
*
|
||||
* const services = getAppServices();
|
||||
* services.analytics.track('product_add_to_cart', {
|
||||
* product_id: '123',
|
||||
* price: 99.99,
|
||||
* });
|
||||
* ```
|
||||
* In the browser, it gathers standard metadata (screen, language, referrer)
|
||||
* and sends it to the proxied '/stats/api/send' endpoint.
|
||||
* On the server, it sends directly to the internal Umami API.
|
||||
*/
|
||||
export class UmamiAnalyticsService implements AnalyticsService {
|
||||
constructor(private readonly options: UmamiAnalyticsServiceOptions) {}
|
||||
private websiteId?: string;
|
||||
private endpoint: string;
|
||||
private logger: LoggerService;
|
||||
private serverContext?: {
|
||||
userAgent?: string;
|
||||
language?: string;
|
||||
referrer?: string;
|
||||
ip?: string;
|
||||
};
|
||||
|
||||
constructor(
|
||||
private readonly options: UmamiAnalyticsServiceOptions,
|
||||
logger: LoggerService,
|
||||
) {
|
||||
this.websiteId = config.analytics.umami.websiteId;
|
||||
this.logger = logger.child({ component: 'analytics-umami' });
|
||||
|
||||
// On server, use the full internal URL; on client, use the proxied path
|
||||
this.endpoint = typeof window === 'undefined' ? config.analytics.umami.apiEndpoint : '/stats';
|
||||
|
||||
this.logger.debug('Umami service initialized', {
|
||||
enabled: this.options.enabled,
|
||||
websiteId: this.websiteId ? 'configured' : 'not configured (client-side proxy mode)',
|
||||
endpoint: this.endpoint,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Track a custom event with optional properties.
|
||||
*
|
||||
* This method checks if analytics are enabled and if we're in a browser environment
|
||||
* before attempting to track the event.
|
||||
*
|
||||
* @param eventName - The name of the event to track
|
||||
* @param props - Optional event properties
|
||||
*
|
||||
* @example
|
||||
* ```typescript
|
||||
* service.track('product_add_to_cart', {
|
||||
* product_id: '123',
|
||||
* product_name: 'Cable',
|
||||
* price: 99.99,
|
||||
* quantity: 1,
|
||||
* });
|
||||
* ```
|
||||
* Set the server-side context for the current request.
|
||||
* This allows the service to use real request headers for tracking.
|
||||
*/
|
||||
track(eventName: string, props?: AnalyticsEventProperties) {
|
||||
setServerContext(context: {
|
||||
userAgent?: string;
|
||||
language?: string;
|
||||
referrer?: string;
|
||||
ip?: string;
|
||||
}) {
|
||||
this.serverContext = context;
|
||||
}
|
||||
|
||||
/**
|
||||
* Internal method to send the payload to Umami API.
|
||||
*/
|
||||
private async sendPayload(type: 'event', data: Record<string, any>) {
|
||||
if (!this.options.enabled) return;
|
||||
|
||||
// Server-side tracking via proxy
|
||||
if (typeof window === 'undefined') {
|
||||
const { getServerAppServices } = require('../create-services.server');
|
||||
const { config } = require('../../config');
|
||||
const websiteId = config.analytics.umami.websiteId;
|
||||
const umamiUrl = config.analytics.umami.scriptUrl.replace('/script.js', '');
|
||||
|
||||
if (!websiteId) return;
|
||||
|
||||
const logger = getServerAppServices().logger.child({ component: 'analytics' });
|
||||
logger.info('Sending analytics event', { eventName, props });
|
||||
// On the client, we don't need the websiteId (it's injected by the server-side proxy handler).
|
||||
// On the server, we need it because we're calling the Umami API directly.
|
||||
const isClient = typeof window !== 'undefined';
|
||||
|
||||
fetch(`${umamiUrl}/api/send`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json', 'User-Agent': 'KLZ-Server' },
|
||||
body: JSON.stringify({ type: 'event', payload: { website: websiteId, name: eventName, data: props } }),
|
||||
}).catch((error) => {
|
||||
logger.error('Failed to send analytics event', { eventName, props, error });
|
||||
});
|
||||
if (!isClient && !this.websiteId) {
|
||||
this.logger.warn('Umami tracking called on server but no Website ID configured');
|
||||
return;
|
||||
}
|
||||
|
||||
const umami = (window as unknown as { umami?: UmamiGlobal }).umami;
|
||||
umami?.track?.(eventName, props);
|
||||
try {
|
||||
const payload = {
|
||||
website: this.websiteId,
|
||||
hostname: isClient ? window.location.hostname : 'server',
|
||||
screen: isClient ? `${window.screen.width}x${window.screen.height}` : undefined,
|
||||
language: isClient ? navigator.language : this.serverContext?.language,
|
||||
referrer: isClient ? document.referrer : this.serverContext?.referrer,
|
||||
...data,
|
||||
};
|
||||
|
||||
this.logger.trace('Sending analytics payload', { type, url: data.url });
|
||||
|
||||
// Add a timeout to prevent hanging requests
|
||||
const controller = new AbortController();
|
||||
const timeoutId = setTimeout(() => controller.abort(), 10000); // 10s timeout
|
||||
|
||||
const headers: Record<string, string> = {
|
||||
'Content-Type': 'application/json',
|
||||
};
|
||||
|
||||
// Set User-Agent
|
||||
if (isClient) {
|
||||
headers['User-Agent'] = navigator.userAgent;
|
||||
} else if (this.serverContext?.userAgent) {
|
||||
headers['User-Agent'] = this.serverContext.userAgent;
|
||||
} else {
|
||||
headers['User-Agent'] = 'KLZ-Server-Proxy';
|
||||
}
|
||||
|
||||
// Forward client IP if available (Umami must be configured to trust this)
|
||||
if (this.serverContext?.ip) {
|
||||
headers['X-Forwarded-For'] = this.serverContext.ip;
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await fetch(`${this.endpoint}/api/send`, {
|
||||
method: 'POST',
|
||||
headers,
|
||||
body: JSON.stringify({ type, payload }),
|
||||
keepalive: true,
|
||||
signal: controller.signal,
|
||||
} as any);
|
||||
|
||||
clearTimeout(timeoutId);
|
||||
|
||||
if (!response.ok) {
|
||||
const errorText = await response.text();
|
||||
this.logger.warn('Umami API responded with error', {
|
||||
status: response.status,
|
||||
error: errorText.slice(0, 100),
|
||||
});
|
||||
}
|
||||
} catch (fetchError) {
|
||||
clearTimeout(timeoutId);
|
||||
if ((fetchError as Error).name === 'AbortError') {
|
||||
this.logger.error('Umami request timed out');
|
||||
} else {
|
||||
throw fetchError;
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
this.logger.error('Failed to send analytics', {
|
||||
error: (error as Error).message,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Track a custom event.
|
||||
*/
|
||||
track(eventName: string, props?: AnalyticsEventProperties) {
|
||||
this.sendPayload('event', {
|
||||
name: eventName,
|
||||
data: props,
|
||||
url:
|
||||
typeof window !== 'undefined'
|
||||
? window.location.pathname + window.location.search
|
||||
: undefined,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Track a pageview.
|
||||
*
|
||||
* This method checks if analytics are enabled and if we're in a browser environment
|
||||
* before attempting to track the pageview.
|
||||
*
|
||||
* Umami treats `track(url)` as a pageview override, so we can use the same
|
||||
* `track` function for both events and pageviews.
|
||||
*
|
||||
* @param url - The URL to track (defaults to current location)
|
||||
*
|
||||
* @example
|
||||
* ```typescript
|
||||
* // Track current page
|
||||
* service.trackPageview();
|
||||
*
|
||||
* // Track custom URL
|
||||
* service.trackPageview('/products/123?category=cables');
|
||||
* ```
|
||||
*/
|
||||
trackPageview(url?: string) {
|
||||
if (!this.options.enabled) return;
|
||||
|
||||
// Server-side tracking via proxy
|
||||
if (typeof window === 'undefined') {
|
||||
const { getServerAppServices } = require('../create-services.server');
|
||||
const { config } = require('../../config');
|
||||
const websiteId = config.analytics.umami.websiteId;
|
||||
const umamiUrl = config.analytics.umami.scriptUrl.replace('/script.js', '');
|
||||
|
||||
if (!websiteId || !url) return;
|
||||
|
||||
const logger = getServerAppServices().logger.child({ component: 'analytics' });
|
||||
logger.info('Sending analytics pageview', { url });
|
||||
|
||||
fetch(`${umamiUrl}/api/send`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json', 'User-Agent': 'KLZ-Server' },
|
||||
body: JSON.stringify({ type: 'event', payload: { website: websiteId, url } }),
|
||||
}).catch((error) => {
|
||||
logger.error('Failed to send analytics pageview', { url, error });
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const umami = (window as unknown as { umami?: UmamiGlobal }).umami;
|
||||
|
||||
// Umami treats `track(url)` as a pageview override.
|
||||
if (url) umami?.track?.(url);
|
||||
else umami?.track?.(window.location.pathname + window.location.search);
|
||||
this.sendPayload('event', {
|
||||
url:
|
||||
url ||
|
||||
(typeof window !== 'undefined'
|
||||
? window.location.pathname + window.location.search
|
||||
: undefined),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,6 +2,7 @@ import type { AnalyticsService } from './analytics/analytics-service';
|
||||
import type { CacheService } from './cache/cache-service';
|
||||
import type { ErrorReportingService } from './errors/error-reporting-service';
|
||||
import type { LoggerService } from './logging/logger-service';
|
||||
import type { NotificationService } from './notifications/notification-service';
|
||||
|
||||
// Simple constructor-based DI container.
|
||||
export class AppServices {
|
||||
@@ -9,6 +10,7 @@ export class AppServices {
|
||||
public readonly analytics: AnalyticsService,
|
||||
public readonly errors: ErrorReportingService,
|
||||
public readonly cache: CacheService,
|
||||
public readonly logger: LoggerService
|
||||
public readonly logger: LoggerService,
|
||||
public readonly notifications: NotificationService,
|
||||
) {}
|
||||
}
|
||||
|
||||
@@ -4,6 +4,10 @@ import { UmamiAnalyticsService } from './analytics/umami-analytics-service';
|
||||
import { MemoryCacheService } from './cache/memory-cache-service';
|
||||
import { GlitchtipErrorReportingService } from './errors/glitchtip-error-reporting-service';
|
||||
import { NoopErrorReportingService } from './errors/noop-error-reporting-service';
|
||||
import {
|
||||
GotifyNotificationService,
|
||||
NoopNotificationService,
|
||||
} from './notifications/gotify-notification-service';
|
||||
import { PinoLoggerService } from './logging/pino-logger-service';
|
||||
import { config, getMaskedConfig } from '../config';
|
||||
|
||||
@@ -13,7 +17,7 @@ export function getServerAppServices(): AppServices {
|
||||
|
||||
// Create logger first to log initialization
|
||||
const logger = new PinoLoggerService('server');
|
||||
|
||||
|
||||
logger.info('Initializing server application services', {
|
||||
environment: getMaskedConfig(),
|
||||
timestamp: new Date().toISOString(),
|
||||
@@ -23,10 +27,11 @@ export function getServerAppServices(): AppServices {
|
||||
umamiEnabled: config.analytics.umami.enabled,
|
||||
sentryEnabled: config.errors.glitchtip.enabled,
|
||||
mailEnabled: Boolean(config.mail.host && config.mail.user),
|
||||
gotifyEnabled: config.notifications.gotify.enabled,
|
||||
});
|
||||
|
||||
const analytics = config.analytics.umami.enabled
|
||||
? new UmamiAnalyticsService({ enabled: true })
|
||||
? new UmamiAnalyticsService({ enabled: true }, logger)
|
||||
: new NoopAnalyticsService();
|
||||
|
||||
if (config.analytics.umami.enabled) {
|
||||
@@ -35,12 +40,28 @@ export function getServerAppServices(): AppServices {
|
||||
logger.info('Noop analytics service initialized (analytics disabled)');
|
||||
}
|
||||
|
||||
const notifications = config.notifications.gotify.enabled
|
||||
? new GotifyNotificationService({
|
||||
url: config.notifications.gotify.url!,
|
||||
token: config.notifications.gotify.token!,
|
||||
enabled: true,
|
||||
})
|
||||
: new NoopNotificationService();
|
||||
|
||||
if (config.notifications.gotify.enabled) {
|
||||
logger.info('Gotify notification service initialized');
|
||||
} else {
|
||||
logger.info('Noop notification service initialized (notifications disabled)');
|
||||
}
|
||||
|
||||
const errors = config.errors.glitchtip.enabled
|
||||
? new GlitchtipErrorReportingService({ enabled: true })
|
||||
? new GlitchtipErrorReportingService({ enabled: true }, logger, notifications)
|
||||
: new NoopErrorReportingService();
|
||||
|
||||
if (config.errors.glitchtip.enabled) {
|
||||
logger.info('GlitchTip error reporting service initialized');
|
||||
logger.info('GlitchTip error reporting service initialized', {
|
||||
dsnPresent: Boolean(config.errors.glitchtip.dsn),
|
||||
});
|
||||
} else {
|
||||
logger.info('Noop error reporting service initialized (error reporting disabled)');
|
||||
}
|
||||
@@ -53,10 +74,9 @@ export function getServerAppServices(): AppServices {
|
||||
level: config.logging.level,
|
||||
});
|
||||
|
||||
singleton = new AppServices(analytics, errors, cache, logger);
|
||||
|
||||
singleton = new AppServices(analytics, errors, cache, logger, notifications);
|
||||
|
||||
logger.info('All application services initialized successfully');
|
||||
|
||||
|
||||
return singleton;
|
||||
}
|
||||
|
||||
|
||||
@@ -1,10 +1,12 @@
|
||||
import { AppServices } from './app-services';
|
||||
import { NoopAnalyticsService } from './analytics/noop-analytics-service';
|
||||
import { UmamiAnalyticsService } from './analytics/umami-analytics-service';
|
||||
import { MemoryCacheService } from './cache/memory-cache-service';
|
||||
import { GlitchtipErrorReportingService } from './errors/glitchtip-error-reporting-service';
|
||||
import { NoopErrorReportingService } from './errors/noop-error-reporting-service';
|
||||
import { NoopLoggerService } from './logging/noop-logger-service';
|
||||
import { PinoLoggerService } from './logging/pino-logger-service';
|
||||
import { NoopNotificationService } from './notifications/gotify-notification-service';
|
||||
import { config, getMaskedConfig } from '../config';
|
||||
|
||||
/**
|
||||
@@ -27,9 +29,8 @@ let singleton: AppServices | undefined;
|
||||
* - Cache service (in-memory)
|
||||
*
|
||||
* The services are configured based on environment variables:
|
||||
* - `NEXT_PUBLIC_UMAMI_WEBSITE_ID` - Enables Umami analytics
|
||||
* - `NEXT_PUBLIC_SENTRY_DSN` - Enables client-side error reporting
|
||||
* - `SENTRY_DSN` - Enables server-side error reporting
|
||||
* - `UMAMI_WEBSITE_ID` - Enables Umami analytics
|
||||
* - `SENTRY_DSN` - Enables error reporting (server-side direct, client-side via relay)
|
||||
*
|
||||
* @returns {AppServices} The application services singleton
|
||||
*
|
||||
@@ -71,9 +72,7 @@ export function getAppServices(): AppServices {
|
||||
|
||||
// Create logger first to log initialization
|
||||
const logger =
|
||||
typeof window === 'undefined'
|
||||
? new PinoLoggerService('server')
|
||||
: new NoopLoggerService();
|
||||
typeof window === 'undefined' ? new PinoLoggerService('server') : new NoopLoggerService();
|
||||
|
||||
// Log initialization
|
||||
if (typeof window === 'undefined') {
|
||||
@@ -101,12 +100,8 @@ export function getAppServices(): AppServices {
|
||||
});
|
||||
|
||||
// Create analytics service (Umami or no-op)
|
||||
// Use dynamic import to avoid importing server-only code in client components
|
||||
const analytics = umamiEnabled
|
||||
? (() => {
|
||||
const { UmamiAnalyticsService } = require('./analytics/umami-analytics-service');
|
||||
return new UmamiAnalyticsService({ enabled: true });
|
||||
})()
|
||||
? new UmamiAnalyticsService({ enabled: true }, logger)
|
||||
: new NoopAnalyticsService();
|
||||
|
||||
if (umamiEnabled) {
|
||||
@@ -115,13 +110,19 @@ export function getAppServices(): AppServices {
|
||||
logger.info('Noop analytics service initialized (analytics disabled)');
|
||||
}
|
||||
|
||||
// Create notification service
|
||||
const notifications = new NoopNotificationService();
|
||||
logger.info('Notification service initialized (noop)');
|
||||
|
||||
// Create error reporting service (GlitchTip/Sentry or no-op)
|
||||
const errors = sentryEnabled
|
||||
? new GlitchtipErrorReportingService({ enabled: true })
|
||||
? new GlitchtipErrorReportingService({ enabled: true }, logger, notifications)
|
||||
: new NoopErrorReportingService();
|
||||
|
||||
if (sentryEnabled) {
|
||||
logger.info(`GlitchTip error reporting service initialized (${typeof window === 'undefined' ? 'server' : 'client'})`);
|
||||
logger.info(
|
||||
`GlitchTip error reporting service initialized (${typeof window === 'undefined' ? 'server' : 'client'})`,
|
||||
);
|
||||
} else {
|
||||
logger.info('Noop error reporting service initialized (error reporting disabled)');
|
||||
}
|
||||
@@ -138,9 +139,9 @@ export function getAppServices(): AppServices {
|
||||
});
|
||||
|
||||
// Create and cache the singleton
|
||||
singleton = new AppServices(analytics, errors, cache, logger);
|
||||
|
||||
singleton = new AppServices(analytics, errors, cache, logger, notifications);
|
||||
|
||||
logger.info('All application services initialized successfully');
|
||||
|
||||
|
||||
return singleton;
|
||||
}
|
||||
|
||||
@@ -7,10 +7,15 @@ export type ErrorReportingUser = {
|
||||
export type ErrorReportingLevel = 'fatal' | 'error' | 'warning' | 'info' | 'debug' | 'log';
|
||||
|
||||
export interface ErrorReportingService {
|
||||
captureException(error: unknown, context?: Record<string, unknown>): string | undefined;
|
||||
captureMessage(message: string, level?: ErrorReportingLevel): string | undefined;
|
||||
captureException(
|
||||
error: unknown,
|
||||
context?: Record<string, unknown>,
|
||||
): Promise<string | undefined> | string | undefined;
|
||||
captureMessage(
|
||||
message: string,
|
||||
level?: ErrorReportingLevel,
|
||||
): Promise<string | undefined> | string | undefined;
|
||||
setUser(user: ErrorReportingUser | null): void;
|
||||
setTag(key: string, value: string): void;
|
||||
withScope<T>(fn: () => T, context?: Record<string, unknown>): T;
|
||||
}
|
||||
|
||||
|
||||
@@ -4,6 +4,8 @@ import type {
|
||||
ErrorReportingService,
|
||||
ErrorReportingUser,
|
||||
} from './error-reporting-service';
|
||||
import type { NotificationService } from '../notifications/notification-service';
|
||||
import type { LoggerService } from '../logging/logger-service';
|
||||
|
||||
type SentryLike = typeof Sentry;
|
||||
|
||||
@@ -13,14 +15,36 @@ export type GlitchtipErrorReportingServiceOptions = {
|
||||
|
||||
// GlitchTip speaks the Sentry protocol; @sentry/nextjs can send to GlitchTip via DSN.
|
||||
export class GlitchtipErrorReportingService implements ErrorReportingService {
|
||||
private logger: LoggerService;
|
||||
|
||||
constructor(
|
||||
private readonly options: GlitchtipErrorReportingServiceOptions,
|
||||
private readonly sentry: SentryLike = Sentry
|
||||
) {}
|
||||
logger: LoggerService,
|
||||
private readonly notifications?: NotificationService,
|
||||
private readonly sentry: SentryLike = Sentry,
|
||||
) {
|
||||
this.logger = logger.child({ component: 'error-reporting-glitchtip' });
|
||||
}
|
||||
|
||||
captureException(error: unknown, context?: Record<string, unknown>) {
|
||||
async captureException(error: unknown, context?: Record<string, unknown>) {
|
||||
if (!this.options.enabled) return undefined;
|
||||
return this.sentry.captureException(error, context as any) as any;
|
||||
const result = this.sentry.captureException(error, context as any) as any;
|
||||
|
||||
// Send to Gotify if it's considered critical or if we just want all exceptions there
|
||||
// For now, let's send all exceptions to Gotify as requested "notify me via gotify about critical error messages"
|
||||
// We'll treat all captureException calls as potentially critical or at least noteworthy
|
||||
if (this.notifications) {
|
||||
const errorMessage = error instanceof Error ? error.message : String(error);
|
||||
const contextStr = context ? `\nContext: ${JSON.stringify(context, null, 2)}` : '';
|
||||
|
||||
await this.notifications.notify({
|
||||
title: '🔥 Critical Error Captured',
|
||||
message: `Error: ${errorMessage}${contextStr}`,
|
||||
priority: 7,
|
||||
});
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
captureMessage(message: string, level: ErrorReportingLevel = 'error') {
|
||||
|
||||
@@ -1,11 +1,15 @@
|
||||
import type { ErrorReportingLevel, ErrorReportingService, ErrorReportingUser } from './error-reporting-service';
|
||||
import type {
|
||||
ErrorReportingLevel,
|
||||
ErrorReportingService,
|
||||
ErrorReportingUser,
|
||||
} from './error-reporting-service';
|
||||
|
||||
export class NoopErrorReportingService implements ErrorReportingService {
|
||||
captureException(_error: unknown, _context?: Record<string, unknown>) {
|
||||
async captureException(_error: unknown, _context?: Record<string, unknown>) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
captureMessage(_message: string, _level?: ErrorReportingLevel) {
|
||||
async captureMessage(_message: string, _level?: ErrorReportingLevel) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
|
||||
@@ -12,20 +12,19 @@ export class PinoLoggerService implements LoggerService {
|
||||
// In Next.js, especially in the Edge runtime or during instrumentation,
|
||||
// pino transports (which use worker threads) can cause issues.
|
||||
// We disable transport in production and during instrumentation.
|
||||
const useTransport = !config.isProduction && typeof window === 'undefined';
|
||||
const useTransport = config.isDevelopment && typeof window === 'undefined';
|
||||
|
||||
this.logger = pino({
|
||||
name: name || 'app',
|
||||
level: config.logging.level,
|
||||
transport:
|
||||
useTransport
|
||||
? {
|
||||
target: 'pino-pretty',
|
||||
options: {
|
||||
colorize: true,
|
||||
},
|
||||
}
|
||||
: undefined,
|
||||
transport: useTransport
|
||||
? {
|
||||
target: 'pino-pretty',
|
||||
options: {
|
||||
colorize: true,
|
||||
},
|
||||
}
|
||||
: undefined,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
49
lib/services/notifications/gotify-notification-service.ts
Normal file
49
lib/services/notifications/gotify-notification-service.ts
Normal file
@@ -0,0 +1,49 @@
|
||||
import { NotificationOptions, NotificationService } from './notification-service';
|
||||
|
||||
export interface GotifyConfig {
|
||||
url: string;
|
||||
token: string;
|
||||
enabled: boolean;
|
||||
}
|
||||
|
||||
export class GotifyNotificationService implements NotificationService {
|
||||
constructor(private config: GotifyConfig) {}
|
||||
|
||||
async notify(options: NotificationOptions): Promise<void> {
|
||||
if (!this.config.enabled) return;
|
||||
|
||||
try {
|
||||
const { title, message, priority = 4 } = options;
|
||||
const url = new URL('message', this.config.url);
|
||||
url.searchParams.set('token', this.config.token);
|
||||
|
||||
const response = await fetch(url.toString(), {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({
|
||||
title,
|
||||
message,
|
||||
priority,
|
||||
}),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const errorText = await response.text();
|
||||
console.error('Gotify notification failed:', {
|
||||
status: response.status,
|
||||
error: errorText,
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Gotify notification error:', error);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export class NoopNotificationService implements NotificationService {
|
||||
async notify(): Promise<void> {
|
||||
// Do nothing
|
||||
}
|
||||
}
|
||||
9
lib/services/notifications/notification-service.ts
Normal file
9
lib/services/notifications/notification-service.ts
Normal file
@@ -0,0 +1,9 @@
|
||||
export interface NotificationOptions {
|
||||
title: string;
|
||||
message: string;
|
||||
priority?: number;
|
||||
}
|
||||
|
||||
export interface NotificationService {
|
||||
notify(options: NotificationOptions): Promise<void>;
|
||||
}
|
||||
85
lib/strapi.ts
Normal file
85
lib/strapi.ts
Normal file
@@ -0,0 +1,85 @@
|
||||
import axios from 'axios';
|
||||
|
||||
const STRAPI_URL = process.env.STRAPI_URL || 'http://localhost:1337';
|
||||
const STRAPI_TOKEN = process.env.STRAPI_API_TOKEN;
|
||||
|
||||
const strapi = axios.create({
|
||||
baseURL: `${STRAPI_URL}/api`,
|
||||
headers: {
|
||||
Authorization: `Bearer ${STRAPI_TOKEN}`,
|
||||
},
|
||||
});
|
||||
|
||||
export interface StrapiResponse<T> {
|
||||
data: {
|
||||
id: number;
|
||||
attributes: T;
|
||||
}[];
|
||||
meta: {
|
||||
pagination: {
|
||||
page: number;
|
||||
pageSize: number;
|
||||
pageCount: number;
|
||||
total: number;
|
||||
};
|
||||
};
|
||||
}
|
||||
|
||||
export interface Product {
|
||||
title: string;
|
||||
sku: string;
|
||||
description: string;
|
||||
application: string;
|
||||
content: string;
|
||||
technicalData: any;
|
||||
locale: string;
|
||||
images?: {
|
||||
data: {
|
||||
attributes: {
|
||||
url: string;
|
||||
alternativeText: string;
|
||||
};
|
||||
}[];
|
||||
};
|
||||
}
|
||||
|
||||
export async function getProducts(locale: string = 'de') {
|
||||
try {
|
||||
const response = await strapi.get<StrapiResponse<Product>>('/products', {
|
||||
params: {
|
||||
locale,
|
||||
populate: '*',
|
||||
},
|
||||
});
|
||||
return response.data.data.map(item => ({
|
||||
id: item.id,
|
||||
...item.attributes,
|
||||
}));
|
||||
} catch (error) {
|
||||
console.error('Error fetching products from Strapi:', error);
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
export async function getProductBySku(sku: string, locale: string = 'de') {
|
||||
try {
|
||||
const response = await strapi.get<StrapiResponse<Product>>('/products', {
|
||||
params: {
|
||||
filters: { sku: { $eq: sku } },
|
||||
locale,
|
||||
populate: '*',
|
||||
},
|
||||
});
|
||||
if (response.data.data.length === 0) return null;
|
||||
const item = response.data.data[0];
|
||||
return {
|
||||
id: item.id,
|
||||
...item.attributes,
|
||||
};
|
||||
} catch (error) {
|
||||
console.error(`Error fetching product ${sku} from Strapi:`, error);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export default strapi;
|
||||
19
lighthouserc.js
Normal file
19
lighthouserc.js
Normal file
@@ -0,0 +1,19 @@
|
||||
module.exports = {
|
||||
ci: {
|
||||
collect: {
|
||||
numberOfRuns: 1,
|
||||
settings: {
|
||||
preset: 'desktop',
|
||||
onlyCategories: ['performance', 'accessibility', 'best-practices', 'seo'],
|
||||
},
|
||||
},
|
||||
assert: {
|
||||
assertions: {
|
||||
'categories:performance': ['warn', { minScore: 0.9 }],
|
||||
'categories:accessibility': ['warn', { minScore: 0.9 }],
|
||||
'categories:best-practices': ['warn', { minScore: 0.9 }],
|
||||
'categories:seo': ['warn', { minScore: 0.9 }],
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user