Compare commits
47 Commits
v2.3.18-rc
...
4dcdb717f0
| Author | SHA1 | Date | |
|---|---|---|---|
| 4dcdb717f0 | |||
| 81ce3a4588 | |||
| 8b2e82888c | |||
| da637d4a74 | |||
| 1640b57c87 | |||
| 57d624839d | |||
| e3393d73c3 | |||
| 94e15656a4 | |||
| d41ec9b66b | |||
| 68380a3af9 | |||
| 3496720e22 | |||
| 8162a8b1cb | |||
| 6729d53c78 | |||
| 884adeabaf | |||
| a66674fcdf | |||
| ee7a40d39b | |||
| 79bbf852a1 | |||
| 8adc3c5b51 | |||
| d89b7930c2 | |||
| 6cea2f0c45 | |||
| 9be7cb7ac9 | |||
| e7116f75fc | |||
| dacb100218 | |||
| 1862b54540 | |||
| df2514e461 | |||
| 1434569dd8 | |||
| 7bab6a55db | |||
| 5797261d1a | |||
| 88dfeba502 | |||
| ce8829ece5 | |||
| 6f393fbc59 | |||
| b1363d9d52 | |||
| 0bd55c1dee | |||
| bb7c50c780 | |||
| 3c89c50a88 | |||
| 118cecf423 | |||
| 4ca0b53bd9 | |||
| c8286f4f67 | |||
| 73c8b4dcc2 | |||
| 7ca1b9c143 | |||
| 3570e766f6 | |||
| ec0abffc55 | |||
| f1a28b9db2 | |||
| 7fb1945ce5 | |||
| ec013a32a2 | |||
| 40e26117bd | |||
| 20fd889751 |
38
.env
38
.env
@@ -1,38 +0,0 @@
|
||||
# Application
|
||||
NODE_ENV=production
|
||||
NEXT_PUBLIC_BASE_URL=https://klz-cables.com
|
||||
UMAMI_WEBSITE_ID=e4a2cd1c-59fb-4e5b-bac5-9dfd1d02dd81
|
||||
UMAMI_API_ENDPOINT=https://analytics.infra.mintel.me
|
||||
SENTRY_DSN=https://c10957d0182245b1a2a806ac2d34a197@errors.infra.mintel.me/1
|
||||
LOG_LEVEL=info
|
||||
NEXT_PUBLIC_FEEDBACK_ENABLED=false
|
||||
NEXT_PUBLIC_RECORD_MODE_ENABLED=false
|
||||
NPM_TOKEN=263e7f75d8ada27f3a2e71fd6bd9d95298d48a4d
|
||||
|
||||
# SMTP Configuration
|
||||
MAIL_HOST=smtp.eu.mailgun.org
|
||||
MAIL_PORT=587
|
||||
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
|
||||
|
||||
# ────────────────────────────────────────────────────────────────────────────
|
||||
# Payload Infrastructure (Dockerized)
|
||||
# ────────────────────────────────────────────────────────────────────────────
|
||||
# The POSTGRES_URI and PAYLOAD_SECRET are automatically constructed and injected
|
||||
# by docker-compose.yml using these base DB credentials, so you don't need to
|
||||
# manually write the connection strings here.
|
||||
PAYLOAD_DB_NAME=payload
|
||||
PAYLOAD_DB_USER=payload
|
||||
PAYLOAD_DB_PASSWORD=120in09oenaoinsd9iaidon
|
||||
|
||||
# ────────────────────────────────────────────────────────────────────────────
|
||||
# Hetzner S3 Object Storage
|
||||
# ────────────────────────────────────────────────────────────────────────────
|
||||
S3_ENDPOINT=https://fsn1.your-objectstorage.com
|
||||
S3_ACCESS_KEY=ROB3MSWMEIGRL7N94ZKS
|
||||
S3_SECRET_KEY=9QJV3NE8xeLxhyufhNU7lsUB0RffJxPhGuEuFSH3
|
||||
S3_BUCKET=mintel
|
||||
S3_REGION=fsn1
|
||||
S3_PREFIX=klz-cables
|
||||
@@ -48,6 +48,12 @@ GATEKEEPER_PASSWORD=klz2026
|
||||
SENTRY_DSN=
|
||||
# SENTRY_ENVIRONMENT is set automatically by CI
|
||||
|
||||
# ────────────────────────────────────────────────────────────────────────────
|
||||
# AI Agent (Payload CMS Agent via OpenRouter)
|
||||
# ────────────────────────────────────────────────────────────────────────────
|
||||
# Required for the Payload CMS AI Chat Agent
|
||||
MISTRAL_API_KEY=
|
||||
|
||||
# ────────────────────────────────────────────────────────────────────────────
|
||||
# Payload Infrastructure (Dockerized)
|
||||
# ────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
@@ -1,51 +0,0 @@
|
||||
name: CI - Lint, Typecheck & Test
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
|
||||
concurrency:
|
||||
group: deploy-pipeline
|
||||
cancel-in-progress: true
|
||||
|
||||
jobs:
|
||||
quality-assurance:
|
||||
runs-on: docker
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Setup pnpm
|
||||
uses: pnpm/action-setup@v3
|
||||
with:
|
||||
version: 10
|
||||
run_install: false
|
||||
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: 20
|
||||
|
||||
- name: 🔐 Configure Private Registry
|
||||
run: |
|
||||
echo "@mintel:registry=https://git.infra.mintel.me/api/packages/mmintel/npm" > .npmrc
|
||||
echo "//git.infra.mintel.me/api/packages/mmintel/npm/:_authToken=${{ secrets.NPM_TOKEN }}" >> .npmrc
|
||||
|
||||
- name: Install dependencies
|
||||
run: pnpm install --no-frozen-lockfile
|
||||
env:
|
||||
NPM_TOKEN: ${{ secrets.NPM_TOKEN }}
|
||||
|
||||
- name: 🧪 QA Checks
|
||||
env:
|
||||
TURBO_TELEMETRY_DISABLED: "1"
|
||||
run: npx turbo run check:mdx lint typecheck test --cache-dir=".turbo"
|
||||
|
||||
- name: 🏗️ Build
|
||||
run: pnpm build
|
||||
|
||||
- name: ♿ Accessibility Check
|
||||
run: pnpm start-server-and-test start http://localhost:3000 "pnpm check:a11y http://localhost:3000"
|
||||
|
||||
- name: ♿ WCAG Sitemap Audit
|
||||
run: pnpm start-server-and-test start http://localhost:3000 "pnpm run check:wcag http://localhost:3000"
|
||||
# monitor trigger
|
||||
@@ -37,6 +37,8 @@ jobs:
|
||||
next_public_url: ${{ steps.determine.outputs.next_public_url }}
|
||||
project_name: ${{ steps.determine.outputs.project_name }}
|
||||
short_sha: ${{ steps.determine.outputs.short_sha }}
|
||||
slug: ${{ steps.determine.outputs.slug }}
|
||||
gatekeeper_host: ${{ steps.determine.outputs.gatekeeper_host }}
|
||||
container:
|
||||
image: catthehacker/ubuntu:act-latest
|
||||
steps:
|
||||
@@ -83,7 +85,7 @@ jobs:
|
||||
SLUG=$(echo "$REF" | tr '[:upper:]' '[:lower:]' | sed 's/[^a-z0-9]/-/g' | sed 's/--*/-/g' | sed 's/^-//;s/-$//')
|
||||
IMAGE_TAG="branch-${SLUG}-${SHORT_SHA}"
|
||||
ENV_FILE=".env.branch-${SLUG}"
|
||||
TRAEFIK_HOST="${SLUG}.branch.mintel.me"
|
||||
TRAEFIK_HOST="${SLUG}.branch.klz-cables.com"
|
||||
fi
|
||||
|
||||
# Standardize Traefik Rule (escaped backticks for Traefik v3)
|
||||
@@ -113,6 +115,7 @@ jobs:
|
||||
echo "project_name=$PRJ-$TARGET"
|
||||
fi
|
||||
echo "short_sha=$SHORT_SHA"
|
||||
echo "slug=$SLUG"
|
||||
} >> "$GITHUB_OUTPUT"
|
||||
|
||||
# ⏳ Wait for Upstream Packages/Images if Tagged
|
||||
@@ -124,13 +127,13 @@ jobs:
|
||||
|
||||
if [[ -n "$UPSTREAM_VERSION" && "$UPSTREAM_VERSION" != "workspace:"* ]]; then
|
||||
# 1. Discovery (Works without token for public repositories)
|
||||
UPSTREAM_SHA=$(git ls-remote --tags https://git.infra.mintel.me/mmintel/at-mintel.git "$TAG_TO_WAIT" 2>/dev/null | grep "$TAG_TO_WAIT" | awk '{print $1}' | tail -n1 || echo "")
|
||||
UPSTREAM_SHA=$(git ls-remote --tags https://git.infra.mintel.me/mmintel/at-mintel.git "$TAG_TO_WAIT" | grep "$TAG_TO_WAIT" | tail -n1 | awk '{print $1}')
|
||||
|
||||
if [[ -z "$UPSTREAM_SHA" ]]; then
|
||||
echo "⚠️ Warning: Tag $TAG_TO_WAIT not found in mmintel/at-mintel."
|
||||
else
|
||||
echo "✅ Tag verified: Found upstream SHA $UPSTREAM_SHA for $TAG_TO_WAIT"
|
||||
echo "❌ Error: Tag $TAG_TO_WAIT not found in mmintel/at-mintel."
|
||||
exit 1
|
||||
fi
|
||||
echo "✅ Tag verified: Found upstream SHA $UPSTREAM_SHA for $TAG_TO_WAIT"
|
||||
|
||||
# 2. Status Check (Requires GITEA_PAT for cross-repo API access)
|
||||
POLL_TOKEN="${{ secrets.GITEA_PAT || secrets.MINTEL_PRIVATE_TOKEN }}"
|
||||
@@ -156,6 +159,8 @@ jobs:
|
||||
needs: prepare
|
||||
if: needs.prepare.outputs.target != 'skip'
|
||||
runs-on: docker
|
||||
env:
|
||||
PUPPETEER_EXECUTABLE_PATH: /usr/bin/chromium
|
||||
container:
|
||||
image: catthehacker/ubuntu:act-latest
|
||||
steps:
|
||||
@@ -181,12 +186,12 @@ jobs:
|
||||
|
||||
- name: 🔒 Security Audit
|
||||
run: pnpm audit --audit-level high || echo "⚠️ Audit found vulnerabilities (non-blocking)"
|
||||
|
||||
- name: 🧪 QA Checks
|
||||
if: github.event.inputs.skip_checks != 'true'
|
||||
env:
|
||||
TURBO_TELEMETRY_DISABLED: "1"
|
||||
run: npx turbo run lint typecheck test --cache-dir=".turbo"
|
||||
|
||||
# ──────────────────────────────────────────────────────────────────────────────
|
||||
# JOB 3: Build & Push
|
||||
# ──────────────────────────────────────────────────────────────────────────────
|
||||
@@ -203,7 +208,8 @@ jobs:
|
||||
- name: 🐳 Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@v3
|
||||
- name: 🔐 Registry Login
|
||||
run: echo "${{ secrets.REGISTRY_PASS }}" | docker login registry.infra.mintel.me -u "${{ secrets.REGISTRY_USER }}" --password-stdin
|
||||
run: |
|
||||
echo "${{ secrets.REGISTRY_PASS }}" | docker login registry.infra.mintel.me -u "${{ secrets.REGISTRY_USER }}" --password-stdin
|
||||
- name: 🏗️ Build and Push
|
||||
uses: docker/build-push-action@v5
|
||||
with:
|
||||
@@ -219,7 +225,22 @@ jobs:
|
||||
NPM_TOKEN=${{ secrets.NPM_TOKEN }}
|
||||
tags: registry.infra.mintel.me/mintel/klz-2026:${{ needs.prepare.outputs.image_tag }}
|
||||
secrets: |
|
||||
"NPM_TOKEN=${{ secrets.NPM_TOKEN }}"
|
||||
NPM_TOKEN=${{ secrets.NPM_TOKEN }}
|
||||
- name: 🔄 Build and Push Migrator
|
||||
uses: docker/build-push-action@v5
|
||||
with:
|
||||
context: .
|
||||
push: true
|
||||
provenance: false
|
||||
platforms: linux/amd64
|
||||
target: migrator
|
||||
build-args: |
|
||||
NEXT_PUBLIC_BASE_URL=${{ needs.prepare.outputs.next_public_url }}
|
||||
NEXT_PUBLIC_TARGET=${{ needs.prepare.outputs.target }}
|
||||
NPM_TOKEN=${{ secrets.NPM_TOKEN }}
|
||||
tags: registry.infra.mintel.me/mintel/klz-2026:migrate-${{ needs.prepare.outputs.image_tag }}
|
||||
secrets: |
|
||||
NPM_TOKEN=${{ secrets.NPM_TOKEN }}
|
||||
|
||||
# ──────────────────────────────────────────────────────────────────────────────
|
||||
# JOB 4: Deploy
|
||||
@@ -237,6 +258,7 @@ jobs:
|
||||
NEXT_PUBLIC_BASE_URL: ${{ needs.prepare.outputs.next_public_url }}
|
||||
TRAEFIK_HOST: ${{ needs.prepare.outputs.traefik_host }}
|
||||
GATEKEEPER_HOST: ${{ needs.prepare.outputs.gatekeeper_host }}
|
||||
SLUG: ${{ needs.prepare.outputs.slug }}
|
||||
|
||||
# Secrets mapping (Payload CMS)
|
||||
PAYLOAD_SECRET: ${{ secrets.PAYLOAD_SECRET || vars.PAYLOAD_SECRET || 'you-need-to-set-a-payload-secret' }}
|
||||
@@ -249,8 +271,8 @@ jobs:
|
||||
MAIL_PORT: ${{ secrets.SMTP_PORT || vars.SMTP_PORT || '587' }}
|
||||
MAIL_USERNAME: ${{ secrets.SMTP_USER || vars.SMTP_USER }}
|
||||
MAIL_PASSWORD: ${{ secrets.SMTP_PASS || vars.SMTP_PASS }}
|
||||
MAIL_FROM: ${{ secrets.SMTP_FROM || vars.SMTP_FROM || 'noreply@klz-cables.com' }}
|
||||
MAIL_RECIPIENTS: ${{ secrets.CONTACT_RECIPIENT || vars.CONTACT_RECIPIENT || 'info@klz-cables.com' }}
|
||||
MAIL_FROM: ${{ secrets.SMTP_FROM || vars.SMTP_FROM }}
|
||||
MAIL_RECIPIENTS: ${{ secrets.CONTACT_RECIPIENT || vars.CONTACT_RECIPIENT }}
|
||||
|
||||
# Monitoring
|
||||
SENTRY_DSN: ${{ secrets.SENTRY_DSN || vars.SENTRY_DSN }}
|
||||
@@ -261,6 +283,15 @@ jobs:
|
||||
# Analytics
|
||||
UMAMI_WEBSITE_ID: ${{ secrets.UMAMI_WEBSITE_ID || vars.UMAMI_WEBSITE_ID }}
|
||||
UMAMI_API_ENDPOINT: ${{ secrets.UMAMI_API_ENDPOINT || vars.UMAMI_API_ENDPOINT || 'https://analytics.infra.mintel.me' }}
|
||||
|
||||
# Search & AI
|
||||
OPENROUTER_API_KEY: ${{ secrets.OPENROUTER_API_KEY || vars.OPENROUTER_API_KEY }}
|
||||
QDRANT_URL: ${{ secrets.QDRANT_URL || vars.QDRANT_URL || 'http://klz-qdrant:6333' }}
|
||||
QDRANT_API_KEY: ${{ secrets.QDRANT_API_KEY || vars.QDRANT_API_KEY }}
|
||||
REDIS_URL: ${{ secrets.REDIS_URL || vars.REDIS_URL || 'redis://klz-redis:6379' }}
|
||||
# Container Registry (standalone)
|
||||
REGISTRY_USER: ${{ secrets.REGISTRY_USER }}
|
||||
REGISTRY_PASS: ${{ secrets.REGISTRY_PASS }}
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v4
|
||||
@@ -319,6 +350,12 @@ jobs:
|
||||
echo "UMAMI_WEBSITE_ID=$UMAMI_WEBSITE_ID"
|
||||
echo "UMAMI_API_ENDPOINT=$UMAMI_API_ENDPOINT"
|
||||
echo ""
|
||||
echo "# Search & AI"
|
||||
echo "OPENROUTER_API_KEY=$OPENROUTER_API_KEY"
|
||||
echo "QDRANT_URL=$QDRANT_URL"
|
||||
echo "QDRANT_API_KEY=$QDRANT_API_KEY"
|
||||
echo "REDIS_URL=$REDIS_URL"
|
||||
echo ""
|
||||
echo "TARGET=$TARGET"
|
||||
echo "SENTRY_ENVIRONMENT=$TARGET"
|
||||
echo "PROJECT_NAME=$PROJECT_NAME"
|
||||
@@ -338,6 +375,32 @@ jobs:
|
||||
cat .env.deploy
|
||||
echo "----------------------------"
|
||||
|
||||
- name: 🔐 Registry Auth
|
||||
id: auth
|
||||
run: |
|
||||
echo "Testing available secrets against git.infra.mintel.me Docker registry..."
|
||||
TOKENS="${{ secrets.GITEA_PAT }} ${{ secrets.MINTEL_PRIVATE_TOKEN }} ${{ secrets.NPM_TOKEN }}"
|
||||
USERS="${{ github.repository_owner }} ${{ github.actor }} marcmintel mintel mmintel"
|
||||
|
||||
VALID_TOKEN=""
|
||||
VALID_USER=""
|
||||
for T in $TOKENS; do
|
||||
if [ -n "$T" ]; then
|
||||
for U in $USERS; do
|
||||
if [ -n "$U" ]; then
|
||||
if echo "$T" | docker login git.infra.mintel.me -u "$U" --password-stdin > /dev/null 2>&1; then
|
||||
VALID_TOKEN="$T"
|
||||
VALID_USER="$U"
|
||||
break 2
|
||||
fi
|
||||
fi
|
||||
done
|
||||
fi
|
||||
done
|
||||
if [ -z "$VALID_TOKEN" ]; then echo "❌ All tokens failed to authenticate!"; exit 1; fi
|
||||
echo "token=$VALID_TOKEN" >> $GITHUB_OUTPUT
|
||||
echo "user=$VALID_USER" >> $GITHUB_OUTPUT
|
||||
|
||||
- name: 🚀 SSH Deploy
|
||||
shell: bash
|
||||
env:
|
||||
@@ -348,6 +411,9 @@ jobs:
|
||||
chmod 600 ~/.ssh/id_ed25519
|
||||
ssh-keyscan -H alpha.mintel.me >> ~/.ssh/known_hosts 2>/dev/null
|
||||
|
||||
# Determine deployment paths
|
||||
echo "Preparing deployment for $TARGET..."
|
||||
|
||||
# Transfer and Restart
|
||||
if [[ "$TARGET" == "production" ]]; then
|
||||
SITE_DIR="/home/deploy/sites/klz-cables.com"
|
||||
@@ -356,16 +422,15 @@ jobs:
|
||||
elif [[ "$TARGET" == "staging" ]]; then
|
||||
SITE_DIR="/home/deploy/sites/staging.klz-cables.com"
|
||||
else
|
||||
SITE_DIR="/home/deploy/sites/branch.klz-cables.com/${SLUG:-unknown}"
|
||||
SITE_DIR="/home/deploy/sites/branch.klz-cables.com/$SLUG"
|
||||
fi
|
||||
# Transfer files
|
||||
ssh root@alpha.mintel.me "mkdir -p $SITE_DIR"
|
||||
|
||||
scp .env.deploy root@alpha.mintel.me:$SITE_DIR/$ENV_FILE
|
||||
scp docker-compose.yml root@alpha.mintel.me:$SITE_DIR/docker-compose.yml
|
||||
|
||||
ssh root@alpha.mintel.me "cd $SITE_DIR && echo '${{ secrets.REGISTRY_PASS }}' | docker login registry.infra.mintel.me -u '${{ secrets.REGISTRY_USER }}' --password-stdin"
|
||||
ssh root@alpha.mintel.me "cd $SITE_DIR && docker compose -p '${{ needs.prepare.outputs.project_name }}' --env-file '$ENV_FILE' pull"
|
||||
ssh root@alpha.mintel.me "cd $SITE_DIR && docker compose -p '${{ needs.prepare.outputs.project_name }}' --env-file '$ENV_FILE' up -d --remove-orphans"
|
||||
# Execute remote commands — alpha is pre-logged into registry.infra.mintel.me
|
||||
ssh root@alpha.mintel.me "cd $SITE_DIR && docker compose -p '${{ needs.prepare.outputs.project_name }}' --env-file $ENV_FILE pull && docker compose -p '${{ needs.prepare.outputs.project_name }}' --env-file $ENV_FILE up -d --remove-orphans"
|
||||
|
||||
# Sanitize Payload Migrations: Replace 'dev' push entries with proper migration names.
|
||||
# Without this, Payload prompts interactively for confirmation and blocks forever in Docker.
|
||||
@@ -386,33 +451,25 @@ jobs:
|
||||
REMOTE_DB_USER="${REMOTE_DB_USER:-payload}"
|
||||
REMOTE_DB_NAME="${REMOTE_DB_NAME:-payload}"
|
||||
|
||||
# Auto-detect migrations from src/migrations/*.ts
|
||||
BATCH=1
|
||||
VALUES=""
|
||||
for f in $(ls src/migrations/*.ts 2>/dev/null | sort); do
|
||||
NAME=$(basename "$f" .ts)
|
||||
[ -n "$VALUES" ] && VALUES="$VALUES,"
|
||||
VALUES="$VALUES ('$NAME', $BATCH)"
|
||||
((BATCH++))
|
||||
done
|
||||
|
||||
if [ -n "$VALUES" ]; then
|
||||
ssh root@alpha.mintel.me "docker exec $DB_CONTAINER psql -U $REMOTE_DB_USER -d $REMOTE_DB_NAME -c \"
|
||||
DO \\\$\\\$ BEGIN
|
||||
DELETE FROM payload_migrations WHERE batch = -1;
|
||||
INSERT INTO payload_migrations (name, batch)
|
||||
SELECT name, batch FROM (VALUES $VALUES) AS v(name, batch)
|
||||
WHERE NOT EXISTS (SELECT 1 FROM payload_migrations pm WHERE pm.name = v.name);
|
||||
EXCEPTION WHEN undefined_table THEN
|
||||
RAISE NOTICE 'payload_migrations table does not exist yet — skipping sanitization';
|
||||
END \\\$\\\$;
|
||||
\"" || echo "⚠️ Migration sanitization skipped (table may not exist yet)"
|
||||
fi
|
||||
# Run Payload migrations via a temporary container before restarting the app.
|
||||
# This ensures fresh branch deployments (empty DBs) get their schema on first deploy.
|
||||
echo "🔄 Running Payload migrations..."
|
||||
MIGRATOR_IMAGE="registry.infra.mintel.me/mintel/klz-2026:migrate-$IMAGE_TAG"
|
||||
|
||||
ssh root@alpha.mintel.me "
|
||||
echo '${{ steps.auth.outputs.token }}' | docker login registry.infra.mintel.me -u '${{ steps.auth.outputs.user }}' --password-stdin 2>/dev/null || true
|
||||
docker pull $MIGRATOR_IMAGE
|
||||
docker run --rm \
|
||||
--network ${PROJECT_NAME}_default \
|
||||
--env-file $SITE_DIR/$ENV_FILE \
|
||||
$MIGRATOR_IMAGE \
|
||||
&& echo '✅ Migrations complete.' \
|
||||
|| echo '⚠️ Migrations failed or already up-to-date — continuing.'
|
||||
"
|
||||
|
||||
# Restart app to pick up clean migration state
|
||||
APP_CONTAINER="${{ needs.prepare.outputs.project_name }}-klz-app-1"
|
||||
APP_CONTAINER="${PROJECT_NAME}-klz-app-1"
|
||||
ssh root@alpha.mintel.me "docker restart $APP_CONTAINER"
|
||||
|
||||
ssh root@alpha.mintel.me "docker system prune -f --filter 'until=24h'"
|
||||
|
||||
- name: 🧹 Post-Deploy Cleanup (Runner)
|
||||
@@ -486,33 +543,25 @@ jobs:
|
||||
|
||||
# ── Critical Smoke Tests (MUST pass) ──────────────────────────────────
|
||||
- name: 🏥 CMS Deep Health Check
|
||||
continue-on-error: true
|
||||
env:
|
||||
DEPLOY_URL: ${{ needs.prepare.outputs.next_public_url }}
|
||||
GK_PASS: ${{ secrets.GATEKEEPER_PASSWORD || 'klz2026' }}
|
||||
run: |
|
||||
echo "Waiting 60s for app to fully start (cold start / migrations / hydration)..."
|
||||
sleep 60
|
||||
|
||||
echo "Waiting 10s for app to fully start..."
|
||||
sleep 10
|
||||
echo "Checking basic health..."
|
||||
curl -sfL --retry 5 --retry-delay 10 "$DEPLOY_URL/health" || { echo "❌ Basic health check failed after retries"; exit 1; }
|
||||
curl -sf "$DEPLOY_URL/health" || { echo "❌ Basic health check failed"; exit 1; }
|
||||
echo "✅ Basic health OK"
|
||||
|
||||
echo "Checking CMS DB connectivity..."
|
||||
# We use -v to see if we hit Gatekeeper (307) or 503/504
|
||||
URL="$DEPLOY_URL/api/health/cms?gk_bypass=$GK_PASS&cb=$(date +%s)"
|
||||
RESPONSE=$(curl -sfL --retry 5 --retry-delay 10 "$URL" 2>&1) || {
|
||||
echo "⚠️ CMS health check failed, but site might still be fine."
|
||||
echo "--- DEBUG INFO ---"
|
||||
curl -svI "$URL"
|
||||
echo "--- RESPONSE BODY ---"
|
||||
curl -sL "$URL" | head -c 1000
|
||||
RESPONSE=$(curl -sf "$DEPLOY_URL/api/health/cms?gk_bypass=$GK_PASS" 2>&1) || {
|
||||
echo "❌ CMS health check failed!"
|
||||
echo "$RESPONSE"
|
||||
echo ""
|
||||
echo "Proceeding to smoke tests to confirm actual site functionality."
|
||||
echo "This usually means Payload CMS migrations failed or DB tables are missing."
|
||||
echo "Check: docker logs \$APP_CONTAINER | grep -i error"
|
||||
exit 1
|
||||
}
|
||||
echo "✅ CMS health output: $RESPONSE"
|
||||
|
||||
|
||||
echo "✅ CMS health: $RESPONSE"
|
||||
- name: 🚀 OG Image Check
|
||||
if: always() && steps.deps.outcome == 'success'
|
||||
env:
|
||||
@@ -579,15 +628,8 @@ jobs:
|
||||
STATUS_LINE="All checks passed"
|
||||
fi
|
||||
|
||||
TITLE="$EMOJI klz-cables.com $VERSION → $TARGET"
|
||||
MESSAGE="$STATUS_LINE
|
||||
Deploy: $DEPLOY | Smoke: $SMOKE | Perf: $PERF
|
||||
$URL"
|
||||
|
||||
if [[ -z "${{ secrets.GOTIFY_URL }}" || -z "${{ secrets.GOTIFY_TOKEN }}" ]]; then
|
||||
echo "⚠️ Gotify credentials missing, skipping notification."
|
||||
exit 0
|
||||
fi
|
||||
TITLE="$EMOJI klz-cables.com $VERSION -> $TARGET"
|
||||
MESSAGE="$STATUS_LINE | Deploy: $DEPLOY | Smoke: $SMOKE | $URL"
|
||||
|
||||
curl -s -k -X POST "${{ secrets.GOTIFY_URL }}/message?token=${{ secrets.GOTIFY_TOKEN }}" \
|
||||
-F "title=$TITLE" \
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
name: Nightly QA
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [main]
|
||||
schedule:
|
||||
- cron: '0 3 * * *'
|
||||
workflow_dispatch:
|
||||
@@ -225,11 +227,6 @@ jobs:
|
||||
MESSAGE="Static: $STATIC | A11y: $A11Y | Lighthouse: $LIGHTHOUSE | Links: $LINKS
|
||||
${{ env.TARGET_URL }}"
|
||||
|
||||
if [[ -z "${{ secrets.GOTIFY_URL }}" || -z "${{ secrets.GOTIFY_TOKEN }}" ]]; then
|
||||
echo "⚠️ Gotify credentials missing, skipping notification."
|
||||
exit 0
|
||||
fi
|
||||
|
||||
curl -s -k -X POST "${{ secrets.GOTIFY_URL }}/message?token=${{ secrets.GOTIFY_TOKEN }}" \
|
||||
-F "title=$TITLE" \
|
||||
-F "message=$MESSAGE" \
|
||||
|
||||
2
.gitignore
vendored
2
.gitignore
vendored
@@ -28,3 +28,5 @@ html-errors*.json
|
||||
reference/
|
||||
# Database backups
|
||||
backups/
|
||||
|
||||
.env
|
||||
1
.npmrc
1
.npmrc
@@ -1,2 +1 @@
|
||||
@mintel:registry=https://git.infra.mintel.me/api/packages/mmintel/npm/
|
||||
//git.infra.mintel.me/api/packages/mmintel/npm/:_authToken=${NPM_TOKEN}
|
||||
|
||||
648
AVB_2026.html
648
AVB_2026.html
@@ -1,648 +0,0 @@
|
||||
<h1 id="allgemeine-verkaufsbedingungen">Allgemeine
|
||||
Verkaufsbedingungen</h1>
|
||||
<h3 id="geltungsbereich">1 Geltungsbereich</h3>
|
||||
<p>1.1 Ausschließlich die nachfolgenden Allgemeinen Verkaufsbedingungen
|
||||
(nachfolgend „AVB“) gelten für den gesamten Geschäftsverkehr
|
||||
(nachfolgend „Lieferung von Waren“) der KLZ Vertriebs GmbH,
|
||||
Raiffeisenstraße 22, 73630 Remshalden (nachfolgend „KLZ“) und
|
||||
Unternehmern in Ausübung ihrer gewerblichen oder selbständigen
|
||||
beruflichen Tätigkeit und juristischen Personen des öffentlichen Rechts
|
||||
(nachfolgend „Kunde“). Der Geltung von abweichenden, zusätzlichen oder
|
||||
entgegenstehenden allgemeinen Geschäftsbedingungen des Kunden wird
|
||||
hiermit widersprochen.</p>
|
||||
<p>1.2 Diese AVB gelten auch dann, wenn KLZ die Lieferung von Waren an
|
||||
den Kunden vorbehaltlos in Kenntnis entgegenstehender oder von diesen
|
||||
AVB abweichenden allgemeinen Geschäftsbedingungen des Kunden
|
||||
ausführt.</p>
|
||||
<p>1.3 Im Rahmen laufender Geschäftsbeziehungen gelten diese AVB auch
|
||||
dann für künftige Lieferungen von Waren, wenn sie nicht ausdrücklich
|
||||
erneut in den Vertrag einbezogen wurden.</p>
|
||||
<p>1.4 Änderungen dieser AVB werden dem Kunden schriftlich, per Telefax
|
||||
oder per E-Mail mitgeteilt. Widerspricht der Kunde diesen Änderungen
|
||||
nicht innerhalb von vier (4) Wochen nach Zugang der Mitteilung, gelten
|
||||
die Änderungen als durch den Kunden anerkannt. Auf das Widerspruchsrecht
|
||||
und die Rechtsfolgen des Schweigens wird der Kunde im Rahmen der
|
||||
Mitteilung über die Änderung der Geschäftsbedingungen gesondert
|
||||
hingewiesen.</p>
|
||||
<p>1.5 Hinweise auf die Geltung gesetzlicher Vorschriften im Rahmen
|
||||
dieser AVB haben nur klarstellende Bedeutung. Auch ohne eine derartige
|
||||
Klarstellung gelten daher die gesetzlichen Vorschriften, soweit sie in
|
||||
diesen AVB nicht unmittelbar abgeändert oder ausdrücklich ausgeschlossen
|
||||
werden.</p>
|
||||
<h3 id="vertragsschluss">2 Vertragsschluss</h3>
|
||||
<p>2.1 Der Vertrag über die Lieferung von Waren kommt grundsätzlich
|
||||
durch ein Angebot des Kunden, z.B. über ein Auftragsformular, per E-Mail
|
||||
oder per Brief, und eine Annahme dieses Angebots durch KLZ in Textform
|
||||
z.B. durch eine Auftragsbestätigung per E-Mail oder Fax zustande. Der
|
||||
Annahme des Angebots durch KLZ steht es gleich, wenn KLZ den Auftrag
|
||||
oder die Bestellung binnen vierzehn (14) Kalendertagen ab Zugang des
|
||||
Angebots bei KLZ vorbehaltlos ausführt.</p>
|
||||
<p>2.2 Alle Angebote von KLZ sind – insbesondere hinsichtlich
|
||||
Verfügbarkeit, angegebener Mengen, Lieferfristen und Nebenleistungen –
|
||||
freibleibend und unverbindlich, sofern sie nicht ausdrücklich in
|
||||
Textform als verbindlich gekennzeichnet wurden. Die Angebote stehen
|
||||
unter dem Vorbehalt der zeitgerechten, qualitativen und quantitativen
|
||||
Selbstbelieferung von KLZ.</p>
|
||||
<p>2.3 Der Umfang der von KLZ zu liefernden Waren ergibt sich aus dem
|
||||
jeweiligen Angebot oder der jeweiligen Auftragsbestätigung von KLZ.</p>
|
||||
<p>2.4 Technische Angaben, Muster, Preise, Abbildungen, Zeichnungen,
|
||||
Gewichts-, Maß-, Leistungs- und Gebrauchsangaben, sowie sonstige
|
||||
Beschreibungen der zu liefernden Ware in dem Angebot oder der
|
||||
Auftragsbestätigung sind nur annähernd maßgebend, soweit sie nicht
|
||||
ausdrücklich von KLZ in Textform als verbindlich gekennzeichnet wurden.
|
||||
Sie stellen keine Beschaffenheitsvereinbarung oder Garantie einer
|
||||
entsprechenden Beschaffenheit oder Haltbarkeit der zu liefernden Ware
|
||||
dar, es sei denn, sie wurden ausdrücklich in Textform als solche
|
||||
vereinbart. Entsprechendes gilt für Erwartungen des Kunden hinsichtlich
|
||||
der zu liefernden Ware oder deren Verwendung.</p>
|
||||
<p>2.5 Handelsübliche und branchenübliche Abweichungen vom
|
||||
Leistungsumfang der zu liefernden Ware durch KLZ sind zulässig, soweit
|
||||
sie die Verwendbarkeit der zu liefernden Ware zum vertraglich
|
||||
vorgesehenen Zweck nicht beeinträchtigen. Dies umfasst auch Über- oder
|
||||
Unterlieferungen. Abweichungen der zu liefernden Ware, die durch
|
||||
zwingende rechtliche oder technische Normen, die nach
|
||||
Auftragsbestätigung in Kraft treten, bedingt sind, sind zulässig, soweit
|
||||
sie nicht erheblich und dem Kunden zumutbar sind. Änderungen, die zu
|
||||
einer technischen Verbesserung der zu liefernden Ware führen, sind stets
|
||||
zulässig.</p>
|
||||
<p>2.6 Änderungen des Lieferumfangs durch den Kunden bedürfen zu ihrer
|
||||
Wirksamkeit stets der in Textform erklärten Einwilligung von KLZ.
|
||||
Mehraufwände und sonstige Kosten, die durch die Änderung des
|
||||
Lieferumfangs auf Wunsch des Kunden entstehen, sind von dem Kunden
|
||||
gesondert zu vergüten.</p>
|
||||
<p>2.7 Soweit das Angebot oder die Auftragsbestätigung von KLZ
|
||||
offensichtliche Irrtümer, Schreib- oder Rechenfehler enthält, ist das
|
||||
Dokument für KLZ hinsichtlich der offensichtlichen Fehler nicht
|
||||
verbindlich. Der Kunde wird KLZ auf entsprechende Fehler hinweisen.</p>
|
||||
<p>2.8 Verschlechtern sich die Vermögensverhältnisse des Kunden
|
||||
wesentlich oder wird der Antrag zur Eröffnung eines Insolvenz- oder
|
||||
vergleichbaren Verfahrens über das Vermögen des Kunden mangels Masse
|
||||
abgelehnt, ist KLZ berechtigt, ganz oder teilweise vom Vertrag
|
||||
zurückzutreten.</p>
|
||||
<h3 id="abrufaufträge">3 Abrufaufträge</h3>
|
||||
<p>3.1 Erteilt der Kunde KLZ einen Abrufauftrag und werden über die
|
||||
Abruftermine keine gesonderten Vereinbarungen in Textform getroffen, ist
|
||||
der Kunde verpflichtet, KLZ die einzelnen Abruftermine so mitzuteilen,
|
||||
dass zwischen Eingang der Abrufmitteilung bei KLZ und Auslieferung der
|
||||
Ware mindestens vierzehn (14) Werktage und zwischen Eingang der
|
||||
Abrufmitteilung und der letzten Auslieferung der Ware maximal neunzig
|
||||
(90) Werktage liegen. Werktage sind dabei alle Kalendertage, bei denen
|
||||
es sich nicht um einen Sonntag oder einen gesetzlichen Feiertag am Sitz
|
||||
von KLZ handelt.</p>
|
||||
<h3 id="preise-und-zahlungsbedingungen">4 Preise und
|
||||
Zahlungsbedingungen</h3>
|
||||
<p>4.1 Die in dem Angebot oder der Auftragsbestätigung von KLZ
|
||||
angegebenen Preise gelten für den in dem Angebot oder der
|
||||
Auftragsbestätigung von KLZ aufgeführten Lieferumfang und verstehen sich
|
||||
grundsätzlich in EURO und ohne Abzüge zuzüglich Versandkosten ab dem
|
||||
jeweiligen Werk des Herstellers der Ware, exklusive Verpackung und
|
||||
zuzüglich der jeweils geltenden gesetzlichen Umsatzsteuer, sofern nicht
|
||||
ausdrücklich etwas anderes in Textform vereinbart wurde. Die in dem
|
||||
Angebot oder der Auftragsbestätigung von KLZ ausgewiesenen Hohlpreise
|
||||
verstehen sich darüber hinaus zuzüglich Metallzuschlag und
|
||||
auftragsspezifischer Schnittkosten.</p>
|
||||
<p>4.2 Sollte sich nach Vertragsschluss herausstellen, dass zur
|
||||
Durchführung des Vertrages bzw. zur Lieferung der Waren Mehrleistungen
|
||||
erforderlich sind, so werden diese Mehrleistungen gesondert abgerechnet
|
||||
und vergütet.</p>
|
||||
<p>4.3 Hat sich der Preis für eine zu liefernde Ware, bzw. der
|
||||
vereinbarte Hohlpreis z.B. aufgrund gestiegener Preise für Energie,
|
||||
Transport, Arbeitskraft oder Material zwischen Vertragsschluss und
|
||||
Lieferdatum um mehr als fünf (5) % erhöht, werden die Parteien
|
||||
partnerschaftlich und konstruktiv über eine Anpassung des Preises bzw.
|
||||
des Hohlpreises diskutieren. KLZ ist zum Rücktritt von dem betroffenen
|
||||
Vertrag berechtigt, wenn innerhalb von vierzehn (14) Kalendertagen ab
|
||||
Anzeige der Preiserhöhung durch KLZ keine einvernehmliche Lösung
|
||||
gefunden wird.</p>
|
||||
<p>4.4 Der Kaufpreis wird sofort mit Erhalt der Rechnung oder Lieferung
|
||||
der Ware, je nachdem welcher Zeitpunkt früher eintritt, zur Zahlung
|
||||
fällig, sofern keine anderen Zahlungskonditionen in Textform vereinbart
|
||||
sind. Der Kunde hat den ausstehenden Kaufpreis innerhalb von zehn (10)
|
||||
Kalendertagen nach Erhalt der Rechnung oder Erhalt der Ware, je nachdem
|
||||
welcher Zeitpunkt früher eintritt, frei Konto von KLZ und ohne Abzug zu
|
||||
bezahlen. Die Zahlungsfrist gilt mit vollständiger Gutschrift des
|
||||
ausstehenden Kaufpreises auf dem Konto von KLZ als gewahrt. Nach Ablauf
|
||||
der Zahlungsfrist kommt der Kunde automatisch mit der Zahlung des
|
||||
ausstehenden Kaufpreises in Verzug, ohne dass es hierfür einer
|
||||
gesonderten Mahnung von KLZ bedarf.</p>
|
||||
<p>4.5 Befindet sich der Kunde mit einer Zahlung in Verzug, ist KLZ
|
||||
berechtigt, die zu liefernde Ware nur Zug-um-Zug gegen Zahlung der
|
||||
Summe, mit der sich der Kunde in Verzug befindet, zu liefern. Dies gilt
|
||||
auch für alle künftigen Lieferungen von Waren von KLZ an den Kunden.
|
||||
Darüber hinaus stehen KLZ sämtliche Ansprüche gegen den Kunden aufgrund
|
||||
dessen Zahlungsverzug uneingeschränkt zu.</p>
|
||||
<p>4.6 Befindet sich der Kunde seit mehr als vierzehn (14) Kalendertagen
|
||||
in Zahlungsverzug, ist KLZ dazu berechtigt, vom Vertrag zurückzutreten
|
||||
und die gelieferte Ware unverzüglich zurück zu verlangen, ohne dass dem
|
||||
Kunden hieraus Ansprüche entstehen.</p>
|
||||
<p>4.7 Das Recht des Kunden, Zahlungen zurückzuhalten oder mit
|
||||
Gegenansprüchen aufzurechnen, steht dem Kunden nur insoweit zu, als
|
||||
seine Gegenansprüche unbestritten oder rechtskräftig festgestellt sind,
|
||||
oder wenn die jeweiligen Ansprüche in einem Gegenseitigkeitsverhältnis
|
||||
zueinanderstehen.</p>
|
||||
<h3 id="metallnotierung">5 Metallnotierung</h3>
|
||||
<p>5.1 Basis zur Berechnung des Kupferpreises ist der Durchschnitt des
|
||||
Liefervormonats der Notierung „LME Copper official price cash offer“,
|
||||
zuzüglich der jeweils aktuellen von KLZ benannten Kupfer-Prämie.</p>
|
||||
<p>5.2 Basis zur Berechnung des Aluminiumpreises ist der Durchschnitt
|
||||
des Liefervormonats der Notierung „LME Aluminium official price cash
|
||||
offer” zuzüglich der jeweils von KLZ benannten Aluminium-Prämie.</p>
|
||||
<p>5.3 USD werden auf Basis des EUR/USD LME-FX-Rate (MTLE) in EUR
|
||||
umgerechnet. Die entsprechenden Notierungen können der Webseite
|
||||
www.westmetall.com entnommen werden. Die Prämienzuschläge können stark
|
||||
variieren und KLZ behält sich das Recht vor, diese fristgerecht
|
||||
anzupassen, ungeachtet der Angebotslegung.</p>
|
||||
<h3 id="metallzahl">6 Metallzahl</h3>
|
||||
<p>6.1 Die von KLZ in dem Angebot oder der Auftragsbestätigung
|
||||
ausgewiesene Metallzahl ist eine rein kaufmännische Berechnungsgröße für
|
||||
den Metallinhalt, der in die Berechnung des Gesamtpreises eines Kabels
|
||||
eingeht.</p>
|
||||
<p>6.2 Die Angabe der Metallzahl dient ausschließlich dazu, dem Kunden
|
||||
eine Vergleichbarkeit auf Grundlage der Holpreisbasis zu ermöglichen.
|
||||
Die Metallzahl definiert nicht das Gewicht des tatsächlich im Kabel
|
||||
enthaltenen Leitermetalls. Die Metallzahl ist ein rein kalkulatorischer
|
||||
Berechnungsfaktor, der keine unmittelbaren Rückschlüsse auf die im Kabel
|
||||
verwendeten Kupfer- bzw. Aluminiummengen zulässt. KLZ weist den Kunden
|
||||
hiermit ausdrücklich darauf hin, final nur den Vollpreis für
|
||||
Vergleichszwecke heranzuziehen.</p>
|
||||
<p>6.3 Auf Anfrage des Kunden in Textform ist KLZ grundsätzlich dazu
|
||||
bereit, andere Metallzahlen zu berechnen und auszuweisen. Bei jeglicher
|
||||
Änderung bleibt der Vollpreis der gleiche Betrag. Ein Anspruch des
|
||||
Kunden auf Berechnung und Angabe anderer Metallzahlen besteht nicht.</p>
|
||||
<h3 id="lieferung">7 Lieferung</h3>
|
||||
<p>7.1 Lieferungen erfolgen grundsätzlich DAP (INCOTERMS® 2020) sofern
|
||||
die Parteien keine anderweitige Vereinbarung in Textform getroffen
|
||||
haben.</p>
|
||||
<p>7.2 Die Lieferung der Ware wird nach Möglichkeit geschlossen
|
||||
vorgenommen. KLZ ist jedoch berechtigt, Teillieferungen vorzunehmen,
|
||||
wenn dies für den Kunden zumutbar ist, insbesondere wenn wegen Fehlens
|
||||
von Waren eine erhebliche Verzögerung der gesamten Lieferung eintreten
|
||||
würde.</p>
|
||||
<p>7.3 Lieferfristen aus Angeboten und Auftragsbestätigungen sind
|
||||
grundsätzlich für KLZ unverbindlich. Eine verbindliche Lieferfrist muss
|
||||
ausdrücklich in Textform als solche gekennzeichnet werden.</p>
|
||||
<p>7.4 Die Lieferfrist beginnt mit dem Zugang der Auftragsbestätigung
|
||||
bei dem Kunden. Die Einhaltung der Lieferfrist setzt die rechtzeitige
|
||||
Klärung aller kaufmännischen und technischen Fragen, sowie die
|
||||
Einhaltung der vereinbarten Zahlungsbedingungen und sonstigen
|
||||
Verpflichtungen des Kunden voraus. Werden diese Voraussetzungen nicht
|
||||
rechtzeitig erfüllt, so verlängern sich die Fristen angemessen; dies
|
||||
gilt nicht, wenn ausschließlich KLZ die Verzögerung zu vertreten
|
||||
hat.</p>
|
||||
<p>7.5 Die Einhaltung auch einer verbindlichen Lieferfrist steht unter
|
||||
dem Vorbehalt ordnungsgemäßer, insbesondere rechtzeitiger quantitativer
|
||||
und qualitativer Selbstbelieferung von KLZ durch dessen Vorlieferanten.
|
||||
KLZ ist im Fall der nicht ordnungsgemäßen Selbstbelieferung zum
|
||||
Rücktritt vom Vertrag berechtigt. KLZ informiert den Kunden
|
||||
unverzüglich, wenn und soweit KLZ von seinem Recht zum Rücktritt vom
|
||||
Vertrag Gebrauch macht und gewährt etwa erbrachte Vorleistungen des
|
||||
Kunden zurück.</p>
|
||||
<p>7.6 Die Lieferfrist gilt als gewahrt, wenn KLZ die zu liefernde Ware
|
||||
dem Kunden bis zum Ablauf der Lieferfrist an dem benannten Lieferort
|
||||
innerhalb der üblichen Geschäftszeiten zur Annahme erstmalig angeboten
|
||||
hat.</p>
|
||||
<p>7.7 Auch wenn eine verbindliche Lieferfrist vereinbart wurde, kommt
|
||||
KLZ mit der Lieferung der Ware nur dann in Verzug, wenn der Kunde KLZ
|
||||
nach Ablauf der Lieferfrist eine angemessene Nachfrist zur Lieferung der
|
||||
Ware in Textform gesetzt hat und diese Nachfrist erfolglos verstrichen
|
||||
ist.</p>
|
||||
<p>7.8 Wird die Lieferung der Ware, oder das Abladen der Ware, auf
|
||||
Wunsch des Kunden verzögert, oder verzögert sich die Lieferung der Ware,
|
||||
oder das Abladen der Ware, aus sonstigen Gründen, die der Kunde zu
|
||||
vertreten hat, ist KLZ berechtigt, dem Kunden nach Ablauf der
|
||||
vertraglich vereinbarten Lieferfrist ein Lagergeld in Höhe von zwei (2)
|
||||
% des jeweiligen Nettoauftragswertes für jeden angefangenen Monat,
|
||||
maximal jedoch fünf (5) % des jeweiligen Nettoauftragswertes in Rechnung
|
||||
zu stellen. Das Lagergeld ist der Höhe nach auf maximal fünf (5) % des
|
||||
Nettoauftragswertes pro Vertrag beschränkt.</p>
|
||||
<p>7.9 KLZ behält sich die Geltendmachung aller darüberhinausgehenden
|
||||
Ansprüche gegenüber dem Kunden aufgrund dessen verzögerter Übernahme der
|
||||
Ware uneingeschränkt vor. Das Lagergeld ist auf weitergehende
|
||||
Zahlungsansprüche von KLZ gegenüber dem Kunden aufgrund dessen
|
||||
verzögerter Übernahme der Ware anzurechnen. Dem Kunden bleibt der
|
||||
Nachweis gestattet, dass KLZ überhaupt kein oder nur ein geringerer
|
||||
Schaden entstanden ist.</p>
|
||||
<p>7.10 Kabel werden von KLZ auf stabilen Vollholztrommeln geliefert.
|
||||
Auf Wunsch vermittelt KLZ dem Kunden Partner, die diese Trommeln gegen
|
||||
eine Gebühr abholen. Bei Lagerzeiten über drei (3) Monaten ist zu
|
||||
beachten, dass die Trommel regelmäßig bewegt wird, um eine einseitige
|
||||
Belastung zu vermeiden. Folgeschäden bei Nichtbeachtung wie Instabilität
|
||||
schließt KLZ aus.</p>
|
||||
<p>7.11 Rücksendungen von Waren an KLZ, die nicht vorher von KLZ in
|
||||
Textform bestätigt wurden, erfolgen auf alleinige Gefahr des Kunden.</p>
|
||||
<h3 id="gefahrübergang">8 Gefahrübergang</h3>
|
||||
<p>8.1 Die Gefahr des zufälligen Untergangs und der zufälligen
|
||||
Verschlechterung der zu liefernden Ware geht grundsätzlich DAP
|
||||
(INCOTERMS® 2020) auf den Kunden über, sofern die Parteien keine
|
||||
anderweitige Vereinbarung in Textform getroffen haben.</p>
|
||||
<p>8.2 Verzögert sich die Versendung bzw. Übergabe der Ware aus Gründen,
|
||||
die der Kunde zu vertreten hat, geht die Gefahr des zufälligen
|
||||
Untergangs und der zufälligen Verschlechterung der Ware mit der
|
||||
Durchführung des ersten Zustellversuchs innerhalb der gewöhnlichen
|
||||
Öffnungszeiten des Kunden durch KLZ auf den Kunden über.</p>
|
||||
<h3 id="eigentumsvorbehalt">9 Eigentumsvorbehalt</h3>
|
||||
<p>9.1 Die jeweils gelieferten Waren bleiben bis zur vollständigen
|
||||
Bezahlung sämtlicher fälliger Forderungen aus der Geschäftsbeziehung
|
||||
zwischen dem Kunden und KLZ in dem Eigentum von KLZ (nachfolgend
|
||||
„Vorbehaltsware“). Dabei ist unerheblich, aus welchem Rechtsgrund die
|
||||
Forderungen von KLZ gegen den Kunden bestehen. Erfasst sind insbesondere
|
||||
auch Saldoforderungen aus laufender Rechnung.</p>
|
||||
<p>9.2 Der Kunde ist berechtigt, die gelieferte Ware im Rahmen des
|
||||
ordnungsgemäßen Geschäftsbetriebes zu veräußern, umzubilden oder
|
||||
weiterzuverarbeiten.</p>
|
||||
<p>9.3 Die Verarbeitung oder Umbildung der Vorbehaltsware durch den
|
||||
Kunden erfolgt jedoch stets für KLZ und KLZ erwirbt Eigentum an der
|
||||
neuen Sache. Wird die Vorbehaltsware mit anderen Sachen zu einer
|
||||
einheitlichen Sache verbunden, verarbeitet oder untrennbar vermischt
|
||||
oder vermengt oder in sonstiger Weise verbunden, erwirbt KLZ Miteigentum
|
||||
an der neuen Sache im Verhältnis des Wertes der Vorbehaltsware zu den
|
||||
anderen Gegenständen zur Zeit der Verarbeitung bzw. Vermischung,
|
||||
Vermengung oder sonstigen Verbindung. Erfolgt die Vermischung,
|
||||
Vermengung oder sonstige Verbindung dergestalt, dass die Sache des
|
||||
Kunden als Hauptsache anzusehen ist, so willigt der Kunde bereits jetzt
|
||||
dazu ein, KLZ anteiliges Miteigentum an der neuen Sache, in dem
|
||||
Mengenverhältnis der Vorbehaltsware zu den fremden Sachen, zu
|
||||
übertragen. Für die so entstandenen Sachen gelten die Bestimmungen für
|
||||
Vorbehaltsware.</p>
|
||||
<p>9.4 Der Kunde hat die Vorbehaltsware mit kaufmännischer Sorgfalt für
|
||||
KLZ zu verwahren, pfleglich zu behandeln und auf seine Kosten gegen
|
||||
Feuer, Wasser und Diebstahl zum Neuwert zu versichern. Der Kunde tritt
|
||||
seine entsprechenden Ansprüche aus den Versicherungsverträgen an KLZ ab;
|
||||
KLZ nimmt die Abtretung an.</p>
|
||||
<p>9.5 Der Kunde darf die Vorbehaltsware nur im ordnungsgemäßen
|
||||
Geschäftsgang bis zum Eintritt des Verwertungsfalles veräußern.
|
||||
Verpfändungen und Sicherungsübereignungen hinsichtlich der
|
||||
Vorbehaltsware sind unzulässig. Eingriffe Dritter, wie etwa die Pfändung
|
||||
oder Zwangsvollstreckung, durch welche Rechte von KLZ beeinträchtigt
|
||||
werden, hat der Kunde KLZ unverzüglich in Textform anzuzeigen. Der Kunde
|
||||
hat den Dritten darüber hinaus unverzüglich in Textform darauf
|
||||
hinzuweisen, dass KLZ Eigentümer der Vorbehaltsware ist. Soweit der
|
||||
Dritte nicht in der Lage ist, KLZ die gerichtlichen und
|
||||
außergerichtlichen Kosten einer Klage nach § 771 ZPO zu erstatten,
|
||||
haftet der Kunde für den dadurch entstandenen Ausfall. Weitergehende
|
||||
Ansprüche gegen den Kunden bleiben unberührt.</p>
|
||||
<p>9.6 Für den Fall der Weiterveräußerung der Vorbehaltsware tritt der
|
||||
Kunde die ihm daraus seinerseits erwachsenden Forderungen gegen seinen
|
||||
Abnehmer bereits jetzt an KLZ ab; KLZ nimmt die Abtretung an. Gleiches
|
||||
gilt für sonstige Forderungen, die an die Stelle der Vorbehaltsware
|
||||
treten oder sonst hinsichtlich der Vorbehaltsware entstehen, wie z. B.
|
||||
Versicherungsansprüche oder Ansprüche aus unerlaubter Handlung bei
|
||||
Verlust oder Zerstörung.</p>
|
||||
<p>9.7 Wird die Vorbehaltsware nach Verarbeitung oder zusammen mit
|
||||
anderen Waren, die KLZ nicht gehören, weiterveräußert oder wird sie mit
|
||||
einem Grundstück oder mit beweglichen Sachen verbunden, so erklärt der
|
||||
Kunde bereits jetzt die Abtretung der Forderung des Kunden gegen seine
|
||||
Abnehmer in Höhe des zwischen dem Kunden und KLZ vereinbarten
|
||||
Kaufpreises für die Vorbehaltsware; KLZ nimmt die Abtretung an.</p>
|
||||
<p>9.8 Der Kunde wird ermächtigt, die abgetretenen Forderungen für KLZ
|
||||
einzuziehen. KLZ bleibt auch weiterhin zur Einziehung der abgetretenen
|
||||
Forderungen berechtigt, wird die abgetretenen Forderungen jedoch nicht
|
||||
selbst einziehen, solange kein Grund nach Ziffer 9.9 vorliegt, der KLZ
|
||||
zum Widerruf der Einziehungsermächtigung berechtigt. Zur Abtretung der
|
||||
Forderung an Dritte, einschließlich Forderungsverkauf an Factoring
|
||||
Banken, ist der Kunde nur mit vorheriger schriftlicher Zustimmung von
|
||||
KLZ berechtigt. KLZ wird die Zustimmung nicht verweigern, sofern eine
|
||||
gleichwertige Sicherheit gestellt wird.</p>
|
||||
<p>9.9 KLZ kann die Ermächtigung zur Weiterveräußerung und Einziehung
|
||||
widerrufen, sobald sich der Kunde gegenüber KLZ im Zahlungsverzug
|
||||
befindet, oder das Insolvenzverfahren oder ein vergleichbares Verfahren
|
||||
über das Vermögen des Kunden eröffnet oder mangels Masse abgewiesen
|
||||
wurde.</p>
|
||||
<p>9.10 Der Kunde hat die eingezogenen Forderungen binnen zehn (10)
|
||||
Werktagen auf ein von KLZ benanntes Konto zu überweisen.</p>
|
||||
<p>9.11 KLZ ist zur Offenlegung der Abtretung berechtigt und kann von
|
||||
dem Kunden verlangen, dass dieser die Abtretung seinen Abnehmern
|
||||
anzeigt. Auf Verlangen ist der Kunde verpflichtet, KLZ die Namen seiner
|
||||
Abnehmer bekanntzugeben und alle erforderlichen Unterlagen
|
||||
herauszugeben, die für die Einziehung der Forderung durch KLZ selbst
|
||||
erforderlich sind. Auch ist der Kunde ab Verzugseintritt verpflichtet,
|
||||
die Abtretung der Forderung an KLZ seinem Abnehmer gegenüber schriftlich
|
||||
mitzuteilen.</p>
|
||||
<p>9.12 KLZ wird die Vorbehaltsware sowie die an ihre Stelle tretenden
|
||||
Sachen oder Forderungen freigeben, soweit ihr Wert die Höhe der
|
||||
gesicherten Forderungen um mehr als zehn (10) % übersteigt. Die Auswahl
|
||||
der danach freizugebenden Gegenstände liegt bei KLZ.</p>
|
||||
<p>9.13 Tritt KLZ bei vertragswidrigem Verhalten des Kunden,
|
||||
insbesondere Zahlungsverzug, vom Vertrag zurück (Verwertungsfall), ist
|
||||
KLZ berechtigt, die Vorbehaltsware herauszuverlangen. Der Kunde hat in
|
||||
diesem Fall KLZ alle Informationen und Unterlagen zu übergeben, die
|
||||
erforderlich sind, um die Vorbehaltsware herauszuverlangen und die an
|
||||
KLZ abgetretenen Ansprüche des Kunden gegenüber seinen Abnehmern geltend
|
||||
zu machen, mindestens jedoch eine Aufstellung über die bei dem Kunden
|
||||
oder bei den Abnehmern befindliche Vorbehaltsware, auch soweit sie
|
||||
bereits verarbeitet ist, und eine Aufstellung aller abgetretenen
|
||||
Forderungen nebst Rechnungsgutschriften.</p>
|
||||
<h3 id="höhere-gewalt">10 Höhere Gewalt</h3>
|
||||
<p>10.1 „Höhere Gewalt“ bedeutet das Eintreten eines Ereignisses oder
|
||||
Umstands, welcher eine Partei daran hindert, eine oder mehrere ihrer
|
||||
vertraglichen Verpflichtungen zu erfüllen, wenn und soweit die von dem
|
||||
Hindernis betroffene Partei nachweist, dass (a) dieses Hindernis
|
||||
außerhalb der ihr zumutbaren Kontrolle liegt und (b) es zu dem Zeitpunkt
|
||||
des Vertragsschlusses nicht in zumutbarer Weise vorhersehbar war und (c)
|
||||
die Auswirkungen des Hindernisses von der betroffenen Partei nicht in
|
||||
zumutbarer Weise hätte vermieden oder überwunden werden können.</p>
|
||||
<p>10.2 Bei dem Beweis des Gegenteils wird insbesondere, aber nicht
|
||||
abschließend, bei den folgenden Ereignissen ein Fall Höherer Gewalt im
|
||||
Sinne des vorstehenden Absatzes vermutet: Krieg, Terrorakte, Währungs-
|
||||
und Handelsbeschränkungen, Embargo, Sanktionen, Amtshandlungen,
|
||||
Befolgung von Gesetzen oder Regierungsanordnungen, staatliche Ausreise-
|
||||
und Exportverbote oder staatliche Einreise- und Importverbote,
|
||||
Enteignung, Epidemie, Naturkatastrophe, extremes Naturereignis,
|
||||
Explosionen, Feuer, Zerstörung von Ausrüstungen, Demonstrationen oder
|
||||
Versammlungen, Ereignisse, die das Passieren von wichtigen
|
||||
Transportrouten verhindern, allgemeine Arbeitsunruhen, insbesondere
|
||||
Boykott, Streik und Aussperrungen, Energieknappheit oder
|
||||
Beeinträchtigung von Transportmitteln.</p>
|
||||
<p>10.3 Eine Partei, die sich mit Erfolg auf diese Klausel beruft, ist
|
||||
ab dem Zeitpunkt, zu dem das Hindernis ihr die Leistungserbringung
|
||||
unmöglich macht, von ihrer Pflicht zur Erfüllung ihrer vertraglichen
|
||||
Verpflichtungen und von jeder Schadensersatzpflicht oder jedem anderen
|
||||
vertraglichen Rechtsbehelf wegen Vertragsverletzung suspendiert, sofern
|
||||
der Eintritt eines Ereignisses Höherer Gewalt der anderen Partei
|
||||
unverzüglich mitgeteilt wird. Ist die Auswirkung des geltend gemachten
|
||||
Hindernisses oder Ereignisses vorübergehend, so gelten die eben
|
||||
dargelegten Folgen nur so lange, wie das geltend gemachte Hindernis die
|
||||
Vertragserfüllung durch die betroffene Partei verhindert sowie einer
|
||||
angemessenen Anlaufzeit. Dauert das Hindernis oder Ereignis höherer
|
||||
Gewalt bei der betroffenen Partei länger als vier (4) Monate an, ist die
|
||||
andere Partei zur ganzen oder teilweisen außerordentlichen Kündigung
|
||||
bzw. zum Rücktritt vom Vertrag berechtigt. Wechselseitige
|
||||
Schadensersatzansprüche wegen dieser Kündigung bzw. dieses Rücktritts
|
||||
sind ausgeschlossen.</p>
|
||||
<p>10.4 Der Kunde ist verpflichtet, auf Verlangen von KLZ innerhalb
|
||||
einer angemessenen Frist zu erklären, ob er wegen einem bei KLZ
|
||||
eingetretenen Ereignis Höherer Gewalt den Vertrag kündigt bzw. von
|
||||
diesem Zurücktritt oder weiterhin auf der Durchführung der Lieferung der
|
||||
Ware besteht.</p>
|
||||
<p>10.5 Sofern sich aufgrund eines Ereignisses Höherer Gewalt die
|
||||
wirtschaftliche Bedeutung oder der Inhalt der Lieferung der Ware
|
||||
erheblich verändert oder auf den Betrieb von KLZ erheblich einwirkt,
|
||||
werden die Parteien den zugrundeliegenden Vertrag unter Beachtung von
|
||||
Treu und Glauben angemessen anpassen. Soweit dies wirtschaftlich nicht
|
||||
vertretbar ist, steht KLZ das Recht zu, von dem Vertrag zurückzutreten.
|
||||
Will KLZ von diesem Rücktrittsrecht Gebrauch machen, so hat er dies nach
|
||||
Erkenntnis der Tragweite des Ereignisses Höherer Gewalt unverzüglich dem
|
||||
Kunden mitzuteilen, und zwar auch dann, wenn zunächst mit dem Kunden
|
||||
eine Verlängerung der Lieferzeit vereinbart war.</p>
|
||||
<h3 id="gewährleistung-und-mängelrüge">11 Gewährleistung und
|
||||
Mängelrüge</h3>
|
||||
<p>11.1 Mängelansprüche bestehen nicht bei nur unerheblicher Abweichung
|
||||
der gelieferten Ware von den vereinbarten Spezifikationen, bei
|
||||
unerheblicher Beeinträchtigung der von KLZ vorgegebenen Brauchbarkeit,
|
||||
bei natürlicher Abnutzung, übermäßiger Beanspruchung, Verwendung
|
||||
ungeeigneter Betriebsmittel, oder bei Mängeln, die aufgrund besonderer
|
||||
äußerer Einflüsse entstehen, sowie bei nicht reproduzierbaren
|
||||
Softwarefehlern. Werden vom Kunden oder von Dritten unsachgemäß
|
||||
Änderungen oder Instandsetzungsarbeiten vorgenommen, so bestehen für
|
||||
diese und die daraus entstehenden Folgen ebenfalls keine
|
||||
Mängelansprüche.</p>
|
||||
<p>11.2 Eine Gewährleistungsverpflichtung von KLZ besteht nur, wenn der
|
||||
Kunde den ihm obliegenden Untersuchungs- und Rügepflichten fristgerecht
|
||||
nachgekommen ist. Eingehende Waren hat der Kunde unverzüglich auf
|
||||
äußerlich erkennbare Transportschäden und Identität der gelieferten Ware
|
||||
mit der Bestellung zu überprüfen. Äußerlich erkennbare Mängel sind
|
||||
unverzüglich, spätestens jedoch innerhalb von drei (3) Werktagen ab
|
||||
Übergabe, sonstige Mängel innerhalb von fünf (5) Werktagen nach ihrer
|
||||
Entdeckung gegenüber KLZ im Rahmen einer Mängelrüge mindestens in
|
||||
Textform geltend zu machen.</p>
|
||||
<p>11.3 Mängelrügen müssen eine genaue Beschreibung der aufgetretenen
|
||||
Mängel, der betroffenen Lieferungen und wenn möglich, auch eine
|
||||
bildhafte Dokumentation des Mangels enthalten. KLZ ist berechtigt,
|
||||
mangelhafte Teile auf eigene Kosten zur Übersendung und Untersuchung
|
||||
anzufordern.</p>
|
||||
<p>11.4 Beim Verlegen von Kabeln in Gräben oder Rohren, bzw. in
|
||||
Bauwerken ist eine ständige Sichtkontrolle durch den Kabelverleger
|
||||
vorzunehmen. Der Kabelverleger hat laufend zu überprüfen, ob
|
||||
Auffälligkeiten zu vermerken sind.</p>
|
||||
<p>11.5 KLZ ist berechtigt, innerhalb einer angemessenen Frist,
|
||||
anerkannte Mängel nach eigener Wahl durch Beseitigung (Nachbesserung)
|
||||
oder durch Lieferung mangelfreier Ware (Ersatzlieferung) zu beheben. Der
|
||||
Kunde wird KLZ bei der Beseitigung von Mängeln in zumutbarem Umfang
|
||||
unterstützen.</p>
|
||||
<p>11.6 Die Kosten der Nacherfüllung und Mangeluntersuchung werden bei
|
||||
berechtigten Beanstandungen von KLZ getragen. Dies gilt nicht für
|
||||
Aufwendungen, die dadurch entstehen, dass die gelieferten Waren durch
|
||||
den Kunden an einen anderen als den Ort der gewerblichen Niederlassung
|
||||
des Kunden verbracht worden sind, es sei denn, das Verbringen entspricht
|
||||
dem vertraglich vorgesehenen Verwendungszweck der gelieferten Ware. Satz
|
||||
1 gilt ebenfalls nicht für Mehraufwendungen, die dadurch entstehen, dass
|
||||
der Kunde die gelieferte Ware ohne Zustimmung von KLZ verändert hat.</p>
|
||||
<p>11.7 Hat der Kunde die Ware verbaut, obwohl er positive Kenntnis oder
|
||||
grob fahrlässige Unkenntnis des Mangels hatte, ist KLZ nicht dazu
|
||||
verpflichtet, die Ein- und Ausbaukosten der Ware zu erstatten.</p>
|
||||
<p>11.8 Der Kunde kann erst nach zweimaligem Fehlschlagen der
|
||||
Mängelbeseitigung vom Vertrag zurücktreten oder den vereinbarten
|
||||
Kaufpreis angemessen mindern. Bei nur unerheblicher Minderung des Werts
|
||||
oder der Tauglichkeit der gelieferten Waren ist der Rücktritt
|
||||
ausgeschlossen, ebenso wenn der Kunde sich im Annahmeverzug befindet
|
||||
oder wenn der Kunde den Mangel zu vertreten hat. Solange der Kunde KLZ
|
||||
gegenüber nicht den Rücktritt vom Vertrag erklärt oder Schadensersatz
|
||||
wegen Nichterfüllung verlangt hat, ist KLZ auch nach Ablauf der vom
|
||||
Kunden gesetzten Frist zur Nacherfüllung berechtigt, es sei denn, der
|
||||
Kunde hat zuvor schriftlich die Ablehnung der Nacherfüllung
|
||||
angezeigt.</p>
|
||||
<p>11.9 Beruht ein Mangel auf dem Verschulden von KLZ, kann der Kunde
|
||||
unter den in Ziffer 12 bestimmten Voraussetzungen Schadensersatz
|
||||
verlangen. Ziffer 12 gilt entsprechend für den Ersatz vergeblicher
|
||||
Aufwendungen, den der Kunde anstelle eines Schadensersatzes statt der
|
||||
Leistung verlangen kann.</p>
|
||||
<p>11.10 Besteht Uneinigkeit zwischen den Parteien in Bezug auf Mängel
|
||||
an den Waren, ist ein Gutachten bei dem VDE-Institut über die streitige
|
||||
Frage einzuholen. Das Ergebnis des Gutachtens des VDE-Instituts ist für
|
||||
die Feststellung der Mangelfreiheit oder eines Mangels maßgebend. KLZ
|
||||
ist nur dann verpflichtet, die Kosten des Gutachtens des VDE-Instituts
|
||||
zu tragen, wenn das Gutachten einen Mangel feststellt.</p>
|
||||
<p>11.11 Die gesetzlichen Mängelgewährleistungsansprüche des Kunden
|
||||
verjähren innerhalb von zwölf (12) Monaten ab Gefahrübergang. §§ 438
|
||||
Abs. 1 Nr. 2, 479 Abs. 1 und 634a Abs. 1 Nr. 2 BGB bleiben hiervon
|
||||
unberührt. Diese Verkürzung der Verjährungsfrist gilt nicht bei Vorsatz,
|
||||
grober Fahrlässigkeit, der Verletzung von Leben, Körper, Gesundheit,
|
||||
oder jedweder wesentlicher Vertragspflichtverletzung, zwingender
|
||||
gesetzlicher Haftungsnormen wie denen des ProdHaftG, sowie bei
|
||||
Nichteinhaltung einer Beschaffenheitsgarantie oder dem arglistigen
|
||||
Verschweigen eines Mangels. Die gesetzlichen Regelungen über
|
||||
Ablaufhemmung, Hemmung und Neubeginn der Fristen bleiben unberührt.</p>
|
||||
<h3 id="haftung">12 Haftung</h3>
|
||||
<p>12.1 KLZ haftet uneingeschränkt für Vorsatz und grobe Fahrlässigkeit.
|
||||
KLZ haftet ferner uneingeschränkt für Schäden aus der Verletzung des
|
||||
Lebens, des Körpers oder der Gesundheit, sowie für arglistiges
|
||||
Verschweigen eines Mangels und Verletzung von garantierten
|
||||
Beschaffenheitsmerkmalen, sowie im Rahmen zwingender gesetzlicher
|
||||
Vorschriften, wie etwa denen des Produkthaftungsgesetzes.</p>
|
||||
<p>12.2 Bei der Verletzung von wesentlichen Vertragspflichten haftet KLZ
|
||||
im Übrigen der Höhe nach begrenzt auf den bei Vertragsschluss für KLZ
|
||||
typischerweise vorhersehbaren Schaden bzw. die typischerweise
|
||||
vorhersehbaren Aufwendungen. Vertragswesentlich sind die
|
||||
Verpflichtungen, deren Erfüllung die ordnungsgemäße Vertragsdurchführung
|
||||
erst ermöglichen und auf deren Einhaltung der Kunde vertraut und auch
|
||||
vertrauen darf. Im Übrigen haftet KLZ bei leichter Fahrlässigkeit nicht
|
||||
auf Schadensersatz.</p>
|
||||
<p>12.3 Eine Haftung für mittelbare Schäden und Folgeschäden, die Folge
|
||||
von Mängeln der gelieferten Waren sind, besteht nur für solche Schäden,
|
||||
die bei bestimmungsgemäßer Verwendung der gelieferten Ware
|
||||
typischerweise zu erwarten sind.</p>
|
||||
<p>12.4 Die Haftung entfällt für Schäden, die auf Reparaturen,
|
||||
Änderungen oder anderen Eingriffen oder auf die Verwendung von
|
||||
Nicht-Originalteilen von KLZ im Rahmen der Reparatur oder Änderung bzw.
|
||||
des Eingriffs zurückzuführen sind, die der Kunde ohne Zustimmung von KLZ
|
||||
vorgenommen hat.</p>
|
||||
<p>12.5 Soweit die Haftung von KLZ ausgeschlossen oder beschränkt ist,
|
||||
gilt dies auch für die Haftung der Angestellten, Arbeitnehmer,
|
||||
Mitarbeiter, Vertreter und Erfüllungsgehilfen von KLZ.</p>
|
||||
<h3 id="produkthaftung">13 Produkthaftung</h3>
|
||||
<p>13.1 Der Kunde wird die gelieferten Waren ausschließlich
|
||||
vertragsgemäß und entsprechend den verfügbaren Anleitungen von KLZ
|
||||
verwenden, insbesondere wird er vorhandene Warnhinweise über Gefahren
|
||||
bei Gebrauch der gelieferten Waren nicht verändern oder entfernen. Bei
|
||||
Verletzung dieser Pflicht stellt der Kunde KLZ im Innenverhältnis von
|
||||
Produkthaftungsansprüchen Dritter frei, es sei denn der Kunde ist für
|
||||
den die Haftung auslösenden Fehler nicht verantwortlich.</p>
|
||||
<p>13.2 Wird KLZ aufgrund eines Produktfehlers zu einem Produktrückruf
|
||||
oder einer -warnung veranlasst, so wird der Kunde nach besten Kräften
|
||||
bei den Maßnahmen mitwirken, die KLZ für erforderlich und zweckmäßig
|
||||
hält. KLZ ist verpflichtet, die Kosten des Produktrückrufs oder der
|
||||
-warnung zu tragen, es sei denn KLZ ist für den Produktfehler und den
|
||||
eingetretenen Schaden nach produkthaftungsrechtlichen Grundsätzen nicht
|
||||
verantwortlich.</p>
|
||||
<p>13.3 Der Kunde wird KLZ unverzüglich über ihm bekanntwerdende Risiken
|
||||
bei der Verwendung der gelieferten Ware und mögliche Produktfehler in
|
||||
Textform informieren.</p>
|
||||
<h3 id="exportkontrolle">14 Exportkontrolle</h3>
|
||||
<p>14.1 Lieferungen von Waren von KLZ stehen unter dem Vorbehalt, dass
|
||||
der Vertragserfüllung keine Hindernisse aufgrund von nationalen oder
|
||||
internationalen exportkontrollrechtlichen Vorschriften entgegenstehen.
|
||||
Sofern sich die Durchführung des Vertrages aufgrund von
|
||||
Exportkontrollprüfungen oder Genehmigungsverfahren verzögert, werden
|
||||
Fristen und Lieferzeiten außer Kraft gesetzt. KLZ ist berechtigt, von
|
||||
der Einzelbestellung zurückzutreten und den Liefervertrag fristlos zu
|
||||
kündigen, wenn ein solcher Rücktritt oder eine Kündigung zur Einhaltung
|
||||
nationaler oder internationalen Exportkontrollbestimmung erforderlich
|
||||
ist. Die Geltendmachung von Schadensersatzansprüchen oder anderen
|
||||
Rechten durch den Kunden wegen vorgenannter Kündigung oder Verzögerung
|
||||
ist ausgeschlossen.</p>
|
||||
<p>14.2 Der Kunde verpflichtet sich zur Einhaltung des nationalen und
|
||||
internationalen Exportkontrollrechts, insbesondere bei Weitergabe der
|
||||
gelieferten Ware an Dritte.</p>
|
||||
<h3 id="urheberrechte-und-datenschutz">15 Urheberrechte und
|
||||
Datenschutz</h3>
|
||||
<p>15.1 An Kostenvoranschlägen, Zeichnungen, Modellen, Teilen,
|
||||
Schablonen, Berechnungen, Beschreibungen, Mustern und anderen Unterlagen
|
||||
(im Folgenden: „Unterlagen“ genannt) behält sich KLZ seine eigentums-
|
||||
und urheberrechtlichen Verwertungsrechte uneingeschränkt vor. Die
|
||||
Unterlagen dürfen nur nach vorheriger schriftlicher Zustimmung von KLZ
|
||||
Dritten zugänglich gemacht werden und sind KLZ auf Verlangen
|
||||
unverzüglich zurückzugeben.</p>
|
||||
<p>15.2 Ohne ausdrückliche Zustimmung von KLZ in Textform ist der Kunde
|
||||
nicht berechtigt, die gelieferte Ware zu Werbezwecken zu verwenden.</p>
|
||||
<p>15.3 KLZ erhebt und verwendet personenbezogenen Daten ausschließlich
|
||||
gemäß den Bestimmungen der Datenschutzgrundverordnung und des
|
||||
Bundesdatenschutzgesetzes.</p>
|
||||
<p>15.4 Personenbezogenen Daten, die für die Begründung, inhaltliche
|
||||
Ausgestaltung, Abwicklung oder Änderung des Vertragsverhältnisses
|
||||
erforderlich sind (Bestandsdaten), werden ausschließlich zur
|
||||
Durchführung des zwischen KLZ und dem Kunden abgeschlossenen Vertrages
|
||||
verwendet. Zur Abwicklung des Vertragsverhältnisses müssen diese Daten
|
||||
im erforderlichen Umfang an Dritte (z.B. Transporteure oder Spediteure)
|
||||
weitergegeben werden. Der Kunde erteilt für die vorbeschriebene
|
||||
Verwendung seiner personenbezogenen Daten hiermit eine entsprechende
|
||||
Einwilligung.</p>
|
||||
<p>15.5 Diese Einwilligungserklärung ist jederzeit -ohne Angabe von
|
||||
Gründen- widerruflich. Der Widerruf ist zu richten an
|
||||
info@klz-cables.com .</p>
|
||||
<p>15.6 Eine darüberhinausgehende Nutzung der Bestandsdaten des Kunden
|
||||
für Zwecke der Werbung, der Marktforschung oder zur bedarfsgerechten
|
||||
Gestaltung der Angebote von KLZ bedarf der gesonderten ausdrücklichen
|
||||
Einwilligung des Kunden.</p>
|
||||
<p>15.7 Auf Anordnung einer zuständigen Stelle darf KLZ im Einzelfall
|
||||
Auskunft über personenbezogene Daten des Kunden im Rahmen der Anordnung
|
||||
an die zuständige Stelle erteilen, soweit dies für Zwecke der
|
||||
Strafverfolgung, zur Gefahrenabwehr, zur Erfüllung der gesetzlichen
|
||||
Aufgaben der Verfassungsschutzbehörden oder zur Durchsetzung der Rechte
|
||||
am geistigen Eigentum erforderlich ist.</p>
|
||||
<h3 id="geheimhaltung">16 Geheimhaltung</h3>
|
||||
<p>16.1 „Vertrauliche Informationen“ sind (i) alle Informationen, Daten,
|
||||
Zeichnungen, Unterlagen, Materialien, Know-how oder sonstige
|
||||
Geschäftsgeheimnisse im Sinne von § 2 Nr. 1 des Gesetzes zum Schutz von
|
||||
Geschäftsgeheimnissen (GeschGehG), insbesondere alle Entwürfe, Skizzen,
|
||||
Zeichnungen, technischen Protokolle, Modelle oder elektronische Daten,
|
||||
gleich in welcher Verkörperung, (ii) sowie alle nicht offenkundigen
|
||||
kaufmännischen, betrieblichen oder technischen Informationen oder
|
||||
Gegenstände, gleich in welcher Verkörperung, die dem Kunden im Rahmen
|
||||
der Geschäftsbeziehung mit KLZ offengelegt oder anderweitig bekannt
|
||||
werden, (iii) die entweder direkt als vertraulich gekennzeichnet wurden
|
||||
oder nachträglich schriftlich oder in Textform als vertraulich
|
||||
gekennzeichnet werden. Des Weiteren gelten auch solche Informationen als
|
||||
vertraulich, welche der Natur nach bzw. aus den Umständen heraus oder
|
||||
aus anderen Gründen erkennbar als vertraulich anzusehen sind, soweit die
|
||||
Parteien nichts anderes in Schriftform vereinbart haben.</p>
|
||||
<p>16.2 Vertrauliche Informationen im Sinne von Ziffer 16.1 sind jedoch
|
||||
nicht solche Informationen, (i) die der Kunde von öffentlich
|
||||
zugänglichen Quellen bezogen hat und die allgemein bekannt oder auf
|
||||
legalem Wege zugänglich sind und diese Quellen die Informationen
|
||||
ebenfalls auf legalem Wege erlangt haben, (ii) die der Kunde von einem
|
||||
berechtigten Dritten rechtmäßig erhalten hat, (iii) die der Kunde
|
||||
eigenständig entwickelt hat, (iv) die kraft Gesetzes oder aufgrund
|
||||
gerichtlicher oder behördlicher Anordnung offengelegt wurden oder
|
||||
offenzulegen sind. Der Kunde ist in diesem Fall verpflichtet, KLZ vor
|
||||
einer solchen Offenlegung der Informationen unverzüglich zu informieren
|
||||
und zu versuchen, die Offenlegung zu verhindern, soweit dies mit
|
||||
angemessenen und gesetzlich zulässigen Mitteln möglich ist.</p>
|
||||
<p>16.3 Die Darlegungs- und Beweislast für das Vorliegen einer in Ziffer
|
||||
16.2 benannten Ausnahmetatbestände trägt diejenige Partei, die sich auf
|
||||
das Vorliegen einer oder mehrerer dieser Ausnahmetatbestände beruft.</p>
|
||||
<p>16.4 Der Kunde verpflichtet sich, die Vertraulichen Informationen
|
||||
strikt vertraulich zu behandeln und ausschließlich zur Erfüllung des
|
||||
Vertragszwecks zu verwenden. Die Vertraulichen Informationen dürfen
|
||||
durch den Kunden nur an solche Mitarbeiter oder andere Personen
|
||||
weitergegeben werden, die sie zur Erreichung des Vertragszwecks zwingend
|
||||
erhalten müssen. Sämtliche Mitarbeiter des Kunden, welche Kenntnis von
|
||||
Vertraulichen Informationen erhalten, müssen durch den Kunden ebenfalls
|
||||
zur Geheimhaltung hinsichtlich der Vertraulichen Informationen
|
||||
verpflichtet sein oder werden. Diese Geheimhaltungspflicht soll auch für
|
||||
die Zeit nach einem etwaigen Ausscheiden des Mitarbeiters aus dem
|
||||
Unternehmen des Kunden für weitere fünf (5) Jahre gelten, soweit dies
|
||||
rechtlich möglich ist.</p>
|
||||
<p>16.5 Der Kunde verpflichtet sich, auf erstes Anfordern von KLZ
|
||||
und/oder nach Beendigung der Geschäftsbeziehung oder Beendigung des
|
||||
zugrundeliegenden Vertrage, gleich aus welchem Rechtsgrund, alle unter
|
||||
diese Geheimhaltungsvereinbarung fallenden Vertraulichen Informationen
|
||||
und davon angefertigte Kopien an KLZ herauszugeben oder sachgemäß zu
|
||||
zerstören bzw. sicher und dauerhaft zu löschen und die vollständige
|
||||
Herausgabe, vollständige sachgemäße Zerstörung oder vollständige sichere
|
||||
und dauerhafte Löschung KLZ gegenüber schriftlich zu bestätigen.</p>
|
||||
<p>16.6 Ziffer 16.5 gilt nicht für Vertrauliche Informationen, die
|
||||
zwingenden gesetzlichen Aufbewahrungspflichten unterliegen oder in
|
||||
automatisierten Back-Up Dateien im Rahmen des ordnungsgemäßen
|
||||
Sicherungsprozesses sicher gespeichert wurden. Für diese Vertraulichen
|
||||
Informationen gelten die Bestimmungen dieser Geheimhaltungsvereinbarung
|
||||
bis zu ihrer Herausgabe, sicheren und dauerhaften Löschung oder
|
||||
vollständigen sachgemäßen Zerstörung fort.</p>
|
||||
<p>16.7 Die Geheimhaltungsverpflichtungen gemäß dieser Ziffer 16 gelten
|
||||
für einen Zeitraum von fünf (5) Jahren nach Beendigung des
|
||||
zugrundeliegenden Vertrages, gleich aus welchem Rechtsgrund, fort.</p>
|
||||
<p>16.8 Für jeden Einzelfall eines Verstoßes des Kunden gegen die
|
||||
Pflichten aus dieser Geheimhaltungsvereinbarung ist der Kunde zur
|
||||
Zahlung einer Vertragsstrafe an KLZ verpflichtet, deren Höhe in das
|
||||
billige Ermessen von KLZ gestellt wird und im Streitfall durch das
|
||||
zuständige Gericht überprüft werden kann. Durch die Geltendmachung der
|
||||
Vertragsstrafe werden weitergehende Schadensersatzansprüche von KLZ
|
||||
nicht ausgeschlossen.</p>
|
||||
<h3 id="schlussbestimmungen">17 Schlussbestimmungen</h3>
|
||||
<p>17.1 Änderungen oder Ergänzungen dieser AVB bedürfen der Textform.
|
||||
Dies gilt auch für die Abänderung des Textformerfordernisses selbst.</p>
|
||||
<p>17.2 Auf Verträge zwischen KLZ und dem Kunden, der Auslegung dieser
|
||||
AVB, sowie die gesamten Rechtsbeziehungen zwischen KLZ und dem Kunden
|
||||
gilt ausschließlich das Recht der Bundesrepublik Deutschland unter
|
||||
Ausschluss des UN-Kaufrechts und des Kollisionsrechts. Erfüllungsort
|
||||
sowie ausschließlicher Gerichtsstand für alle Streitigkeiten aus oder im
|
||||
Zusammenhang mit der Rechtsbeziehung zwischen KLZ und dem Kunden ist
|
||||
Stuttgart.</p>
|
||||
<p>17.3 Sollten einzelne Bestimmungen dieser AVB unwirksam oder
|
||||
undurchführbar sein oder werden, so wird hierdurch die Wirksamkeit aller
|
||||
sonstigen Bestimmungen dieser AVB im Übrigen nicht berührt. Die
|
||||
unwirksame oder undurchführbare Bestimmung wird von den Vertragsparteien
|
||||
einvernehmlich durch eine rechtswirksame Regelung ersetzt, welche dem
|
||||
wirtschaftlichen Erfolg und Zweck der unwirksamen oder undurchführbaren
|
||||
Bestimmung am nächsten kommt. Dies gilt gleichermaßen für den Fall einer
|
||||
Regelungslücke.</p>
|
||||
<p>Stand: April 2026</p>
|
||||
238
AVB_2026.md
238
AVB_2026.md
@@ -1,238 +0,0 @@
|
||||
# Allgemeine Verkaufsbedingungen
|
||||
|
||||
### 1 Geltungsbereich
|
||||
|
||||
1.1 Ausschließlich die nachfolgenden Allgemeinen Verkaufsbedingungen (nachfolgend „AVB“) gelten für den gesamten Geschäftsverkehr (nachfolgend „Lieferung von Waren“) der KLZ Vertriebs GmbH, Raiffeisenstraße 22, 73630 Remshalden (nachfolgend „KLZ“) und Unternehmern in Ausübung ihrer gewerblichen oder selbständigen beruflichen Tätigkeit und juristischen Personen des öffentlichen Rechts (nachfolgend „Kunde“). Der Geltung von abweichenden, zusätzlichen oder entgegenstehenden allgemeinen Geschäftsbedingungen des Kunden wird hiermit widersprochen.
|
||||
|
||||
1.2 Diese AVB gelten auch dann, wenn KLZ die Lieferung von Waren an den Kunden vorbehaltlos in Kenntnis entgegenstehender oder von diesen AVB abweichenden allgemeinen Geschäftsbedingungen des Kunden ausführt.
|
||||
|
||||
1.3 Im Rahmen laufender Geschäftsbeziehungen gelten diese AVB auch dann für künftige Lieferungen von Waren, wenn sie nicht ausdrücklich erneut in den Vertrag einbezogen wurden.
|
||||
|
||||
1.4 Änderungen dieser AVB werden dem Kunden schriftlich, per Telefax oder per E-Mail mitgeteilt. Widerspricht der Kunde diesen Änderungen nicht innerhalb von vier (4) Wochen nach Zugang der Mitteilung, gelten die Änderungen als durch den Kunden anerkannt. Auf das Widerspruchsrecht und die Rechtsfolgen des Schweigens wird der Kunde im Rahmen der Mitteilung über die Änderung der Geschäftsbedingungen gesondert hingewiesen.
|
||||
|
||||
1.5 Hinweise auf die Geltung gesetzlicher Vorschriften im Rahmen dieser AVB haben nur klarstellende Bedeutung. Auch ohne eine derartige Klarstellung gelten daher die gesetzlichen Vorschriften, soweit sie in diesen AVB nicht unmittelbar abgeändert oder ausdrücklich ausgeschlossen werden.
|
||||
|
||||
### 2 Vertragsschluss
|
||||
|
||||
2.1 Der Vertrag über die Lieferung von Waren kommt grundsätzlich durch ein Angebot des Kunden, z.B. über ein Auftragsformular, per E-Mail oder per Brief, und eine Annahme dieses Angebots durch KLZ in Textform z.B. durch eine Auftragsbestätigung per E-Mail oder Fax zustande. Der Annahme des Angebots durch KLZ steht es gleich, wenn KLZ den Auftrag oder die Bestellung binnen vierzehn (14) Kalendertagen ab Zugang des Angebots bei KLZ vorbehaltlos ausführt.
|
||||
|
||||
2.2 Alle Angebote von KLZ sind – insbesondere hinsichtlich Verfügbarkeit, angegebener Mengen, Lieferfristen und Nebenleistungen – freibleibend und unverbindlich, sofern sie nicht ausdrücklich in Textform als verbindlich gekennzeichnet wurden. Die Angebote stehen unter dem Vorbehalt der zeitgerechten, qualitativen und quantitativen Selbstbelieferung von KLZ.
|
||||
|
||||
2.3 Der Umfang der von KLZ zu liefernden Waren ergibt sich aus dem jeweiligen Angebot oder der jeweiligen Auftragsbestätigung von KLZ.
|
||||
|
||||
2.4 Technische Angaben, Muster, Preise, Abbildungen, Zeichnungen, Gewichts-, Maß-, Leistungs- und Gebrauchsangaben, sowie sonstige Beschreibungen der zu liefernden Ware in dem Angebot oder der Auftragsbestätigung sind nur annähernd maßgebend, soweit sie nicht ausdrücklich von KLZ in Textform als verbindlich gekennzeichnet wurden. Sie stellen keine Beschaffenheitsvereinbarung oder Garantie einer entsprechenden Beschaffenheit oder Haltbarkeit der zu liefernden Ware dar, es sei denn, sie wurden ausdrücklich in Textform als solche vereinbart. Entsprechendes gilt für Erwartungen des Kunden hinsichtlich der zu liefernden Ware oder deren Verwendung.
|
||||
|
||||
2.5 Handelsübliche und branchenübliche Abweichungen vom Leistungsumfang der zu liefernden Ware durch KLZ sind zulässig, soweit sie die Verwendbarkeit der zu liefernden Ware zum vertraglich vorgesehenen Zweck nicht beeinträchtigen. Dies umfasst auch Über- oder Unterlieferungen. Abweichungen der zu liefernden Ware, die durch zwingende rechtliche oder technische Normen, die nach Auftragsbestätigung in Kraft treten, bedingt sind, sind zulässig, soweit sie nicht erheblich und dem Kunden zumutbar sind. Änderungen, die zu einer technischen Verbesserung der zu liefernden Ware führen, sind stets zulässig.
|
||||
|
||||
2.6 Änderungen des Lieferumfangs durch den Kunden bedürfen zu ihrer Wirksamkeit stets der in Textform erklärten Einwilligung von KLZ. Mehraufwände und sonstige Kosten, die durch die Änderung des Lieferumfangs auf Wunsch des Kunden entstehen, sind von dem Kunden gesondert zu vergüten.
|
||||
|
||||
2.7 Soweit das Angebot oder die Auftragsbestätigung von KLZ offensichtliche Irrtümer, Schreib- oder Rechenfehler enthält, ist das Dokument für KLZ hinsichtlich der offensichtlichen Fehler nicht verbindlich. Der Kunde wird KLZ auf entsprechende Fehler hinweisen.
|
||||
|
||||
2.8 Verschlechtern sich die Vermögensverhältnisse des Kunden wesentlich oder wird der Antrag zur Eröffnung eines Insolvenz- oder vergleichbaren Verfahrens über das Vermögen des Kunden mangels Masse abgelehnt, ist KLZ berechtigt, ganz oder teilweise vom Vertrag zurückzutreten.
|
||||
|
||||
### 3 Abrufaufträge
|
||||
|
||||
3.1 Erteilt der Kunde KLZ einen Abrufauftrag und werden über die Abruftermine keine gesonderten Vereinbarungen in Textform getroffen, ist der Kunde verpflichtet, KLZ die einzelnen Abruftermine so mitzuteilen, dass zwischen Eingang der Abrufmitteilung bei KLZ und Auslieferung der Ware mindestens vierzehn (14) Werktage und zwischen Eingang der Abrufmitteilung und der letzten Auslieferung der Ware maximal neunzig (90) Werktage liegen. Werktage sind dabei alle Kalendertage, bei denen es sich nicht um einen Sonntag oder einen gesetzlichen Feiertag am Sitz von KLZ handelt.
|
||||
|
||||
### 4 Preise und Zahlungsbedingungen
|
||||
|
||||
4.1 Die in dem Angebot oder der Auftragsbestätigung von KLZ angegebenen Preise gelten für den in dem Angebot oder der Auftragsbestätigung von KLZ aufgeführten Lieferumfang und verstehen sich grundsätzlich in EURO und ohne Abzüge zuzüglich Versandkosten ab dem jeweiligen Werk des Herstellers der Ware, exklusive Verpackung und zuzüglich der jeweils geltenden gesetzlichen Umsatzsteuer, sofern nicht ausdrücklich etwas anderes in Textform vereinbart wurde. Die in dem Angebot oder der Auftragsbestätigung von KLZ ausgewiesenen Hohlpreise verstehen sich darüber hinaus zuzüglich Metallzuschlag und auftragsspezifischer Schnittkosten.
|
||||
|
||||
4.2 Sollte sich nach Vertragsschluss herausstellen, dass zur Durchführung des Vertrages bzw. zur Lieferung der Waren Mehrleistungen erforderlich sind, so werden diese Mehrleistungen gesondert abgerechnet und vergütet.
|
||||
|
||||
4.3 Hat sich der Preis für eine zu liefernde Ware, bzw. der vereinbarte Hohlpreis z.B. aufgrund gestiegener Preise für Energie, Transport, Arbeitskraft oder Material zwischen Vertragsschluss und Lieferdatum um mehr als fünf (5) % erhöht, werden die Parteien partnerschaftlich und konstruktiv über eine Anpassung des Preises bzw. des Hohlpreises diskutieren. KLZ ist zum Rücktritt von dem betroffenen Vertrag berechtigt, wenn innerhalb von vierzehn (14) Kalendertagen ab Anzeige der Preiserhöhung durch KLZ keine einvernehmliche Lösung gefunden wird.
|
||||
|
||||
4.4 Der Kaufpreis wird sofort mit Erhalt der Rechnung oder Lieferung der Ware, je nachdem welcher Zeitpunkt früher eintritt, zur Zahlung fällig, sofern keine anderen Zahlungskonditionen in Textform vereinbart sind. Der Kunde hat den ausstehenden Kaufpreis innerhalb von zehn (10) Kalendertagen nach Erhalt der Rechnung oder Erhalt der Ware, je nachdem welcher Zeitpunkt früher eintritt, frei Konto von KLZ und ohne Abzug zu bezahlen. Die Zahlungsfrist gilt mit vollständiger Gutschrift des ausstehenden Kaufpreises auf dem Konto von KLZ als gewahrt. Nach Ablauf der Zahlungsfrist kommt der Kunde automatisch mit der Zahlung des ausstehenden Kaufpreises in Verzug, ohne dass es hierfür einer gesonderten Mahnung von KLZ bedarf.
|
||||
|
||||
4.5 Befindet sich der Kunde mit einer Zahlung in Verzug, ist KLZ berechtigt, die zu liefernde Ware nur Zug-um-Zug gegen Zahlung der Summe, mit der sich der Kunde in Verzug befindet, zu liefern. Dies gilt auch für alle künftigen Lieferungen von Waren von KLZ an den Kunden. Darüber hinaus stehen KLZ sämtliche Ansprüche gegen den Kunden aufgrund dessen Zahlungsverzug uneingeschränkt zu.
|
||||
|
||||
4.6 Befindet sich der Kunde seit mehr als vierzehn (14) Kalendertagen in Zahlungsverzug, ist KLZ dazu berechtigt, vom Vertrag zurückzutreten und die gelieferte Ware unverzüglich zurück zu verlangen, ohne dass dem Kunden hieraus Ansprüche entstehen.
|
||||
|
||||
4.7 Das Recht des Kunden, Zahlungen zurückzuhalten oder mit Gegenansprüchen aufzurechnen, steht dem Kunden nur insoweit zu, als seine Gegenansprüche unbestritten oder rechtskräftig festgestellt sind, oder wenn die jeweiligen Ansprüche in einem Gegenseitigkeitsverhältnis zueinanderstehen.
|
||||
|
||||
### 5 Metallnotierung
|
||||
|
||||
5.1 Basis zur Berechnung des Kupferpreises ist der Durchschnitt des Liefervormonats der Notierung „LME Copper official price cash offer“, zuzüglich der jeweils aktuellen von KLZ benannten Kupfer-Prämie.
|
||||
|
||||
5.2 Basis zur Berechnung des Aluminiumpreises ist der Durchschnitt des Liefervormonats der Notierung „LME Aluminium official price cash offer" zuzüglich der jeweils von KLZ benannten Aluminium-Prämie.
|
||||
|
||||
5.3 USD werden auf Basis des EUR/USD LME-FX-Rate (MTLE) in EUR umgerechnet. Die entsprechenden Notierungen können der Webseite www.westmetall.com entnommen werden. Die Prämienzuschläge können stark variieren und KLZ behält sich das Recht vor, diese fristgerecht anzupassen, ungeachtet der Angebotslegung.
|
||||
|
||||
### 6 Metallzahl
|
||||
|
||||
6.1 Die von KLZ in dem Angebot oder der Auftragsbestätigung ausgewiesene Metallzahl ist eine rein kaufmännische Berechnungsgröße für den Metallinhalt, der in die Berechnung des Gesamtpreises eines Kabels eingeht.
|
||||
|
||||
6.2 Die Angabe der Metallzahl dient ausschließlich dazu, dem Kunden eine Vergleichbarkeit auf Grundlage der Holpreisbasis zu ermöglichen. Die Metallzahl definiert nicht das Gewicht des tatsächlich im Kabel enthaltenen Leitermetalls. Die Metallzahl ist ein rein kalkulatorischer Berechnungsfaktor, der keine unmittelbaren Rückschlüsse auf die im Kabel verwendeten Kupfer- bzw. Aluminiummengen zulässt. KLZ weist den Kunden hiermit ausdrücklich darauf hin, final nur den Vollpreis für Vergleichszwecke heranzuziehen.
|
||||
|
||||
6.3 Auf Anfrage des Kunden in Textform ist KLZ grundsätzlich dazu bereit, andere Metallzahlen zu berechnen und auszuweisen. Bei jeglicher Änderung bleibt der Vollpreis der gleiche Betrag. Ein Anspruch des Kunden auf Berechnung und Angabe anderer Metallzahlen besteht nicht.
|
||||
|
||||
### 7 Lieferung
|
||||
|
||||
7.1 Lieferungen erfolgen grundsätzlich DAP (INCOTERMS® 2020) sofern die Parteien keine anderweitige Vereinbarung in Textform getroffen haben.
|
||||
|
||||
7.2 Die Lieferung der Ware wird nach Möglichkeit geschlossen vorgenommen. KLZ ist jedoch berechtigt, Teillieferungen vorzunehmen, wenn dies für den Kunden zumutbar ist, insbesondere wenn wegen Fehlens von Waren eine erhebliche Verzögerung der gesamten Lieferung eintreten würde.
|
||||
|
||||
7.3 Lieferfristen aus Angeboten und Auftragsbestätigungen sind grundsätzlich für KLZ unverbindlich. Eine verbindliche Lieferfrist muss ausdrücklich in Textform als solche gekennzeichnet werden.
|
||||
|
||||
7.4 Die Lieferfrist beginnt mit dem Zugang der Auftragsbestätigung bei dem Kunden. Die Einhaltung der Lieferfrist setzt die rechtzeitige Klärung aller kaufmännischen und technischen Fragen, sowie die Einhaltung der vereinbarten Zahlungsbedingungen und sonstigen Verpflichtungen des Kunden voraus. Werden diese Voraussetzungen nicht rechtzeitig erfüllt, so verlängern sich die Fristen angemessen; dies gilt nicht, wenn ausschließlich KLZ die Verzögerung zu vertreten hat.
|
||||
|
||||
7.5 Die Einhaltung auch einer verbindlichen Lieferfrist steht unter dem Vorbehalt ordnungsgemäßer, insbesondere rechtzeitiger quantitativer und qualitativer Selbstbelieferung von KLZ durch dessen Vorlieferanten. KLZ ist im Fall der nicht ordnungsgemäßen Selbstbelieferung zum Rücktritt vom Vertrag berechtigt. KLZ informiert den Kunden unverzüglich, wenn und soweit KLZ von seinem Recht zum Rücktritt vom Vertrag Gebrauch macht und gewährt etwa erbrachte Vorleistungen des Kunden zurück.
|
||||
|
||||
7.6 Die Lieferfrist gilt als gewahrt, wenn KLZ die zu liefernde Ware dem Kunden bis zum Ablauf der Lieferfrist an dem benannten Lieferort innerhalb der üblichen Geschäftszeiten zur Annahme erstmalig angeboten hat.
|
||||
|
||||
7.7 Auch wenn eine verbindliche Lieferfrist vereinbart wurde, kommt KLZ mit der Lieferung der Ware nur dann in Verzug, wenn der Kunde KLZ nach Ablauf der Lieferfrist eine angemessene Nachfrist zur Lieferung der Ware in Textform gesetzt hat und diese Nachfrist erfolglos verstrichen ist.
|
||||
|
||||
7.8 Wird die Lieferung der Ware, oder das Abladen der Ware, auf Wunsch des Kunden verzögert, oder verzögert sich die Lieferung der Ware, oder das Abladen der Ware, aus sonstigen Gründen, die der Kunde zu vertreten hat, ist KLZ berechtigt, dem Kunden nach Ablauf der vertraglich vereinbarten Lieferfrist ein Lagergeld in Höhe von zwei (2) % des jeweiligen Nettoauftragswertes für jeden angefangenen Monat, maximal jedoch fünf (5) % des jeweiligen Nettoauftragswertes in Rechnung zu stellen. Das Lagergeld ist der Höhe nach auf maximal fünf (5) % des Nettoauftragswertes pro Vertrag beschränkt.
|
||||
|
||||
7.9 KLZ behält sich die Geltendmachung aller darüberhinausgehenden Ansprüche gegenüber dem Kunden aufgrund dessen verzögerter Übernahme der Ware uneingeschränkt vor. Das Lagergeld ist auf weitergehende Zahlungsansprüche von KLZ gegenüber dem Kunden aufgrund dessen verzögerter Übernahme der Ware anzurechnen. Dem Kunden bleibt der Nachweis gestattet, dass KLZ überhaupt kein oder nur ein geringerer Schaden entstanden ist.
|
||||
|
||||
7.10 Kabel werden von KLZ auf stabilen Vollholztrommeln geliefert. Auf Wunsch vermittelt KLZ dem Kunden Partner, die diese Trommeln gegen eine Gebühr abholen. Bei Lagerzeiten über drei (3) Monaten ist zu beachten, dass die Trommel regelmäßig bewegt wird, um eine einseitige Belastung zu vermeiden. Folgeschäden bei Nichtbeachtung wie Instabilität schließt KLZ aus.
|
||||
|
||||
7.11 Rücksendungen von Waren an KLZ, die nicht vorher von KLZ in Textform bestätigt wurden, erfolgen auf alleinige Gefahr des Kunden.
|
||||
|
||||
### 8 Gefahrübergang
|
||||
|
||||
8.1 Die Gefahr des zufälligen Untergangs und der zufälligen Verschlechterung der zu liefernden Ware geht grundsätzlich DAP (INCOTERMS® 2020) auf den Kunden über, sofern die Parteien keine anderweitige Vereinbarung in Textform getroffen haben.
|
||||
|
||||
8.2 Verzögert sich die Versendung bzw. Übergabe der Ware aus Gründen, die der Kunde zu vertreten hat, geht die Gefahr des zufälligen Untergangs und der zufälligen Verschlechterung der Ware mit der Durchführung des ersten Zustellversuchs innerhalb der gewöhnlichen Öffnungszeiten des Kunden durch KLZ auf den Kunden über.
|
||||
|
||||
### 9 Eigentumsvorbehalt
|
||||
|
||||
9.1 Die jeweils gelieferten Waren bleiben bis zur vollständigen Bezahlung sämtlicher fälliger Forderungen aus der Geschäftsbeziehung zwischen dem Kunden und KLZ in dem Eigentum von KLZ (nachfolgend „Vorbehaltsware“). Dabei ist unerheblich, aus welchem Rechtsgrund die Forderungen von KLZ gegen den Kunden bestehen. Erfasst sind insbesondere auch Saldoforderungen aus laufender Rechnung.
|
||||
|
||||
9.2 Der Kunde ist berechtigt, die gelieferte Ware im Rahmen des ordnungsgemäßen Geschäftsbetriebes zu veräußern, umzubilden oder weiterzuverarbeiten.
|
||||
|
||||
9.3 Die Verarbeitung oder Umbildung der Vorbehaltsware durch den Kunden erfolgt jedoch stets für KLZ und KLZ erwirbt Eigentum an der neuen Sache. Wird die Vorbehaltsware mit anderen Sachen zu einer einheitlichen Sache verbunden, verarbeitet oder untrennbar vermischt oder vermengt oder in sonstiger Weise verbunden, erwirbt KLZ Miteigentum an der neuen Sache im Verhältnis des Wertes der Vorbehaltsware zu den anderen Gegenständen zur Zeit der Verarbeitung bzw. Vermischung, Vermengung oder sonstigen Verbindung. Erfolgt die Vermischung, Vermengung oder sonstige Verbindung dergestalt, dass die Sache des Kunden als Hauptsache anzusehen ist, so willigt der Kunde bereits jetzt dazu ein, KLZ anteiliges Miteigentum an der neuen Sache, in dem Mengenverhältnis der Vorbehaltsware zu den fremden Sachen, zu übertragen. Für die so entstandenen Sachen gelten die Bestimmungen für Vorbehaltsware.
|
||||
|
||||
9.4 Der Kunde hat die Vorbehaltsware mit kaufmännischer Sorgfalt für KLZ zu verwahren, pfleglich zu behandeln und auf seine Kosten gegen Feuer, Wasser und Diebstahl zum Neuwert zu versichern. Der Kunde tritt seine entsprechenden Ansprüche aus den Versicherungsverträgen an KLZ ab; KLZ nimmt die Abtretung an.
|
||||
|
||||
9.5 Der Kunde darf die Vorbehaltsware nur im ordnungsgemäßen Geschäftsgang bis zum Eintritt des Verwertungsfalles veräußern. Verpfändungen und Sicherungsübereignungen hinsichtlich der Vorbehaltsware sind unzulässig. Eingriffe Dritter, wie etwa die Pfändung oder Zwangsvollstreckung, durch welche Rechte von KLZ beeinträchtigt werden, hat der Kunde KLZ unverzüglich in Textform anzuzeigen. Der Kunde hat den Dritten darüber hinaus unverzüglich in Textform darauf hinzuweisen, dass KLZ Eigentümer der Vorbehaltsware ist. Soweit der Dritte nicht in der Lage ist, KLZ die gerichtlichen und außergerichtlichen Kosten einer Klage nach § 771 ZPO zu erstatten, haftet der Kunde für den dadurch entstandenen Ausfall. Weitergehende Ansprüche gegen den Kunden bleiben unberührt.
|
||||
|
||||
9.6 Für den Fall der Weiterveräußerung der Vorbehaltsware tritt der Kunde die ihm daraus seinerseits erwachsenden Forderungen gegen seinen Abnehmer bereits jetzt an KLZ ab; KLZ nimmt die Abtretung an. Gleiches gilt für sonstige Forderungen, die an die Stelle der Vorbehaltsware treten oder sonst hinsichtlich der Vorbehaltsware entstehen, wie z. B. Versicherungsansprüche oder Ansprüche aus unerlaubter Handlung bei Verlust oder Zerstörung.
|
||||
|
||||
9.7 Wird die Vorbehaltsware nach Verarbeitung oder zusammen mit anderen Waren, die KLZ nicht gehören, weiterveräußert oder wird sie mit einem Grundstück oder mit beweglichen Sachen verbunden, so erklärt der Kunde bereits jetzt die Abtretung der Forderung des Kunden gegen seine Abnehmer in Höhe des zwischen dem Kunden und KLZ vereinbarten Kaufpreises für die Vorbehaltsware; KLZ nimmt die Abtretung an.
|
||||
|
||||
9.8 Der Kunde wird ermächtigt, die abgetretenen Forderungen für KLZ einzuziehen. KLZ bleibt auch weiterhin zur Einziehung der abgetretenen Forderungen berechtigt, wird die abgetretenen Forderungen jedoch nicht selbst einziehen, solange kein Grund nach Ziffer 9.9 vorliegt, der KLZ zum Widerruf der Einziehungsermächtigung berechtigt. Zur Abtretung der Forderung an Dritte, einschließlich Forderungsverkauf an Factoring Banken, ist der Kunde nur mit vorheriger schriftlicher Zustimmung von KLZ berechtigt. KLZ wird die Zustimmung nicht verweigern, sofern eine gleichwertige Sicherheit gestellt wird.
|
||||
|
||||
9.9 KLZ kann die Ermächtigung zur Weiterveräußerung und Einziehung widerrufen, sobald sich der Kunde gegenüber KLZ im Zahlungsverzug befindet, oder das Insolvenzverfahren oder ein vergleichbares Verfahren über das Vermögen des Kunden eröffnet oder mangels Masse abgewiesen wurde.
|
||||
|
||||
9.10 Der Kunde hat die eingezogenen Forderungen binnen zehn (10) Werktagen auf ein von KLZ benanntes Konto zu überweisen.
|
||||
|
||||
9.11 KLZ ist zur Offenlegung der Abtretung berechtigt und kann von dem Kunden verlangen, dass dieser die Abtretung seinen Abnehmern anzeigt. Auf Verlangen ist der Kunde verpflichtet, KLZ die Namen seiner Abnehmer bekanntzugeben und alle erforderlichen Unterlagen herauszugeben, die für die Einziehung der Forderung durch KLZ selbst erforderlich sind. Auch ist der Kunde ab Verzugseintritt verpflichtet, die Abtretung der Forderung an KLZ seinem Abnehmer gegenüber schriftlich mitzuteilen.
|
||||
|
||||
9.12 KLZ wird die Vorbehaltsware sowie die an ihre Stelle tretenden Sachen oder Forderungen freigeben, soweit ihr Wert die Höhe der gesicherten Forderungen um mehr als zehn (10) % übersteigt. Die Auswahl der danach freizugebenden Gegenstände liegt bei KLZ.
|
||||
|
||||
9.13 Tritt KLZ bei vertragswidrigem Verhalten des Kunden, insbesondere Zahlungsverzug, vom Vertrag zurück (Verwertungsfall), ist KLZ berechtigt, die Vorbehaltsware herauszuverlangen. Der Kunde hat in diesem Fall KLZ alle Informationen und Unterlagen zu übergeben, die erforderlich sind, um die Vorbehaltsware herauszuverlangen und die an KLZ abgetretenen Ansprüche des Kunden gegenüber seinen Abnehmern geltend zu machen, mindestens jedoch eine Aufstellung über die bei dem Kunden oder bei den Abnehmern befindliche Vorbehaltsware, auch soweit sie bereits verarbeitet ist, und eine Aufstellung aller abgetretenen Forderungen nebst Rechnungsgutschriften.
|
||||
|
||||
### 10 Höhere Gewalt
|
||||
|
||||
10.1 „Höhere Gewalt“ bedeutet das Eintreten eines Ereignisses oder Umstands, welcher eine Partei daran hindert, eine oder mehrere ihrer vertraglichen Verpflichtungen zu erfüllen, wenn und soweit die von dem Hindernis betroffene Partei nachweist, dass (a) dieses Hindernis außerhalb der ihr zumutbaren Kontrolle liegt und (b) es zu dem Zeitpunkt des Vertragsschlusses nicht in zumutbarer Weise vorhersehbar war und (c) die Auswirkungen des Hindernisses von der betroffenen Partei nicht in zumutbarer Weise hätte vermieden oder überwunden werden können.
|
||||
|
||||
10.2 Bei dem Beweis des Gegenteils wird insbesondere, aber nicht abschließend, bei den folgenden Ereignissen ein Fall Höherer Gewalt im Sinne des vorstehenden Absatzes vermutet: Krieg, Terrorakte, Währungs- und Handelsbeschränkungen, Embargo, Sanktionen, Amtshandlungen, Befolgung von Gesetzen oder Regierungsanordnungen, staatliche Ausreise- und Exportverbote oder staatliche Einreise- und Importverbote, Enteignung, Epidemie, Naturkatastrophe, extremes Naturereignis, Explosionen, Feuer, Zerstörung von Ausrüstungen, Demonstrationen oder Versammlungen, Ereignisse, die das Passieren von wichtigen Transportrouten verhindern, allgemeine Arbeitsunruhen, insbesondere Boykott, Streik und Aussperrungen, Energieknappheit oder Beeinträchtigung von Transportmitteln.
|
||||
|
||||
10.3 Eine Partei, die sich mit Erfolg auf diese Klausel beruft, ist ab dem Zeitpunkt, zu dem das Hindernis ihr die Leistungserbringung unmöglich macht, von ihrer Pflicht zur Erfüllung ihrer vertraglichen Verpflichtungen und von jeder Schadensersatzpflicht oder jedem anderen vertraglichen Rechtsbehelf wegen Vertragsverletzung suspendiert, sofern der Eintritt eines Ereignisses Höherer Gewalt der anderen Partei unverzüglich mitgeteilt wird. Ist die Auswirkung des geltend gemachten Hindernisses oder Ereignisses vorübergehend, so gelten die eben dargelegten Folgen nur so lange, wie das geltend gemachte Hindernis die Vertragserfüllung durch die betroffene Partei verhindert sowie einer angemessenen Anlaufzeit. Dauert das Hindernis oder Ereignis höherer Gewalt bei der betroffenen Partei länger als vier (4) Monate an, ist die andere Partei zur ganzen oder teilweisen außerordentlichen Kündigung bzw. zum Rücktritt vom Vertrag berechtigt. Wechselseitige Schadensersatzansprüche wegen dieser Kündigung bzw. dieses Rücktritts sind ausgeschlossen.
|
||||
|
||||
10.4 Der Kunde ist verpflichtet, auf Verlangen von KLZ innerhalb einer angemessenen Frist zu erklären, ob er wegen einem bei KLZ eingetretenen Ereignis Höherer Gewalt den Vertrag kündigt bzw. von diesem Zurücktritt oder weiterhin auf der Durchführung der Lieferung der Ware besteht.
|
||||
|
||||
10.5 Sofern sich aufgrund eines Ereignisses Höherer Gewalt die wirtschaftliche Bedeutung oder der Inhalt der Lieferung der Ware erheblich verändert oder auf den Betrieb von KLZ erheblich einwirkt, werden die Parteien den zugrundeliegenden Vertrag unter Beachtung von Treu und Glauben angemessen anpassen. Soweit dies wirtschaftlich nicht vertretbar ist, steht KLZ das Recht zu, von dem Vertrag zurückzutreten. Will KLZ von diesem Rücktrittsrecht Gebrauch machen, so hat er dies nach Erkenntnis der Tragweite des Ereignisses Höherer Gewalt unverzüglich dem Kunden mitzuteilen, und zwar auch dann, wenn zunächst mit dem Kunden eine Verlängerung der Lieferzeit vereinbart war.
|
||||
|
||||
### 11 Gewährleistung und Mängelrüge
|
||||
|
||||
11.1 Mängelansprüche bestehen nicht bei nur unerheblicher Abweichung der gelieferten Ware von den vereinbarten Spezifikationen, bei unerheblicher Beeinträchtigung der von KLZ vorgegebenen Brauchbarkeit, bei natürlicher Abnutzung, übermäßiger Beanspruchung, Verwendung ungeeigneter Betriebsmittel, oder bei Mängeln, die aufgrund besonderer äußerer Einflüsse entstehen, sowie bei nicht reproduzierbaren Softwarefehlern. Werden vom Kunden oder von Dritten unsachgemäß Änderungen oder Instandsetzungsarbeiten vorgenommen, so bestehen für diese und die daraus entstehenden Folgen ebenfalls keine Mängelansprüche.
|
||||
|
||||
11.2 Eine Gewährleistungsverpflichtung von KLZ besteht nur, wenn der Kunde den ihm obliegenden Untersuchungs- und Rügepflichten fristgerecht nachgekommen ist. Eingehende Waren hat der Kunde unverzüglich auf äußerlich erkennbare Transportschäden und Identität der gelieferten Ware mit der Bestellung zu überprüfen. Äußerlich erkennbare Mängel sind unverzüglich, spätestens jedoch innerhalb von drei (3) Werktagen ab Übergabe, sonstige Mängel innerhalb von fünf (5) Werktagen nach ihrer Entdeckung gegenüber KLZ im Rahmen einer Mängelrüge mindestens in Textform geltend zu machen.
|
||||
|
||||
11.3 Mängelrügen müssen eine genaue Beschreibung der aufgetretenen Mängel, der betroffenen Lieferungen und wenn möglich, auch eine bildhafte Dokumentation des Mangels enthalten. KLZ ist berechtigt, mangelhafte Teile auf eigene Kosten zur Übersendung und Untersuchung anzufordern.
|
||||
|
||||
11.4 Beim Verlegen von Kabeln in Gräben oder Rohren, bzw. in Bauwerken ist eine ständige Sichtkontrolle durch den Kabelverleger vorzunehmen. Der Kabelverleger hat laufend zu überprüfen, ob Auffälligkeiten zu vermerken sind.
|
||||
|
||||
11.5 KLZ ist berechtigt, innerhalb einer angemessenen Frist, anerkannte Mängel nach eigener Wahl durch Beseitigung (Nachbesserung) oder durch Lieferung mangelfreier Ware (Ersatzlieferung) zu beheben. Der Kunde wird KLZ bei der Beseitigung von Mängeln in zumutbarem Umfang unterstützen.
|
||||
|
||||
11.6 Die Kosten der Nacherfüllung und Mangeluntersuchung werden bei berechtigten Beanstandungen von KLZ getragen. Dies gilt nicht für Aufwendungen, die dadurch entstehen, dass die gelieferten Waren durch den Kunden an einen anderen als den Ort der gewerblichen Niederlassung des Kunden verbracht worden sind, es sei denn, das Verbringen entspricht dem vertraglich vorgesehenen Verwendungszweck der gelieferten Ware. Satz 1 gilt ebenfalls nicht für Mehraufwendungen, die dadurch entstehen, dass der Kunde die gelieferte Ware ohne Zustimmung von KLZ verändert hat.
|
||||
|
||||
11.7 Hat der Kunde die Ware verbaut, obwohl er positive Kenntnis oder grob fahrlässige Unkenntnis des Mangels hatte, ist KLZ nicht dazu verpflichtet, die Ein- und Ausbaukosten der Ware zu erstatten.
|
||||
|
||||
11.8 Der Kunde kann erst nach zweimaligem Fehlschlagen der Mängelbeseitigung vom Vertrag zurücktreten oder den vereinbarten Kaufpreis angemessen mindern. Bei nur unerheblicher Minderung des Werts oder der Tauglichkeit der gelieferten Waren ist der Rücktritt ausgeschlossen, ebenso wenn der Kunde sich im Annahmeverzug befindet oder wenn der Kunde den Mangel zu vertreten hat. Solange der Kunde KLZ gegenüber nicht den Rücktritt vom Vertrag erklärt oder Schadensersatz wegen Nichterfüllung verlangt hat, ist KLZ auch nach Ablauf der vom Kunden gesetzten Frist zur Nacherfüllung berechtigt, es sei denn, der Kunde hat zuvor schriftlich die Ablehnung der Nacherfüllung angezeigt.
|
||||
|
||||
11.9 Beruht ein Mangel auf dem Verschulden von KLZ, kann der Kunde unter den in Ziffer 12 bestimmten Voraussetzungen Schadensersatz verlangen. Ziffer 12 gilt entsprechend für den Ersatz vergeblicher Aufwendungen, den der Kunde anstelle eines Schadensersatzes statt der Leistung verlangen kann.
|
||||
|
||||
11.10 Besteht Uneinigkeit zwischen den Parteien in Bezug auf Mängel an den Waren, ist ein Gutachten bei dem VDE-Institut über die streitige Frage einzuholen. Das Ergebnis des Gutachtens des VDE-Instituts ist für die Feststellung der Mangelfreiheit oder eines Mangels maßgebend. KLZ ist nur dann verpflichtet, die Kosten des Gutachtens des VDE-Instituts zu tragen, wenn das Gutachten einen Mangel feststellt.
|
||||
|
||||
11.11 Die gesetzlichen Mängelgewährleistungsansprüche des Kunden verjähren innerhalb von zwölf (12) Monaten ab Gefahrübergang. §§ 438 Abs. 1 Nr. 2, 479 Abs. 1 und 634a Abs. 1 Nr. 2 BGB bleiben hiervon unberührt. Diese Verkürzung der Verjährungsfrist gilt nicht bei Vorsatz, grober Fahrlässigkeit, der Verletzung von Leben, Körper, Gesundheit, oder jedweder wesentlicher Vertragspflichtverletzung, zwingender gesetzlicher Haftungsnormen wie denen des ProdHaftG, sowie bei Nichteinhaltung einer Beschaffenheitsgarantie oder dem arglistigen Verschweigen eines Mangels. Die gesetzlichen Regelungen über Ablaufhemmung, Hemmung und Neubeginn der Fristen bleiben unberührt.
|
||||
|
||||
### 12 Haftung
|
||||
|
||||
12.1 KLZ haftet uneingeschränkt für Vorsatz und grobe Fahrlässigkeit. KLZ haftet ferner uneingeschränkt für Schäden aus der Verletzung des Lebens, des Körpers oder der Gesundheit, sowie für arglistiges Verschweigen eines Mangels und Verletzung von garantierten Beschaffenheitsmerkmalen, sowie im Rahmen zwingender gesetzlicher Vorschriften, wie etwa denen des Produkthaftungsgesetzes.
|
||||
|
||||
12.2 Bei der Verletzung von wesentlichen Vertragspflichten haftet KLZ im Übrigen der Höhe nach begrenzt auf den bei Vertragsschluss für KLZ typischerweise vorhersehbaren Schaden bzw. die typischerweise vorhersehbaren Aufwendungen. Vertragswesentlich sind die Verpflichtungen, deren Erfüllung die ordnungsgemäße Vertragsdurchführung erst ermöglichen und auf deren Einhaltung der Kunde vertraut und auch vertrauen darf. Im Übrigen haftet KLZ bei leichter Fahrlässigkeit nicht auf Schadensersatz.
|
||||
|
||||
12.3 Eine Haftung für mittelbare Schäden und Folgeschäden, die Folge von Mängeln der gelieferten Waren sind, besteht nur für solche Schäden, die bei bestimmungsgemäßer Verwendung der gelieferten Ware typischerweise zu erwarten sind.
|
||||
|
||||
12.4 Die Haftung entfällt für Schäden, die auf Reparaturen, Änderungen oder anderen Eingriffen oder auf die Verwendung von Nicht-Originalteilen von KLZ im Rahmen der Reparatur oder Änderung bzw. des Eingriffs zurückzuführen sind, die der Kunde ohne Zustimmung von KLZ vorgenommen hat.
|
||||
|
||||
12.5 Soweit die Haftung von KLZ ausgeschlossen oder beschränkt ist, gilt dies auch für die Haftung der Angestellten, Arbeitnehmer, Mitarbeiter, Vertreter und Erfüllungsgehilfen von KLZ.
|
||||
|
||||
### 13 Produkthaftung
|
||||
|
||||
13.1 Der Kunde wird die gelieferten Waren ausschließlich vertragsgemäß und entsprechend den verfügbaren Anleitungen von KLZ verwenden, insbesondere wird er vorhandene Warnhinweise über Gefahren bei Gebrauch der gelieferten Waren nicht verändern oder entfernen. Bei Verletzung dieser Pflicht stellt der Kunde KLZ im Innenverhältnis von Produkthaftungsansprüchen Dritter frei, es sei denn der Kunde ist für den die Haftung auslösenden Fehler nicht verantwortlich.
|
||||
|
||||
13.2 Wird KLZ aufgrund eines Produktfehlers zu einem Produktrückruf oder einer -warnung veranlasst, so wird der Kunde nach besten Kräften bei den Maßnahmen mitwirken, die KLZ für erforderlich und zweckmäßig hält. KLZ ist verpflichtet, die Kosten des Produktrückrufs oder der -warnung zu tragen, es sei denn KLZ ist für den Produktfehler und den eingetretenen Schaden nach produkthaftungsrechtlichen Grundsätzen nicht verantwortlich.
|
||||
|
||||
13.3 Der Kunde wird KLZ unverzüglich über ihm bekanntwerdende Risiken bei der Verwendung der gelieferten Ware und mögliche Produktfehler in Textform informieren.
|
||||
|
||||
### 14 Exportkontrolle
|
||||
|
||||
14.1 Lieferungen von Waren von KLZ stehen unter dem Vorbehalt, dass der Vertragserfüllung keine Hindernisse aufgrund von nationalen oder internationalen exportkontrollrechtlichen Vorschriften entgegenstehen. Sofern sich die Durchführung des Vertrages aufgrund von Exportkontrollprüfungen oder Genehmigungsverfahren verzögert, werden Fristen und Lieferzeiten außer Kraft gesetzt. KLZ ist berechtigt, von der Einzelbestellung zurückzutreten und den Liefervertrag fristlos zu kündigen, wenn ein solcher Rücktritt oder eine Kündigung zur Einhaltung nationaler oder internationalen Exportkontrollbestimmung erforderlich ist. Die Geltendmachung von Schadensersatzansprüchen oder anderen Rechten durch den Kunden wegen vorgenannter Kündigung oder Verzögerung ist ausgeschlossen.
|
||||
|
||||
14.2 Der Kunde verpflichtet sich zur Einhaltung des nationalen und internationalen Exportkontrollrechts, insbesondere bei Weitergabe der gelieferten Ware an Dritte.
|
||||
|
||||
### 15 Urheberrechte und Datenschutz
|
||||
|
||||
15.1 An Kostenvoranschlägen, Zeichnungen, Modellen, Teilen, Schablonen, Berechnungen, Beschreibungen, Mustern und anderen Unterlagen (im Folgenden: „Unterlagen“ genannt) behält sich KLZ seine eigentums- und urheberrechtlichen Verwertungsrechte uneingeschränkt vor. Die Unterlagen dürfen nur nach vorheriger schriftlicher Zustimmung von KLZ Dritten zugänglich gemacht werden und sind KLZ auf Verlangen unverzüglich zurückzugeben.
|
||||
|
||||
15.2 Ohne ausdrückliche Zustimmung von KLZ in Textform ist der Kunde nicht berechtigt, die gelieferte Ware zu Werbezwecken zu verwenden.
|
||||
|
||||
15.3 KLZ erhebt und verwendet personenbezogenen Daten ausschließlich gemäß den Bestimmungen der Datenschutzgrundverordnung und des Bundesdatenschutzgesetzes.
|
||||
|
||||
15.4 Personenbezogenen Daten, die für die Begründung, inhaltliche Ausgestaltung, Abwicklung oder Änderung des Vertragsverhältnisses erforderlich sind (Bestandsdaten), werden ausschließlich zur Durchführung des zwischen KLZ und dem Kunden abgeschlossenen Vertrages verwendet. Zur Abwicklung des Vertragsverhältnisses müssen diese Daten im erforderlichen Umfang an Dritte (z.B. Transporteure oder Spediteure) weitergegeben werden. Der Kunde erteilt für die vorbeschriebene Verwendung seiner personenbezogenen Daten hiermit eine entsprechende Einwilligung.
|
||||
|
||||
15.5 Diese Einwilligungserklärung ist jederzeit -ohne Angabe von Gründen- widerruflich. Der Widerruf ist zu richten an info@klz-cables.com .
|
||||
|
||||
15.6 Eine darüberhinausgehende Nutzung der Bestandsdaten des Kunden für Zwecke der Werbung, der Marktforschung oder zur bedarfsgerechten Gestaltung der Angebote von KLZ bedarf der gesonderten ausdrücklichen Einwilligung des Kunden.
|
||||
|
||||
15.7 Auf Anordnung einer zuständigen Stelle darf KLZ im Einzelfall Auskunft über personenbezogene Daten des Kunden im Rahmen der Anordnung an die zuständige Stelle erteilen, soweit dies für Zwecke der Strafverfolgung, zur Gefahrenabwehr, zur Erfüllung der gesetzlichen Aufgaben der Verfassungsschutzbehörden oder zur Durchsetzung der Rechte am geistigen Eigentum erforderlich ist.
|
||||
|
||||
### 16 Geheimhaltung
|
||||
|
||||
16.1 „Vertrauliche Informationen“ sind
|
||||
(i) alle Informationen, Daten, Zeichnungen, Unterlagen, Materialien, Know-how oder sonstige Geschäftsgeheimnisse im Sinne von § 2 Nr. 1 des Gesetzes zum Schutz von Geschäftsgeheimnissen (GeschGehG), insbesondere alle Entwürfe, Skizzen, Zeichnungen, technischen Protokolle, Modelle oder elektronische Daten, gleich in welcher Verkörperung,
|
||||
(ii) sowie alle nicht offenkundigen kaufmännischen, betrieblichen oder technischen Informationen oder Gegenstände, gleich in welcher Verkörperung, die dem Kunden im Rahmen der Geschäftsbeziehung mit KLZ offengelegt oder anderweitig bekannt werden,
|
||||
(iii) die entweder direkt als vertraulich gekennzeichnet wurden oder nachträglich schriftlich oder in Textform als vertraulich gekennzeichnet werden. Des Weiteren gelten auch solche Informationen als vertraulich, welche der Natur nach bzw. aus den Umständen heraus oder aus anderen Gründen erkennbar als vertraulich anzusehen sind, soweit die Parteien nichts anderes in Schriftform vereinbart haben.
|
||||
|
||||
16.2 Vertrauliche Informationen im Sinne von Ziffer 16.1 sind jedoch nicht solche Informationen,
|
||||
(i) die der Kunde von öffentlich zugänglichen Quellen bezogen hat und die allgemein bekannt oder auf legalem Wege zugänglich sind und diese Quellen die Informationen ebenfalls auf legalem Wege erlangt haben,
|
||||
(ii) die der Kunde von einem berechtigten Dritten rechtmäßig erhalten hat,
|
||||
(iii) die der Kunde eigenständig entwickelt hat,
|
||||
(iv) die kraft Gesetzes oder aufgrund gerichtlicher oder behördlicher Anordnung offengelegt wurden oder offenzulegen sind. Der Kunde ist in diesem Fall verpflichtet, KLZ vor einer solchen Offenlegung der Informationen unverzüglich zu informieren und zu versuchen, die Offenlegung zu verhindern, soweit dies mit angemessenen und gesetzlich zulässigen Mitteln möglich ist.
|
||||
|
||||
16.3 Die Darlegungs- und Beweislast für das Vorliegen einer in Ziffer 16.2 benannten Ausnahmetatbestände trägt diejenige Partei, die sich auf das Vorliegen einer oder mehrerer dieser Ausnahmetatbestände beruft.
|
||||
|
||||
16.4 Der Kunde verpflichtet sich, die Vertraulichen Informationen strikt vertraulich zu behandeln und ausschließlich zur Erfüllung des Vertragszwecks zu verwenden. Die Vertraulichen Informationen dürfen durch den Kunden nur an solche Mitarbeiter oder andere Personen weitergegeben werden, die sie zur Erreichung des Vertragszwecks zwingend erhalten müssen. Sämtliche Mitarbeiter des Kunden, welche Kenntnis von Vertraulichen Informationen erhalten, müssen durch den Kunden ebenfalls zur Geheimhaltung hinsichtlich der Vertraulichen Informationen verpflichtet sein oder werden. Diese Geheimhaltungspflicht soll auch für die Zeit nach einem etwaigen Ausscheiden des Mitarbeiters aus dem Unternehmen des Kunden für weitere fünf (5) Jahre gelten, soweit dies rechtlich möglich ist.
|
||||
|
||||
16.5 Der Kunde verpflichtet sich, auf erstes Anfordern von KLZ und/oder nach Beendigung der Geschäftsbeziehung oder Beendigung des zugrundeliegenden Vertrage, gleich aus welchem Rechtsgrund, alle unter diese Geheimhaltungsvereinbarung fallenden Vertraulichen Informationen und davon angefertigte Kopien an KLZ herauszugeben oder sachgemäß zu zerstören bzw. sicher und dauerhaft zu löschen und die vollständige Herausgabe, vollständige sachgemäße Zerstörung oder vollständige sichere und dauerhafte Löschung KLZ gegenüber schriftlich zu bestätigen.
|
||||
|
||||
16.6 Ziffer 16.5 gilt nicht für Vertrauliche Informationen, die zwingenden gesetzlichen Aufbewahrungspflichten unterliegen oder in automatisierten Back-Up Dateien im Rahmen des ordnungsgemäßen Sicherungsprozesses sicher gespeichert wurden. Für diese Vertraulichen Informationen gelten die Bestimmungen dieser Geheimhaltungsvereinbarung bis zu ihrer Herausgabe, sicheren und dauerhaften Löschung oder vollständigen sachgemäßen Zerstörung fort.
|
||||
|
||||
16.7 Die Geheimhaltungsverpflichtungen gemäß dieser Ziffer 16 gelten für einen Zeitraum von fünf (5) Jahren nach Beendigung des zugrundeliegenden Vertrages, gleich aus welchem Rechtsgrund, fort.
|
||||
|
||||
16.8 Für jeden Einzelfall eines Verstoßes des Kunden gegen die Pflichten aus dieser Geheimhaltungsvereinbarung ist der Kunde zur Zahlung einer Vertragsstrafe an KLZ verpflichtet, deren Höhe in das billige Ermessen von KLZ gestellt wird und im Streitfall durch das zuständige Gericht überprüft werden kann. Durch die Geltendmachung der Vertragsstrafe werden weitergehende Schadensersatzansprüche von KLZ nicht ausgeschlossen.
|
||||
|
||||
### 17 Schlussbestimmungen
|
||||
|
||||
17.1 Änderungen oder Ergänzungen dieser AVB bedürfen der Textform. Dies gilt auch für die Abänderung des Textformerfordernisses selbst.
|
||||
|
||||
17.2 Auf Verträge zwischen KLZ und dem Kunden, der Auslegung dieser AVB, sowie die gesamten Rechtsbeziehungen zwischen KLZ und dem Kunden gilt ausschließlich das Recht der Bundesrepublik Deutschland unter Ausschluss des UN-Kaufrechts und des Kollisionsrechts. Erfüllungsort sowie ausschließlicher Gerichtsstand für alle Streitigkeiten aus oder im Zusammenhang mit der Rechtsbeziehung zwischen KLZ und dem Kunden ist Stuttgart.
|
||||
|
||||
17.3 Sollten einzelne Bestimmungen dieser AVB unwirksam oder undurchführbar sein oder werden, so wird hierdurch die Wirksamkeit aller sonstigen Bestimmungen dieser AVB im Übrigen nicht berührt. Die unwirksame oder undurchführbare Bestimmung wird von den Vertragsparteien einvernehmlich durch eine rechtswirksame Regelung ersetzt, welche dem wirtschaftlichen Erfolg und Zweck der unwirksamen oder undurchführbaren Bestimmung am nächsten kommt. Dies gilt gleichermaßen für den Fall einer Regelungslücke.
|
||||
|
||||
Stand: April 2026
|
||||
20
Dockerfile
20
Dockerfile
@@ -1,22 +1,16 @@
|
||||
# Stage 1: Builder
|
||||
FROM node:20-alpine AS base
|
||||
RUN apk add --no-cache libc6-compat curl
|
||||
|
||||
# Enable pnpm
|
||||
RUN corepack enable && corepack prepare pnpm@10.3.0 --activate
|
||||
FROM git.infra.mintel.me/mmintel/nextjs:latest AS base
|
||||
WORKDIR /app
|
||||
|
||||
# Arguments for build-time configuration
|
||||
ARG NEXT_PUBLIC_BASE_URL
|
||||
ARG NEXT_PUBLIC_TARGET
|
||||
ARG DIRECTUS_URL
|
||||
ARG UMAMI_WEBSITE_ID
|
||||
ARG UMAMI_API_ENDPOINT
|
||||
|
||||
# Environment variables for Next.js build
|
||||
ENV NEXT_PUBLIC_BASE_URL=$NEXT_PUBLIC_BASE_URL
|
||||
ENV NEXT_PUBLIC_TARGET=$NEXT_PUBLIC_TARGET
|
||||
ENV DIRECTUS_URL=$DIRECTUS_URL
|
||||
ENV UMAMI_WEBSITE_ID=$UMAMI_WEBSITE_ID
|
||||
ENV UMAMI_API_ENDPOINT=$UMAMI_API_ENDPOINT
|
||||
ENV SKIP_RUNTIME_ENV_VALIDATION=true
|
||||
@@ -56,19 +50,9 @@ ENV UV_THREADPOOL_SIZE=3
|
||||
RUN pnpm build
|
||||
|
||||
# Stage 2: Runner
|
||||
FROM node:20-alpine AS runner
|
||||
FROM git.infra.mintel.me/mmintel/runtime:latest AS runner
|
||||
WORKDIR /app
|
||||
|
||||
# Install curl for health checks
|
||||
RUN apk add --no-cache curl
|
||||
|
||||
# Create nextjs user and group (standardized in runtime image but ensuring local ownership)
|
||||
RUN addgroup --system --gid 1001 nodejs && \
|
||||
adduser --system --uid 1001 nextjs && \
|
||||
chown -R nextjs:nodejs /app
|
||||
|
||||
USER nextjs
|
||||
|
||||
ENV HOSTNAME="0.0.0.0"
|
||||
ENV PORT=3000
|
||||
ENV NODE_ENV=production
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
FROM node:20-alpine
|
||||
|
||||
# Install essential build tools if needed (e.g., for node-gyp)
|
||||
RUN apk add --no-cache libc6-compat python3 make g++
|
||||
RUN apk add --no-cache libc6-compat python3 make g++ curl
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
|
||||
Binary file not shown.
Binary file not shown.
@@ -1,57 +1,84 @@
|
||||
import { RscEntryLexicalCell as RscEntryLexicalCell_44fe37237e0ebf4470c9990d8cb7b07e } from '@payloadcms/richtext-lexical/rsc'
|
||||
import { RscEntryLexicalField as RscEntryLexicalField_44fe37237e0ebf4470c9990d8cb7b07e } from '@payloadcms/richtext-lexical/rsc'
|
||||
import { LexicalDiffComponent as LexicalDiffComponent_44fe37237e0ebf4470c9990d8cb7b07e } from '@payloadcms/richtext-lexical/rsc'
|
||||
import { BlocksFeatureClient as BlocksFeatureClient_e70f5e05f09f93e00b997edb1ef0c864 } from '@payloadcms/richtext-lexical/client'
|
||||
import { InlineToolbarFeatureClient as InlineToolbarFeatureClient_e70f5e05f09f93e00b997edb1ef0c864 } from '@payloadcms/richtext-lexical/client'
|
||||
import { HorizontalRuleFeatureClient as HorizontalRuleFeatureClient_e70f5e05f09f93e00b997edb1ef0c864 } from '@payloadcms/richtext-lexical/client'
|
||||
import { UploadFeatureClient as UploadFeatureClient_e70f5e05f09f93e00b997edb1ef0c864 } from '@payloadcms/richtext-lexical/client'
|
||||
import { BlockquoteFeatureClient as BlockquoteFeatureClient_e70f5e05f09f93e00b997edb1ef0c864 } from '@payloadcms/richtext-lexical/client'
|
||||
import { RelationshipFeatureClient as RelationshipFeatureClient_e70f5e05f09f93e00b997edb1ef0c864 } from '@payloadcms/richtext-lexical/client'
|
||||
import { LinkFeatureClient as LinkFeatureClient_e70f5e05f09f93e00b997edb1ef0c864 } from '@payloadcms/richtext-lexical/client'
|
||||
import { ChecklistFeatureClient as ChecklistFeatureClient_e70f5e05f09f93e00b997edb1ef0c864 } from '@payloadcms/richtext-lexical/client'
|
||||
import { OrderedListFeatureClient as OrderedListFeatureClient_e70f5e05f09f93e00b997edb1ef0c864 } from '@payloadcms/richtext-lexical/client'
|
||||
import { UnorderedListFeatureClient as UnorderedListFeatureClient_e70f5e05f09f93e00b997edb1ef0c864 } from '@payloadcms/richtext-lexical/client'
|
||||
import { IndentFeatureClient as IndentFeatureClient_e70f5e05f09f93e00b997edb1ef0c864 } from '@payloadcms/richtext-lexical/client'
|
||||
import { AlignFeatureClient as AlignFeatureClient_e70f5e05f09f93e00b997edb1ef0c864 } from '@payloadcms/richtext-lexical/client'
|
||||
import { HeadingFeatureClient as HeadingFeatureClient_e70f5e05f09f93e00b997edb1ef0c864 } from '@payloadcms/richtext-lexical/client'
|
||||
import { ParagraphFeatureClient as ParagraphFeatureClient_e70f5e05f09f93e00b997edb1ef0c864 } from '@payloadcms/richtext-lexical/client'
|
||||
import { InlineCodeFeatureClient as InlineCodeFeatureClient_e70f5e05f09f93e00b997edb1ef0c864 } from '@payloadcms/richtext-lexical/client'
|
||||
import { SuperscriptFeatureClient as SuperscriptFeatureClient_e70f5e05f09f93e00b997edb1ef0c864 } from '@payloadcms/richtext-lexical/client'
|
||||
import { SubscriptFeatureClient as SubscriptFeatureClient_e70f5e05f09f93e00b997edb1ef0c864 } from '@payloadcms/richtext-lexical/client'
|
||||
import { StrikethroughFeatureClient as StrikethroughFeatureClient_e70f5e05f09f93e00b997edb1ef0c864 } from '@payloadcms/richtext-lexical/client'
|
||||
import { UnderlineFeatureClient as UnderlineFeatureClient_e70f5e05f09f93e00b997edb1ef0c864 } from '@payloadcms/richtext-lexical/client'
|
||||
import { BoldFeatureClient as BoldFeatureClient_e70f5e05f09f93e00b997edb1ef0c864 } from '@payloadcms/richtext-lexical/client'
|
||||
import { ItalicFeatureClient as ItalicFeatureClient_e70f5e05f09f93e00b997edb1ef0c864 } from '@payloadcms/richtext-lexical/client'
|
||||
import { default as default_9ed509b5e5f7d08a16335393f27586cc } from '../../../src/payload/components/Icon'
|
||||
import { default as default_5470ea90f7a8fd882c2fe59ff2b1c5b9 } from '../../../src/payload/components/Logo'
|
||||
import { CollectionCards as CollectionCards_f9c02e79a4aed9a3924487c0cd4cafb1 } from '@payloadcms/next/rsc'
|
||||
import { RscEntryLexicalCell as RscEntryLexicalCell_44fe37237e0ebf4470c9990d8cb7b07e } from '@payloadcms/richtext-lexical/rsc';
|
||||
import { RscEntryLexicalField as RscEntryLexicalField_44fe37237e0ebf4470c9990d8cb7b07e } from '@payloadcms/richtext-lexical/rsc';
|
||||
import { LexicalDiffComponent as LexicalDiffComponent_44fe37237e0ebf4470c9990d8cb7b07e } from '@payloadcms/richtext-lexical/rsc';
|
||||
import { BlocksFeatureClient as BlocksFeatureClient_e70f5e05f09f93e00b997edb1ef0c864 } from '@payloadcms/richtext-lexical/client';
|
||||
import { InlineToolbarFeatureClient as InlineToolbarFeatureClient_e70f5e05f09f93e00b997edb1ef0c864 } from '@payloadcms/richtext-lexical/client';
|
||||
import { HorizontalRuleFeatureClient as HorizontalRuleFeatureClient_e70f5e05f09f93e00b997edb1ef0c864 } from '@payloadcms/richtext-lexical/client';
|
||||
import { UploadFeatureClient as UploadFeatureClient_e70f5e05f09f93e00b997edb1ef0c864 } from '@payloadcms/richtext-lexical/client';
|
||||
import { BlockquoteFeatureClient as BlockquoteFeatureClient_e70f5e05f09f93e00b997edb1ef0c864 } from '@payloadcms/richtext-lexical/client';
|
||||
import { RelationshipFeatureClient as RelationshipFeatureClient_e70f5e05f09f93e00b997edb1ef0c864 } from '@payloadcms/richtext-lexical/client';
|
||||
import { LinkFeatureClient as LinkFeatureClient_e70f5e05f09f93e00b997edb1ef0c864 } from '@payloadcms/richtext-lexical/client';
|
||||
import { ChecklistFeatureClient as ChecklistFeatureClient_e70f5e05f09f93e00b997edb1ef0c864 } from '@payloadcms/richtext-lexical/client';
|
||||
import { OrderedListFeatureClient as OrderedListFeatureClient_e70f5e05f09f93e00b997edb1ef0c864 } from '@payloadcms/richtext-lexical/client';
|
||||
import { UnorderedListFeatureClient as UnorderedListFeatureClient_e70f5e05f09f93e00b997edb1ef0c864 } from '@payloadcms/richtext-lexical/client';
|
||||
import { IndentFeatureClient as IndentFeatureClient_e70f5e05f09f93e00b997edb1ef0c864 } from '@payloadcms/richtext-lexical/client';
|
||||
import { AlignFeatureClient as AlignFeatureClient_e70f5e05f09f93e00b997edb1ef0c864 } from '@payloadcms/richtext-lexical/client';
|
||||
import { HeadingFeatureClient as HeadingFeatureClient_e70f5e05f09f93e00b997edb1ef0c864 } from '@payloadcms/richtext-lexical/client';
|
||||
import { ParagraphFeatureClient as ParagraphFeatureClient_e70f5e05f09f93e00b997edb1ef0c864 } from '@payloadcms/richtext-lexical/client';
|
||||
import { InlineCodeFeatureClient as InlineCodeFeatureClient_e70f5e05f09f93e00b997edb1ef0c864 } from '@payloadcms/richtext-lexical/client';
|
||||
import { SuperscriptFeatureClient as SuperscriptFeatureClient_e70f5e05f09f93e00b997edb1ef0c864 } from '@payloadcms/richtext-lexical/client';
|
||||
import { SubscriptFeatureClient as SubscriptFeatureClient_e70f5e05f09f93e00b997edb1ef0c864 } from '@payloadcms/richtext-lexical/client';
|
||||
import { StrikethroughFeatureClient as StrikethroughFeatureClient_e70f5e05f09f93e00b997edb1ef0c864 } from '@payloadcms/richtext-lexical/client';
|
||||
import { UnderlineFeatureClient as UnderlineFeatureClient_e70f5e05f09f93e00b997edb1ef0c864 } from '@payloadcms/richtext-lexical/client';
|
||||
import { BoldFeatureClient as BoldFeatureClient_e70f5e05f09f93e00b997edb1ef0c864 } from '@payloadcms/richtext-lexical/client';
|
||||
import { ItalicFeatureClient as ItalicFeatureClient_e70f5e05f09f93e00b997edb1ef0c864 } from '@payloadcms/richtext-lexical/client';
|
||||
import { default as default_9ed509b5e5f7d08a16335393f27586cc } from '../../../src/payload/components/Icon';
|
||||
import { default as default_5470ea90f7a8fd882c2fe59ff2b1c5b9 } from '../../../src/payload/components/Logo';
|
||||
import { ChatWindowProvider as ChatWindowProvider_d32a660df96f186e48bfc5b31626ccf5 } from '@mintel/payload-ai/components/ChatWindow';
|
||||
import { CollectionCards as CollectionCards_f9c02e79a4aed9a3924487c0cd4cafb1 } from '@payloadcms/next/rsc';
|
||||
|
||||
export const importMap = {
|
||||
"@payloadcms/richtext-lexical/rsc#RscEntryLexicalCell": RscEntryLexicalCell_44fe37237e0ebf4470c9990d8cb7b07e,
|
||||
"@payloadcms/richtext-lexical/rsc#RscEntryLexicalField": RscEntryLexicalField_44fe37237e0ebf4470c9990d8cb7b07e,
|
||||
"@payloadcms/richtext-lexical/rsc#LexicalDiffComponent": LexicalDiffComponent_44fe37237e0ebf4470c9990d8cb7b07e,
|
||||
"@payloadcms/richtext-lexical/client#BlocksFeatureClient": BlocksFeatureClient_e70f5e05f09f93e00b997edb1ef0c864,
|
||||
"@payloadcms/richtext-lexical/client#InlineToolbarFeatureClient": InlineToolbarFeatureClient_e70f5e05f09f93e00b997edb1ef0c864,
|
||||
"@payloadcms/richtext-lexical/client#HorizontalRuleFeatureClient": HorizontalRuleFeatureClient_e70f5e05f09f93e00b997edb1ef0c864,
|
||||
"@payloadcms/richtext-lexical/client#UploadFeatureClient": UploadFeatureClient_e70f5e05f09f93e00b997edb1ef0c864,
|
||||
"@payloadcms/richtext-lexical/client#BlockquoteFeatureClient": BlockquoteFeatureClient_e70f5e05f09f93e00b997edb1ef0c864,
|
||||
"@payloadcms/richtext-lexical/client#RelationshipFeatureClient": RelationshipFeatureClient_e70f5e05f09f93e00b997edb1ef0c864,
|
||||
"@payloadcms/richtext-lexical/client#LinkFeatureClient": LinkFeatureClient_e70f5e05f09f93e00b997edb1ef0c864,
|
||||
"@payloadcms/richtext-lexical/client#ChecklistFeatureClient": ChecklistFeatureClient_e70f5e05f09f93e00b997edb1ef0c864,
|
||||
"@payloadcms/richtext-lexical/client#OrderedListFeatureClient": OrderedListFeatureClient_e70f5e05f09f93e00b997edb1ef0c864,
|
||||
"@payloadcms/richtext-lexical/client#UnorderedListFeatureClient": UnorderedListFeatureClient_e70f5e05f09f93e00b997edb1ef0c864,
|
||||
"@payloadcms/richtext-lexical/client#IndentFeatureClient": IndentFeatureClient_e70f5e05f09f93e00b997edb1ef0c864,
|
||||
"@payloadcms/richtext-lexical/client#AlignFeatureClient": AlignFeatureClient_e70f5e05f09f93e00b997edb1ef0c864,
|
||||
"@payloadcms/richtext-lexical/client#HeadingFeatureClient": HeadingFeatureClient_e70f5e05f09f93e00b997edb1ef0c864,
|
||||
"@payloadcms/richtext-lexical/client#ParagraphFeatureClient": ParagraphFeatureClient_e70f5e05f09f93e00b997edb1ef0c864,
|
||||
"@payloadcms/richtext-lexical/client#InlineCodeFeatureClient": InlineCodeFeatureClient_e70f5e05f09f93e00b997edb1ef0c864,
|
||||
"@payloadcms/richtext-lexical/client#SuperscriptFeatureClient": SuperscriptFeatureClient_e70f5e05f09f93e00b997edb1ef0c864,
|
||||
"@payloadcms/richtext-lexical/client#SubscriptFeatureClient": SubscriptFeatureClient_e70f5e05f09f93e00b997edb1ef0c864,
|
||||
"@payloadcms/richtext-lexical/client#StrikethroughFeatureClient": StrikethroughFeatureClient_e70f5e05f09f93e00b997edb1ef0c864,
|
||||
"@payloadcms/richtext-lexical/client#UnderlineFeatureClient": UnderlineFeatureClient_e70f5e05f09f93e00b997edb1ef0c864,
|
||||
"@payloadcms/richtext-lexical/client#BoldFeatureClient": BoldFeatureClient_e70f5e05f09f93e00b997edb1ef0c864,
|
||||
"@payloadcms/richtext-lexical/client#ItalicFeatureClient": ItalicFeatureClient_e70f5e05f09f93e00b997edb1ef0c864,
|
||||
"/src/payload/components/Icon#default": default_9ed509b5e5f7d08a16335393f27586cc,
|
||||
"/src/payload/components/Logo#default": default_5470ea90f7a8fd882c2fe59ff2b1c5b9,
|
||||
"@payloadcms/next/rsc#CollectionCards": CollectionCards_f9c02e79a4aed9a3924487c0cd4cafb1
|
||||
}
|
||||
'@payloadcms/richtext-lexical/rsc#RscEntryLexicalCell':
|
||||
RscEntryLexicalCell_44fe37237e0ebf4470c9990d8cb7b07e,
|
||||
'@payloadcms/richtext-lexical/rsc#RscEntryLexicalField':
|
||||
RscEntryLexicalField_44fe37237e0ebf4470c9990d8cb7b07e,
|
||||
'@payloadcms/richtext-lexical/rsc#LexicalDiffComponent':
|
||||
LexicalDiffComponent_44fe37237e0ebf4470c9990d8cb7b07e,
|
||||
'@payloadcms/richtext-lexical/client#BlocksFeatureClient':
|
||||
BlocksFeatureClient_e70f5e05f09f93e00b997edb1ef0c864,
|
||||
'@payloadcms/richtext-lexical/client#InlineToolbarFeatureClient':
|
||||
InlineToolbarFeatureClient_e70f5e05f09f93e00b997edb1ef0c864,
|
||||
'@payloadcms/richtext-lexical/client#HorizontalRuleFeatureClient':
|
||||
HorizontalRuleFeatureClient_e70f5e05f09f93e00b997edb1ef0c864,
|
||||
'@payloadcms/richtext-lexical/client#UploadFeatureClient':
|
||||
UploadFeatureClient_e70f5e05f09f93e00b997edb1ef0c864,
|
||||
'@payloadcms/richtext-lexical/client#BlockquoteFeatureClient':
|
||||
BlockquoteFeatureClient_e70f5e05f09f93e00b997edb1ef0c864,
|
||||
'@payloadcms/richtext-lexical/client#RelationshipFeatureClient':
|
||||
RelationshipFeatureClient_e70f5e05f09f93e00b997edb1ef0c864,
|
||||
'@payloadcms/richtext-lexical/client#LinkFeatureClient':
|
||||
LinkFeatureClient_e70f5e05f09f93e00b997edb1ef0c864,
|
||||
'@payloadcms/richtext-lexical/client#ChecklistFeatureClient':
|
||||
ChecklistFeatureClient_e70f5e05f09f93e00b997edb1ef0c864,
|
||||
'@payloadcms/richtext-lexical/client#OrderedListFeatureClient':
|
||||
OrderedListFeatureClient_e70f5e05f09f93e00b997edb1ef0c864,
|
||||
'@payloadcms/richtext-lexical/client#UnorderedListFeatureClient':
|
||||
UnorderedListFeatureClient_e70f5e05f09f93e00b997edb1ef0c864,
|
||||
'@payloadcms/richtext-lexical/client#IndentFeatureClient':
|
||||
IndentFeatureClient_e70f5e05f09f93e00b997edb1ef0c864,
|
||||
'@payloadcms/richtext-lexical/client#AlignFeatureClient':
|
||||
AlignFeatureClient_e70f5e05f09f93e00b997edb1ef0c864,
|
||||
'@payloadcms/richtext-lexical/client#HeadingFeatureClient':
|
||||
HeadingFeatureClient_e70f5e05f09f93e00b997edb1ef0c864,
|
||||
'@payloadcms/richtext-lexical/client#ParagraphFeatureClient':
|
||||
ParagraphFeatureClient_e70f5e05f09f93e00b997edb1ef0c864,
|
||||
'@payloadcms/richtext-lexical/client#InlineCodeFeatureClient':
|
||||
InlineCodeFeatureClient_e70f5e05f09f93e00b997edb1ef0c864,
|
||||
'@payloadcms/richtext-lexical/client#SuperscriptFeatureClient':
|
||||
SuperscriptFeatureClient_e70f5e05f09f93e00b997edb1ef0c864,
|
||||
'@payloadcms/richtext-lexical/client#SubscriptFeatureClient':
|
||||
SubscriptFeatureClient_e70f5e05f09f93e00b997edb1ef0c864,
|
||||
'@payloadcms/richtext-lexical/client#StrikethroughFeatureClient':
|
||||
StrikethroughFeatureClient_e70f5e05f09f93e00b997edb1ef0c864,
|
||||
'@payloadcms/richtext-lexical/client#UnderlineFeatureClient':
|
||||
UnderlineFeatureClient_e70f5e05f09f93e00b997edb1ef0c864,
|
||||
'@payloadcms/richtext-lexical/client#BoldFeatureClient':
|
||||
BoldFeatureClient_e70f5e05f09f93e00b997edb1ef0c864,
|
||||
'@payloadcms/richtext-lexical/client#ItalicFeatureClient':
|
||||
ItalicFeatureClient_e70f5e05f09f93e00b997edb1ef0c864,
|
||||
'/src/payload/components/Icon#default': default_9ed509b5e5f7d08a16335393f27586cc,
|
||||
'/src/payload/components/Logo#default': default_5470ea90f7a8fd882c2fe59ff2b1c5b9,
|
||||
'@mintel/payload-ai/components/ChatWindow#ChatWindowProvider':
|
||||
ChatWindowProvider_d32a660df96f186e48bfc5b31626ccf5,
|
||||
'@payloadcms/next/rsc#CollectionCards': CollectionCards_f9c02e79a4aed9a3924487c0cd4cafb1,
|
||||
};
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { notFound, redirect, permanentRedirect } from 'next/navigation';
|
||||
import { notFound, redirect } from 'next/navigation';
|
||||
import { Container, Badge, Heading } from '@/components/ui';
|
||||
import { getTranslations, setRequestLocale } from 'next-intl/server';
|
||||
import { Metadata } from 'next';
|
||||
@@ -62,15 +62,6 @@ export default async function StandardPage({ params }: PageProps) {
|
||||
notFound();
|
||||
}
|
||||
|
||||
// Handle explicit CMS redirects (e.g. /en/terms -> /de/terms)
|
||||
if (pageData.redirectUrl) {
|
||||
if (pageData.redirectPermanent) {
|
||||
permanentRedirect(pageData.redirectUrl);
|
||||
} else {
|
||||
redirect(pageData.redirectUrl);
|
||||
}
|
||||
}
|
||||
|
||||
// Redirect if accessed via a different locale's slug
|
||||
const fileSlug = await mapSlugToFileSlug(pageData.slug || slug, locale);
|
||||
const correctSlug = await mapFileSlugToTranslated(fileSlug, locale);
|
||||
|
||||
@@ -6,7 +6,6 @@ import {
|
||||
getAdjacentPosts,
|
||||
getReadingTime,
|
||||
extractLexicalHeadings,
|
||||
getPostSlugs,
|
||||
} from '@/lib/blog';
|
||||
import { Metadata } from 'next';
|
||||
import Link from 'next/link';
|
||||
@@ -34,21 +33,12 @@ export async function generateMetadata({ params }: BlogPostProps): Promise<Metad
|
||||
|
||||
if (!post) return {};
|
||||
|
||||
const slugs = await getPostSlugs(slug, locale);
|
||||
const deSlug = slugs?.de || post.slug;
|
||||
const enSlug = slugs?.en || post.slug;
|
||||
|
||||
const description = post.frontmatter.excerpt || '';
|
||||
return {
|
||||
title: post.frontmatter.title,
|
||||
description: description,
|
||||
alternates: {
|
||||
canonical: `${SITE_URL}/${locale}/blog/${post.slug}`,
|
||||
languages: {
|
||||
de: `${SITE_URL}/de/blog/${deSlug}`,
|
||||
en: `${SITE_URL}/en/blog/${enSlug}`,
|
||||
'x-default': `${SITE_URL}/en/blog/${enSlug}`,
|
||||
},
|
||||
},
|
||||
openGraph: {
|
||||
title: `${post.frontmatter.title} | KLZ Cables`,
|
||||
@@ -134,14 +124,11 @@ export default async function BlogPost({ params }: BlogPostProps) {
|
||||
</Heading>
|
||||
<div className="flex flex-wrap items-center gap-6 text-white/80 text-sm md:text-base font-medium">
|
||||
<time dateTime={post.frontmatter.date} suppressHydrationWarning>
|
||||
{new Date(post.frontmatter.date).toLocaleDateString(
|
||||
locale === 'en' ? 'en-US' : 'de-DE',
|
||||
{
|
||||
year: 'numeric',
|
||||
month: 'long',
|
||||
day: 'numeric',
|
||||
},
|
||||
)}
|
||||
{new Date(post.frontmatter.date).toLocaleDateString(locale || 'de', {
|
||||
year: 'numeric',
|
||||
month: 'long',
|
||||
day: 'numeric',
|
||||
})}
|
||||
</time>
|
||||
<span className="w-1 h-1 bg-white/30 rounded-full" />
|
||||
<span>{getReadingTime(rawTextContent)} min read</span>
|
||||
@@ -174,14 +161,11 @@ export default async function BlogPost({ params }: BlogPostProps) {
|
||||
</Heading>
|
||||
<div className="flex items-center gap-6 text-text-primary/80 font-medium">
|
||||
<time dateTime={post.frontmatter.date} suppressHydrationWarning>
|
||||
{new Date(post.frontmatter.date).toLocaleDateString(
|
||||
locale === 'en' ? 'en-US' : 'de-DE',
|
||||
{
|
||||
year: 'numeric',
|
||||
month: 'long',
|
||||
day: 'numeric',
|
||||
},
|
||||
)}
|
||||
{new Date(post.frontmatter.date).toLocaleDateString(locale || 'de', {
|
||||
year: 'numeric',
|
||||
month: 'long',
|
||||
day: 'numeric',
|
||||
})}
|
||||
</time>
|
||||
<span className="w-1 h-1 bg-neutral-400 rounded-full" />
|
||||
<span>{getReadingTime(rawTextContent)} min read</span>
|
||||
|
||||
@@ -198,14 +198,11 @@ export default async function BlogIndex({ params }: BlogIndexProps) {
|
||||
|
||||
<div className="flex items-center gap-3 text-xs md:text-sm font-bold text-white/80 mb-3 tracking-widest uppercase">
|
||||
<time dateTime={post.frontmatter.date} suppressHydrationWarning>
|
||||
{new Date(post.frontmatter.date).toLocaleDateString(
|
||||
locale === 'en' ? 'en-US' : 'de-DE',
|
||||
{
|
||||
year: 'numeric',
|
||||
month: 'long',
|
||||
day: 'numeric',
|
||||
},
|
||||
)}
|
||||
{new Date(post.frontmatter.date).toLocaleDateString(locale || 'de', {
|
||||
year: 'numeric',
|
||||
month: 'long',
|
||||
day: 'numeric',
|
||||
})}
|
||||
</time>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -35,6 +35,13 @@ export async function generateMetadata(props: {
|
||||
},
|
||||
metadataBase: new URL(baseUrl),
|
||||
manifest: '/manifest.webmanifest',
|
||||
alternates: {
|
||||
canonical: `${baseUrl}/${locale}`,
|
||||
languages: {
|
||||
de: `${baseUrl}/de`,
|
||||
en: `${baseUrl}/en`,
|
||||
},
|
||||
},
|
||||
icons: {
|
||||
icon: [
|
||||
{ url: '/favicon.ico', sizes: 'any' },
|
||||
@@ -125,7 +132,11 @@ export default async function Layout(props: {
|
||||
const feedbackEnabled = process.env.NEXT_PUBLIC_FEEDBACK_ENABLED === 'true';
|
||||
|
||||
return (
|
||||
<html lang={safeLocale} className={`overflow-x-hidden ${inter.variable}`}>
|
||||
<html
|
||||
lang={safeLocale}
|
||||
className={`scroll-smooth overflow-x-hidden ${inter.variable}`}
|
||||
data-scroll-behavior="smooth"
|
||||
>
|
||||
<head>
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com" />
|
||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossOrigin="anonymous" />
|
||||
|
||||
@@ -1,84 +0,0 @@
|
||||
'use server';
|
||||
|
||||
import * as React from 'react';
|
||||
import { renderToBuffer } from '@react-pdf/renderer';
|
||||
import { getAllProducts } from '@/lib/products';
|
||||
import { getExcelTechnicalDataForProduct } from '@/lib/excel-products';
|
||||
import { PDFBrochure } from '@/lib/pdf-brochure';
|
||||
import { ProductData as PDFProductData } from '@/lib/pdf-datasheet';
|
||||
|
||||
/**
|
||||
* Server action to generate a PDF brochure.
|
||||
* Fetches products from Payload CMS and technical data from Excel.
|
||||
* Returns the PDF as a base64 string.
|
||||
*/
|
||||
export async function generateBrochureAction(params: {
|
||||
locale: 'en' | 'de';
|
||||
productSlugs?: string[];
|
||||
title?: string;
|
||||
subtitle?: string;
|
||||
}) {
|
||||
try {
|
||||
const { locale, productSlugs, title, subtitle } = params;
|
||||
|
||||
// 1. Fetch products from Payload
|
||||
let products = await getAllProducts(locale);
|
||||
|
||||
// 2. Filter by slugs if provided
|
||||
if (productSlugs && productSlugs.length > 0) {
|
||||
products = products.filter((p) => productSlugs.includes(p.slug));
|
||||
}
|
||||
|
||||
// 3. Map Payload products to PDF internal model and attach Excel data
|
||||
const pdfProducts: PDFProductData[] = products.map((p, index) => {
|
||||
// Get technical data from Excel
|
||||
const excelData = getExcelTechnicalDataForProduct({
|
||||
slug: p.slug,
|
||||
sku: p.frontmatter.sku,
|
||||
name: p.frontmatter.title,
|
||||
});
|
||||
|
||||
return {
|
||||
id: index + 1,
|
||||
name: p.frontmatter.title,
|
||||
sku: p.frontmatter.sku,
|
||||
shortDescriptionHtml: p.frontmatter.description,
|
||||
descriptionHtml: p.frontmatter.description,
|
||||
// Application text is usually part of description in Payload v3
|
||||
applicationHtml: '',
|
||||
featuredImage: p.frontmatter.images[0] || null,
|
||||
images: p.frontmatter.images,
|
||||
categories: p.frontmatter.categories.map((name) => ({ name })),
|
||||
attributes: excelData?.attributes || [],
|
||||
};
|
||||
});
|
||||
|
||||
if (pdfProducts.length === 0) {
|
||||
throw new Error('No products found for brochure generation.');
|
||||
}
|
||||
|
||||
// 4. Render to PDF buffer
|
||||
// Note: React 19 / Next 15+ might need specific handling for renderToBuffer in actions
|
||||
const buffer = await renderToBuffer(
|
||||
React.createElement(PDFBrochure, {
|
||||
products: pdfProducts,
|
||||
locale,
|
||||
title,
|
||||
subtitle,
|
||||
})
|
||||
);
|
||||
|
||||
// 5. Convert to Base64 for returning to client
|
||||
return {
|
||||
success: true,
|
||||
data: buffer.toString('base64'),
|
||||
fileName: `KLZ_Cables_Brochure_${locale.toUpperCase()}.pdf`,
|
||||
};
|
||||
} catch (error) {
|
||||
console.error('[Action] Brochure generation failed:', error);
|
||||
return {
|
||||
success: false,
|
||||
error: error instanceof Error ? error.message : 'Unknown error occurred',
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -1,7 +1,6 @@
|
||||
'use server';
|
||||
|
||||
import { sendEmail } from '@/lib/mail/mailer';
|
||||
import { env } from '@/lib/env';
|
||||
import { render, ContactFormNotification, ConfirmationMessage } from '@mintel/mail';
|
||||
import React from 'react';
|
||||
import { getServerAppServices } from '@/lib/services/create-services.server';
|
||||
@@ -55,7 +54,6 @@ export async function sendContactFormAction(formData: FormData) {
|
||||
type: productName ? 'product_quote' : 'contact',
|
||||
productName: productName || undefined,
|
||||
},
|
||||
overrideAccess: true,
|
||||
});
|
||||
|
||||
logger.info('Successfully saved form submission to Payload CMS', {
|
||||
@@ -88,7 +86,6 @@ export async function sendContactFormAction(formData: FormData) {
|
||||
);
|
||||
|
||||
if (!isTestSubmission) {
|
||||
logger.info('Sending internal notification', { recipients: env.MAIL_RECIPIENTS });
|
||||
const notificationResult = await sendEmail({
|
||||
replyTo: email,
|
||||
subject: notificationSubject,
|
||||
@@ -100,18 +97,14 @@ export async function sendContactFormAction(formData: FormData) {
|
||||
messageId: notificationResult.messageId,
|
||||
});
|
||||
} else {
|
||||
logger.error('Notification email DELIVERY FAILED', {
|
||||
logger.error('Notification email FAILED', {
|
||||
error: notificationResult.error,
|
||||
subject: notificationSubject,
|
||||
recipients: env.MAIL_RECIPIENTS,
|
||||
email,
|
||||
});
|
||||
services.errors.captureException(
|
||||
new Error(`Notification email failed: ${notificationResult.error}`),
|
||||
{
|
||||
action: 'sendContactFormAction_notification',
|
||||
email,
|
||||
recipients: env.MAIL_RECIPIENTS
|
||||
},
|
||||
{ action: 'sendContactFormAction_notification', email },
|
||||
);
|
||||
}
|
||||
} else {
|
||||
@@ -128,7 +121,6 @@ export async function sendContactFormAction(formData: FormData) {
|
||||
);
|
||||
|
||||
if (!isTestSubmission) {
|
||||
logger.info('Sending customer confirmation', { to: email });
|
||||
const confirmationResult = await sendEmail({
|
||||
to: email,
|
||||
subject: confirmationSubject,
|
||||
@@ -140,7 +132,7 @@ export async function sendContactFormAction(formData: FormData) {
|
||||
messageId: confirmationResult.messageId,
|
||||
});
|
||||
} else {
|
||||
logger.error('Confirmation email DELIVERY FAILED', {
|
||||
logger.error('Confirmation email FAILED', {
|
||||
error: confirmationResult.error,
|
||||
subject: confirmationSubject,
|
||||
to: email,
|
||||
|
||||
223
app/api/ai-search/route.ts
Normal file
223
app/api/ai-search/route.ts
Normal file
@@ -0,0 +1,223 @@
|
||||
import { NextResponse, NextRequest } from 'next/server'; // Added NextRequest
|
||||
import { searchProducts } from '../../../src/lib/qdrant';
|
||||
import redis from '../../../src/lib/redis';
|
||||
import { z } from 'zod';
|
||||
import * as Sentry from '@sentry/nextjs';
|
||||
export const dynamic = 'force-dynamic';
|
||||
export const maxDuration = 60; // Max allowed duration (Vercel)
|
||||
|
||||
// Config and constants
|
||||
const RATE_LIMIT_POINTS = 20; // 20 requests per minute
|
||||
const RATE_LIMIT_DURATION = 60; // 1 minute window
|
||||
const DAILY_BUDGET_LIMIT = 200; // max 200 requests per IP per day
|
||||
const DAILY_BUDGET_DURATION = 60 * 60 * 24; // 24h
|
||||
const MAX_CONVERSATION_MESSAGES = 20; // max messages in context
|
||||
const MAX_RESPONSE_TOKENS = 300; // cap AI response length — keeps it chat-like
|
||||
|
||||
// Removed requestSchema as it's replaced by direct parsing
|
||||
|
||||
export async function POST(req: NextRequest) {
|
||||
// Changed req type to NextRequest
|
||||
try {
|
||||
let body: any;
|
||||
try {
|
||||
body = await req.json();
|
||||
} catch {
|
||||
return NextResponse.json({ error: 'Invalid request body' }, { status: 400 });
|
||||
}
|
||||
const { messages, honeypot } = body;
|
||||
|
||||
// Get client IP for rate limiting
|
||||
const forwarded = req.headers.get('x-forwarded-for');
|
||||
const clientIp = forwarded?.split(',')[0]?.trim() || req.headers.get('x-real-ip') || 'unknown';
|
||||
|
||||
// 1. Basic Validation
|
||||
if (!messages || !Array.isArray(messages) || messages.length === 0) {
|
||||
return NextResponse.json({ error: 'Valid messages array is required' }, { status: 400 });
|
||||
}
|
||||
|
||||
const latestMessage = messages[messages.length - 1].content;
|
||||
const isBot = honeypot && honeypot.length > 0;
|
||||
|
||||
// Check if the input itself is obviously spam/too long
|
||||
if (latestMessage.length > 500) {
|
||||
return NextResponse.json({ error: 'Message too long' }, { status: 400 });
|
||||
}
|
||||
|
||||
// 2. Honeypot check
|
||||
if (isBot) {
|
||||
console.warn('Honeypot triggered in AI search');
|
||||
// Tarpit the bot
|
||||
await new Promise((resolve) => setTimeout(resolve, 3000));
|
||||
return NextResponse.json({
|
||||
answerText: 'Vielen Dank für Ihre Anfrage.',
|
||||
products: [],
|
||||
});
|
||||
}
|
||||
|
||||
// 3. Rate Limiting via Redis (IP-based)
|
||||
try {
|
||||
// Per-minute burst limit
|
||||
const minuteKey = `ai_rate:${clientIp}:min`;
|
||||
const minuteCount = await redis.incr(minuteKey);
|
||||
if (minuteCount === 1) await redis.expire(minuteKey, RATE_LIMIT_DURATION);
|
||||
|
||||
if (minuteCount > RATE_LIMIT_POINTS) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Zu viele Anfragen. Bitte warte einen Moment.' },
|
||||
{ status: 429 },
|
||||
);
|
||||
}
|
||||
|
||||
// Daily budget limit
|
||||
const dayKey = `ai_rate:${clientIp}:day`;
|
||||
const dayCount = await redis.incr(dayKey);
|
||||
if (dayCount === 1) await redis.expire(dayKey, DAILY_BUDGET_DURATION);
|
||||
|
||||
if (dayCount > DAILY_BUDGET_LIMIT) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Tägliches Limit erreicht. Bitte versuche es morgen erneut.' },
|
||||
{ status: 429 },
|
||||
);
|
||||
}
|
||||
} catch (redisError) {
|
||||
console.error('Redis Rate Limiting Error:', redisError);
|
||||
Sentry.captureException(redisError, { tags: { context: 'ai-search-rate-limit' } });
|
||||
// Fail open if Redis is down
|
||||
}
|
||||
|
||||
// 4. Cap conversation length to limit token usage
|
||||
const cappedMessages = messages.slice(-MAX_CONVERSATION_MESSAGES);
|
||||
|
||||
// 4. Fetch Context from Qdrant based on the latest message
|
||||
let contextStr = '';
|
||||
let foundProducts: any[] = [];
|
||||
|
||||
// Team context — hardcoded from translation data (no Payload collection for team)
|
||||
const teamContextStr = `
|
||||
Das ECHTE KLZ Team:
|
||||
- Michael Bodemer (Geschäftsführer) — Der Macher, packt an wenn es kompliziert wird, kennt Kabelnetze in- und auswendig
|
||||
- Klaus Mintel (Geschäftsführer) — Der Fels in der Brandung, jahrzehntelange Erfahrung, stabiles Netzwerk`;
|
||||
|
||||
try {
|
||||
const searchResults = await searchProducts(latestMessage, 5);
|
||||
|
||||
if (searchResults && searchResults.length > 0) {
|
||||
const productDescriptions = searchResults
|
||||
.filter((p) => p.payload?.type === 'product' || !p.payload?.type)
|
||||
.map((p: any) => p.payload?.content)
|
||||
.join('\n\n');
|
||||
|
||||
const knowledgeDescriptions = searchResults
|
||||
.filter((p) => p.payload?.type === 'knowledge')
|
||||
.map((p: any) => p.payload?.content)
|
||||
.join('\n\n');
|
||||
|
||||
contextStr = `KATALOG & PRODUKTE:\n${productDescriptions}\n\nKABELWISSEN (Handbuch):\n${knowledgeDescriptions}`;
|
||||
|
||||
foundProducts = searchResults
|
||||
.filter((p) => (p.payload?.type === 'product' || !p.payload?.type) && p.payload?.data)
|
||||
.map((p: any) => ({
|
||||
id: p.id as string,
|
||||
title: p.payload?.data?.title as string,
|
||||
sku: p.payload?.data?.sku as string,
|
||||
slug: p.payload?.data?.slug as string,
|
||||
}));
|
||||
}
|
||||
} catch (searchError) {
|
||||
console.error('Qdrant Search Error:', searchError);
|
||||
Sentry.captureException(searchError, { tags: { context: 'ai-search-qdrant' } });
|
||||
// We can still proceed without context if Qdrant fails
|
||||
}
|
||||
|
||||
// 5. Generate AI Response via OpenRouter (Mistral for DSGVO)
|
||||
const systemPrompt = `Du bist "Ohm" — der digitale KI-Berater von KLZ Cables. Dein Name ist eine Anspielung auf die Einheit des elektrischen Widerstands.
|
||||
|
||||
STIL & PERSÖNLICHKEIT:
|
||||
- Antworte KURZ, KNAPP und PROFESSIONELL (maximal 2-3 Sätze).
|
||||
- Schreibe wie in einem lockeren, aber kompetenten B2B-Chat (Du-Form ist okay, aber fachlich top).
|
||||
- Kein Markdown, nur Fließtext.
|
||||
- NIEMALS Platzhalter wie [Ihr Name], [Name], [Firma] verwenden.
|
||||
|
||||
DEINE HAUPTAUFGABE: BERATEN, NICHT AUSFRAGEN!
|
||||
- Wenn der Kunde ein Projekt nennt (z.B. "Windpark 30kV"), dann lies im KONTEXT nach, welche Kabel passen, und EMPFIEHL SIE DIREKT! (z.B. "Für 30kV Windparks nehmen wir meistens NA2XS(F)2Y.").
|
||||
- Stelle NIEMALS mehr als EINE Rückfrage pro Nachricht.
|
||||
- FRAGE NICHT nach abstrakten Dingen wie "Welchen Kabeltyp brauchst du?" -> DAS IST DEIN JOB, IHM DAS ZU SAGEN!
|
||||
- FRAGE NICHT nach Längen oder genauen Trassen, es sei denn, der Kunde hat schon ganz klar gesagt, was er kaufen will.
|
||||
- Biete aktiv Hilfe an: "Ich kann dir die passenden Querschnitte raussuchen, wenn du willst."
|
||||
|
||||
VORGEHEN:
|
||||
1. Prüfe den KONTEXT auf passende Kabel für das Kundenprojekt.
|
||||
2. Nenne direkt 1-2 passende Produktserien aus dem Kontext, die für diesen Fall Sinn machen.
|
||||
3. Biete eine konkrete Hilfestellung an (z.B. Leitungsberechnung, Verfügbarkeitsprüfung) ODER stelle EINE einzige fachliche Rückfrage, um das Kabel weiter einzugrenzen (z.B. Alu oder Kupfer?).
|
||||
4. Wenn das Projekt klar ist und die Kabeltypen besprochen sind, frag nach, ob ein Kollege (z.B. Micha) ein konkretes Angebot machen soll.
|
||||
|
||||
GRENZEN:
|
||||
- PRIVAT-ANFRAGEN: B2B only. Private Hausinstallationen lehnen wir freundlich ab.
|
||||
- Keine Preise oder genauen Lieferzeiten versprechen. Immer auf die menschlichen Kollegen verweisen für finale Angebote.
|
||||
|
||||
KONTEXT KABEL & TEAM:
|
||||
${contextStr || 'Kein Katalogkontext verfügbar.'}
|
||||
${teamContextStr}
|
||||
`;
|
||||
|
||||
const mistralKey = process.env.MISTRAL_API_KEY;
|
||||
if (!mistralKey) {
|
||||
throw new Error('MISTRAL_API_KEY is not set');
|
||||
}
|
||||
|
||||
// DSGVO: Mistral AI API direkt (EU/Frankreich) statt OpenRouter (US)
|
||||
const fetchRes = await fetch('https://api.mistral.ai/v1/chat/completions', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
Authorization: `Bearer ${mistralKey}`,
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({
|
||||
model: 'ministral-8b-latest',
|
||||
temperature: 0.3,
|
||||
max_tokens: MAX_RESPONSE_TOKENS,
|
||||
messages: [
|
||||
{ role: 'system', content: systemPrompt },
|
||||
...cappedMessages.map((m: any) => ({
|
||||
role: m.role,
|
||||
content: typeof m.content === 'string' ? m.content : JSON.stringify(m.content),
|
||||
})),
|
||||
],
|
||||
}),
|
||||
});
|
||||
|
||||
if (!fetchRes.ok) {
|
||||
const errBody = await fetchRes.text();
|
||||
console.error('Mistral API Error:', errBody);
|
||||
Sentry.captureException(new Error(`Mistral ${fetchRes.status}: ${errBody}`), {
|
||||
tags: { context: 'ai-search-mistral' },
|
||||
});
|
||||
|
||||
// Return user-friendly error based on status
|
||||
const userMsg =
|
||||
fetchRes.status === 429
|
||||
? 'Der KI-Service ist gerade überlastet. Bitte versuche es in ein paar Sekunden erneut.'
|
||||
: fetchRes.status >= 500
|
||||
? 'Der KI-Service ist vorübergehend nicht erreichbar. Bitte versuche es gleich nochmal.'
|
||||
: 'Es gab ein Problem mit der KI-Anfrage. Bitte versuche es erneut.';
|
||||
return NextResponse.json({ error: userMsg }, { status: 502 });
|
||||
}
|
||||
|
||||
const data = await fetchRes.json();
|
||||
const text = data.choices[0].message.content;
|
||||
|
||||
// Return the AI's answer along with any found products
|
||||
return NextResponse.json({
|
||||
answerText: text,
|
||||
products: foundProducts,
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('AI Search API Error:', error);
|
||||
Sentry.captureException(error, { tags: { context: 'ai-search-api' } });
|
||||
return NextResponse.json(
|
||||
{ error: 'Ein interner Fehler ist aufgetreten. Bitte versuche es erneut.' },
|
||||
{ status: 500 },
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -27,7 +27,7 @@ export async function GET() {
|
||||
}
|
||||
}
|
||||
|
||||
const hasErrors = Object.values(checks).some(v => v.startsWith('error'));
|
||||
const hasErrors = Object.values(checks).some((v) => v.startsWith('error'));
|
||||
return NextResponse.json(
|
||||
{ status: hasErrors ? 'degraded' : 'ok', checks },
|
||||
{ status: hasErrors ? 503 : 200 },
|
||||
|
||||
@@ -1,64 +0,0 @@
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { getPayload } from 'payload';
|
||||
import configPromise from '@payload-config';
|
||||
import { renderToStream } from '@react-pdf/renderer';
|
||||
import React from 'react';
|
||||
import { PDFPage } from '@/lib/pdf-page';
|
||||
|
||||
export async function GET(req: NextRequest, { params }: { params: Promise<{ slug: string }> }) {
|
||||
try {
|
||||
const { slug } = await params;
|
||||
|
||||
// Get Payload App
|
||||
const payload = await getPayload({ config: configPromise });
|
||||
|
||||
// Fetch the page
|
||||
const pages = await payload.find({
|
||||
collection: 'pages',
|
||||
where: {
|
||||
slug: { equals: slug },
|
||||
_status: { equals: 'published' },
|
||||
},
|
||||
limit: 1,
|
||||
});
|
||||
|
||||
if (pages.totalDocs === 0) {
|
||||
return new NextResponse('Page not found', { status: 404 });
|
||||
}
|
||||
|
||||
const page = pages.docs[0];
|
||||
|
||||
// Determine locale from searchParams or default to 'de'
|
||||
const searchParams = req.nextUrl.searchParams;
|
||||
const locale = (searchParams.get('locale') as 'en' | 'de') || 'de';
|
||||
|
||||
// Render the React-PDF document into a stream
|
||||
const stream = await renderToStream(<PDFPage page={page} locale={locale} />);
|
||||
|
||||
// Pipe the Node.js Readable stream into a valid fetch/Web Response stream
|
||||
const body = new ReadableStream({
|
||||
start(controller) {
|
||||
stream.on('data', (chunk) => controller.enqueue(chunk));
|
||||
stream.on('end', () => controller.close());
|
||||
stream.on('error', (err) => controller.error(err));
|
||||
},
|
||||
cancel() {
|
||||
(stream as any).destroy?.();
|
||||
},
|
||||
});
|
||||
|
||||
const filename = `${slug}.pdf`;
|
||||
|
||||
return new NextResponse(body, {
|
||||
status: 200,
|
||||
headers: {
|
||||
'Content-Type': 'application/pdf',
|
||||
'Content-Disposition': `attachment; filename="${filename}"`,
|
||||
// Cache control if needed, skip for now.
|
||||
},
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Error generating PDF:', error);
|
||||
return new NextResponse('Internal Server Error', { status: 500 });
|
||||
}
|
||||
}
|
||||
126
app/api/sync-qdrant/route.ts
Normal file
126
app/api/sync-qdrant/route.ts
Normal file
@@ -0,0 +1,126 @@
|
||||
import { NextResponse } from 'next/server';
|
||||
import { getPayload } from 'payload';
|
||||
import configPromise from '../../../payload.config';
|
||||
import { upsertProductVector } from '../../../src/lib/qdrant';
|
||||
|
||||
export const dynamic = 'force-dynamic';
|
||||
export const maxDuration = 120;
|
||||
|
||||
/**
|
||||
* Internal endpoint called by the warmup script on every dev boot.
|
||||
* Syncs posts, pages, and products from Payload CMS into Qdrant.
|
||||
* NOT for form entries, media, or users.
|
||||
*/
|
||||
export async function GET() {
|
||||
const results = { products: 0, posts: 0, pages: 0, errors: [] as string[] };
|
||||
|
||||
try {
|
||||
const payload = await getPayload({ config: configPromise });
|
||||
|
||||
// ── Products ──
|
||||
const { docs: products } = await payload.find({
|
||||
collection: 'products',
|
||||
limit: 1000,
|
||||
depth: 0,
|
||||
where: { _status: { equals: 'published' } },
|
||||
});
|
||||
|
||||
for (const product of products) {
|
||||
try {
|
||||
const contentText = `${product.title} - SKU: ${product.sku}\n${product.description || ''}`;
|
||||
await upsertProductVector(String(product.id), contentText, {
|
||||
type: 'product',
|
||||
data: {
|
||||
title: product.title,
|
||||
sku: product.sku,
|
||||
slug: product.slug,
|
||||
description: product.description,
|
||||
},
|
||||
});
|
||||
results.products++;
|
||||
} catch (e: any) {
|
||||
results.errors.push(`product:${product.sku}: ${e.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
// ── Posts ──
|
||||
const { docs: posts } = await payload.find({
|
||||
collection: 'posts',
|
||||
limit: 1000,
|
||||
depth: 0,
|
||||
where: { _status: { equals: 'published' } },
|
||||
});
|
||||
|
||||
for (const post of posts) {
|
||||
try {
|
||||
const contentText = [
|
||||
`Blog-Artikel: ${post.title}`,
|
||||
post.excerpt ? `Zusammenfassung: ${post.excerpt}` : '',
|
||||
post.category ? `Kategorie: ${post.category}` : '',
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join('\n');
|
||||
|
||||
await upsertProductVector(`post_${post.id}`, contentText, {
|
||||
type: 'knowledge',
|
||||
content: contentText,
|
||||
data: {
|
||||
title: post.title,
|
||||
slug: post.slug,
|
||||
},
|
||||
});
|
||||
results.posts++;
|
||||
} catch (e: any) {
|
||||
results.errors.push(`post:${post.slug}: ${e.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
// ── Pages ──
|
||||
const { docs: pages } = await payload.find({
|
||||
collection: 'pages',
|
||||
limit: 1000,
|
||||
depth: 0,
|
||||
where: { _status: { equals: 'published' } },
|
||||
});
|
||||
|
||||
for (const page of pages) {
|
||||
try {
|
||||
const contentText = [
|
||||
`Seite: ${page.title}`,
|
||||
page.excerpt ? `Beschreibung: ${page.excerpt}` : '',
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join('\n');
|
||||
|
||||
await upsertProductVector(`page_${page.id}`, contentText, {
|
||||
type: 'knowledge',
|
||||
content: contentText,
|
||||
data: {
|
||||
title: page.title,
|
||||
slug: page.slug,
|
||||
},
|
||||
});
|
||||
results.pages++;
|
||||
} catch (e: any) {
|
||||
results.errors.push(`page:${page.slug}: ${e.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
console.log(
|
||||
`[Qdrant Sync] ✅ ${results.products} products, ${results.posts} posts, ${results.pages} pages synced`,
|
||||
);
|
||||
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
synced: {
|
||||
products: results.products,
|
||||
posts: results.posts,
|
||||
pages: results.pages,
|
||||
},
|
||||
errors: results.errors.length > 0 ? results.errors : undefined,
|
||||
});
|
||||
} catch (error: any) {
|
||||
console.error('[Qdrant Sync] ❌ Fatal error:', error);
|
||||
return NextResponse.json({ error: error.message }, { status: 500 });
|
||||
}
|
||||
}
|
||||
@@ -24,34 +24,14 @@ export async function POST(request: NextRequest) {
|
||||
return NextResponse.json({ status: 'ignored_in_dev' }, { status: 200 });
|
||||
}
|
||||
|
||||
let body;
|
||||
try {
|
||||
body = await request.json();
|
||||
} catch (_parseError) {
|
||||
logger.warn('Received malformed or empty JSON in analytics proxy');
|
||||
return NextResponse.json({ status: 'ignored_bad_payload' }, { status: 200 });
|
||||
}
|
||||
|
||||
if (!body || typeof body !== 'object') {
|
||||
logger.warn('Received invalid body type in analytics proxy', { type: typeof body });
|
||||
return NextResponse.json({ status: 'ignored_invalid_body' }, { status: 200 });
|
||||
}
|
||||
|
||||
const body = await request.json();
|
||||
const { type, payload } = body;
|
||||
|
||||
if (!type || !payload) {
|
||||
logger.warn('Received incomplete analytics payload', {
|
||||
hasType: !!type,
|
||||
hasPayload: !!payload
|
||||
});
|
||||
return NextResponse.json({ status: 'ignored_incomplete_payload' }, { status: 200 });
|
||||
}
|
||||
|
||||
// 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_no_config' }, { status: 200 });
|
||||
return NextResponse.json({ status: 'ignored' }, { status: 200 });
|
||||
}
|
||||
|
||||
// Prepare the enhanced payload with the secret ID
|
||||
|
||||
2129
ast_updated.json
2129
ast_updated.json
File diff suppressed because it is too large
Load Diff
@@ -15,10 +15,12 @@ export default function CMSConnectivityNotice() {
|
||||
const isDebug = new URLSearchParams(window.location.search).has('cms_debug');
|
||||
const isLocal = config.isDevelopment;
|
||||
const isTesting = config.isTesting;
|
||||
const target = process.env.NEXT_PUBLIC_TARGET || '';
|
||||
const isBranch = target === 'branch';
|
||||
|
||||
// Only proceed with check if it's developer context (Local or Testing)
|
||||
// Only proceed with check if it's developer context (Local, Testing, or Branch preview)
|
||||
// Staging and Production should NEVER see this unless forced with ?cms_debug
|
||||
if (!isLocal && !isTesting && !isDebug) return;
|
||||
if (!isLocal && !isTesting && !isBranch && !isDebug) return;
|
||||
|
||||
try {
|
||||
const response = await fetch('/api/health/cms');
|
||||
@@ -58,8 +60,8 @@ export default function CMSConnectivityNotice() {
|
||||
<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.'}
|
||||
? 'The database schema is missing. Please run migrations for this environment.'
|
||||
: 'A content service is unavailable. Check the deployment logs for details.'}
|
||||
</p>
|
||||
<div className="flex gap-2">
|
||||
<button
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
import Link from 'next/link';
|
||||
import Image from 'next/image';
|
||||
import { useTranslations, useLocale } from 'next-intl';
|
||||
import { ShieldCheck, Leaf, Lock, Accessibility, Zap } from 'lucide-react';
|
||||
import { Container } from './ui';
|
||||
import { useAnalytics } from './analytics/useAnalytics';
|
||||
import { AnalyticsEvents } from './analytics/analytics-events';
|
||||
@@ -276,6 +277,48 @@ export default function Footer() {
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Brand & Quality Sub-Footer */}
|
||||
<div className="pt-8 mt-8 border-t border-white/5 flex flex-col md:flex-row justify-between items-center gap-6 text-white/40 text-[10px] sm:text-xs">
|
||||
<div>
|
||||
<a
|
||||
href="https://mintel.me"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
onClick={() =>
|
||||
trackEvent(AnalyticsEvents.LINK_CLICK, {
|
||||
target: 'mintel_agency',
|
||||
location: 'sub_footer',
|
||||
})
|
||||
}
|
||||
className="hover:text-white/80 transition-colors flex items-center gap-1.5"
|
||||
>
|
||||
Website entwickelt von Marc Mintel
|
||||
</a>
|
||||
</div>
|
||||
<div className="flex flex-wrap justify-center md:justify-end gap-x-6 gap-y-3">
|
||||
<div className="flex items-center gap-1.5" title="SSL Secured">
|
||||
<ShieldCheck className="w-3.5 h-3.5" />
|
||||
<span>SSL Secured</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-1.5" title="Green Hosting">
|
||||
<Leaf className="w-3.5 h-3.5" />
|
||||
<span>Green Hosting</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-1.5" title="DSGVO Compliant">
|
||||
<Lock className="w-3.5 h-3.5" />
|
||||
<span>DSGVO Compliant</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-1.5" title="WCAG">
|
||||
<Accessibility className="w-3.5 h-3.5" />
|
||||
<span>WCAG</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-1.5" title="PageSpeed 90+">
|
||||
<Zap className="w-3.5 h-3.5" />
|
||||
<span>PageSpeed 90+</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Container>
|
||||
</footer>
|
||||
);
|
||||
|
||||
@@ -9,6 +9,8 @@ import { useEffect, useState, useRef } from 'react';
|
||||
import { cn } from './ui';
|
||||
import { useAnalytics } from './analytics/useAnalytics';
|
||||
import { AnalyticsEvents } from './analytics/analytics-events';
|
||||
import { Search } from 'lucide-react';
|
||||
import { AISearchResults } from './search/AISearchResults';
|
||||
|
||||
export default function Header() {
|
||||
const t = useTranslations('Navigation');
|
||||
@@ -16,6 +18,7 @@ export default function Header() {
|
||||
const { trackEvent } = useAnalytics();
|
||||
const [isScrolled, setIsScrolled] = useState(false);
|
||||
const [isMobileMenuOpen, setIsMobileMenuOpen] = useState(false);
|
||||
const [isSearchOpen, setIsSearchOpen] = useState(false);
|
||||
const mobileMenuRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
// Extract locale from pathname
|
||||
@@ -273,6 +276,19 @@ export default function Header() {
|
||||
<div
|
||||
className="animate-in fade-in zoom-in-95 fill-mode-both"
|
||||
style={{ animationDuration: '600ms', animationDelay: '700ms' }}
|
||||
>
|
||||
<button
|
||||
onClick={() => setIsSearchOpen(true)}
|
||||
className="hover:text-accent transition-colors p-2"
|
||||
aria-label="Search"
|
||||
>
|
||||
<Search className="w-5 h-5 md:w-6 md:h-6" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div
|
||||
className="animate-in fade-in zoom-in-95 fill-mode-both"
|
||||
style={{ animationDuration: '600ms', animationDelay: '800ms' }}
|
||||
>
|
||||
<Button
|
||||
href={`/${currentLocale}/${currentLocale === 'de' ? 'kontakt' : 'contact'}`}
|
||||
@@ -467,6 +483,8 @@ export default function Header() {
|
||||
</div>
|
||||
</nav>
|
||||
</div>
|
||||
|
||||
<AISearchResults isOpen={isSearchOpen} onClose={() => setIsSearchOpen(false)} />
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -7,14 +7,16 @@ interface JsonLdProps {
|
||||
|
||||
export default function JsonLd({ id, data }: JsonLdProps) {
|
||||
// If data is provided, use it. Otherwise, use the default Organization + WebSite schema.
|
||||
const rawData = data || [
|
||||
const schemaData = data || [
|
||||
{
|
||||
'@context': 'https://schema.org',
|
||||
'@type': 'Organization',
|
||||
name: 'KLZ Cables',
|
||||
url: 'https://klz-cables.com',
|
||||
logo: 'https://klz-cables.com/logo-blue.svg',
|
||||
sameAs: ['https://www.linkedin.com/company/klz-cables'],
|
||||
sameAs: [
|
||||
'https://www.linkedin.com/company/klz-cables',
|
||||
],
|
||||
description: 'Premium Cable Solutions for Renewable Energy and Infrastructure.',
|
||||
address: {
|
||||
'@type': 'PostalAddress',
|
||||
@@ -34,32 +36,15 @@ export default function JsonLd({ id, data }: JsonLdProps) {
|
||||
},
|
||||
'query-input': 'required name=search_term_string',
|
||||
},
|
||||
},
|
||||
}
|
||||
];
|
||||
|
||||
// Harden schema items: Ensure each item is an object and has @context
|
||||
const schemaData = (Array.isArray(rawData) ? rawData : [rawData])
|
||||
.filter((item): item is WithContext<Thing> => {
|
||||
// Basic sanity check: must be an object and not null
|
||||
return typeof item === 'object' && item !== null;
|
||||
})
|
||||
.map((item) => {
|
||||
// Ensure @context is present and correctly typed for consumer libraries
|
||||
if (!item['@context']) {
|
||||
return {
|
||||
...(item as Record<string, any>),
|
||||
'@context': 'https://schema.org',
|
||||
} as WithContext<Thing>;
|
||||
}
|
||||
return item;
|
||||
});
|
||||
|
||||
return (
|
||||
<script
|
||||
id={id}
|
||||
type="application/ld+json"
|
||||
dangerouslySetInnerHTML={{
|
||||
__html: JSON.stringify(schemaData.length === 1 ? schemaData[0] : schemaData),
|
||||
__html: JSON.stringify(schemaData),
|
||||
}}
|
||||
/>
|
||||
);
|
||||
|
||||
@@ -1,34 +0,0 @@
|
||||
'use client';
|
||||
|
||||
import React from 'react';
|
||||
import { usePathname } from 'next/navigation';
|
||||
|
||||
export const PDFDownloadBlock: React.FC<{ label: string; style: string }> = ({ label, style }) => {
|
||||
const pathname = usePathname();
|
||||
|
||||
// Extract slug from pathname
|
||||
const segments = pathname.split('/').filter(Boolean);
|
||||
// Pathname is usually /[locale]/[slug] or /[locale]/products/[slug]
|
||||
// We want the page slug.
|
||||
const slug = segments[segments.length - 1] || 'home';
|
||||
|
||||
const href = `/api/pages/${slug}/pdf`;
|
||||
|
||||
return (
|
||||
<div className="my-8">
|
||||
<a
|
||||
href={href}
|
||||
className={`inline-flex items-center px-8 py-3.5 font-bold rounded-full transition-all duration-300 shadow-lg hover:shadow-xl group ${
|
||||
style === 'primary'
|
||||
? 'bg-primary text-white hover:bg-primary-dark'
|
||||
: style === 'secondary'
|
||||
? 'bg-accent text-primary-dark hover:bg-neutral-light'
|
||||
: 'border-2 border-primary text-primary hover:bg-primary hover:text-white'
|
||||
}`}
|
||||
>
|
||||
<span className="mr-3 transition-transform group-hover:scale-12 bit-bounce">📄</span>
|
||||
{label}
|
||||
</a>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -37,7 +37,6 @@ import MeetTheTeam from '@/components/home/MeetTheTeam';
|
||||
import GallerySection from '@/components/home/GallerySection';
|
||||
import VideoSection from '@/components/home/VideoSection';
|
||||
import CTA from '@/components/home/CTA';
|
||||
import { PDFDownloadBlock } from '@/components/PDFDownloadBlock';
|
||||
|
||||
/**
|
||||
* Splits a text string on \n and intersperses <br /> elements.
|
||||
@@ -430,12 +429,6 @@ const jsxConverters: JSXConverters = {
|
||||
{node.fields.content && <RichText data={node.fields.content} converters={jsxConverters} />}
|
||||
</ProductTabs>
|
||||
),
|
||||
pdfDownload: ({ node }: any) => (
|
||||
<PDFDownloadBlock label={node.fields.label} style={node.fields.style} />
|
||||
),
|
||||
'block-pdfDownload': ({ node }: any) => (
|
||||
<PDFDownloadBlock label={node.fields.label} style={node.fields.style} />
|
||||
),
|
||||
// ─── New Page Blocks ───────────────────────────────────────────
|
||||
heroSection: ({ node }: any) => {
|
||||
const f = node.fields;
|
||||
|
||||
@@ -1,98 +1,221 @@
|
||||
'use client';
|
||||
|
||||
import Scribble from '@/components/Scribble';
|
||||
import { Button, Container, Heading, Section } from '@/components/ui';
|
||||
import { useTranslations, useLocale } from 'next-intl';
|
||||
import dynamic from 'next/dynamic';
|
||||
import { useAnalytics } from '../analytics/useAnalytics';
|
||||
import { AnalyticsEvents } from '../analytics/analytics-events';
|
||||
import { useState, useEffect, useRef, useCallback } from 'react';
|
||||
import { ChevronRight } from 'lucide-react';
|
||||
import { AISearchResults } from '../search/AISearchResults';
|
||||
const HeroIllustration = dynamic(() => import('./HeroIllustration'), { ssr: false });
|
||||
const AIOrb = dynamic(() => import('../search/AIOrb'), { ssr: false });
|
||||
|
||||
export default function Hero({ data }: { data?: any }) {
|
||||
const t = useTranslations('Home.hero');
|
||||
const locale = useLocale();
|
||||
const { trackEvent } = useAnalytics();
|
||||
|
||||
const [searchQuery, setSearchQuery] = useState('');
|
||||
const [isSearchOpen, setIsSearchOpen] = useState(false);
|
||||
const [heroPlaceholder, setHeroPlaceholder] = useState(
|
||||
'Projekt beschreiben oder Kabel suchen...',
|
||||
);
|
||||
const typingRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
|
||||
const HERO_PLACEHOLDERS = [
|
||||
'Querschnittsberechnung für 110kV Trasse', // Hochspannung
|
||||
'Wie schwer ist NAYY 4x150?',
|
||||
'Ich plane einen Solarpark, was brauche ich?', // Projekt Solar
|
||||
'Unterschied zwischen N2XSY und NAY2XSY?', // Fach
|
||||
'Mittelspannungskabel für Windkraftanlage', // Windpark
|
||||
'Welches Aluminiumkabel für 20kV?', // Mittelspannung
|
||||
];
|
||||
|
||||
// Typing animation for the hero search placeholder
|
||||
useEffect(() => {
|
||||
if (searchQuery) {
|
||||
setHeroPlaceholder('Projekt beschreiben oder Kabel suchen...');
|
||||
return;
|
||||
}
|
||||
|
||||
let textIdx = 0;
|
||||
let charIdx = 0;
|
||||
let deleting = false;
|
||||
|
||||
const tick = () => {
|
||||
const fullText = HERO_PLACEHOLDERS[textIdx];
|
||||
|
||||
if (deleting) {
|
||||
charIdx--;
|
||||
setHeroPlaceholder(fullText.substring(0, charIdx));
|
||||
} else {
|
||||
charIdx++;
|
||||
setHeroPlaceholder(fullText.substring(0, charIdx));
|
||||
}
|
||||
|
||||
let delay = deleting ? 30 : 70;
|
||||
|
||||
if (!deleting && charIdx === fullText.length) {
|
||||
delay = 2500;
|
||||
deleting = true;
|
||||
} else if (deleting && charIdx === 0) {
|
||||
deleting = false;
|
||||
textIdx = (textIdx + 1) % HERO_PLACEHOLDERS.length;
|
||||
delay = 400;
|
||||
}
|
||||
|
||||
typingRef.current = setTimeout(tick, delay);
|
||||
};
|
||||
|
||||
typingRef.current = setTimeout(tick, 1500);
|
||||
|
||||
return () => {
|
||||
if (typingRef.current) clearTimeout(typingRef.current);
|
||||
};
|
||||
}, [searchQuery]);
|
||||
|
||||
const handleSearchSubmit = (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
if (searchQuery.trim()) {
|
||||
setIsSearchOpen(true);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Section className="relative min-h-[85vh] md:h-[90vh] flex flex-col items-center justify-center overflow-hidden bg-primary py-12 md:py-0 lg:py-0">
|
||||
<Container className="relative z-10 text-center md:text-left text-white w-full order-2 md:order-none">
|
||||
<div className="max-w-5xl mx-auto md:mx-0">
|
||||
<div>
|
||||
<Heading
|
||||
level={1}
|
||||
className="text-center md:text-left mb-6 md:mb-8 md:max-w-none text-white text-3xl sm:text-4xl md:text-5xl font-extrabold"
|
||||
>
|
||||
{data?.title ? (
|
||||
<span
|
||||
dangerouslySetInnerHTML={{
|
||||
__html: data.title
|
||||
.replace(/<green>/g, '<span class="text-accent italic">')
|
||||
.replace(/<\/green>/g, '</span>'),
|
||||
}}
|
||||
/>
|
||||
) : (
|
||||
t.rich('title', {
|
||||
green: (chunks) => <span className="text-accent italic">{chunks}</span>,
|
||||
})
|
||||
)}
|
||||
</Heading>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-lg md:text-xl text-white leading-relaxed max-w-2xl mb-10 md:mb-12">
|
||||
{data?.subtitle || t('subtitle')}
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex flex-col sm:flex-row justify-center md:justify-start gap-4 md:gap-6">
|
||||
<>
|
||||
<Section className="relative min-h-[85vh] md:h-[90vh] flex flex-col items-center justify-center overflow-hidden bg-primary py-12 md:py-0 lg:py-0">
|
||||
<Container className="relative z-10 text-center md:text-left text-white w-full order-2 md:order-none">
|
||||
<div className="max-w-5xl mx-auto md:mx-0">
|
||||
<div>
|
||||
<Heading
|
||||
level={1}
|
||||
className="text-center md:text-left mb-6 md:mb-8 md:max-w-none text-white text-4xl sm:text-5xl md:text-7xl font-extrabold [text-shadow:_-2px_-2px_0_#002b49,_2px_-2px_0_#002b49,_-2px_2px_0_#002b49,_2px_2px_0_#002b49,_-2px_0_0_#002b49,_2px_0_0_#002b49,_0_-2px_0_#002b49,_0_2px_0_#002b49]"
|
||||
>
|
||||
{data?.title ? (
|
||||
<span
|
||||
dangerouslySetInnerHTML={{
|
||||
__html: data.title
|
||||
.replace(
|
||||
/<green>/g,
|
||||
'<span class="relative inline-block"><span class="relative z-10 text-accent italic inline-block">',
|
||||
)
|
||||
.replace(
|
||||
/<\/green>/g,
|
||||
'</span><div class="w-[140%] h-[140%] -top-[20%] -left-[20%] text-accent/30 hidden md:block absolute -z-10 animate-in fade-in zoom-in-0 duration-1000 ease-out fill-mode-both" style="animation-delay: 500ms;"><Scribble variant="circle" /></div></span>',
|
||||
),
|
||||
}}
|
||||
/>
|
||||
) : (
|
||||
t.rich('title', {
|
||||
green: (chunks) => (
|
||||
<span className="relative inline-block">
|
||||
<span className="relative z-10 text-accent italic inline-block">
|
||||
{chunks}
|
||||
</span>
|
||||
<div
|
||||
className="w-[140%] h-[140%] -top-[20%] -left-[20%] text-accent/30 hidden md:block absolute -z-10 animate-in fade-in zoom-in-0 duration-1000 ease-out fill-mode-both"
|
||||
style={{ animationDelay: '500ms' }}
|
||||
>
|
||||
<Scribble variant="circle" />
|
||||
</div>
|
||||
</span>
|
||||
),
|
||||
})
|
||||
)}
|
||||
</Heading>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-lg md:text-xl text-white leading-relaxed max-w-2xl mb-10 md:mb-12">
|
||||
{data?.subtitle || t('subtitle')}
|
||||
</p>
|
||||
</div>
|
||||
<form
|
||||
onSubmit={handleSearchSubmit}
|
||||
className="w-full max-w-2xl bg-white/10 backdrop-blur-md border border-white/20 rounded-2xl p-2 flex items-center mt-8 mb-10 transition-all focus-within:bg-white/15 focus-within:border-accent shadow-lg relative"
|
||||
>
|
||||
<div className="absolute left-1 w-20 h-20 flex items-center justify-center z-10 overflow-visible">
|
||||
<AIOrb isThinking={false} />
|
||||
</div>
|
||||
<input
|
||||
type="text"
|
||||
value={searchQuery}
|
||||
onChange={(e) => setSearchQuery(e.target.value)}
|
||||
placeholder={heroPlaceholder}
|
||||
className="flex-1 bg-transparent border-none text-white pl-20 pr-2 py-4 placeholder:text-white/50 focus:outline-none text-lg lg:text-xl"
|
||||
autoFocus
|
||||
/>
|
||||
<Button
|
||||
href="/contact"
|
||||
type="submit"
|
||||
variant="accent"
|
||||
size="lg"
|
||||
className="group w-full sm:w-auto h-14 md:h-16 px-8 md:px-10 text-base md:text-lg hover:scale-105 transition-transform"
|
||||
onClick={() =>
|
||||
trackEvent(AnalyticsEvents.BUTTON_CLICK, {
|
||||
label: data?.ctaLabel || t('cta'),
|
||||
location: 'home_hero_primary',
|
||||
})
|
||||
}
|
||||
className="rounded-xl px-6 py-4 shrink-0 flex items-center shadow-md font-bold cursor-pointer hover:bg-accent hover:brightness-110"
|
||||
>
|
||||
{data?.ctaLabel || t('cta')}
|
||||
<span className="transition-transform group-hover/btn:translate-x-1 ml-2">
|
||||
→
|
||||
</span>
|
||||
</Button>
|
||||
</div>
|
||||
<div>
|
||||
<Button
|
||||
href={`/${locale}/${locale === 'de' ? 'produkte' : 'products'}`}
|
||||
variant="white"
|
||||
size="lg"
|
||||
className="group w-full sm:w-auto h-14 md:h-16 px-8 md:px-10 text-base md:text-lg md:bg-white md:text-primary md:hover:bg-neutral-light md:border-none hover:scale-105 transition-transform"
|
||||
onClick={() =>
|
||||
trackEvent(AnalyticsEvents.BUTTON_CLICK, {
|
||||
label: data?.secondaryCtaLabel || t('exploreProducts'),
|
||||
location: 'home_hero_secondary',
|
||||
})
|
||||
}
|
||||
>
|
||||
{data?.secondaryCtaLabel || t('exploreProducts')}
|
||||
Fragen
|
||||
<ChevronRight className="w-5 h-5 ml-2 -mr-1" />
|
||||
</Button>
|
||||
</form>
|
||||
|
||||
<div className="flex flex-col sm:flex-row justify-center md:justify-start gap-4 md:gap-6">
|
||||
<div>
|
||||
<Button
|
||||
href="/contact"
|
||||
variant="white"
|
||||
size="lg"
|
||||
className="group w-full sm:w-auto h-14 md:h-16 px-8 md:px-10 text-base md:text-lg hover:scale-105 transition-all outline-none"
|
||||
onClick={() =>
|
||||
trackEvent(AnalyticsEvents.BUTTON_CLICK, {
|
||||
label: data?.ctaLabel || t('cta'),
|
||||
location: 'home_hero_primary',
|
||||
})
|
||||
}
|
||||
>
|
||||
{data?.ctaLabel || t('cta')}
|
||||
<span className="transition-transform group-hover/btn:translate-x-1 ml-2">
|
||||
→
|
||||
</span>
|
||||
</Button>
|
||||
</div>
|
||||
<div>
|
||||
<Button
|
||||
href={`/${locale}/${locale === 'de' ? 'produkte' : 'products'}`}
|
||||
variant="outline"
|
||||
size="lg"
|
||||
className="group w-full sm:w-auto h-14 md:h-16 px-8 md:px-10 text-base md:text-lg text-white border-white/30 hover:bg-white/10 hover:border-white transition-all"
|
||||
onClick={() =>
|
||||
trackEvent(AnalyticsEvents.BUTTON_CLICK, {
|
||||
label: data?.secondaryCtaLabel || t('exploreProducts'),
|
||||
location: 'home_hero_secondary',
|
||||
})
|
||||
}
|
||||
>
|
||||
{data?.secondaryCtaLabel || t('exploreProducts')}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Container>
|
||||
</Container>
|
||||
|
||||
<div className="relative md:absolute inset-0 z-0 w-full h-[40vh] md:h-full order-1 md:order-none mb-[-80px] md:mb-0 mt-[80px] md:mt-0 overflow-visible pointer-events-none animate-in fade-in zoom-in-95 duration-1000 ease-out fill-mode-both">
|
||||
<HeroIllustration />
|
||||
</div>
|
||||
|
||||
<div
|
||||
className="absolute bottom-6 md:bottom-10 left-1/2 -translate-x-1/2 hidden sm:block animate-in fade-in slide-in-from-bottom-4 duration-1000 ease-out fill-mode-both"
|
||||
style={{ animationDelay: '2000ms' }}
|
||||
>
|
||||
<div className="w-6 h-10 border-2 border-white/30 rounded-full flex justify-center p-1">
|
||||
<div className="w-1 h-2 bg-white rounded-full animate-bounce" />
|
||||
<div className="relative md:absolute inset-0 z-0 w-full h-[40vh] md:h-full order-1 md:order-none mb-[-80px] md:mb-0 mt-[80px] md:mt-0 overflow-visible pointer-events-none animate-in fade-in zoom-in-95 duration-1000 ease-out fill-mode-both">
|
||||
<HeroIllustration />
|
||||
</div>
|
||||
</div>
|
||||
</Section>
|
||||
|
||||
<div
|
||||
className="absolute bottom-6 md:bottom-10 left-1/2 -translate-x-1/2 hidden sm:block animate-in fade-in slide-in-from-bottom-4 duration-1000 ease-out fill-mode-both"
|
||||
style={{ animationDelay: '2000ms' }}
|
||||
>
|
||||
<div className="w-6 h-10 border-2 border-white/30 rounded-full flex justify-center p-1">
|
||||
<div className="w-1 h-2 bg-white rounded-full animate-bounce" />
|
||||
</div>
|
||||
</div>
|
||||
</Section>
|
||||
<AISearchResults
|
||||
isOpen={isSearchOpen}
|
||||
onClose={() => setIsSearchOpen(false)}
|
||||
initialQuery={searchQuery}
|
||||
triggerSearch={true}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -75,7 +75,7 @@ export default async function RecentPosts({ locale, data }: RecentPostsProps) {
|
||||
className="px-3 py-1 text-white/80 text-[10px] md:text-xs font-bold uppercase tracking-widest border border-white/20 rounded-full bg-white/10 backdrop-blur-md"
|
||||
>
|
||||
{new Date(post.frontmatter.date).toLocaleDateString(
|
||||
locale === 'en' ? 'en-US' : 'de-DE',
|
||||
locale?.length === 2 ? locale : 'de',
|
||||
{
|
||||
year: 'numeric',
|
||||
month: 'short',
|
||||
|
||||
371
components/search/AIOrb.tsx
Normal file
371
components/search/AIOrb.tsx
Normal file
@@ -0,0 +1,371 @@
|
||||
'use client';
|
||||
|
||||
import React, { useRef, useEffect, useCallback } from 'react';
|
||||
|
||||
interface AIOrbProps {
|
||||
isThinking: boolean;
|
||||
hasError?: boolean;
|
||||
}
|
||||
|
||||
function lerp(a: number, b: number, t: number) {
|
||||
return a + (b - a) * t;
|
||||
}
|
||||
|
||||
// Simple noise function for organic movement
|
||||
function noise(x: number, y: number, t: number): number {
|
||||
return (
|
||||
Math.sin(x * 1.3 + t * 0.7) * Math.cos(y * 0.9 + t * 0.5) * 0.5 +
|
||||
Math.sin(x * 2.7 + y * 1.1 + t * 1.3) * 0.25 +
|
||||
Math.cos(x * 0.8 - y * 2.3 + t * 0.9) * 0.25
|
||||
);
|
||||
}
|
||||
|
||||
// ── Particle ───────────────────────────────────────────────────
|
||||
interface Particle {
|
||||
// Sphere position (target shape)
|
||||
theta: number;
|
||||
phi: number;
|
||||
// Current position
|
||||
x: number;
|
||||
y: number;
|
||||
z: number;
|
||||
// Velocity
|
||||
vx: number;
|
||||
vy: number;
|
||||
vz: number;
|
||||
// Properties
|
||||
size: number;
|
||||
baseSize: number;
|
||||
hue: number; // 0=blue, 1=green
|
||||
brightness: number;
|
||||
phase: number;
|
||||
orbitSpeed: number;
|
||||
noiseScale: number;
|
||||
}
|
||||
|
||||
function createParticles(count: number): Particle[] {
|
||||
const particles: Particle[] = [];
|
||||
|
||||
for (let i = 0; i < count; i++) {
|
||||
// Fibonacci sphere distribution for even spacing
|
||||
const golden = Math.PI * (3 - Math.sqrt(5));
|
||||
const y = 1 - (i / (count - 1)) * 2;
|
||||
const radiusAtY = Math.sqrt(1 - y * y);
|
||||
const theta = golden * i;
|
||||
const phi = Math.acos(y);
|
||||
|
||||
particles.push({
|
||||
theta,
|
||||
phi,
|
||||
x: Math.cos(theta) * radiusAtY,
|
||||
y,
|
||||
z: Math.sin(theta) * radiusAtY,
|
||||
vx: 0,
|
||||
vy: 0,
|
||||
vz: 0,
|
||||
size: 0.4 + Math.random() * 0.8,
|
||||
baseSize: 0.4 + Math.random() * 0.8,
|
||||
hue: Math.random() > 0.45 ? 0 : 1,
|
||||
brightness: 0.5 + Math.random() * 0.5,
|
||||
phase: Math.random() * Math.PI * 2,
|
||||
orbitSpeed: (0.1 + Math.random() * 0.4) * (Math.random() > 0.5 ? 1 : -1),
|
||||
noiseScale: 0.5 + Math.random() * 1.5,
|
||||
});
|
||||
}
|
||||
return particles;
|
||||
}
|
||||
|
||||
export default function AIOrb({ isThinking = false, hasError = false }: AIOrbProps) {
|
||||
const canvasRef = useRef<HTMLCanvasElement>(null);
|
||||
const wrapRef = useRef<HTMLDivElement>(null);
|
||||
const animRef = useRef<number>(0);
|
||||
const particlesRef = useRef<Particle[]>([]);
|
||||
|
||||
const mouse = useRef({ x: 0.5, y: 0.5, hover: false });
|
||||
const state = useRef({
|
||||
pulse: 0,
|
||||
hover: 0,
|
||||
error: 0,
|
||||
mouseX: 0.5,
|
||||
mouseY: 0.5,
|
||||
rotY: 0,
|
||||
rotX: 0,
|
||||
breathe: 0,
|
||||
scatter: 0,
|
||||
shake: 0,
|
||||
});
|
||||
|
||||
const onMove = useCallback((e: React.PointerEvent) => {
|
||||
const r = wrapRef.current?.getBoundingClientRect();
|
||||
if (!r) return;
|
||||
mouse.current.x = (e.clientX - r.left) / r.width;
|
||||
mouse.current.y = (e.clientY - r.top) / r.height;
|
||||
}, []);
|
||||
const onEnter = useCallback(() => {
|
||||
mouse.current.hover = true;
|
||||
}, []);
|
||||
const onLeave = useCallback(() => {
|
||||
mouse.current.hover = false;
|
||||
mouse.current.x = 0.5;
|
||||
mouse.current.y = 0.5;
|
||||
}, []);
|
||||
|
||||
const draw = useCallback(
|
||||
function drawStep() {
|
||||
const canvas = canvasRef.current;
|
||||
if (!canvas) return;
|
||||
const ctx = canvas.getContext('2d');
|
||||
if (!ctx) return;
|
||||
|
||||
const dpr = window.devicePixelRatio || 1;
|
||||
const rect = canvas.getBoundingClientRect();
|
||||
const w = rect.width * dpr;
|
||||
const h = rect.height * dpr;
|
||||
if (canvas.width !== w || canvas.height !== h) {
|
||||
canvas.width = w;
|
||||
canvas.height = h;
|
||||
}
|
||||
|
||||
const cx = w / 2;
|
||||
const cy = h / 2;
|
||||
const minDim = Math.min(w, h);
|
||||
// Reduced further to give maximum breathing room for glow + movement
|
||||
const sphereR = minDim * 0.16;
|
||||
const time = performance.now() / 1000;
|
||||
const s = state.current;
|
||||
const m = mouse.current;
|
||||
|
||||
// ── Interpolate state ──
|
||||
s.pulse = lerp(s.pulse, isThinking ? 1 : 0, 0.03);
|
||||
s.hover = lerp(s.hover, m.hover ? 1 : 0, 0.12);
|
||||
s.error = lerp(s.error, hasError ? 1 : 0, 0.05);
|
||||
s.mouseX = lerp(s.mouseX, m.x, 0.12);
|
||||
s.mouseY = lerp(s.mouseY, m.y, 0.12);
|
||||
s.scatter = lerp(s.scatter, m.hover ? 0.8 : hasError ? 0.5 : 0, 0.06);
|
||||
s.shake += 0.15 * s.error;
|
||||
|
||||
// Global rotation — ALWAYS rotating + ALWAYS facing cursor
|
||||
s.rotY += lerp(0.008, 0.04, Math.max(s.pulse, s.hover));
|
||||
const mouseRotY = (s.mouseX - 0.5) * 1.2; // always face cursor
|
||||
const mouseRotX = (s.mouseY - 0.5) * 0.8;
|
||||
|
||||
s.breathe += lerp(1.2, 3.0, s.pulse) / 60;
|
||||
const breathe = Math.sin(s.breathe) * 0.5 + 0.5;
|
||||
|
||||
// ── Clear ──
|
||||
ctx.clearRect(0, 0, w, h);
|
||||
|
||||
// ── Subtle core glow ──
|
||||
const shakeX = Math.sin(s.shake * 17) * s.error * minDim * 0.02;
|
||||
const glowCX = cx + shakeX;
|
||||
const glowCY = cy;
|
||||
// Clamp glow radius so it never exceeds ~48% of canvas (leaves padding for movement)
|
||||
const glowR = Math.min(
|
||||
sphereR * lerp(2.2, 4.0, Math.max(s.pulse, s.hover * 0.8)),
|
||||
minDim * 0.48,
|
||||
);
|
||||
const glowA = lerp(0.1, 0.4, Math.max(s.pulse, s.hover * 0.7, s.error * 0.8));
|
||||
const glow = ctx.createRadialGradient(glowCX, glowCY, 0, glowCX, glowCY, glowR);
|
||||
// Glow color: blue normally, red on error
|
||||
const glowR1 = Math.round(lerp(20, 255, s.error));
|
||||
const glowG1 = Math.round(lerp(60, 40, s.error));
|
||||
const glowB1 = Math.round(lerp(255, 40, s.error));
|
||||
glow.addColorStop(0, `rgba(${glowR1}, ${glowG1}, ${glowB1}, ${glowA * 2})`);
|
||||
glow.addColorStop(
|
||||
0.25,
|
||||
`rgba(${Math.round(lerp(80, 200, s.error))}, ${Math.round(lerp(140, 50, s.error))}, ${Math.round(lerp(255, 50, s.error))}, ${glowA * 1.2})`,
|
||||
);
|
||||
glow.addColorStop(0.6, `rgba(${glowR1}, ${glowG1}, ${glowB1}, ${glowA * 0.4})`);
|
||||
glow.addColorStop(1, `rgba(${glowR1}, ${glowG1}, ${glowB1}, 0)`);
|
||||
ctx.fillStyle = glow;
|
||||
ctx.beginPath();
|
||||
ctx.arc(glowCX, glowCY, glowR, 0, Math.PI * 2);
|
||||
ctx.fill();
|
||||
|
||||
// ── Create particles if empty ──
|
||||
if (particlesRef.current.length === 0) {
|
||||
particlesRef.current = createParticles(350);
|
||||
}
|
||||
|
||||
// ── Update & draw particles ──
|
||||
const cosRY = Math.cos(s.rotY + mouseRotY);
|
||||
const sinRY = Math.sin(s.rotY + mouseRotY);
|
||||
const cosRX = Math.cos(mouseRotX);
|
||||
const sinRX = Math.sin(mouseRotX);
|
||||
|
||||
// Sort by z for correct layering
|
||||
type ParticleWithScreen = { p: Particle; sx: number; sy: number; sz: number; depth: number };
|
||||
const projected: ParticleWithScreen[] = [];
|
||||
|
||||
for (const p of particlesRef.current) {
|
||||
// Target position: sphere surface + noise displacement
|
||||
const n = noise(p.theta * p.noiseScale, p.phi * p.noiseScale, time * 0.5 + p.phase);
|
||||
const displacement = 1 + n * lerp(0.12, 0.3, s.pulse);
|
||||
|
||||
// Orbit: rotate theta — always moving, faster idle
|
||||
const activeTheta = p.theta + time * p.orbitSpeed * lerp(0.35, 0.8, s.pulse);
|
||||
|
||||
// Sphere coordinates to cartesian
|
||||
const sinPhi = Math.sin(p.phi);
|
||||
const tgtX = Math.cos(activeTheta) * sinPhi * displacement;
|
||||
// Excitement from hover + pulse + error
|
||||
const targetExcite = Math.max(s.hover * 0.9, s.pulse, s.error * 0.8);
|
||||
const tgtY = Math.cos(p.phi) * displacement;
|
||||
const tgtZ = Math.sin(activeTheta) * sinPhi * displacement;
|
||||
|
||||
// Scatter on hover: push particles outward
|
||||
const scatterMul = 1 + s.scatter * (0.5 + n * 0.5);
|
||||
|
||||
// Spring physics toward target
|
||||
const tx = tgtX * scatterMul;
|
||||
const ty = tgtY * scatterMul;
|
||||
const tz = tgtZ * scatterMul;
|
||||
|
||||
p.vx += (tx - p.x) * 0.08;
|
||||
p.vy += (ty - p.y) * 0.08;
|
||||
p.vz += (tz - p.z) * 0.08;
|
||||
p.vx *= 0.88;
|
||||
p.vy *= 0.88;
|
||||
p.vz *= 0.88;
|
||||
p.x += p.vx;
|
||||
p.y += p.vy;
|
||||
p.z += p.vz;
|
||||
|
||||
// 3D rotation (Y then X)
|
||||
const rx = p.x * cosRY - p.z * sinRY;
|
||||
const rz = p.x * sinRY + p.z * cosRY;
|
||||
const ry = p.y * cosRX - rz * sinRX;
|
||||
const finalZ = p.y * sinRX + rz * cosRX;
|
||||
|
||||
// Project to screen
|
||||
const perspective = 3;
|
||||
const scale = perspective / (perspective + finalZ);
|
||||
const sx = cx + rx * sphereR * scale;
|
||||
const sy = cy + ry * sphereR * scale;
|
||||
|
||||
projected.push({ p, sx, sy, sz: finalZ, depth: scale });
|
||||
}
|
||||
|
||||
// Sort back-to-front
|
||||
projected.sort((a, b) => a.sz - b.sz);
|
||||
|
||||
for (const { p, sx, sy, sz, depth } of projected) {
|
||||
// Depth-based alpha and size
|
||||
const depthAlpha = 0.25 + (sz + 1) * 0.375; // 0.25 (back) → 1.0 (front)
|
||||
const twinkle = 0.75 + 0.25 * Math.sin(time * 3.5 + p.phase);
|
||||
|
||||
const alpha =
|
||||
depthAlpha * twinkle * p.brightness * lerp(0.8, 1.3, Math.max(s.pulse, s.hover * 0.8));
|
||||
|
||||
const drawSize =
|
||||
p.baseSize * depth * dpr * lerp(1.0, 2.0, Math.max(s.pulse, s.hover * 0.7));
|
||||
|
||||
// Color — shift to red on error
|
||||
let r: number, g: number, b: number;
|
||||
if (s.error > 0.1) {
|
||||
// Error: red family
|
||||
if (p.hue === 0) {
|
||||
r = Math.round(lerp(40 + sz * 30, 255, s.error));
|
||||
g = Math.round(lerp(80 + sz * 40, 40 + sz * 20, s.error));
|
||||
b = Math.round(lerp(255, 40, s.error));
|
||||
} else {
|
||||
r = Math.round(lerp(100 + sz * 30, 230, s.error));
|
||||
g = Math.round(lerp(220 + sz * 17, 60, s.error));
|
||||
b = Math.round(lerp(20, 20, s.error));
|
||||
}
|
||||
} else if (p.hue === 0) {
|
||||
r = 60 + Math.round(sz * 40);
|
||||
g = 100 + Math.round(sz * 50);
|
||||
b = 255;
|
||||
} else {
|
||||
r = 120 + Math.round(sz * 30);
|
||||
g = 237 + Math.round(sz * 10);
|
||||
b = 30;
|
||||
}
|
||||
|
||||
// Thinking: shift toward brighter, more saturated
|
||||
if (s.pulse > 0.1) {
|
||||
r = Math.round(lerp(r, p.hue === 0 ? 100 : 130, s.pulse * 0.3));
|
||||
g = Math.round(lerp(g, p.hue === 0 ? 140 : 237, s.pulse * 0.3));
|
||||
b = Math.round(lerp(b, p.hue === 0 ? 255 : 32, s.pulse * 0.3));
|
||||
}
|
||||
|
||||
// Micro glow — always visible, stronger on front
|
||||
if (depthAlpha > 0.25) {
|
||||
const gSize = drawSize * lerp(4, 7, s.hover);
|
||||
const pg = ctx.createRadialGradient(sx, sy, 0, sx, sy, gSize);
|
||||
pg.addColorStop(0, `rgba(${r},${g},${b},${alpha * 0.5})`);
|
||||
pg.addColorStop(1, `rgba(${r},${g},${b},0)`);
|
||||
ctx.fillStyle = pg;
|
||||
ctx.beginPath();
|
||||
ctx.arc(sx, sy, gSize, 0, Math.PI * 2);
|
||||
ctx.fill();
|
||||
}
|
||||
|
||||
// Core dot — bright
|
||||
ctx.fillStyle = `rgba(${Math.min(r + 40, 255)},${Math.min(g + 30, 255)},${b},${Math.min(alpha * 1.6, 1)})`;
|
||||
ctx.beginPath();
|
||||
ctx.arc(sx, sy, Math.max(drawSize * 0.5, 0.3 * dpr), 0, Math.PI * 2);
|
||||
ctx.fill();
|
||||
}
|
||||
|
||||
// ── Loading rings (thinking) ──
|
||||
if (s.pulse > 0.02) {
|
||||
ctx.save();
|
||||
ctx.translate(cx, cy);
|
||||
|
||||
// Spinning arc
|
||||
const spinAngle = time * 2;
|
||||
const arcLen = Math.PI * lerp(0.3, 1.0, (Math.sin(time * 1.5) + 1) / 2);
|
||||
ctx.rotate(spinAngle);
|
||||
ctx.strokeStyle = `rgba(130, 237, 32, ${s.pulse * 0.4})`;
|
||||
ctx.lineWidth = 1.2 * dpr;
|
||||
ctx.lineCap = 'round';
|
||||
ctx.beginPath();
|
||||
ctx.arc(0, 0, sphereR * 1.25, 0, arcLen);
|
||||
ctx.stroke();
|
||||
|
||||
// Counter-spinning arc
|
||||
ctx.rotate(-spinAngle * 2);
|
||||
ctx.strokeStyle = `rgba(1, 29, 255, ${s.pulse * 0.3})`;
|
||||
ctx.lineWidth = 0.8 * dpr;
|
||||
ctx.beginPath();
|
||||
ctx.arc(0, 0, sphereR * 1.35, 0, arcLen * 0.6);
|
||||
ctx.stroke();
|
||||
|
||||
ctx.restore();
|
||||
|
||||
// Expanding pulse
|
||||
const pulsePhase = (time * 0.8) % 1;
|
||||
const pulseR = sphereR * (1 + pulsePhase * 1.5);
|
||||
const pulseA = s.pulse * (1 - pulsePhase) * 0.15;
|
||||
ctx.strokeStyle = `rgba(130, 237, 32, ${pulseA})`;
|
||||
ctx.lineWidth = 1 * dpr;
|
||||
ctx.beginPath();
|
||||
ctx.arc(cx, cy, pulseR, 0, Math.PI * 2);
|
||||
ctx.stroke();
|
||||
}
|
||||
|
||||
animRef.current = requestAnimationFrame(drawStep);
|
||||
},
|
||||
[isThinking, hasError],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
animRef.current = requestAnimationFrame(draw);
|
||||
return () => cancelAnimationFrame(animRef.current);
|
||||
}, [draw]);
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={wrapRef}
|
||||
className="w-full h-full relative overflow-visible"
|
||||
onPointerMove={onMove}
|
||||
onPointerEnter={onEnter}
|
||||
onPointerLeave={onLeave}
|
||||
style={{ cursor: 'pointer' }}
|
||||
>
|
||||
<canvas ref={canvasRef} className="w-full h-full block" style={{ imageRendering: 'auto' }} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
646
components/search/AISearchResults.tsx
Normal file
646
components/search/AISearchResults.tsx
Normal file
@@ -0,0 +1,646 @@
|
||||
'use client';
|
||||
|
||||
import { useState, useRef, useEffect, KeyboardEvent } from 'react';
|
||||
import { ArrowUp, X, Sparkles, ChevronRight, RotateCcw, Copy, Check } from 'lucide-react';
|
||||
import Link from 'next/link';
|
||||
import { useAnalytics } from '../analytics/useAnalytics';
|
||||
import { AnalyticsEvents } from '../analytics/analytics-events';
|
||||
import ReactMarkdown from 'react-markdown';
|
||||
import remarkGfm from 'remark-gfm';
|
||||
import dynamic from 'next/dynamic';
|
||||
const AIOrb = dynamic(() => import('./AIOrb'), { ssr: false });
|
||||
|
||||
const LOADING_TEXTS = [
|
||||
'Durchsuche das Kabelhandbuch... 📖',
|
||||
'Frage den Senior-Ingenieur... 👴🔧',
|
||||
'Frage ChatGPTs Cousin 2. Grades... 🤖',
|
||||
];
|
||||
|
||||
interface ProductMatch {
|
||||
id: string;
|
||||
title: string;
|
||||
sku: string;
|
||||
slug: string;
|
||||
}
|
||||
|
||||
interface Message {
|
||||
role: 'user' | 'assistant';
|
||||
content: string;
|
||||
products?: ProductMatch[];
|
||||
timestamp: number;
|
||||
}
|
||||
interface ComponentProps {
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
initialQuery?: string;
|
||||
triggerSearch?: boolean;
|
||||
}
|
||||
|
||||
export function AISearchResults({
|
||||
isOpen,
|
||||
onClose,
|
||||
initialQuery = '',
|
||||
triggerSearch = false,
|
||||
}: ComponentProps) {
|
||||
const { trackEvent } = useAnalytics();
|
||||
|
||||
const [query, setQuery] = useState('');
|
||||
const [messages, setMessages] = useState<Message[]>([]);
|
||||
const [honeypot, setHoneypot] = useState('');
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [copiedIndex, setCopiedIndex] = useState<number | null>(null);
|
||||
const [copiedAll, setCopiedAll] = useState(false);
|
||||
const [loadingText, setLoadingText] = useState(LOADING_TEXTS[0]);
|
||||
|
||||
const inputRef = useRef<HTMLInputElement>(null);
|
||||
const modalRef = useRef<HTMLDivElement>(null);
|
||||
const messagesEndRef = useRef<HTMLDivElement>(null);
|
||||
const loadingIntervalRef = useRef<ReturnType<typeof setInterval> | null>(null);
|
||||
const hasTriggeredRef = useRef(false);
|
||||
|
||||
// Dedicated focus effect — polls until the input actually has focus
|
||||
useEffect(() => {
|
||||
if (!isOpen) return;
|
||||
let attempts = 0;
|
||||
const focusTimer = setInterval(() => {
|
||||
const el = inputRef.current;
|
||||
if (el && document.activeElement !== el) {
|
||||
el.focus({ preventScroll: true });
|
||||
}
|
||||
attempts++;
|
||||
if (attempts >= 15 || document.activeElement === el) {
|
||||
clearInterval(focusTimer);
|
||||
}
|
||||
}, 100);
|
||||
return () => clearInterval(focusTimer);
|
||||
}, [isOpen]);
|
||||
|
||||
useEffect(() => {
|
||||
if (isOpen) {
|
||||
document.body.style.overflow = 'hidden';
|
||||
|
||||
// Trigger initial search only once
|
||||
if (triggerSearch && initialQuery && !hasTriggeredRef.current) {
|
||||
hasTriggeredRef.current = true;
|
||||
handleSearch(initialQuery);
|
||||
}
|
||||
} else {
|
||||
document.body.style.overflow = 'unset';
|
||||
setQuery('');
|
||||
setMessages([]);
|
||||
setError(null);
|
||||
setIsLoading(false);
|
||||
hasTriggeredRef.current = false;
|
||||
}
|
||||
return () => {
|
||||
document.body.style.overflow = 'unset';
|
||||
};
|
||||
}, [isOpen, triggerSearch]);
|
||||
|
||||
useEffect(() => {
|
||||
messagesEndRef.current?.scrollIntoView({ behavior: 'smooth' });
|
||||
}, [messages, isLoading]);
|
||||
|
||||
// Global ESC handler
|
||||
useEffect(() => {
|
||||
if (!isOpen) return;
|
||||
const handleEsc = (e: globalThis.KeyboardEvent) => {
|
||||
if (e.key === 'Escape') {
|
||||
const activeElement = document.activeElement;
|
||||
const isInputFocused = activeElement === inputRef.current;
|
||||
|
||||
if (query.trim()) {
|
||||
// If there's text, clear it but keep chat open
|
||||
setQuery('');
|
||||
inputRef.current?.focus();
|
||||
} else if (!isInputFocused) {
|
||||
// If no text and input is not focused, focus it
|
||||
inputRef.current?.focus();
|
||||
} else {
|
||||
// If no text and input IS focused, close the chat
|
||||
onClose();
|
||||
}
|
||||
}
|
||||
};
|
||||
document.addEventListener('keydown', handleEsc);
|
||||
return () => document.removeEventListener('keydown', handleEsc);
|
||||
}, [isOpen, onClose, query]);
|
||||
|
||||
const handleSearch = async (searchQuery: string = query) => {
|
||||
if (!searchQuery.trim() || isLoading) return;
|
||||
|
||||
const newUserMessage: Message = { role: 'user', content: searchQuery, timestamp: Date.now() };
|
||||
const newMessagesContext = [...messages, newUserMessage];
|
||||
|
||||
setMessages(newMessagesContext);
|
||||
setQuery(''); // Always clear input after send
|
||||
setError(null);
|
||||
|
||||
// Give the user message animation 400ms to arrive before showing "thinking"
|
||||
setTimeout(() => {
|
||||
setIsLoading(true);
|
||||
// Start rotating loading texts
|
||||
let textIdx = Math.floor(Math.random() * LOADING_TEXTS.length);
|
||||
setLoadingText(LOADING_TEXTS[textIdx]);
|
||||
loadingIntervalRef.current = setInterval(() => {
|
||||
textIdx = (textIdx + 1) % LOADING_TEXTS.length;
|
||||
setLoadingText(LOADING_TEXTS[textIdx]);
|
||||
}, 2500);
|
||||
}, 400);
|
||||
|
||||
trackEvent(AnalyticsEvents.FORM_SUBMIT, {
|
||||
type: 'ai_chat',
|
||||
query: searchQuery,
|
||||
});
|
||||
|
||||
try {
|
||||
const controller = new AbortController();
|
||||
const timeout = setTimeout(() => controller.abort(), 60000);
|
||||
|
||||
const res = await fetch('/api/ai-search', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
signal: controller.signal,
|
||||
body: JSON.stringify({
|
||||
messages: newMessagesContext,
|
||||
_honeypot: honeypot,
|
||||
}),
|
||||
});
|
||||
|
||||
clearTimeout(timeout);
|
||||
|
||||
const data = await res.json().catch(() => null);
|
||||
|
||||
if (!res.ok || !data) {
|
||||
throw new Error(data?.error || `Server antwortete mit Status ${res.status}`);
|
||||
}
|
||||
|
||||
setMessages((prev) => [
|
||||
...prev,
|
||||
{
|
||||
role: 'assistant',
|
||||
content: data.answerText,
|
||||
products: data.products,
|
||||
timestamp: Date.now(),
|
||||
},
|
||||
]);
|
||||
|
||||
setTimeout(() => inputRef.current?.focus(), 100);
|
||||
} catch (err: any) {
|
||||
console.error(err);
|
||||
const msg =
|
||||
err.name === 'AbortError'
|
||||
? 'Anfrage hat zu lange gedauert. Bitte versuche es erneut.'
|
||||
: err.message || 'Ein Fehler ist aufgetreten.';
|
||||
|
||||
// Show error as a system message in the chat instead of a separate error banner
|
||||
setMessages((prev) => [
|
||||
...prev,
|
||||
{
|
||||
role: 'assistant',
|
||||
content: `⚠️ ${msg}`,
|
||||
timestamp: Date.now(),
|
||||
},
|
||||
]);
|
||||
|
||||
trackEvent(AnalyticsEvents.ERROR, {
|
||||
location: 'ai_chat',
|
||||
message: err.message,
|
||||
query: searchQuery,
|
||||
});
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
if (loadingIntervalRef.current) {
|
||||
clearInterval(loadingIntervalRef.current);
|
||||
loadingIntervalRef.current = null;
|
||||
}
|
||||
// Always re-focus the input
|
||||
setTimeout(() => inputRef.current?.focus(), 50);
|
||||
}
|
||||
};
|
||||
|
||||
const onKeyDown = (e: KeyboardEvent<HTMLInputElement>) => {
|
||||
if (e.key === 'Enter') {
|
||||
e.preventDefault();
|
||||
handleSearch();
|
||||
}
|
||||
if (e.key === 'ArrowUp' && !query) {
|
||||
// Find the last user message and put it into the input
|
||||
const lastUserNav = [...messages].reverse().find((m) => m.role === 'user');
|
||||
if (lastUserNav) {
|
||||
e.preventDefault();
|
||||
setQuery(lastUserNav.content);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const handleCopy = (content: string, index?: number) => {
|
||||
navigator.clipboard.writeText(content);
|
||||
if (index !== undefined) {
|
||||
setCopiedIndex(index);
|
||||
setTimeout(() => setCopiedIndex(null), 2000);
|
||||
} else {
|
||||
setCopiedAll(true);
|
||||
setTimeout(() => setCopiedAll(false), 2000);
|
||||
}
|
||||
};
|
||||
|
||||
const handleCopyChat = () => {
|
||||
const fullChat = messages
|
||||
.map((m) => `${m.role === 'user' ? 'Du' : 'Ohm'}:\n${m.content}`)
|
||||
.join('\n\n');
|
||||
handleCopy(fullChat);
|
||||
};
|
||||
|
||||
if (!isOpen) return null;
|
||||
|
||||
const handleBackdropClick = (e: React.MouseEvent) => {
|
||||
if (e.target === e.currentTarget) {
|
||||
onClose();
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
className="fixed inset-0 z-[100] flex items-start justify-center pt-6 md:pt-12 px-4"
|
||||
onClick={handleBackdropClick}
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
style={{ animation: 'chatBackdropIn 0.4s ease-out forwards' }}
|
||||
>
|
||||
{/* Animated backdrop */}
|
||||
<div
|
||||
className="absolute inset-0 bg-[#000a18]/90 backdrop-blur-2xl"
|
||||
style={{ animation: 'chatFadeIn 0.3s ease-out' }}
|
||||
/>
|
||||
|
||||
<div
|
||||
ref={modalRef}
|
||||
className="relative w-full max-w-3xl flex flex-col"
|
||||
style={{
|
||||
height: 'min(90vh, 900px)',
|
||||
animation: 'chatSlideUp 0.5s cubic-bezier(0.16, 1, 0.3, 1) forwards',
|
||||
}}
|
||||
>
|
||||
{/* ── Glassmorphism container ── */}
|
||||
<div className="flex flex-col h-full rounded-3xl overflow-hidden border border-white/[0.08] bg-gradient-to-b from-white/[0.06] to-white/[0.02] shadow-[0_32px_64px_-12px_rgba(0,0,0,0.6)]">
|
||||
{/* ── Header ── */}
|
||||
<div className="flex items-center justify-between px-5 py-4 border-b border-white/[0.06]">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="w-8 h-8 overflow-hidden rounded-full">
|
||||
<AIOrb isThinking={isLoading} hasError={!!error} />
|
||||
</div>
|
||||
<div>
|
||||
<h2 className="text-white font-bold text-sm tracking-wide">Ohm</h2>
|
||||
<p className="text-[10px] text-white/30 font-medium tracking-wider uppercase">
|
||||
{isLoading ? 'Denkt nach...' : error ? 'Fehler aufgetreten' : 'Online'}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
{messages.length > 0 && (
|
||||
<button
|
||||
onClick={handleCopyChat}
|
||||
className="flex items-center gap-1.5 text-[10px] font-bold text-white/40 hover:text-white/80 transition-all duration-200 hover:bg-white/5 rounded-full px-3 py-1.5 cursor-pointer uppercase tracking-wider"
|
||||
title="gesamten Chat kopieren"
|
||||
>
|
||||
{copiedAll ? (
|
||||
<Check className="w-3.5 h-3.5 text-accent" />
|
||||
) : (
|
||||
<Copy className="w-3.5 h-3.5" />
|
||||
)}
|
||||
<span>{copiedAll ? 'Kopiert' : 'Chat kopieren'}</span>
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="text-white/30 hover:text-white/80 transition-all duration-200 hover:bg-white/5 rounded-xl p-2 cursor-pointer"
|
||||
aria-label="Close"
|
||||
>
|
||||
<X className="w-5 h-5" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* ── Chat Area ── */}
|
||||
<div className="flex-1 overflow-y-auto px-5 py-6 space-y-5 scroll-smooth chat-scrollbar">
|
||||
{/* Empty state */}
|
||||
{messages.length === 0 && !isLoading && !error && (
|
||||
<div
|
||||
className="flex flex-col items-center justify-center h-full text-center space-y-5"
|
||||
style={{ animation: 'chatFadeIn 0.6s ease-out 0.3s both' }}
|
||||
>
|
||||
<div className="w-24 h-24 mb-2">
|
||||
<AIOrb isThinking={false} hasError={false} />
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-xl md:text-2xl font-bold text-white/80">
|
||||
Wie kann ich helfen?
|
||||
</p>
|
||||
<p className="text-sm text-white/30 mt-2 max-w-md">
|
||||
Beschreibe dein Projekt, frag nach bestimmten Kabeln, oder nenne mir deine
|
||||
Anforderungen.
|
||||
</p>
|
||||
</div>
|
||||
{/* Quick prompts */}
|
||||
<div className="flex flex-wrap justify-center gap-2 mt-4">
|
||||
{['Windpark 33kV Verkabelung', 'NYCWY 4x185', 'Erdkabel für Solarpark'].map(
|
||||
(prompt) => (
|
||||
<button
|
||||
key={prompt}
|
||||
onClick={() => handleSearch(prompt)}
|
||||
className="text-xs text-white/40 hover:text-white/80 border border-white/10 hover:border-white/20 hover:bg-white/5 rounded-full px-4 py-2 transition-all duration-200 cursor-pointer"
|
||||
>
|
||||
{prompt}
|
||||
</button>
|
||||
),
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Messages */}
|
||||
{messages.map((msg, index) => (
|
||||
<div
|
||||
key={index}
|
||||
className={`flex ${msg.role === 'user' ? 'justify-end' : 'justify-start'}`}
|
||||
style={{
|
||||
animation: `chatMessageIn 0.4s cubic-bezier(0.16, 1, 0.3, 1) ${index * 0.05}s both`,
|
||||
}}
|
||||
>
|
||||
<div
|
||||
className={`relative group max-w-[85%] rounded-2xl px-5 py-4 ${
|
||||
msg.role === 'user'
|
||||
? 'bg-accent text-primary font-semibold rounded-br-lg'
|
||||
: 'bg-white/[0.05] border border-white/[0.06] text-white/90 rounded-bl-lg'
|
||||
}`}
|
||||
>
|
||||
{/* Copy Button */}
|
||||
<button
|
||||
onClick={() => handleCopy(msg.content, index)}
|
||||
className={`absolute opacity-0 group-hover:opacity-100 transition-opacity p-1.5 rounded-lg cursor-pointer ${
|
||||
msg.role === 'user'
|
||||
? 'top-2 right-2 bg-primary/10 hover:bg-primary/20 text-primary/60 hover:text-primary'
|
||||
: 'top-2 right-2 bg-white/5 hover:bg-white/10 text-white/40 hover:text-white'
|
||||
}`}
|
||||
title="Nachricht kopieren"
|
||||
>
|
||||
{copiedIndex === index ? (
|
||||
<Check className="w-3.5 h-3.5" />
|
||||
) : (
|
||||
<Copy className="w-3.5 h-3.5" />
|
||||
)}
|
||||
</button>
|
||||
|
||||
{msg.role === 'assistant' && (
|
||||
<div className="flex items-center gap-2 mb-2">
|
||||
<Sparkles className="w-3 h-3 text-accent/60" />
|
||||
<span className="text-[10px] font-bold tracking-widest uppercase text-accent/50">
|
||||
Ohm
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
<div
|
||||
className={`text-sm md:text-[15px] leading-relaxed ${
|
||||
msg.role === 'assistant'
|
||||
? 'prose prose-invert prose-sm prose-p:leading-relaxed prose-a:text-accent prose-strong:text-accent/90 prose-ul:list-disc prose-ol:list-decimal'
|
||||
: ''
|
||||
}`}
|
||||
>
|
||||
{msg.role === 'assistant' ? (
|
||||
<ReactMarkdown remarkPlugins={[remarkGfm]}>{msg.content}</ReactMarkdown>
|
||||
) : (
|
||||
<p className="whitespace-pre-wrap">{msg.content}</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Timestamp */}
|
||||
{!msg.products?.length && (
|
||||
<p
|
||||
className={`text-[9px] mt-2 font-medium tracking-wide ${msg.role === 'user' ? 'text-primary/40' : 'text-white/20'}`}
|
||||
>
|
||||
{new Date(msg.timestamp).toLocaleTimeString('de', {
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
})}
|
||||
</p>
|
||||
)}
|
||||
|
||||
{/* Product cards */}
|
||||
{msg.role === 'assistant' && msg.products && msg.products.length > 0 && (
|
||||
<div className="mt-4 space-y-2 border-t border-white/[0.06] pt-4">
|
||||
<h4 className="text-[10px] font-bold tracking-widest uppercase text-white/30 mb-2">
|
||||
Empfohlene Produkte
|
||||
</h4>
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-2">
|
||||
{msg.products.map((product, idx) => (
|
||||
<Link
|
||||
key={idx}
|
||||
href={`/produkte/${product.slug}`}
|
||||
onClick={() => {
|
||||
onClose();
|
||||
trackEvent(AnalyticsEvents.BUTTON_CLICK, {
|
||||
target: product.slug,
|
||||
location: 'ai_chat',
|
||||
});
|
||||
}}
|
||||
className="group flex items-center justify-between bg-white/[0.04] hover:bg-white/[0.08] border border-white/[0.06] hover:border-accent/30 rounded-xl px-4 py-3 transition-all duration-300"
|
||||
style={{ animation: `chatFadeIn 0.3s ease-out ${idx * 0.1}s both` }}
|
||||
>
|
||||
<div className="min-w-0">
|
||||
<p className="text-[9px] font-bold text-white/25 tracking-wider">
|
||||
{product.sku}
|
||||
</p>
|
||||
<h5 className="text-xs font-bold text-white/70 group-hover:text-accent truncate transition-colors">
|
||||
{product.title}
|
||||
</h5>
|
||||
</div>
|
||||
<ChevronRight className="w-3.5 h-3.5 text-white/20 group-hover:text-accent shrink-0 ml-3 group-hover:translate-x-0.5 transition-all" />
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
|
||||
{/* Loading indicator */}
|
||||
{isLoading && (
|
||||
<div
|
||||
className="flex justify-start"
|
||||
style={{ animation: 'chatMessageIn 0.4s cubic-bezier(0.16, 1, 0.3, 1) forwards' }}
|
||||
>
|
||||
<div className="flex items-center gap-4 bg-white/[0.03] border border-white/[0.06] rounded-2xl rounded-bl-lg px-5 py-4">
|
||||
<div className="w-10 h-10 shrink-0">
|
||||
<AIOrb isThinking={true} hasError={false} />
|
||||
</div>
|
||||
<div>
|
||||
<p
|
||||
className="text-sm text-white/50 font-medium"
|
||||
style={{ animation: 'chatTextSwap 0.4s ease-out' }}
|
||||
key={loadingText}
|
||||
>
|
||||
{loadingText}
|
||||
</p>
|
||||
<div className="flex gap-1 mt-2">
|
||||
{[0, 1, 2].map((i) => (
|
||||
<div
|
||||
key={i}
|
||||
className="w-1.5 h-1.5 rounded-full bg-accent/40"
|
||||
style={{
|
||||
animation: 'chatDotBounce 1.2s ease-in-out infinite',
|
||||
animationDelay: `${i * 0.15}s`,
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Error */}
|
||||
{error && (
|
||||
<div className="flex justify-start" style={{ animation: 'chatShake 0.5s ease-out' }}>
|
||||
<div className="flex items-center gap-4 bg-red-500/[0.06] border border-red-500/20 rounded-2xl rounded-bl-lg px-5 py-4">
|
||||
<div className="w-10 h-10 shrink-0">
|
||||
<AIOrb isThinking={false} hasError={true} />
|
||||
</div>
|
||||
<div>
|
||||
<h3 className="text-sm font-bold text-red-300">Da ist was schiefgelaufen 😬</h3>
|
||||
<p className="text-xs text-red-300/60 mt-1">{error}</p>
|
||||
<button
|
||||
onClick={() => {
|
||||
setError(null);
|
||||
inputRef.current?.focus();
|
||||
}}
|
||||
className="flex items-center gap-1.5 text-[10px] font-bold text-red-300/50 hover:text-red-300 mt-2 transition-colors cursor-pointer"
|
||||
>
|
||||
<RotateCcw className="w-3 h-3" />
|
||||
Nochmal versuchen
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div ref={messagesEndRef} />
|
||||
</div>
|
||||
|
||||
{/* ── Input Area ── */}
|
||||
<div className="px-5 pb-5 pt-3 border-t border-white/[0.04]">
|
||||
<div
|
||||
className={`relative flex items-center rounded-2xl transition-all duration-300 ${
|
||||
query.trim()
|
||||
? 'bg-white/[0.08] border border-accent/30 shadow-[0_0_20px_-4px_rgba(130,237,32,0.1)]'
|
||||
: 'bg-white/[0.04] border border-white/[0.06]'
|
||||
}`}
|
||||
>
|
||||
<input
|
||||
ref={inputRef}
|
||||
type="text"
|
||||
value={query}
|
||||
onChange={(e) => setQuery(e.target.value)}
|
||||
onKeyDown={onKeyDown}
|
||||
placeholder="Nachricht eingeben..."
|
||||
className="flex-1 bg-transparent border-none text-white text-sm md:text-base px-5 py-4 focus:outline-none placeholder:text-white/20"
|
||||
disabled={isLoading}
|
||||
tabIndex={1}
|
||||
autoFocus
|
||||
/>
|
||||
<input
|
||||
type="text"
|
||||
className="hidden"
|
||||
value={honeypot}
|
||||
onChange={(e) => setHoneypot(e.target.value)}
|
||||
tabIndex={-1}
|
||||
autoComplete="off"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
<button
|
||||
onClick={() => handleSearch()}
|
||||
disabled={!query.trim() || isLoading}
|
||||
className={`mr-2 w-9 h-9 rounded-xl flex items-center justify-center shrink-0 transition-all duration-300 cursor-pointer ${
|
||||
query.trim()
|
||||
? 'bg-accent text-primary shadow-lg shadow-accent/20 hover:shadow-accent/40 hover:scale-105 active:scale-95'
|
||||
: 'bg-white/5 text-white/20'
|
||||
}`}
|
||||
aria-label="Nachricht senden"
|
||||
>
|
||||
<ArrowUp className="w-4 h-4" strokeWidth={2.5} />
|
||||
</button>
|
||||
</div>
|
||||
<div className="flex items-center justify-center gap-3 mt-2.5">
|
||||
<span className="text-[9px] uppercase tracking-[0.15em] font-medium text-white/15">
|
||||
Enter zum Senden · Esc zum Schließen
|
||||
</span>
|
||||
<span className="text-[9px] uppercase tracking-[0.15em] font-medium text-white/15">
|
||||
·
|
||||
</span>
|
||||
<span className="text-[9px] uppercase tracking-[0.15em] font-medium text-accent/40 flex items-center gap-1">
|
||||
🛡️ DSGVO-konform · EU-Server
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* ── Keyframe animations ── */}
|
||||
<style>{`
|
||||
@keyframes chatBackdropIn {
|
||||
from { opacity: 0; }
|
||||
to { opacity: 1; }
|
||||
}
|
||||
@keyframes chatFadeIn {
|
||||
from { opacity: 0; }
|
||||
to { opacity: 1; }
|
||||
}
|
||||
@keyframes chatSlideUp {
|
||||
from { opacity: 0; transform: translateY(40px) scale(0.96); }
|
||||
to { opacity: 1; transform: translateY(0) scale(1); }
|
||||
}
|
||||
@keyframes chatMessageIn {
|
||||
from { opacity: 0; transform: translateY(12px); }
|
||||
to { opacity: 1; transform: translateY(0); }
|
||||
}
|
||||
@keyframes chatDotBounce {
|
||||
0%, 80%, 100% { transform: scale(0.6); opacity: 0.3; }
|
||||
40% { transform: scale(1); opacity: 1; }
|
||||
}
|
||||
@keyframes chatTextSwap {
|
||||
from { opacity: 0; transform: translateY(4px); }
|
||||
to { opacity: 1; transform: translateY(0); }
|
||||
}
|
||||
@keyframes chatShake {
|
||||
0%, 100% { transform: translateX(0); }
|
||||
15% { transform: translateX(-6px); }
|
||||
30% { transform: translateX(5px); }
|
||||
45% { transform: translateX(-4px); }
|
||||
60% { transform: translateX(3px); }
|
||||
75% { transform: translateX(-1px); }
|
||||
}
|
||||
|
||||
/* Custom scrollbar */
|
||||
.chat-scrollbar::-webkit-scrollbar {
|
||||
width: 4px;
|
||||
}
|
||||
.chat-scrollbar::-webkit-scrollbar-track {
|
||||
background: transparent;
|
||||
}
|
||||
.chat-scrollbar::-webkit-scrollbar-thumb {
|
||||
background: rgba(255, 255, 255, 0.08);
|
||||
border-radius: 4px;
|
||||
}
|
||||
.chat-scrollbar::-webkit-scrollbar-thumb:hover {
|
||||
background: rgba(255, 255, 255, 0.16);
|
||||
}
|
||||
.chat-scrollbar {
|
||||
scrollbar-width: thin;
|
||||
scrollbar-color: rgba(255, 255, 255, 0.08) transparent;
|
||||
}
|
||||
`}</style>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,38 +0,0 @@
|
||||
import puppeteer from 'puppeteer';
|
||||
import fs from 'fs';
|
||||
|
||||
async function generatePDF() {
|
||||
const pandocHtml = fs.readFileSync('AVB_2026.html', 'utf-8');
|
||||
|
||||
const html = `
|
||||
<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<style>
|
||||
body { font-family: 'Helvetica Neue', Helvetica, Arial, sans-serif; font-size: 10pt; line-height: 1.5; color: #000; margin: 40px; }
|
||||
h2 { font-size: 16pt; margin-bottom: 20px; font-weight: bold; }
|
||||
h3 { font-size: 12pt; margin-top: 25px; margin-bottom: 10px; font-weight: bold; color: #222; }
|
||||
p { margin-bottom: 12px; text-align: justify; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
${pandocHtml}
|
||||
</body>
|
||||
</html>
|
||||
`;
|
||||
|
||||
const browser = await puppeteer.launch();
|
||||
const page = await browser.newPage();
|
||||
await page.setContent(html, { waitUntil: 'networkidle0' });
|
||||
await page.pdf({
|
||||
path: 'KLZ_AVB_April_2026.pdf',
|
||||
format: 'A4',
|
||||
margin: { top: '2cm', right: '2cm', bottom: '2cm', left: '2cm' },
|
||||
printBackground: true
|
||||
});
|
||||
await browser.close();
|
||||
console.log('PDF generated: KLZ_AVB_April_2026.pdf');
|
||||
}
|
||||
|
||||
generatePDF().catch(console.error);
|
||||
@@ -8,7 +8,7 @@ services:
|
||||
- infra
|
||||
labels:
|
||||
- "caddy=http://${TRAEFIK_HOST:-klz.localhost}"
|
||||
- "caddy.reverse_proxy=host.docker.internal:3100"
|
||||
- "caddy.reverse_proxy=http://klz-app:3000"
|
||||
|
||||
# Full Docker dev (use with `pnpm run dev:docker`)
|
||||
klz-app:
|
||||
@@ -26,13 +26,20 @@ services:
|
||||
- ${ENV_FILE:-.env}
|
||||
environment:
|
||||
NODE_ENV: development
|
||||
# Force Garbage Collection before Docker kills the container (OOM)
|
||||
NODE_OPTIONS: "--max-old-space-size=6144"
|
||||
NEXT_TELEMETRY_DISABLED: "1"
|
||||
POSTGRES_URI: postgres://${PAYLOAD_DB_USER:-payload}:${PAYLOAD_DB_PASSWORD:-120in09oenaoinsd9iaidon}@klz-db:5432/${PAYLOAD_DB_NAME:-payload}
|
||||
PAYLOAD_SECRET: ${PAYLOAD_SECRET:-fallback-secret-for-dev}
|
||||
NODE_OPTIONS: "--max-old-space-size=8192"
|
||||
UV_THREADPOOL_SIZE: "4"
|
||||
UV_THREADPOOL_SIZE: "1"
|
||||
RAYON_NUM_THREADS: "1"
|
||||
NEXT_PRIVATE_WORKER_THREADS: "false"
|
||||
NPM_TOKEN: ${NPM_TOKEN:-}
|
||||
CI: "true"
|
||||
QDRANT_URL: "http://klz-qdrant:6333"
|
||||
REDIS_URL: "redis://klz-redis:6379"
|
||||
MISTRAL_API_KEY: ${MISTRAL_API_KEY:-}
|
||||
OPENROUTER_API_KEY: ${OPENROUTER_API_KEY:-}
|
||||
volumes:
|
||||
- .:/app
|
||||
- klz_node_modules:/app/node_modules
|
||||
@@ -42,19 +49,34 @@ services:
|
||||
- /app/.git
|
||||
- /app/reference
|
||||
- /app/data
|
||||
|
||||
deploy:
|
||||
resources:
|
||||
limits:
|
||||
cpus: '4'
|
||||
memory: 8G
|
||||
command: >
|
||||
sh -c "pnpm install && pnpm next dev --webpack --hostname 0.0.0.0"
|
||||
sh -c "pnpm install --no-frozen-lockfile &&
|
||||
while true; do
|
||||
(
|
||||
echo '[warmup] Waiting for Next.js to be reachable...'
|
||||
until curl -sf http://localhost:3000 > /dev/null; do sleep 2; done
|
||||
echo '[warmup] Server is up! Pre-compiling routes...'
|
||||
curl -sf http://localhost:3000/de > /dev/null 2>&1 && echo '[warmup] /de ready'
|
||||
curl -sf http://localhost:3000/api/health/cms > /dev/null 2>&1 && echo '[warmup] /api/health/cms ready'
|
||||
curl -sf -X POST -H 'Content-Type: application/json' -d '{\"messages\":[{\"role\":\"user\",\"content\":\"warmup\"}]}' http://localhost:3000/api/ai-search > /dev/null 2>&1 && echo '[warmup] /api/ai-search ready'
|
||||
echo '[warmup] Syncing CMS data to Qdrant...'
|
||||
SYNC_RESULT=$(curl -sf http://localhost:3000/api/sync-qdrant 2>&1)
|
||||
echo \"[warmup] Qdrant sync: $SYNC_RESULT\"
|
||||
echo '[warmup] All routes pre-compiled + Qdrant synced ✓'
|
||||
) &
|
||||
pnpm next dev --webpack --hostname 0.0.0.0;
|
||||
echo '[klz-app] next dev exited, restarting in 2s...';
|
||||
sleep 2;
|
||||
done"
|
||||
labels:
|
||||
- "traefik.enable=true"
|
||||
- "traefik.http.services.${PROJECT_NAME:-klz}-app-svc.loadbalancer.server.port=3000"
|
||||
- "traefik.docker.network=infra"
|
||||
- "caddy=http://${TRAEFIK_HOST:-klz.localhost}"
|
||||
- "caddy.reverse_proxy={{upstreams 3000}}"
|
||||
|
||||
klz-db:
|
||||
image: postgres:15-alpine
|
||||
@@ -73,7 +95,25 @@ services:
|
||||
networks:
|
||||
- default
|
||||
ports:
|
||||
- "54324:5432"
|
||||
- "54322:5432"
|
||||
|
||||
klz-redis:
|
||||
image: redis:7-alpine
|
||||
restart: unless-stopped
|
||||
networks:
|
||||
- default
|
||||
ports:
|
||||
- "16379:6379"
|
||||
|
||||
klz-qdrant:
|
||||
image: qdrant/qdrant:v1.13.2
|
||||
restart: unless-stopped
|
||||
volumes:
|
||||
- klz_qdrant_data:/qdrant/storage
|
||||
networks:
|
||||
- default
|
||||
ports:
|
||||
- "16333:6333"
|
||||
|
||||
networks:
|
||||
default:
|
||||
@@ -84,6 +124,8 @@ networks:
|
||||
volumes:
|
||||
klz_db_data:
|
||||
external: false
|
||||
klz_qdrant_data:
|
||||
external: false
|
||||
klz_node_modules:
|
||||
klz_next_cache:
|
||||
klz_turbo_cache:
|
||||
|
||||
@@ -29,8 +29,7 @@ services:
|
||||
- "traefik.http.routers.${PROJECT_NAME:-klz}.middlewares=${AUTH_MIDDLEWARE:-klz-ratelimit,klz-forward,klz-compress}"
|
||||
|
||||
# Public Router – paths that bypass Gatekeeper auth (health, SEO, static assets, OG images)
|
||||
- "traefik.http.routers.${PROJECT_NAME:-klz}-public.rule=(${TRAEFIK_HOST_RULE:-Host(`${TRAEFIK_HOST:-klz-cables.com}`)}) && (PathPrefix(`/_next`) || PathPrefix(`/health`) || PathPrefix(`/api/health`) || PathPrefix(`/login`) || PathPrefix(`/gatekeeper`) || PathPrefix(`/uploads`) || PathPrefix(`/media`) || Path(`/robots.txt`) || Path(`/manifest.webmanifest`) || PathPrefix(`/sitemap`) || PathPrefix(`/api/og`) || PathPrefix(`/opengraph-image`) || PathRegexp(`^/([a-z]{2}/)?(health|login|gatekeeper|uploads|media|robots\\\\.txt|sitemap|api/og|api/health|opengraph-image)`))"
|
||||
|
||||
- "traefik.http.routers.${PROJECT_NAME:-klz}-public.rule=(${TRAEFIK_HOST_RULE:-Host(`${TRAEFIK_HOST:-klz-cables.com}`)}) && PathRegexp(`^/([a-z]{2}/)?(health|login|gatekeeper|uploads|media|robots\\.txt|manifest\\.webmanifest|sitemap(-[0-9]+)?\\.xml|(.*/)?api/og(/.*)?|(.*/)?opengraph-image.*)`)"
|
||||
- "traefik.http.routers.${PROJECT_NAME:-klz}-public.entrypoints=${TRAEFIK_ENTRYPOINT:-web}"
|
||||
- "traefik.http.routers.${PROJECT_NAME:-klz}-public.tls.certresolver=${TRAEFIK_CERT_RESOLVER:-}"
|
||||
- "traefik.http.routers.${PROJECT_NAME:-klz}-public.tls=${TRAEFIK_TLS:-false}"
|
||||
@@ -61,7 +60,7 @@ services:
|
||||
|
||||
klz-gatekeeper:
|
||||
profiles: [ "gatekeeper" ]
|
||||
image: git.infra.mintel.me/mmintel/gatekeeper:v1.9.18
|
||||
image: registry.infra.mintel.me/mintel/gatekeeper:testing
|
||||
restart: unless-stopped
|
||||
networks:
|
||||
infra:
|
||||
@@ -101,6 +100,25 @@ services:
|
||||
networks:
|
||||
- default
|
||||
|
||||
klz-redis:
|
||||
image: redis:7-alpine
|
||||
restart: unless-stopped
|
||||
networks:
|
||||
- default
|
||||
|
||||
klz-qdrant:
|
||||
image: qdrant/qdrant:v1.13.2
|
||||
restart: unless-stopped
|
||||
ports:
|
||||
- "6333:6333"
|
||||
environment:
|
||||
QDRANT__SERVICE__HTTP_PORT: 6333
|
||||
QDRANT__SERVICE__GRPC_PORT: 6334
|
||||
volumes:
|
||||
- klz_qdrant_data:/qdrant/storage
|
||||
networks:
|
||||
- default
|
||||
|
||||
networks:
|
||||
default:
|
||||
name: ${PROJECT_NAME:-klz-cables}-internal
|
||||
@@ -112,3 +130,5 @@ volumes:
|
||||
external: false
|
||||
klz_media_data:
|
||||
external: false
|
||||
klz_qdrant_data:
|
||||
external: false
|
||||
|
||||
2129
lexical_avb.json
2129
lexical_avb.json
File diff suppressed because it is too large
Load Diff
54
lib/blog.ts
54
lib/blog.ts
@@ -136,60 +136,6 @@ export async function getPostBySlug(slug: string, locale: string): Promise<PostD
|
||||
}
|
||||
}
|
||||
|
||||
export async function getPostSlugs(slug: string, locale: string): Promise<Record<string, string>> {
|
||||
try {
|
||||
const payload = await getPayload({ config: configPromise });
|
||||
|
||||
// First, find the post in the current locale to get its ID
|
||||
let { docs } = await payload.find({
|
||||
collection: 'posts',
|
||||
where: {
|
||||
slug: { equals: slug },
|
||||
...(!config.showDrafts ? { _status: { equals: 'published' } } : {}),
|
||||
},
|
||||
locale: locale as any,
|
||||
draft: config.showDrafts,
|
||||
limit: 1,
|
||||
});
|
||||
|
||||
if (!docs || docs.length === 0) {
|
||||
// Fallback: search across all locales
|
||||
const { docs: crossLocaleDocs } = await payload.find({
|
||||
collection: 'posts',
|
||||
where: {
|
||||
slug: { equals: slug },
|
||||
...(!config.showDrafts ? { _status: { equals: 'published' } } : {}),
|
||||
},
|
||||
locale: 'all',
|
||||
draft: config.showDrafts,
|
||||
limit: 1,
|
||||
});
|
||||
docs = crossLocaleDocs;
|
||||
}
|
||||
|
||||
if (!docs || docs.length === 0) return {};
|
||||
|
||||
const postId = docs[0].id;
|
||||
|
||||
// Fetch the post with locale 'all' to get all localized fields
|
||||
const { docs: allLocalesDocs } = await payload.find({
|
||||
collection: 'posts',
|
||||
where: {
|
||||
id: { equals: postId },
|
||||
},
|
||||
locale: 'all',
|
||||
draft: config.showDrafts,
|
||||
limit: 1,
|
||||
});
|
||||
|
||||
if (!allLocalesDocs || allLocalesDocs.length === 0) return {};
|
||||
return (allLocalesDocs[0].slug as unknown as Record<string, string>) || {};
|
||||
} catch (error) {
|
||||
console.error(`[Payload] getPostSlugs failed for ${slug}:`, error);
|
||||
return {};
|
||||
}
|
||||
}
|
||||
|
||||
export async function getAllPosts(locale: string): Promise<PostData[]> {
|
||||
try {
|
||||
const payload = await getPayload({ config: configPromise });
|
||||
|
||||
@@ -66,8 +66,8 @@ function createConfig() {
|
||||
port: env.MAIL_PORT,
|
||||
user: env.MAIL_USERNAME,
|
||||
pass: env.MAIL_PASSWORD,
|
||||
from: env.MAIL_FROM || 'KLZ Cables <postmaster@mg.mintel.me>',
|
||||
recipients: env.MAIL_RECIPIENTS || 'info@klz-cables.com',
|
||||
from: env.MAIL_FROM,
|
||||
recipients: env.MAIL_RECIPIENTS,
|
||||
},
|
||||
infraCMS: {
|
||||
url: env.INFRA_DIRECTUS_URL,
|
||||
|
||||
@@ -42,7 +42,7 @@ const envExtension = {
|
||||
MAIL_USERNAME: z.string().optional(),
|
||||
MAIL_PASSWORD: z.string().optional(),
|
||||
MAIL_FROM: z.string().optional(),
|
||||
MAIL_RECIPIENTS: z.string().trim().optional(),
|
||||
MAIL_RECIPIENTS: z.string().optional(),
|
||||
};
|
||||
|
||||
/**
|
||||
|
||||
@@ -32,27 +32,16 @@ interface SendEmailOptions {
|
||||
}
|
||||
|
||||
export async function sendEmail({ to, replyTo, subject, html }: SendEmailOptions) {
|
||||
const recipients = to || config.mail.recipients;
|
||||
const logger = getServerAppServices().logger.child({ component: 'mailer' });
|
||||
|
||||
// Resolve recipients: priority to 'to' override, fallback to global MAIL_RECIPIENTS
|
||||
const resolvedTo = to || config.mail.recipients;
|
||||
|
||||
// Normalize recipients (handle arrays or comma-strings)
|
||||
const recipients = Array.isArray(resolvedTo)
|
||||
? resolvedTo.join(', ')
|
||||
: (resolvedTo?.toString() || '');
|
||||
|
||||
if (!recipients || recipients.trim() === '') {
|
||||
logger.error('Email delivery ABORTED: No recipients configured', {
|
||||
subject,
|
||||
providedTo: to,
|
||||
configRecipients: config.mail.recipients
|
||||
});
|
||||
if (!recipients) {
|
||||
logger.error('No email recipients configured (MAIL_RECIPIENTS is empty and no "to" provided)', { subject });
|
||||
return { success: false as const, error: 'No recipients configured' };
|
||||
}
|
||||
|
||||
if (!config.mail.from) {
|
||||
logger.error('Email delivery ABORTED: MAIL_FROM is missing', { subject, recipients });
|
||||
logger.error('MAIL_FROM is not configured — cannot send email', { subject, recipients });
|
||||
return { success: false as const, error: 'MAIL_FROM is not configured' };
|
||||
}
|
||||
|
||||
@@ -64,36 +53,14 @@ export async function sendEmail({ to, replyTo, subject, html }: SendEmailOptions
|
||||
html,
|
||||
};
|
||||
|
||||
try {
|
||||
const transporter = getTransporter();
|
||||
logger.info('Attempting to send email via SMTP', {
|
||||
host: config.mail.host,
|
||||
subject,
|
||||
recipients,
|
||||
hasReplyTo: !!replyTo
|
||||
});
|
||||
|
||||
const info = await transporter.sendMail(mailOptions);
|
||||
|
||||
logger.info('Email sent successfully', {
|
||||
messageId: info.messageId,
|
||||
subject,
|
||||
recipients,
|
||||
response: info.response
|
||||
});
|
||||
|
||||
try {
|
||||
const info = await getTransporter().sendMail(mailOptions);
|
||||
logger.info('Email sent successfully', { messageId: info.messageId, subject, recipients });
|
||||
return { success: true, messageId: info.messageId };
|
||||
} catch (error) {
|
||||
const errorMsg = error instanceof Error ? error.message : String(error);
|
||||
logger.error('SMTP Transport failed', {
|
||||
error: errorMsg,
|
||||
subject,
|
||||
recipients,
|
||||
config: {
|
||||
host: config.mail.host,
|
||||
user: config.mail.user ? '***' : 'not set'
|
||||
}
|
||||
});
|
||||
logger.error('Error sending email', { error: errorMsg, subject, recipients });
|
||||
return { success: false, error: errorMsg };
|
||||
}
|
||||
}
|
||||
|
||||
@@ -11,21 +11,10 @@ export async function getOgFonts() {
|
||||
|
||||
try {
|
||||
console.log(`[OG] Loading fonts: bold=${boldFontPath}, regular=${regularFontPath}`);
|
||||
const boldFontBuffer = readFileSync(boldFontPath);
|
||||
const regularFontBuffer = readFileSync(regularFontPath);
|
||||
|
||||
// Satori (Vercel OG) strictly requires an ArrayBuffer, not a Node Buffer view.
|
||||
const boldFont = boldFontBuffer.buffer.slice(
|
||||
boldFontBuffer.byteOffset,
|
||||
boldFontBuffer.byteOffset + boldFontBuffer.byteLength,
|
||||
);
|
||||
const regularFont = regularFontBuffer.buffer.slice(
|
||||
regularFontBuffer.byteOffset,
|
||||
regularFontBuffer.byteOffset + regularFontBuffer.byteLength,
|
||||
);
|
||||
|
||||
const boldFont = readFileSync(boldFontPath);
|
||||
const regularFont = readFileSync(regularFontPath);
|
||||
console.log(
|
||||
`[OG] Fonts loaded successfully (${boldFont.byteLength} and ${regularFont.byteLength} bytes)`,
|
||||
`[OG] Fonts loaded successfully (${boldFont.length} and ${regularFont.length} bytes)`,
|
||||
);
|
||||
|
||||
return [
|
||||
|
||||
@@ -15,8 +15,6 @@ export interface PageFrontmatter {
|
||||
|
||||
export interface PageData {
|
||||
slug: string;
|
||||
redirectUrl?: string;
|
||||
redirectPermanent?: boolean;
|
||||
frontmatter: PageFrontmatter;
|
||||
content: any; // Lexical AST Document
|
||||
}
|
||||
@@ -98,8 +96,6 @@ export async function getPageBySlug(slug: string, locale: string): Promise<PageD
|
||||
|
||||
return {
|
||||
slug: doc.slug,
|
||||
redirectUrl: doc.redirectUrl,
|
||||
redirectPermanent: doc.redirectPermanent ?? true,
|
||||
frontmatter: {
|
||||
title: doc.title,
|
||||
excerpt: doc.excerpt || '',
|
||||
|
||||
@@ -1,95 +0,0 @@
|
||||
import * as React from 'react';
|
||||
import { Document, Page, View, Text, StyleSheet } from '@react-pdf/renderer';
|
||||
import { ProductData, ProductDatasheetPage } from './pdf-datasheet';
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
cover: {
|
||||
backgroundColor: '#001a4d', // Navy
|
||||
height: '100%',
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
color: '#FFFFFF',
|
||||
padding: 40,
|
||||
},
|
||||
logoContainer: {
|
||||
marginBottom: 40,
|
||||
},
|
||||
logoText: {
|
||||
fontSize: 64,
|
||||
fontWeight: 700,
|
||||
letterSpacing: 4,
|
||||
},
|
||||
title: {
|
||||
fontSize: 32,
|
||||
fontWeight: 700,
|
||||
textTransform: 'uppercase',
|
||||
letterSpacing: 2,
|
||||
marginBottom: 20,
|
||||
textAlign: 'center',
|
||||
},
|
||||
subtitle: {
|
||||
fontSize: 16,
|
||||
color: '#82ed20', // Accent Green
|
||||
textTransform: 'uppercase',
|
||||
letterSpacing: 4,
|
||||
marginBottom: 60,
|
||||
},
|
||||
footer: {
|
||||
position: 'absolute',
|
||||
bottom: 60,
|
||||
fontSize: 10,
|
||||
textTransform: 'uppercase',
|
||||
letterSpacing: 2,
|
||||
color: '#e5e7eb',
|
||||
},
|
||||
accentBar: {
|
||||
width: 60,
|
||||
height: 4,
|
||||
backgroundColor: '#82ed20',
|
||||
marginBottom: 40,
|
||||
},
|
||||
});
|
||||
|
||||
interface PDFBrochureProps {
|
||||
products: ProductData[];
|
||||
locale: 'en' | 'de';
|
||||
title?: string;
|
||||
subtitle?: string;
|
||||
}
|
||||
|
||||
export const PDFBrochure: React.FC<PDFBrochureProps> = ({
|
||||
products,
|
||||
locale,
|
||||
title = locale === 'de' ? 'Produktkatalog' : 'Product Catalog',
|
||||
subtitle = '2026',
|
||||
}) => {
|
||||
const dateStr = new Date().toLocaleDateString(locale === 'en' ? 'en-US' : 'de-DE', {
|
||||
year: 'numeric',
|
||||
month: 'long',
|
||||
});
|
||||
|
||||
return (
|
||||
<Document>
|
||||
{/* Cover Page */}
|
||||
<Page size="A4" style={styles.cover}>
|
||||
<View style={styles.logoContainer}>
|
||||
<Text style={styles.logoText}>KLZ</Text>
|
||||
</View>
|
||||
<View style={styles.accentBar} />
|
||||
<Text style={styles.title}>{title}</Text>
|
||||
<Text style={styles.subtitle}>{subtitle}</Text>
|
||||
|
||||
<View style={styles.footer}>
|
||||
<Text>{dateStr} — KLZ CABLES</Text>
|
||||
</View>
|
||||
</Page>
|
||||
|
||||
{/* Product Pages */}
|
||||
{products.map((product) => (
|
||||
<ProductDatasheetPage key={product.id} product={product} locale={locale} />
|
||||
))}
|
||||
</Document>
|
||||
);
|
||||
};
|
||||
@@ -1,8 +1,22 @@
|
||||
import * as React from 'react';
|
||||
import { Document, Page, View, Text, Image, StyleSheet } from '@react-pdf/renderer';
|
||||
import {
|
||||
Document,
|
||||
Page,
|
||||
View,
|
||||
Text,
|
||||
Image,
|
||||
StyleSheet,
|
||||
Font,
|
||||
} from '@react-pdf/renderer';
|
||||
|
||||
// Standard fonts like Helvetica are built-in to PDF and don't require registration
|
||||
// unless we want to use specific TTF files. Using built-in Helvetica for maximum stability.
|
||||
// Register fonts (using system fonts for now, can be customized)
|
||||
Font.register({
|
||||
family: 'Helvetica',
|
||||
fonts: [
|
||||
{ src: '/fonts/Helvetica.ttf', fontWeight: 400 },
|
||||
{ src: '/fonts/Helvetica-Bold.ttf', fontWeight: 700 },
|
||||
],
|
||||
});
|
||||
|
||||
// Industrial/technical/restrained design - STYLEGUIDE.md compliant
|
||||
const styles = StyleSheet.create({
|
||||
@@ -238,7 +252,7 @@ const styles = StyleSheet.create({
|
||||
},
|
||||
});
|
||||
|
||||
export interface ProductData {
|
||||
interface ProductData {
|
||||
id: number;
|
||||
name: string;
|
||||
shortDescriptionHtml: string;
|
||||
@@ -254,123 +268,6 @@ export interface ProductData {
|
||||
}>;
|
||||
}
|
||||
|
||||
interface ProductDatasheetPageProps {
|
||||
product: ProductData;
|
||||
locale: 'en' | 'de';
|
||||
}
|
||||
|
||||
export const ProductDatasheetPage: React.FC<ProductDatasheetPageProps> = ({
|
||||
product,
|
||||
locale,
|
||||
}) => {
|
||||
const labels = getLabels(locale);
|
||||
|
||||
return (
|
||||
<Page size="A4" style={styles.page}>
|
||||
{/* 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.content}>
|
||||
{/* Description section */}
|
||||
{(product.applicationHtml || product.shortDescriptionHtml || product.descriptionHtml) && (
|
||||
<View style={styles.section}>
|
||||
<Text style={styles.sectionTitle}>{labels.description}</Text>
|
||||
<View style={styles.sectionAccent} />
|
||||
<Text style={styles.description}>
|
||||
{stripHtml(
|
||||
product.applicationHtml || product.shortDescriptionHtml || product.descriptionHtml,
|
||||
)}
|
||||
</Text>
|
||||
</View>
|
||||
)}
|
||||
|
||||
{/* Technical specifications */}
|
||||
{product.attributes && product.attributes.length > 0 && (
|
||||
<View style={styles.section}>
|
||||
<Text style={styles.sectionTitle}>{labels.specifications}</Text>
|
||||
<View style={styles.sectionAccent} />
|
||||
<View style={styles.specsTable}>
|
||||
{product.attributes.map((attr, index) => (
|
||||
<View
|
||||
key={index}
|
||||
style={[
|
||||
styles.specsTableRow,
|
||||
index === product.attributes.length - 1 && styles.specsTableRowLast,
|
||||
]}
|
||||
>
|
||||
<View style={styles.specsTableLabelCell}>
|
||||
<Text style={styles.specsTableLabelText}>{attr.name}</Text>
|
||||
</View>
|
||||
<View style={styles.specsTableValueCell}>
|
||||
<Text style={styles.specsTableValueText}>{attr.options.join(', ')}</Text>
|
||||
</View>
|
||||
</View>
|
||||
))}
|
||||
</View>
|
||||
</View>
|
||||
)}
|
||||
|
||||
{/* Categories as clean tags */}
|
||||
{product.categories && product.categories.length > 0 && (
|
||||
<View style={styles.section}>
|
||||
<Text style={styles.sectionTitle}>{labels.categories}</Text>
|
||||
<View style={styles.sectionAccent} />
|
||||
<View style={styles.categories}>
|
||||
{product.categories.map((cat, index) => (
|
||||
<View key={index} style={styles.categoryTag}>
|
||||
<Text style={styles.categoryText}>{cat.name}</Text>
|
||||
</View>
|
||||
))}
|
||||
</View>
|
||||
</View>
|
||||
)}
|
||||
</View>
|
||||
|
||||
{/* Minimal footer */}
|
||||
<View style={styles.footer} fixed>
|
||||
<Text style={styles.footerBrand}>KLZ CABLES</Text>
|
||||
<Text style={styles.footerText}>
|
||||
{new Date().toLocaleDateString(locale === 'en' ? 'en-US' : 'de-DE', {
|
||||
year: 'numeric',
|
||||
month: 'long',
|
||||
day: 'numeric',
|
||||
})}
|
||||
</Text>
|
||||
</View>
|
||||
</Page>
|
||||
);
|
||||
};
|
||||
|
||||
interface PDFDatasheetProps {
|
||||
product: ProductData;
|
||||
locale: 'en' | 'de';
|
||||
@@ -405,11 +302,122 @@ const getLabels = (locale: 'en' | 'de') => {
|
||||
return labels[locale];
|
||||
};
|
||||
|
||||
export const PDFDatasheet: React.FC<PDFDatasheetProps> = ({ product, locale }) => {
|
||||
export const PDFDatasheet: React.FC<PDFDatasheetProps> = ({
|
||||
product,
|
||||
locale,
|
||||
}) => {
|
||||
const labels = getLabels(locale);
|
||||
|
||||
return (
|
||||
<Document>
|
||||
<ProductDatasheetPage product={product} locale={locale} />
|
||||
<Page size="A4" style={styles.page}>
|
||||
{/* 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.content}>
|
||||
{/* Description section */}
|
||||
{(product.applicationHtml || product.shortDescriptionHtml || product.descriptionHtml) && (
|
||||
<View style={styles.section}>
|
||||
<Text style={styles.sectionTitle}>{labels.description}</Text>
|
||||
<View style={styles.sectionAccent} />
|
||||
<Text style={styles.description}>
|
||||
{stripHtml(product.applicationHtml || product.shortDescriptionHtml || product.descriptionHtml)}
|
||||
</Text>
|
||||
</View>
|
||||
)}
|
||||
|
||||
{/* Technical specifications */}
|
||||
{product.attributes && product.attributes.length > 0 && (
|
||||
<View style={styles.section}>
|
||||
<Text style={styles.sectionTitle}>{labels.specifications}</Text>
|
||||
<View style={styles.sectionAccent} />
|
||||
<View style={styles.specsTable}>
|
||||
{product.attributes.map((attr, index) => (
|
||||
<View
|
||||
key={index}
|
||||
style={[
|
||||
styles.specsTableRow,
|
||||
index === product.attributes.length - 1 &&
|
||||
styles.specsTableRowLast,
|
||||
]}
|
||||
>
|
||||
<View style={styles.specsTableLabelCell}>
|
||||
<Text style={styles.specsTableLabelText}>{attr.name}</Text>
|
||||
</View>
|
||||
<View style={styles.specsTableValueCell}>
|
||||
<Text style={styles.specsTableValueText}>
|
||||
{attr.options.join(', ')}
|
||||
</Text>
|
||||
</View>
|
||||
</View>
|
||||
))}
|
||||
</View>
|
||||
</View>
|
||||
)}
|
||||
|
||||
{/* Categories as clean tags */}
|
||||
{product.categories && product.categories.length > 0 && (
|
||||
<View style={styles.section}>
|
||||
<Text style={styles.sectionTitle}>{labels.categories}</Text>
|
||||
<View style={styles.sectionAccent} />
|
||||
<View style={styles.categories}>
|
||||
{product.categories.map((cat, index) => (
|
||||
<View key={index} style={styles.categoryTag}>
|
||||
<Text style={styles.categoryText}>{cat.name}</Text>
|
||||
</View>
|
||||
))}
|
||||
</View>
|
||||
</View>
|
||||
)}
|
||||
</View>
|
||||
|
||||
{/* Minimal footer */}
|
||||
<View style={styles.footer} fixed>
|
||||
<Text style={styles.footerBrand}>KLZ CABLES</Text>
|
||||
<Text style={styles.footerText}>
|
||||
{new Date().toLocaleDateString(locale === 'en' ? 'en-US' : 'de-DE', {
|
||||
year: 'numeric',
|
||||
month: 'long',
|
||||
day: 'numeric',
|
||||
})}
|
||||
</Text>
|
||||
</View>
|
||||
</Page>
|
||||
</Document>
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
387
lib/pdf-page.tsx
387
lib/pdf-page.tsx
@@ -1,387 +0,0 @@
|
||||
import React from 'react';
|
||||
/* eslint-disable react/prop-types */
|
||||
import { Document, Page, View, Text, StyleSheet, Link, Image } from '@react-pdf/renderer';
|
||||
|
||||
// Standard fonts like Helvetica are built-in to PDF and don't require registration
|
||||
// unless we want to use specific TTF files. Using built-in Helvetica for maximum stability.
|
||||
|
||||
// ─── Brand Tokens (matching datasheet) ──────────────────────────────────
|
||||
const C = {
|
||||
navy: '#001a4d',
|
||||
navyDeep: '#000d26',
|
||||
accent: '#82ed20',
|
||||
white: '#FFFFFF',
|
||||
offWhite: '#f8f9fa',
|
||||
gray100: '#f3f4f6',
|
||||
gray200: '#e5e7eb',
|
||||
gray300: '#d1d5db',
|
||||
gray400: '#9ca3af',
|
||||
gray600: '#4b5563',
|
||||
gray900: '#111827',
|
||||
};
|
||||
|
||||
const MARGIN = 72;
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
page: {
|
||||
color: C.gray900,
|
||||
lineHeight: 1.6,
|
||||
backgroundColor: C.white,
|
||||
paddingTop: 0,
|
||||
paddingBottom: 80,
|
||||
fontFamily: 'Helvetica',
|
||||
},
|
||||
|
||||
// Premium Header Layout
|
||||
hero: {
|
||||
backgroundColor: C.offWhite,
|
||||
paddingTop: 40,
|
||||
paddingBottom: 32,
|
||||
paddingHorizontal: MARGIN,
|
||||
marginBottom: 40,
|
||||
borderBottomWidth: 1,
|
||||
borderBottomColor: C.gray200,
|
||||
position: 'relative',
|
||||
},
|
||||
|
||||
headerTop: {
|
||||
flexDirection: 'row',
|
||||
justifyContent: 'space-between',
|
||||
alignItems: 'center',
|
||||
marginBottom: 32,
|
||||
},
|
||||
|
||||
logo: {
|
||||
width: 60,
|
||||
height: 30,
|
||||
objectFit: 'contain',
|
||||
},
|
||||
|
||||
docType: {
|
||||
fontSize: 9,
|
||||
fontWeight: 700,
|
||||
color: C.navy,
|
||||
letterSpacing: 2,
|
||||
textTransform: 'uppercase',
|
||||
opacity: 0.6,
|
||||
},
|
||||
|
||||
headerTitleArea: {
|
||||
marginTop: 0,
|
||||
},
|
||||
|
||||
pageTitle: {
|
||||
fontSize: 28,
|
||||
fontWeight: 700,
|
||||
color: C.navyDeep,
|
||||
marginBottom: 4,
|
||||
textTransform: 'uppercase',
|
||||
letterSpacing: -0.5,
|
||||
},
|
||||
|
||||
accentBar: {
|
||||
width: 40,
|
||||
height: 4,
|
||||
backgroundColor: C.accent,
|
||||
marginTop: 12,
|
||||
borderRadius: 2,
|
||||
},
|
||||
|
||||
// Content Area
|
||||
content: {
|
||||
paddingHorizontal: MARGIN,
|
||||
},
|
||||
|
||||
// Lexical Elements with high-fidelity formatting
|
||||
paragraph: {
|
||||
fontSize: 10,
|
||||
color: C.gray600,
|
||||
lineHeight: 1.7,
|
||||
marginBottom: 14,
|
||||
textAlign: 'justify',
|
||||
},
|
||||
|
||||
heading1: {
|
||||
fontSize: 14,
|
||||
fontWeight: 700,
|
||||
color: C.navyDeep,
|
||||
textTransform: 'uppercase',
|
||||
letterSpacing: 0.8,
|
||||
marginTop: 0,
|
||||
marginBottom: 0,
|
||||
},
|
||||
|
||||
heading1Wrapper: {
|
||||
marginTop: 24,
|
||||
marginBottom: 12,
|
||||
borderLeftWidth: 3,
|
||||
borderLeftColor: C.accent,
|
||||
paddingLeft: 12,
|
||||
},
|
||||
|
||||
heading2: {
|
||||
fontSize: 11,
|
||||
fontWeight: 700,
|
||||
color: C.navyDeep,
|
||||
marginTop: 18,
|
||||
marginBottom: 10,
|
||||
textTransform: 'uppercase',
|
||||
letterSpacing: 0.5,
|
||||
},
|
||||
|
||||
heading3: {
|
||||
fontSize: 10,
|
||||
fontWeight: 700,
|
||||
color: C.navyDeep,
|
||||
marginTop: 14,
|
||||
marginBottom: 8,
|
||||
},
|
||||
|
||||
list: {
|
||||
marginBottom: 16,
|
||||
marginLeft: 4,
|
||||
},
|
||||
|
||||
listItem: {
|
||||
flexDirection: 'row',
|
||||
marginBottom: 6,
|
||||
},
|
||||
|
||||
listItemBullet: {
|
||||
width: 20,
|
||||
fontSize: 10,
|
||||
color: C.gray400,
|
||||
fontWeight: 700,
|
||||
},
|
||||
|
||||
listItemContent: {
|
||||
flex: 1,
|
||||
fontSize: 10,
|
||||
color: C.gray600,
|
||||
lineHeight: 1.7,
|
||||
textAlign: 'justify',
|
||||
},
|
||||
|
||||
link: {
|
||||
color: C.navy,
|
||||
textDecoration: 'underline',
|
||||
},
|
||||
|
||||
textBold: {
|
||||
fontWeight: 700,
|
||||
fontFamily: 'Helvetica-Bold',
|
||||
color: C.navyDeep,
|
||||
},
|
||||
|
||||
textItalic: {
|
||||
fontStyle: 'italic',
|
||||
},
|
||||
|
||||
// Industrial Footer
|
||||
footer: {
|
||||
position: 'absolute',
|
||||
bottom: 30,
|
||||
left: MARGIN,
|
||||
right: MARGIN,
|
||||
flexDirection: 'row',
|
||||
justifyContent: 'space-between',
|
||||
alignItems: 'flex-end',
|
||||
paddingTop: 16,
|
||||
borderTopWidth: 0.5,
|
||||
borderTopColor: C.gray200,
|
||||
},
|
||||
|
||||
footerInfo: {
|
||||
flexDirection: 'column',
|
||||
},
|
||||
|
||||
footerBrand: {
|
||||
fontSize: 10,
|
||||
fontWeight: 700,
|
||||
color: C.navyDeep,
|
||||
textTransform: 'uppercase',
|
||||
letterSpacing: 1,
|
||||
marginBottom: 4,
|
||||
},
|
||||
|
||||
footerText: {
|
||||
fontSize: 7,
|
||||
color: C.gray400,
|
||||
fontWeight: 500,
|
||||
textTransform: 'uppercase',
|
||||
letterSpacing: 0.5,
|
||||
},
|
||||
|
||||
pageNum: {
|
||||
fontSize: 8,
|
||||
color: C.gray400,
|
||||
},
|
||||
});
|
||||
|
||||
// ─── Lexical to React-PDF Renderer ────────────────────────────────
|
||||
|
||||
const renderLexicalNode = (node: any, idx: number): React.ReactNode => {
|
||||
if (!node) return null;
|
||||
|
||||
switch (node.type) {
|
||||
case 'text': {
|
||||
const format = node.format || 0;
|
||||
const isBold = (format & 1) !== 0;
|
||||
const isItalic = (format & 2) !== 0;
|
||||
|
||||
let elementStyle: any = {};
|
||||
if (isBold) elementStyle = { ...elementStyle, ...styles.textBold };
|
||||
if (isItalic) elementStyle = { ...elementStyle, ...styles.textItalic };
|
||||
|
||||
return (
|
||||
<Text key={idx} style={elementStyle}>
|
||||
{node.text}
|
||||
</Text>
|
||||
);
|
||||
}
|
||||
|
||||
case 'paragraph': {
|
||||
if (!node.children || node.children.length === 0) return null;
|
||||
return (
|
||||
<Text key={idx} style={styles.paragraph}>
|
||||
{node.children?.map((child: any, i: number) => renderLexicalNode(child, i))}
|
||||
</Text>
|
||||
);
|
||||
}
|
||||
|
||||
case 'heading': {
|
||||
if (!node.children || node.children.length === 0) return null;
|
||||
let hStyle = styles.heading3;
|
||||
let isH1 = false;
|
||||
if (node.tag === 'h1') {
|
||||
hStyle = styles.heading1;
|
||||
isH1 = true;
|
||||
}
|
||||
if (node.tag === 'h2') hStyle = styles.heading2;
|
||||
|
||||
const content = (
|
||||
<Text key={idx} style={hStyle}>
|
||||
{node.children?.map((child: any, i: number) => renderLexicalNode(child, i))}
|
||||
</Text>
|
||||
);
|
||||
|
||||
if (isH1) {
|
||||
return (
|
||||
<View key={idx} style={styles.heading1Wrapper}>
|
||||
{content}
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
return content;
|
||||
}
|
||||
|
||||
case 'list': {
|
||||
if (!node.children || node.children.length === 0) return null;
|
||||
return (
|
||||
<View key={idx} style={styles.list}>
|
||||
{node.children?.map((child: any, i: number) => {
|
||||
if (child.type === 'listitem') {
|
||||
return (
|
||||
<View key={i} style={styles.listItem}>
|
||||
<Text style={styles.listItemBullet}>
|
||||
{node.listType === 'number' ? `${i + 1}.` : '•'}
|
||||
</Text>
|
||||
<Text style={styles.listItemContent}>
|
||||
{child.children?.map((c: any, ci: number) => renderLexicalNode(c, ci))}
|
||||
</Text>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
return renderLexicalNode(child, i);
|
||||
})}
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
case 'link': {
|
||||
const href = node.fields?.url || node.url || '#';
|
||||
return (
|
||||
<Link key={idx} src={href} style={styles.link}>
|
||||
{node.children?.map((child: any, i: number) => renderLexicalNode(child, i))}
|
||||
</Link>
|
||||
);
|
||||
}
|
||||
|
||||
case 'linebreak': {
|
||||
return <Text key={idx}>{'\n'}</Text>;
|
||||
}
|
||||
|
||||
// Ignore payload blocks recursively to avoid crashing
|
||||
case 'block':
|
||||
return null;
|
||||
|
||||
default:
|
||||
if (node.children) {
|
||||
return (
|
||||
<Text key={idx}>
|
||||
{node.children.map((child: any, i: number) => renderLexicalNode(child, i))}
|
||||
</Text>
|
||||
);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
interface PDFPageProps {
|
||||
page: any;
|
||||
locale?: string;
|
||||
}
|
||||
|
||||
export const PDFPage: React.FC<PDFPageProps> = ({ page, locale = 'de' }) => {
|
||||
const dateStr = new Date().toLocaleDateString(locale === 'en' ? 'en-US' : 'de-DE', {
|
||||
year: 'numeric',
|
||||
month: 'long',
|
||||
day: 'numeric',
|
||||
});
|
||||
|
||||
const logoPath = `${process.cwd()}/public/logo.png`;
|
||||
|
||||
return (
|
||||
<Document>
|
||||
<Page size="A4" style={styles.page}>
|
||||
{/* Improved Hero Header */}
|
||||
<View style={styles.hero} fixed>
|
||||
<View style={styles.headerTop}>
|
||||
<Image src={logoPath} style={styles.logo} />
|
||||
<Text style={styles.docType}>
|
||||
{locale === 'en' ? 'Official Document' : 'Offizielles Dokument'}
|
||||
</Text>
|
||||
</View>
|
||||
|
||||
<View style={styles.headerTitleArea}>
|
||||
<Text style={styles.pageTitle}>{page.title}</Text>
|
||||
<View style={styles.accentBar} />
|
||||
</View>
|
||||
</View>
|
||||
|
||||
<View style={styles.content}>
|
||||
<View>
|
||||
{page.content?.root?.children?.map((node: any, i: number) =>
|
||||
renderLexicalNode(node, i),
|
||||
)}
|
||||
</View>
|
||||
</View>
|
||||
|
||||
{/* Industrial footer with page numbers */}
|
||||
<View style={{ ...styles.footer, position: 'absolute', bottom: 30, height: 40 }} fixed>
|
||||
<View style={styles.footerInfo}>
|
||||
<Text style={styles.footerBrand}>KLZ VERTRIEBS GMBH</Text>
|
||||
<Text style={styles.footerText}>
|
||||
RAIFFEISENSTRASSE 22 • 73630 REMSHALDEN • {dateStr}
|
||||
</Text>
|
||||
</View>
|
||||
<Text
|
||||
style={styles.pageNum}
|
||||
render={({ pageNumber, totalPages }) => `${pageNumber} / ${totalPages}`}
|
||||
/>
|
||||
</View>
|
||||
</Page>
|
||||
</Document>
|
||||
);
|
||||
};
|
||||
@@ -3,7 +3,7 @@
|
||||
"pages": {
|
||||
"impressum": "impressum",
|
||||
"datenschutz": "datenschutz",
|
||||
"agbs": "terms",
|
||||
"agbs": "agbs",
|
||||
"kontakt": "contact",
|
||||
"team": "team",
|
||||
"blog": "blog",
|
||||
@@ -74,7 +74,7 @@
|
||||
"privacyPolicy": "Datenschutz",
|
||||
"privacyPolicySlug": "datenschutz",
|
||||
"terms": "AGB",
|
||||
"termsSlug": "terms",
|
||||
"termsSlug": "agbs",
|
||||
"products": "Produkte",
|
||||
"lowVoltage": "Niederspannungskabel",
|
||||
"mediumVoltage": "Mittelspannungskabel",
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
"pages": {
|
||||
"legal-notice": "impressum",
|
||||
"privacy-policy": "datenschutz",
|
||||
"terms": "terms",
|
||||
"terms": "agbs",
|
||||
"contact": "contact",
|
||||
"team": "team",
|
||||
"blog": "blog",
|
||||
@@ -396,4 +396,4 @@
|
||||
"cta": "Back to Safety"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -14,12 +14,8 @@ export default async function middleware(request: NextRequest) {
|
||||
const { method, url, headers } = request;
|
||||
const { pathname } = request.nextUrl;
|
||||
|
||||
// Explicit bypass for infrastructure, Payload CMS, and Server Actions
|
||||
// Next-Action header is present for all Next.js Server Actions
|
||||
const isServerAction = headers.has('next-action');
|
||||
|
||||
// Explicit bypass for infrastructure and Payload CMS routes to avoid locale redirects/interception
|
||||
if (
|
||||
isServerAction ||
|
||||
pathname.startsWith('/admin') ||
|
||||
pathname.startsWith('/api') ||
|
||||
pathname.startsWith('/stats') ||
|
||||
@@ -56,22 +52,16 @@ export default async function middleware(request: NextRequest) {
|
||||
|
||||
urlObj.protocol = proto;
|
||||
|
||||
// Only create a new request for GET/HEAD to avoid consuming the body stream on POST/PUT
|
||||
// This resolves the "failed to pipe response" error (160k occurrences in GlitchTip)
|
||||
if (['GET', 'HEAD'].includes(request.method)) {
|
||||
// Clone headers to ensure all Next.js internal headers (like router state) are preserved
|
||||
const clonedHeaders = new Headers(request.headers);
|
||||
|
||||
effectiveRequest = new NextRequest(urlObj, {
|
||||
headers: clonedHeaders,
|
||||
method: request.method,
|
||||
});
|
||||
effectiveRequest = new NextRequest(urlObj, {
|
||||
headers: request.headers,
|
||||
method: request.method,
|
||||
body: request.body,
|
||||
});
|
||||
|
||||
if (process.env.NODE_ENV !== 'production' || !process.env.CI) {
|
||||
console.log(
|
||||
`🛡️ Proxy: Fixed internal URL leak: ${url} -> ${urlObj.toString()} | Proto: ${proto} | Host: ${hostHeader}`,
|
||||
);
|
||||
}
|
||||
if (process.env.NODE_ENV !== 'production' || !process.env.CI) {
|
||||
console.log(
|
||||
`🛡️ Proxy: Fixed internal URL leak: ${url} -> ${urlObj.toString()} | Proto: ${proto} | Host: ${hostHeader}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1 +0,0 @@
|
||||
Logging into staging...
|
||||
@@ -7,21 +7,26 @@ import { withPayload } from '@payloadcms/next/withPayload';
|
||||
const isProd = process.env.NODE_ENV === 'production';
|
||||
/** @type {import('next').NextConfig} */
|
||||
const nextConfig = {
|
||||
transpilePackages: ['react-image-crop', '@react-three/fiber'],
|
||||
onDemandEntries: {
|
||||
// Make sure entries are not disposed too quickly
|
||||
maxInactiveAge: 60 * 1000,
|
||||
// Keep compiled pages/routes in memory for 5 minutes (reduced from 25m to prevent OOM)
|
||||
maxInactiveAge: 5 * 60 * 1000,
|
||||
// Keep up to 2 pages in the dev buffer (reduced from 10 to prevent OOM)
|
||||
pagesBufferLength: 2,
|
||||
},
|
||||
experimental: {
|
||||
staleTimes: {
|
||||
dynamic: 0,
|
||||
static: 30,
|
||||
},
|
||||
optimizePackageImports: ['lucide-react', 'framer-motion', '@/components/ui'],
|
||||
cpus: 3,
|
||||
optimizePackageImports: [
|
||||
'lucide-react',
|
||||
'framer-motion',
|
||||
'@/components/ui',
|
||||
'@sentry/nextjs',
|
||||
'@payloadcms/richtext-lexical',
|
||||
'react-hook-form',
|
||||
'zod',
|
||||
'date-fns',
|
||||
],
|
||||
workerThreads: false,
|
||||
serverActions: {
|
||||
allowedOrigins: ["*.klz-cables.com", "*.branch.klz-cables.com", "localhost:3000", "klz.localhost"],
|
||||
},
|
||||
memoryBasedWorkersCount: true,
|
||||
},
|
||||
reactStrictMode: false,
|
||||
productionBrowserSourceMaps: false,
|
||||
@@ -31,6 +36,21 @@ const nextConfig = {
|
||||
},
|
||||
},
|
||||
...(isProd ? { output: 'standalone' } : {}),
|
||||
// Prevent webpack from restarting when .env files are touched via Docker volume mount
|
||||
webpack: (config, { dev }) => {
|
||||
if (dev) {
|
||||
config.watchOptions = {
|
||||
...config.watchOptions,
|
||||
ignored: /node_modules|\.env/,
|
||||
// Reduce poll frequency to lower CPU churn from VirtioFS
|
||||
poll: 1000,
|
||||
aggregateTimeout: 300,
|
||||
};
|
||||
// Reduce source map quality in dev for faster rebuilds
|
||||
config.devtool = 'eval';
|
||||
}
|
||||
return config;
|
||||
},
|
||||
async headers() {
|
||||
const isProd = process.env.NODE_ENV === 'production';
|
||||
const umamiDomain = new URL(process.env.UMAMI_API_ENDPOINT || 'https://analytics.infra.mintel.me').origin;
|
||||
@@ -53,7 +73,7 @@ const nextConfig = {
|
||||
style-src 'self' 'unsafe-inline' https://fonts.googleapis.com;
|
||||
font-src 'self' https://fonts.gstatic.com;
|
||||
img-src 'self' data: blob: ${extraImgDomains};
|
||||
connect-src 'self' ${umamiDomain} ${glitchtipDomain};
|
||||
connect-src 'self' ${umamiDomain} ${glitchtipDomain} https://raw.githack.com https://raw.githubusercontent.com;
|
||||
frame-src 'self';
|
||||
object-src 'none';
|
||||
base-uri 'self';
|
||||
@@ -399,6 +419,7 @@ const nextConfig = {
|
||||
];
|
||||
},
|
||||
images: {
|
||||
qualities: [25, 50, 75, 100],
|
||||
formats: ['image/webp'],
|
||||
deviceSizes: [640, 750, 828, 1080, 1200, 1920, 2048, 3840],
|
||||
remotePatterns: [
|
||||
@@ -464,6 +485,10 @@ const nextConfig = {
|
||||
source: '/en/datenschutz',
|
||||
destination: '/en/privacy-policy',
|
||||
},
|
||||
{
|
||||
source: '/en/agbs',
|
||||
destination: '/en/terms',
|
||||
},
|
||||
],
|
||||
afterFiles: [],
|
||||
fallback: [],
|
||||
|
||||
File diff suppressed because one or more lines are too long
33
package.json
33
package.json
@@ -4,19 +4,26 @@
|
||||
"private": true,
|
||||
"packageManager": "pnpm@10.18.3",
|
||||
"dependencies": {
|
||||
"@mintel/mail": "1.9.18",
|
||||
"@mintel/next-config": "1.9.18",
|
||||
"@mintel/next-feedback": "1.9.18",
|
||||
"@mintel/next-utils": "1.9.18",
|
||||
"@ai-sdk/google": "^3.0.31",
|
||||
"@ai-sdk/openai": "^3.0.36",
|
||||
"@mintel/mail": "^1.9.0",
|
||||
"@mintel/next-config": "^1.9.0",
|
||||
"@mintel/next-feedback": "^1.9.0",
|
||||
"@mintel/next-utils": "^1.9.0",
|
||||
"@mintel/payload-ai": "^1.9.15",
|
||||
"@payloadcms/db-postgres": "^3.77.0",
|
||||
"@payloadcms/email-nodemailer": "^3.77.0",
|
||||
"@payloadcms/next": "^3.77.0",
|
||||
"@payloadcms/richtext-lexical": "^3.77.0",
|
||||
"@payloadcms/ui": "^3.77.0",
|
||||
"@react-email/components": "1.0.8",
|
||||
"@qdrant/js-client-rest": "^1.17.0",
|
||||
"@react-email/components": "^1.0.7",
|
||||
"@react-pdf/renderer": "^4.3.2",
|
||||
"@react-three/drei": "^10.7.7",
|
||||
"@react-three/fiber": "^9.5.0",
|
||||
"@sentry/nextjs": "^10.39.0",
|
||||
"@types/recharts": "^2.0.1",
|
||||
"ai": "^6.0.101",
|
||||
"axios": "^1.13.5",
|
||||
"clsx": "^2.1.1",
|
||||
"framer-motion": "^12.34.0",
|
||||
@@ -24,6 +31,7 @@
|
||||
"gray-matter": "^4.0.3",
|
||||
"i18next": "^25.7.3",
|
||||
"import-in-the-middle": "^1.11.0",
|
||||
"ioredis": "^5.9.3",
|
||||
"jsdom": "^27.4.0",
|
||||
"leaflet": "^1.9.4",
|
||||
"next": "16.1.6",
|
||||
@@ -38,13 +46,17 @@
|
||||
"react-dom": "^19.2.4",
|
||||
"react-email": "^5.2.5",
|
||||
"react-leaflet": "^4.2.1",
|
||||
"react-markdown": "^10.1.0",
|
||||
"recharts": "^3.7.0",
|
||||
"rehype-raw": "^7.0.0",
|
||||
"remark-gfm": "^4.0.1",
|
||||
"require-in-the-middle": "^8.0.1",
|
||||
"resend": "^3.5.0",
|
||||
"schema-dts": "^1.1.5",
|
||||
"sharp": "^0.34.5",
|
||||
"svg-to-pdfkit": "^0.1.8",
|
||||
"tailwind-merge": "^3.4.0",
|
||||
"three": "^0.183.1",
|
||||
"xlsx": "npm:@e965/xlsx@^0.20.3",
|
||||
"zod": "3.25.76"
|
||||
},
|
||||
@@ -53,8 +65,8 @@
|
||||
"@commitlint/config-conventional": "^20.4.0",
|
||||
"@cspell/dict-de-de": "^4.1.2",
|
||||
"@lhci/cli": "^0.15.1",
|
||||
"@mintel/eslint-config": "1.9.18",
|
||||
"@mintel/tsconfig": "1.9.18",
|
||||
"@mintel/eslint-config": "^1.9.0",
|
||||
"@mintel/tsconfig": "^1.9.0",
|
||||
"@next/bundle-analyzer": "^16.1.6",
|
||||
"@tailwindcss/cli": "^4.1.18",
|
||||
"@tailwindcss/postcss": "^4.1.18",
|
||||
@@ -80,6 +92,7 @@
|
||||
"lint-staged": "^16.2.7",
|
||||
"lucide-react": "^0.563.0",
|
||||
"pa11y-ci": "^4.0.1",
|
||||
"pdf-parse": "^2.4.5",
|
||||
"postcss": "^8.5.6",
|
||||
"prettier": "^3.8.1",
|
||||
"puppeteer": "^24.37.3",
|
||||
@@ -92,8 +105,8 @@
|
||||
"vitest": "^4.0.16"
|
||||
},
|
||||
"scripts": {
|
||||
"dev": "bash -c 'trap \"COMPOSE_PROJECT_NAME=klz-2026 docker-compose -f docker-compose.dev.yml down\" EXIT INT TERM; docker network create infra 2>/dev/null || true && COMPOSE_PROJECT_NAME=klz-2026 docker-compose -f docker-compose.dev.yml down && COMPOSE_PROJECT_NAME=klz-2026 docker-compose -f docker-compose.dev.yml up klz-app klz-db --remove-orphans'",
|
||||
"dev:local": "bash -c 'trap \"COMPOSE_PROJECT_NAME=klz-2026 docker-compose -f docker-compose.dev.yml down\" EXIT INT TERM; COMPOSE_PROJECT_NAME=klz-2026 docker-compose -f docker-compose.dev.yml up -d klz-db klz-proxy && POSTGRES_URI=NODE_ENV=development next dev --webpack --port 3100 --hostname 0.0.0.0'",
|
||||
"dev": "bash -c '[ -f .env ] || (cp .env.example .env && sed -i.bak \"s/TRAEFIK_HOST=klz-cables.com/TRAEFIK_HOST=klz.localhost/\" .env && rm -f .env.bak && echo \"✅ Created .env from .env.example\"); trap \"COMPOSE_PROJECT_NAME=klz-2026 docker-compose -f docker-compose.dev.yml down\" EXIT INT TERM; docker network create infra 2>/dev/null || true && COMPOSE_PROJECT_NAME=klz-2026 docker-compose -f docker-compose.dev.yml down && COMPOSE_PROJECT_NAME=klz-2026 docker-compose -f docker-compose.dev.yml up klz-app klz-db klz-proxy klz-qdrant klz-redis --remove-orphans'",
|
||||
"dev:local": "bash -c 'trap \"COMPOSE_PROJECT_NAME=klz-2026 docker-compose -f docker-compose.dev.yml down\" EXIT INT TERM; COMPOSE_PROJECT_NAME=klz-2026 docker-compose -f docker-compose.dev.yml up -d klz-db klz-proxy klz-qdrant klz-redis && POSTGRES_URI=NODE_ENV=development next dev --webpack --port 3100 --hostname 0.0.0.0'",
|
||||
"dev:infra": "COMPOSE_PROJECT_NAME=klz-2026 docker-compose -f docker-compose.dev.yml up -d klz-db klz-proxy",
|
||||
"build": "next build",
|
||||
"start": "next start",
|
||||
@@ -139,7 +152,7 @@
|
||||
"prepare": "husky",
|
||||
"preinstall": "npx only-allow pnpm"
|
||||
},
|
||||
"version": "2.3.18-rc.6",
|
||||
"version": "2.2.12",
|
||||
"pnpm": {
|
||||
"onlyBuiltDependencies": [
|
||||
"@parcel/watcher",
|
||||
|
||||
@@ -98,9 +98,6 @@ export interface Config {
|
||||
globals: {};
|
||||
globalsSelect: {};
|
||||
locale: 'de' | 'en';
|
||||
widgets: {
|
||||
collections: CollectionsWidget;
|
||||
};
|
||||
user: User;
|
||||
jobs: {
|
||||
tasks: unknown;
|
||||
@@ -331,14 +328,6 @@ export interface Page {
|
||||
layout?: ('default' | 'fullBleed') | null;
|
||||
excerpt?: string | null;
|
||||
featuredImage?: (number | null) | Media;
|
||||
/**
|
||||
* If set, visiting this page will immediately redirect the user to this URL (e.g. /de/terms).
|
||||
*/
|
||||
redirectUrl?: string | null;
|
||||
/**
|
||||
* Check for a permanent (301) redirect. Uncheck for a temporary (302) redirect.
|
||||
*/
|
||||
redirectPermanent?: boolean | null;
|
||||
content: {
|
||||
root: {
|
||||
type: string;
|
||||
@@ -585,8 +574,6 @@ export interface PagesSelect<T extends boolean = true> {
|
||||
layout?: T;
|
||||
excerpt?: T;
|
||||
featuredImage?: T;
|
||||
redirectUrl?: T;
|
||||
redirectPermanent?: T;
|
||||
content?: T;
|
||||
updatedAt?: T;
|
||||
createdAt?: T;
|
||||
@@ -632,16 +619,6 @@ export interface PayloadMigrationsSelect<T extends boolean = true> {
|
||||
updatedAt?: T;
|
||||
createdAt?: T;
|
||||
}
|
||||
/**
|
||||
* This interface was referenced by `Config`'s JSON-Schema
|
||||
* via the `definition` "collections_widget".
|
||||
*/
|
||||
export interface CollectionsWidget {
|
||||
data?: {
|
||||
[k: string]: unknown;
|
||||
};
|
||||
width: 'full';
|
||||
}
|
||||
/**
|
||||
* This interface was referenced by `Config`'s JSON-Schema
|
||||
* via the `definition` "StatsBlock".
|
||||
|
||||
@@ -23,6 +23,13 @@ import { Products } from './src/payload/collections/Products';
|
||||
import { Pages } from './src/payload/collections/Pages';
|
||||
import { seedDatabase } from './src/payload/seed';
|
||||
|
||||
const isMigrate = process.argv.includes('migrate');
|
||||
let chatPlugin: any = null;
|
||||
if (!isMigrate) {
|
||||
const mod = await import('@mintel/payload-ai');
|
||||
chatPlugin = mod.payloadChatPlugin;
|
||||
}
|
||||
|
||||
const filename = fileURLToPath(import.meta.url);
|
||||
const dirname = path.dirname(filename);
|
||||
|
||||
@@ -71,8 +78,6 @@ export default buildConfig({
|
||||
},
|
||||
db: postgresAdapter({
|
||||
prodMigrations: migrations,
|
||||
migrationDir:
|
||||
process.env.NODE_ENV === 'production' ? undefined : path.resolve(dirname, 'src/migrations'),
|
||||
pool: {
|
||||
connectionString:
|
||||
process.env.DATABASE_URI ||
|
||||
@@ -100,5 +105,14 @@ export default buildConfig({
|
||||
})
|
||||
: undefined,
|
||||
sharp,
|
||||
plugins: [],
|
||||
plugins: [
|
||||
...(chatPlugin
|
||||
? [
|
||||
chatPlugin({
|
||||
enabled: true,
|
||||
mcpServers: [{ name: 'klz-qdrant-mcp' }],
|
||||
}),
|
||||
]
|
||||
: []),
|
||||
],
|
||||
});
|
||||
|
||||
@@ -1 +0,0 @@
|
||||
PDF rendered successfully offline!
|
||||
5402
pnpm-lock.yaml
generated
5402
pnpm-lock.yaml
generated
File diff suppressed because it is too large
Load Diff
@@ -13,6 +13,7 @@ fi
|
||||
|
||||
DB_NAME="${PAYLOAD_DB_NAME:-payload}"
|
||||
DB_USER="${PAYLOAD_DB_USER:-payload}"
|
||||
DB_CONTAINER="klz-2026-klz-db-1"
|
||||
BACKUP_DIR="./backups"
|
||||
TIMESTAMP=$(date +"%Y%m%d_%H%M%S")
|
||||
BACKUP_FILE="${BACKUP_DIR}/payload_${TIMESTAMP}.sql.gz"
|
||||
@@ -20,21 +21,20 @@ BACKUP_FILE="${BACKUP_DIR}/payload_${TIMESTAMP}.sql.gz"
|
||||
# Ensure backup directory exists
|
||||
mkdir -p "$BACKUP_DIR"
|
||||
|
||||
# Check if database container is running
|
||||
if ! docker compose ps --services --filter "status=running" | grep -qx "klz-db"; then
|
||||
echo "⚠️ Database container 'klz-db' is not running. Starting it..."
|
||||
docker compose up -d klz-db
|
||||
echo "⏳ Waiting for database to be ready..."
|
||||
sleep 3
|
||||
# Check if container is running
|
||||
if ! docker ps --format '{{.Names}}' | grep -q "$DB_CONTAINER"; then
|
||||
echo "❌ Database container '$DB_CONTAINER' is not running."
|
||||
echo " Start it with: docker compose up -d klz-db"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo "📦 Backing up Payload database..."
|
||||
echo " Service: klz-db"
|
||||
echo " Container: $DB_CONTAINER"
|
||||
echo " Database: $DB_NAME"
|
||||
echo " Output: $BACKUP_FILE"
|
||||
|
||||
# Run pg_dump inside the container and compress
|
||||
docker compose exec -T klz-db pg_dump -U "$DB_USER" -d "$DB_NAME" --clean --if-exists | gzip > "$BACKUP_FILE"
|
||||
docker exec "$DB_CONTAINER" pg_dump -U "$DB_USER" -d "$DB_NAME" --clean --if-exists | gzip > "$BACKUP_FILE"
|
||||
|
||||
# Show result
|
||||
SIZE=$(du -h "$BACKUP_FILE" | cut -f1)
|
||||
|
||||
@@ -5,44 +5,9 @@ import * as cheerio from 'cheerio';
|
||||
const targetUrl = process.argv[2] || process.env.NEXT_PUBLIC_BASE_URL || 'http://localhost:3000';
|
||||
const gatekeeperPassword = process.env.GATEKEEPER_PASSWORD || 'klz2026';
|
||||
|
||||
// Utility for hardcoded delays
|
||||
const delay = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms));
|
||||
|
||||
async function main() {
|
||||
console.log(`\n🚀 Starting E2E Form Submission Check for: ${targetUrl}`);
|
||||
|
||||
// 0. Pre-warming settlement: Wait for deployment to respond (sync containers/Varnish)
|
||||
console.log(`⏳ Pre-warming: Waiting for deployment to return 200 OK...`);
|
||||
const maxWaitMs = 60000;
|
||||
const startTime = Date.now();
|
||||
let warmed = false;
|
||||
|
||||
while (Date.now() - startTime < maxWaitMs) {
|
||||
try {
|
||||
const resp = await axios.get(targetUrl, {
|
||||
headers: { Cookie: `klz_gatekeeper_session=${gatekeeperPassword}` },
|
||||
timeout: 5000,
|
||||
validateStatus: (s) => s === 200,
|
||||
});
|
||||
if (resp.status === 200) {
|
||||
warmed = true;
|
||||
break;
|
||||
}
|
||||
} catch (e) {
|
||||
// Quietly retry
|
||||
}
|
||||
await delay(2000);
|
||||
}
|
||||
|
||||
if (!warmed) {
|
||||
console.warn(`⚠️ Pre-warming timed out after 60s. Proceeding anyway...`);
|
||||
} else {
|
||||
console.log(`✅ Deployment settled and responding.`);
|
||||
}
|
||||
|
||||
// Brief stabilization buffer
|
||||
await delay(5000);
|
||||
|
||||
// 1. Fetch Sitemap to discover the contact page and a product page
|
||||
const sitemapUrl = `${targetUrl.replace(/\/$/, '')}/sitemap.xml`;
|
||||
let urls: string[] = [];
|
||||
@@ -95,114 +60,32 @@ async function main() {
|
||||
console.log(`\n🕷️ Launching Puppeteer Headless Engine...`);
|
||||
const browser = await puppeteer.launch({
|
||||
headless: true,
|
||||
args: [
|
||||
'--no-sandbox',
|
||||
'--disable-setuid-sandbox',
|
||||
'--disable-dev-shm-usage',
|
||||
'--disable-gpu',
|
||||
'--ignore-certificate-errors',
|
||||
'--disable-web-security',
|
||||
'--disable-features=IsolateOrigins,site-per-process',
|
||||
],
|
||||
executablePath: process.env.PUPPETEER_EXECUTABLE_PATH || process.env.CHROME_PATH || undefined,
|
||||
args: ['--no-sandbox', '--disable-setuid-sandbox', '--disable-dev-shm-usage'],
|
||||
});
|
||||
|
||||
const page = await browser.newPage();
|
||||
|
||||
// Enable Request Interception to force cache-busting on ALL static assets
|
||||
await page.setRequestInterception(true);
|
||||
page.on('request', (request) => {
|
||||
const url = request.url();
|
||||
// Intercept Next.js chunks and data to bypass Varnish 404-caching
|
||||
// Includes standard /_next/ and subpath /gatekeeper/_next/
|
||||
if (
|
||||
(url.includes('/_next/static/') || url.includes('/_next/data/') || url.includes('/gatekeeper/_next/')) &&
|
||||
!url.includes('?cb=') &&
|
||||
!url.includes('&cb=')
|
||||
) {
|
||||
const buster = `cb=${Date.now()}`;
|
||||
const newUrl = url.includes('?') ? `${url}&${buster}` : `${url}?${buster}`;
|
||||
request.continue({ url: newUrl });
|
||||
} else {
|
||||
request.continue();
|
||||
}
|
||||
});
|
||||
|
||||
let chunkErrorsDetected = false;
|
||||
page.on('console', (msg) => {
|
||||
const text = msg.text();
|
||||
console.log('💻 BROWSER CONSOLE:', text);
|
||||
if (text.includes('ChunkLoadError') || text.includes('failed to load chunk')) {
|
||||
chunkErrorsDetected = true;
|
||||
}
|
||||
});
|
||||
page.on('pageerror', (error) => {
|
||||
console.error('💻 BROWSER ERROR:', error.message);
|
||||
if (error.message.includes('ChunkLoadError')) {
|
||||
chunkErrorsDetected = true;
|
||||
}
|
||||
});
|
||||
page.on('console', (msg) => console.log('💻 BROWSER CONSOLE:', msg.text()));
|
||||
page.on('pageerror', (error) => console.error('💻 BROWSER ERROR:', error.message));
|
||||
page.on('requestfailed', (request) => {
|
||||
const url = request.url();
|
||||
const failure = request.failure()?.errorText;
|
||||
console.error('💻 BROWSER REQUEST FAILED:', url, failure);
|
||||
if (url.endsWith('.js') && (failure === 'net::ERR_ABORTED' || failure?.includes('404'))) {
|
||||
chunkErrorsDetected = true;
|
||||
}
|
||||
console.error('💻 BROWSER REQUEST FAILED:', request.url(), request.failure()?.errorText);
|
||||
});
|
||||
|
||||
// Helper for resilient navigation with cache-busting fallback
|
||||
const navigateWithRetry = async (url: string, label: string) => {
|
||||
chunkErrorsDetected = false;
|
||||
console.log(`\n🧪 Testing ${label} on: ${url}`);
|
||||
|
||||
// First attempt: Wait for network to be relatively idle
|
||||
await page.goto(url, { waitUntil: 'networkidle2', timeout: 30000 });
|
||||
|
||||
// REDIRECTION CHECK: Logging redirected landing page
|
||||
const finalUrl = page.url();
|
||||
if (finalUrl !== url && !finalUrl.includes(url)) {
|
||||
console.log(` 🔀 Redirected to: ${finalUrl}`);
|
||||
}
|
||||
|
||||
if (chunkErrorsDetected) {
|
||||
const buster = `cb=${Date.now()}`;
|
||||
const cbUrl = finalUrl.includes('?') ? `${finalUrl}&${buster}` : `${finalUrl}?${buster}`;
|
||||
console.warn(` ⚠️ Assets failed to load (Varnish staleness suspected). Retrying with cache-buster: ${cbUrl}`);
|
||||
chunkErrorsDetected = false;
|
||||
await page.goto(cbUrl, { waitUntil: 'networkidle2', timeout: 30000 });
|
||||
}
|
||||
|
||||
// Capture HTML on failure during hydration wait
|
||||
try {
|
||||
await page.waitForNetworkIdle({ idleTime: 1000, timeout: 5000 }).catch(() => {});
|
||||
} catch (e) {
|
||||
console.warn(' ⚠️ Network idle timeout, proceeding with current state.');
|
||||
}
|
||||
};
|
||||
|
||||
// 3. Authenticate through Gatekeeper login form
|
||||
console.log(`\n🛡️ Authenticating through Gatekeeper...`);
|
||||
try {
|
||||
// Navigate to a protected page so Gatekeeper redirects us to the login screen
|
||||
// RELAXED NAVIGATION: Use domcontentloaded to handle rapid redirects
|
||||
await page.goto(contactUrl, { waitUntil: 'domcontentloaded', timeout: 60000 });
|
||||
|
||||
// BOUNCE BUFFER: Wait for potential Gatekeeper redirect chain to settle
|
||||
console.log(` Waiting for potential Gatekeeper redirect bounce...`);
|
||||
await delay(3000);
|
||||
await page.goto(contactUrl, { waitUntil: 'networkidle0', timeout: 30000 });
|
||||
|
||||
// Check if we landed on the Gatekeeper login page
|
||||
const isGatekeeperPage = await page.$('input[name="password"]');
|
||||
if (isGatekeeperPage) {
|
||||
console.log(` Gatekeeper gate detected. Logging in...`);
|
||||
await page.type('input[name="password"]', gatekeeperPassword);
|
||||
await Promise.all([
|
||||
page.waitForNavigation({ waitUntil: 'domcontentloaded' }),
|
||||
page.waitForNavigation({ waitUntil: 'networkidle0', timeout: 30000 }),
|
||||
page.click('button[type="submit"]'),
|
||||
]);
|
||||
|
||||
// LOGIN SYNC BUFFER: Let the authenticated session settle
|
||||
console.log(` Authenticating session...`);
|
||||
await delay(3000);
|
||||
console.log(`✅ Gatekeeper authentication successful!`);
|
||||
} else {
|
||||
console.log(`✅ Already authenticated (no Gatekeeper gate detected).`);
|
||||
@@ -217,20 +100,18 @@ async function main() {
|
||||
|
||||
// 4. Test Contact Form
|
||||
try {
|
||||
await navigateWithRetry(contactUrl, 'Contact Form');
|
||||
console.log(`\n🧪 Testing Contact Form on: ${contactUrl}`);
|
||||
await page.goto(contactUrl, { waitUntil: 'networkidle0', timeout: 30000 });
|
||||
|
||||
// Ensure React has hydrated completely - CI runners can be slow
|
||||
console.log(` ⏳ Waiting for hydration (5s)...`);
|
||||
await delay(5000);
|
||||
// Ensure React has hydrated completely
|
||||
await page.waitForNetworkIdle({ idleTime: 1000, timeout: 15000 }).catch(() => {});
|
||||
|
||||
// Ensure form is visible and interactive
|
||||
try {
|
||||
// Find the form input by name
|
||||
await page.waitForSelector('input[name="name"]', { visible: true, timeout: 15000 });
|
||||
} catch (e) {
|
||||
console.error('❌ Failed to find Contact Form input. Page Title:', await page.title());
|
||||
const html = await page.content();
|
||||
console.log(`💻 FULL HTML DUMP (top 2000 chars):\n${html.slice(0, 2000)}`);
|
||||
console.error('Failed to find Contact Form input. Page Title:', await page.title());
|
||||
throw e;
|
||||
}
|
||||
|
||||
@@ -248,32 +129,18 @@ async function main() {
|
||||
// Give state a moment to settle
|
||||
await page.evaluate(() => new Promise((resolve) => setTimeout(resolve, 500)));
|
||||
|
||||
console.log(` ⏳ Waiting for alert selector (30s)...`);
|
||||
try {
|
||||
// Explicitly click submit and wait for navigation/state-change
|
||||
await Promise.all([
|
||||
page.waitForSelector('[role="alert"]', { timeout: 30000 }),
|
||||
page.click('button[type="submit"]'),
|
||||
]);
|
||||
} catch (e) {
|
||||
console.error(`❌ Timeout waiting for [role="alert"]. Dumping page content:`);
|
||||
const bodyHTML = await page.evaluate(() => document.body.innerHTML.slice(0, 1000));
|
||||
console.log(`💻 PAGE BODY (partial): ${bodyHTML}`);
|
||||
throw e;
|
||||
}
|
||||
console.log(` Submitting Contact Form...`);
|
||||
|
||||
// Explicitly click submit and wait for navigation/state-change
|
||||
await Promise.all([
|
||||
page.waitForSelector('[role="alert"]', { timeout: 15000 }),
|
||||
page.click('button[type="submit"]'),
|
||||
]);
|
||||
|
||||
const alertText = await page.$eval('[role="alert"]', (el) => el.textContent);
|
||||
console.log(` 🔔 Alert text: ${alertText}`);
|
||||
console.log(` Alert text: ${alertText}`);
|
||||
|
||||
// Detection robust for both English and German versions
|
||||
const isError =
|
||||
alertText?.toLowerCase().includes('failed') ||
|
||||
alertText?.toLowerCase().includes('wrong') ||
|
||||
alertText?.toLowerCase().includes('fehlgeschlagen') ||
|
||||
alertText?.toLowerCase().includes('schief gelaufen') ||
|
||||
alertText?.toLowerCase().includes('fehler');
|
||||
|
||||
if (isError) {
|
||||
if (alertText?.includes('Failed') || alertText?.includes('went wrong')) {
|
||||
throw new Error(`Form submitted but showed error: ${alertText}`);
|
||||
}
|
||||
|
||||
@@ -285,11 +152,11 @@ async function main() {
|
||||
|
||||
// 4. Test Product Quote Form
|
||||
try {
|
||||
await navigateWithRetry(productUrl, 'Product Quote Form');
|
||||
console.log(`\n🧪 Testing Product Quote Form on: ${productUrl}`);
|
||||
await page.goto(productUrl, { waitUntil: 'networkidle0', timeout: 30000 });
|
||||
|
||||
// Ensure React has hydrated completely
|
||||
console.log(` ⏳ Waiting for hydration (5s)...`);
|
||||
await delay(5000);
|
||||
await page.waitForNetworkIdle({ idleTime: 1000, timeout: 15000 }).catch(() => {});
|
||||
|
||||
// The product form uses dynamic IDs, so we select by input type in the specific form context
|
||||
try {
|
||||
@@ -312,31 +179,18 @@ async function main() {
|
||||
// Give state a moment to settle
|
||||
await page.evaluate(() => new Promise((resolve) => setTimeout(resolve, 500)));
|
||||
|
||||
console.log(` ⏳ Waiting for alert selector (30s)...`);
|
||||
try {
|
||||
// Submit and wait for success state
|
||||
await Promise.all([
|
||||
page.waitForSelector('[role="alert"]', { timeout: 30000 }),
|
||||
page.click('form button[type="submit"]'),
|
||||
]);
|
||||
} catch (e) {
|
||||
console.error(`❌ Timeout waiting for [role="alert"] on Product page. Dumping content:`);
|
||||
const bodyHTML = await page.evaluate(() => document.body.innerHTML.slice(0, 1000));
|
||||
console.log(`💻 PAGE BODY (partial): ${bodyHTML}`);
|
||||
throw e;
|
||||
}
|
||||
console.log(` Submitting Product Quote Form...`);
|
||||
|
||||
// Submit and wait for success state
|
||||
await Promise.all([
|
||||
page.waitForSelector('[role="alert"]', { timeout: 15000 }),
|
||||
page.click('form button[type="submit"]'),
|
||||
]);
|
||||
|
||||
const alertText = await page.$eval('[role="alert"]', (el) => el.textContent);
|
||||
console.log(` 🔔 Alert text: ${alertText}`);
|
||||
console.log(` Alert text: ${alertText}`);
|
||||
|
||||
const isError =
|
||||
alertText?.toLowerCase().includes('failed') ||
|
||||
alertText?.toLowerCase().includes('wrong') ||
|
||||
alertText?.toLowerCase().includes('fehlgeschlagen') ||
|
||||
alertText?.toLowerCase().includes('schief gelaufen') ||
|
||||
alertText?.toLowerCase().includes('fehler');
|
||||
|
||||
if (isError) {
|
||||
if (alertText?.includes('Failed') || alertText?.includes('went wrong')) {
|
||||
throw new Error(`Form submitted but showed error: ${alertText}`);
|
||||
}
|
||||
|
||||
|
||||
@@ -38,21 +38,11 @@ function getExpectedTranslation(
|
||||
sourcePath: string,
|
||||
sourceLocale: string,
|
||||
targetLocale: string,
|
||||
alternates: { hreflang: string; href: string }[],
|
||||
): string | null {
|
||||
): string {
|
||||
const segments = sourcePath.split('/').filter(Boolean);
|
||||
// First segment is locale
|
||||
segments[0] = targetLocale;
|
||||
|
||||
// Blog posts have dynamic slugs. If it's a blog post, trust the alternate tag
|
||||
// if the href is present in the sitemap.
|
||||
// The Smoke Test's primary job is ensuring the alternate links point to valid pages.
|
||||
if (segments[1] === (targetLocale === 'de' ? 'blog' : 'blog') && segments.length > 2) {
|
||||
const altLink = alternates.find((a) => a.hreflang === targetLocale);
|
||||
if (altLink) {
|
||||
return new URL(altLink.href).pathname;
|
||||
}
|
||||
}
|
||||
|
||||
const map = sourceLocale === 'de' ? SLUG_MAP : REVERSE_SLUG_MAP;
|
||||
|
||||
return (
|
||||
@@ -60,7 +50,7 @@ function getExpectedTranslation(
|
||||
segments
|
||||
.map((seg, i) => {
|
||||
if (i === 0) return seg; // locale
|
||||
return map[seg] || seg; // translate or keep
|
||||
return map[seg] || seg; // translate or keep (product names like n2x2y stay the same)
|
||||
})
|
||||
.join('/')
|
||||
);
|
||||
@@ -128,7 +118,7 @@ async function main() {
|
||||
if (alt.hreflang === locale) continue; // Same locale, skip
|
||||
|
||||
// 1. Check slug translation is correct
|
||||
const expectedPath = getExpectedTranslation(path, locale, alt.hreflang, alternates);
|
||||
const expectedPath = getExpectedTranslation(path, locale, alt.hreflang);
|
||||
const actualPath = new URL(alt.href).pathname;
|
||||
|
||||
if (actualPath !== expectedPath) {
|
||||
|
||||
@@ -1,29 +0,0 @@
|
||||
import { Client } from 'pg';
|
||||
|
||||
async function checkSubmissions() {
|
||||
const client = new Client({
|
||||
connectionString: "postgresql://payload:120in09oenaoinsd9iaidon@127.0.0.1:54322/payload"
|
||||
});
|
||||
|
||||
try {
|
||||
await client.connect();
|
||||
console.log("Connected to database.");
|
||||
|
||||
const res = await client.query("SELECT * FROM form_submissions WHERE created_at >= '2026-04-12' ORDER BY created_at DESC;");
|
||||
|
||||
if (res.rows.length === 0) {
|
||||
console.log("No submissions found for today.");
|
||||
} else {
|
||||
console.log(`Found ${res.rows.length} submissions for today:`);
|
||||
res.rows.forEach(row => {
|
||||
console.log(`- [${row.created_at}] ${row.name} (${row.email}): ${row.message.substring(0, 50)}...`);
|
||||
});
|
||||
}
|
||||
} catch (err) {
|
||||
console.error("Database query failed:", err.message);
|
||||
} finally {
|
||||
await client.end();
|
||||
}
|
||||
}
|
||||
|
||||
checkSubmissions();
|
||||
@@ -1,102 +0,0 @@
|
||||
#!/usr/bin/env ts-node
|
||||
/**
|
||||
* Master Brochure Generator
|
||||
*
|
||||
* Generates a multi-page PDF brochure containing all products.
|
||||
* Combines Payload CMS marketing text with Excel technical data.
|
||||
*/
|
||||
|
||||
import * as fs from 'fs';
|
||||
import * as path from 'path';
|
||||
import * as React from 'react';
|
||||
import { renderToBuffer } from '@react-pdf/renderer';
|
||||
import { getAllProducts } from '../lib/products';
|
||||
import { getExcelTechnicalDataForProduct, preloadExcelData } from '../lib/excel-products';
|
||||
import { PDFBrochure } from '../lib/pdf-brochure';
|
||||
import { ProductData as PDFProductData } from '../lib/pdf-datasheet';
|
||||
|
||||
const CONFIG = {
|
||||
outputDir: path.join(process.cwd(), 'public/downloads'),
|
||||
locales: ['en', 'de'] as const,
|
||||
} as const;
|
||||
|
||||
async function ensureOutputDir() {
|
||||
if (!fs.existsSync(CONFIG.outputDir)) {
|
||||
fs.mkdirSync(CONFIG.outputDir, { recursive: true });
|
||||
}
|
||||
}
|
||||
|
||||
async function generateBrochure(locale: 'en' | 'de') {
|
||||
console.log(`\nGenerating brochure for locale: ${locale.toUpperCase()}...`);
|
||||
|
||||
// 1. Load data
|
||||
const products = await getAllProducts(locale);
|
||||
console.log(`- Loaded ${products.length} products from Payload`);
|
||||
|
||||
// 2. Map and Enrich
|
||||
const pdfProducts: PDFProductData[] = products.map((p, index) => {
|
||||
const excelData = getExcelTechnicalDataForProduct({
|
||||
slug: p.slug,
|
||||
sku: p.frontmatter.sku,
|
||||
name: p.frontmatter.title,
|
||||
});
|
||||
|
||||
return {
|
||||
id: index + 1,
|
||||
name: p.frontmatter.title,
|
||||
sku: p.frontmatter.sku,
|
||||
shortDescriptionHtml: p.frontmatter.description,
|
||||
descriptionHtml: p.frontmatter.description,
|
||||
applicationHtml: '',
|
||||
featuredImage: p.frontmatter.images[0] || null,
|
||||
images: p.frontmatter.images,
|
||||
categories: p.frontmatter.categories.map(name => ({ name })),
|
||||
attributes: excelData?.attributes || [],
|
||||
};
|
||||
});
|
||||
|
||||
if (pdfProducts.length === 0) {
|
||||
console.warn(`! No products found for ${locale}. Skipping.`);
|
||||
return;
|
||||
}
|
||||
|
||||
// 3. Render
|
||||
console.log(`- Rendering PDF with ${pdfProducts.length} pages...`);
|
||||
const buffer = await renderToBuffer(
|
||||
React.createElement(PDFBrochure, {
|
||||
products: pdfProducts,
|
||||
locale,
|
||||
title: locale === 'de' ? 'Produktkatalog' : 'Product Catalog',
|
||||
subtitle: '2026',
|
||||
})
|
||||
);
|
||||
|
||||
// 4. Save
|
||||
const fileName = `KLZ_Cables_Brochure_2026_${locale.toUpperCase()}.pdf`;
|
||||
const filePath = path.join(CONFIG.outputDir, fileName);
|
||||
fs.writeFileSync(filePath, buffer);
|
||||
|
||||
const stats = fs.statSync(filePath);
|
||||
console.log(`✓ Generated: ${fileName} (${(stats.size / 1024 / 1024).toFixed(2)} MB)`);
|
||||
}
|
||||
|
||||
async function main() {
|
||||
const start = Date.now();
|
||||
console.log('--- KLZ Brochure Generator ---');
|
||||
|
||||
try {
|
||||
await ensureOutputDir();
|
||||
preloadExcelData();
|
||||
|
||||
for (const locale of CONFIG.locales) {
|
||||
await generateBrochure(locale);
|
||||
}
|
||||
|
||||
console.log(`\n✅ Brochure generation completed in ${((Date.now() - start) / 1000).toFixed(2)}s`);
|
||||
} catch (error) {
|
||||
console.error('\n❌ Fatal error:', error);
|
||||
process.exit(1);
|
||||
}
|
||||
}
|
||||
|
||||
main().catch(console.error);
|
||||
@@ -1,36 +0,0 @@
|
||||
import { getPayload } from 'payload';
|
||||
import configPromise from '../payload.config';
|
||||
import fs from 'fs';
|
||||
import path from 'path';
|
||||
import { fileURLToPath } from 'url';
|
||||
|
||||
const filename = fileURLToPath(import.meta.url);
|
||||
const dirname = path.dirname(filename);
|
||||
|
||||
async function run() {
|
||||
process.env.PAYLOAD_MIGRATE = 'false';
|
||||
process.env.PAYLOAD_MIGRATE_SKIP_PROMPTS = 'true';
|
||||
const payload = await getPayload({ config: configPromise });
|
||||
|
||||
console.log('📖 Reading lexical_avb.json...');
|
||||
const lexicalContent = JSON.parse(fs.readFileSync(path.resolve(dirname, '../lexical_avb.json'), 'utf8'));
|
||||
|
||||
console.log('🚀 Updating AGB page (ID 6) to AVB...');
|
||||
|
||||
const updatedPage = await payload.update({
|
||||
collection: 'pages',
|
||||
id: 6,
|
||||
data: {
|
||||
title: 'Allgemeine Verkaufsbedingungen (AVB)',
|
||||
content: lexicalContent,
|
||||
},
|
||||
});
|
||||
|
||||
console.log('✅ Page updated successfully:', updatedPage.title);
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
run().catch((err) => {
|
||||
console.error('❌ Update failed:', err);
|
||||
process.exit(1);
|
||||
});
|
||||
208
src/lib/qdrant.ts
Normal file
208
src/lib/qdrant.ts
Normal file
@@ -0,0 +1,208 @@
|
||||
import { QdrantClient } from '@qdrant/js-client-rest';
|
||||
import redis from './redis';
|
||||
|
||||
const isDockerContainer =
|
||||
process.env.IS_DOCKER === 'true' || process.env.HOSTNAME?.includes('klz-app');
|
||||
const qdrantUrl =
|
||||
process.env.QDRANT_URL ||
|
||||
(isDockerContainer ? 'http://klz-qdrant:6333' : 'http://localhost:6333');
|
||||
const qdrantApiKey = process.env.QDRANT_API_KEY || '';
|
||||
|
||||
export const qdrant = new QdrantClient({
|
||||
url: qdrantUrl,
|
||||
apiKey: qdrantApiKey || undefined,
|
||||
// Disable qdrant client's own version check to avoid the warning spam
|
||||
checkCompatibility: false,
|
||||
});
|
||||
|
||||
export const COLLECTION_NAME = 'klz_products';
|
||||
export const VECTOR_SIZE = 1536; // OpenAI text-embedding-3-small
|
||||
|
||||
// Cache TTLs
|
||||
const EMBEDDING_CACHE_TTL = 60 * 60 * 24; // 24h — embeddings are deterministic
|
||||
const SEARCH_CACHE_TTL = 60 * 30; // 30 min — product data could change
|
||||
|
||||
// Track collection existence in-memory (don't re-check every request)
|
||||
let collectionVerified = false;
|
||||
|
||||
/**
|
||||
* Ensure the collection exists in Qdrant (only checks once per process lifetime).
|
||||
*/
|
||||
export async function ensureCollection() {
|
||||
if (collectionVerified) return;
|
||||
|
||||
try {
|
||||
const collections = await qdrant.getCollections();
|
||||
const exists = collections.collections.some((c) => c.name === COLLECTION_NAME);
|
||||
if (!exists) {
|
||||
await qdrant.createCollection(COLLECTION_NAME, {
|
||||
vectors: {
|
||||
size: VECTOR_SIZE,
|
||||
distance: 'Cosine',
|
||||
},
|
||||
});
|
||||
console.log(`Successfully created Qdrant collection: ${COLLECTION_NAME}`);
|
||||
}
|
||||
collectionVerified = true;
|
||||
} catch (error) {
|
||||
console.error('Error ensuring Qdrant collection:', error);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Simple hash for cache keys
|
||||
*/
|
||||
function hashKey(text: string): string {
|
||||
let hash = 0;
|
||||
for (let i = 0; i < text.length; i++) {
|
||||
const chr = text.charCodeAt(i);
|
||||
hash = (hash << 5) - hash + chr;
|
||||
hash |= 0;
|
||||
}
|
||||
return hash.toString(36);
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate an embedding for a given text using OpenRouter (OpenAI embedding proxy).
|
||||
* Results are cached in Redis for 24h since embeddings are deterministic.
|
||||
*
|
||||
* NOTE: We keep OpenRouter for embeddings because the Qdrant collection uses 1536-dim
|
||||
* vectors (OpenAI text-embedding-3-small). Switching to Mistral embed (1024-dim) would
|
||||
* require re-indexing the entire product catalog.
|
||||
* User-facing chat uses Mistral AI directly for DSGVO compliance.
|
||||
*/
|
||||
export async function generateEmbedding(text: string): Promise<number[]> {
|
||||
const cacheKey = `emb:${hashKey(text.toLowerCase().trim())}`;
|
||||
|
||||
// Try Redis cache first
|
||||
try {
|
||||
const cached = await redis.get(cacheKey);
|
||||
if (cached) {
|
||||
return JSON.parse(cached);
|
||||
}
|
||||
} catch {
|
||||
// Redis down — proceed without cache
|
||||
}
|
||||
|
||||
const openRouterKey = process.env.OPENROUTER_API_KEY;
|
||||
if (!openRouterKey) {
|
||||
throw new Error('OPENROUTER_API_KEY is not set');
|
||||
}
|
||||
|
||||
const response = await fetch('https://openrouter.ai/api/v1/embeddings', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
Authorization: `Bearer ${openRouterKey}`,
|
||||
'Content-Type': 'application/json',
|
||||
'HTTP-Referer': process.env.NEXT_PUBLIC_BASE_URL || 'https://klz-cables.com',
|
||||
'X-Title': 'KLZ Cables Search AI',
|
||||
},
|
||||
body: JSON.stringify({
|
||||
model: 'openai/text-embedding-3-small',
|
||||
input: text,
|
||||
}),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const errorBody = await response.text();
|
||||
throw new Error(
|
||||
`Failed to generate embedding: ${response.status} ${response.statusText} ${errorBody}`,
|
||||
);
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
const embedding = data.data[0].embedding;
|
||||
|
||||
// Cache the embedding in Redis
|
||||
try {
|
||||
await redis.set(cacheKey, JSON.stringify(embedding), 'EX', EMBEDDING_CACHE_TTL);
|
||||
} catch {
|
||||
// Redis down — proceed without caching
|
||||
}
|
||||
|
||||
return embedding;
|
||||
}
|
||||
|
||||
/**
|
||||
* Upsert a product into Qdrant
|
||||
*/
|
||||
export async function upsertProductVector(
|
||||
id: string | number,
|
||||
text: string,
|
||||
payload: Record<string, any>,
|
||||
) {
|
||||
try {
|
||||
await ensureCollection();
|
||||
const vector = await generateEmbedding(text);
|
||||
|
||||
await qdrant.upsert(COLLECTION_NAME, {
|
||||
wait: true,
|
||||
points: [
|
||||
{
|
||||
id: id,
|
||||
vector,
|
||||
payload,
|
||||
},
|
||||
],
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Error writing to Qdrant:', error);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete a product from Qdrant
|
||||
*/
|
||||
export async function deleteProductVector(id: string | number) {
|
||||
try {
|
||||
await ensureCollection();
|
||||
await qdrant.delete(COLLECTION_NAME, {
|
||||
wait: true,
|
||||
points: [id] as [string | number],
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Error deleting from Qdrant:', error);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Search products in Qdrant.
|
||||
* Results are cached in Redis for 30 minutes keyed by query text.
|
||||
*/
|
||||
export async function searchProducts(query: string, limit = 5) {
|
||||
const cacheKey = `search:${hashKey(query.toLowerCase().trim())}:${limit}`;
|
||||
|
||||
// Try Redis cache first
|
||||
try {
|
||||
const cached = await redis.get(cacheKey);
|
||||
if (cached) {
|
||||
console.log(`[Qdrant] Cache HIT for query: "${query.substring(0, 50)}"`);
|
||||
return JSON.parse(cached);
|
||||
}
|
||||
} catch {
|
||||
// Redis down — proceed without cache
|
||||
}
|
||||
|
||||
try {
|
||||
await ensureCollection();
|
||||
const vector = await generateEmbedding(query);
|
||||
|
||||
const results = await qdrant.search(COLLECTION_NAME, {
|
||||
vector,
|
||||
limit,
|
||||
with_payload: true,
|
||||
});
|
||||
|
||||
// Cache results in Redis
|
||||
try {
|
||||
await redis.set(cacheKey, JSON.stringify(results), 'EX', SEARCH_CACHE_TTL);
|
||||
} catch {
|
||||
// Redis down — proceed without caching
|
||||
}
|
||||
|
||||
return results;
|
||||
} catch (error) {
|
||||
console.error('Error searching in Qdrant:', error);
|
||||
return [];
|
||||
}
|
||||
}
|
||||
22
src/lib/redis.ts
Normal file
22
src/lib/redis.ts
Normal file
@@ -0,0 +1,22 @@
|
||||
import Redis from 'ioredis';
|
||||
|
||||
const isDockerContainer =
|
||||
process.env.IS_DOCKER === 'true' || process.env.HOSTNAME?.includes('klz-app');
|
||||
const redisUrl =
|
||||
process.env.REDIS_URL ||
|
||||
(isDockerContainer ? 'redis://klz-redis:6379' : 'redis://localhost:6379');
|
||||
|
||||
// Only create a single instance in Node.js
|
||||
const globalForRedis = global as unknown as { redis: Redis };
|
||||
|
||||
export const redis =
|
||||
globalForRedis.redis ||
|
||||
new Redis(redisUrl, {
|
||||
maxRetriesPerRequest: 3,
|
||||
});
|
||||
|
||||
if (process.env.NODE_ENV !== 'production') {
|
||||
globalForRedis.redis = redis;
|
||||
}
|
||||
|
||||
export default redis;
|
||||
@@ -55,10 +55,29 @@ export async function up({ db }: MigrateUpArgs): Promise<void> {
|
||||
CREATE TYPE "public"."enum__products_v_version_status" AS ENUM('draft', 'published');
|
||||
EXCEPTION WHEN duplicate_object THEN null; END $$
|
||||
`);
|
||||
await db.execute(sql`
|
||||
DO $$ BEGIN
|
||||
CREATE TYPE "public"."enum_pages_status" AS ENUM('draft', 'published');
|
||||
EXCEPTION WHEN duplicate_object THEN null; END $$
|
||||
`);
|
||||
await db.execute(sql`
|
||||
DO $$ BEGIN
|
||||
CREATE TYPE "public"."enum_posts_status" AS ENUM('draft', 'published');
|
||||
EXCEPTION WHEN duplicate_object THEN null; END $$
|
||||
`);
|
||||
await db.execute(sql`
|
||||
DO $$ BEGIN
|
||||
CREATE TYPE "public"."enum_products_status" AS ENUM('draft', 'published');
|
||||
EXCEPTION WHEN duplicate_object THEN null; END $$
|
||||
`);
|
||||
|
||||
// ── 2. Alter pages table ─────────────────────────────────────────────────────
|
||||
await db.execute(sql`ALTER TABLE "pages" ADD COLUMN IF NOT EXISTS "layout" "enum_pages_layout" DEFAULT 'default'`);
|
||||
await db.execute(sql`ALTER TABLE "pages" ADD COLUMN IF NOT EXISTS "_status" "enum_pages_status" DEFAULT 'draft'`);
|
||||
await db.execute(
|
||||
sql`ALTER TABLE "pages" ADD COLUMN IF NOT EXISTS "layout" "enum_pages_layout" DEFAULT 'default'`,
|
||||
);
|
||||
await db.execute(
|
||||
sql`ALTER TABLE "pages" ADD COLUMN IF NOT EXISTS "_status" "enum_pages_status" DEFAULT 'draft'`,
|
||||
);
|
||||
|
||||
// ── 3. Create pages_locales join table ───────────────────────────────────────
|
||||
await db.execute(sql`
|
||||
@@ -202,9 +221,63 @@ export async function up({ db }: MigrateUpArgs): Promise<void> {
|
||||
await db.execute(sql`ALTER TABLE "products" DROP COLUMN IF EXISTS "locale"`);
|
||||
|
||||
// ── 12. Version tables (_posts_v, _products_v, _pages_v) locale columns ──────
|
||||
await db.execute(sql`ALTER TABLE "_posts_v" ADD COLUMN IF NOT EXISTS "published_locale" "enum__posts_v_published_locale"`);
|
||||
await db.execute(sql`ALTER TABLE "_products_v" ADD COLUMN IF NOT EXISTS "published_locale" "enum__products_v_published_locale"`);
|
||||
await db.execute(sql`ALTER TABLE "_pages_v" ADD COLUMN IF NOT EXISTS "published_locale" "enum__pages_v_published_locale"`);
|
||||
await db.execute(sql`
|
||||
CREATE TABLE IF NOT EXISTS "_posts_v" (
|
||||
"id" serial PRIMARY KEY NOT NULL,
|
||||
"parent_id" integer,
|
||||
"version_title" varchar,
|
||||
"version_slug" varchar,
|
||||
"version_excerpt" varchar,
|
||||
"version_content" jsonb,
|
||||
"version_updated_at" timestamp(3) with time zone,
|
||||
"version_created_at" timestamp(3) with time zone,
|
||||
"version__status" "enum__posts_v_version_status" DEFAULT 'draft',
|
||||
"created_at" timestamp(3) with time zone DEFAULT now() NOT NULL,
|
||||
"updated_at" timestamp(3) with time zone DEFAULT now() NOT NULL,
|
||||
"latest" boolean
|
||||
)
|
||||
`);
|
||||
await db.execute(sql`
|
||||
CREATE TABLE IF NOT EXISTS "_products_v" (
|
||||
"id" serial PRIMARY KEY NOT NULL,
|
||||
"parent_id" integer,
|
||||
"version_title" varchar,
|
||||
"version_description" varchar,
|
||||
"version_content" jsonb,
|
||||
"version_updated_at" timestamp(3) with time zone,
|
||||
"version_created_at" timestamp(3) with time zone,
|
||||
"version__status" "enum__products_v_version_status" DEFAULT 'draft',
|
||||
"created_at" timestamp(3) with time zone DEFAULT now() NOT NULL,
|
||||
"updated_at" timestamp(3) with time zone DEFAULT now() NOT NULL,
|
||||
"latest" boolean
|
||||
)
|
||||
`);
|
||||
await db.execute(sql`
|
||||
CREATE TABLE IF NOT EXISTS "_pages_v" (
|
||||
"id" serial PRIMARY KEY NOT NULL,
|
||||
"parent_id" integer,
|
||||
"version_title" varchar,
|
||||
"version_slug" varchar,
|
||||
"version_excerpt" varchar,
|
||||
"version_content" jsonb,
|
||||
"version_updated_at" timestamp(3) with time zone,
|
||||
"version_created_at" timestamp(3) with time zone,
|
||||
"version__status" "enum__pages_v_version_status" DEFAULT 'draft',
|
||||
"created_at" timestamp(3) with time zone DEFAULT now() NOT NULL,
|
||||
"updated_at" timestamp(3) with time zone DEFAULT now() NOT NULL,
|
||||
"latest" boolean
|
||||
)
|
||||
`);
|
||||
|
||||
await db.execute(
|
||||
sql`ALTER TABLE "_posts_v" ADD COLUMN IF NOT EXISTS "published_locale" "enum__posts_v_published_locale"`,
|
||||
);
|
||||
await db.execute(
|
||||
sql`ALTER TABLE "_products_v" ADD COLUMN IF NOT EXISTS "published_locale" "enum__products_v_published_locale"`,
|
||||
);
|
||||
await db.execute(
|
||||
sql`ALTER TABLE "_pages_v" ADD COLUMN IF NOT EXISTS "published_locale" "enum__pages_v_published_locale"`,
|
||||
);
|
||||
|
||||
// ── 13. Create _posts_v_locales ──────────────────────────────────────────────
|
||||
await db.execute(sql`
|
||||
|
||||
@@ -1,52 +0,0 @@
|
||||
import { MigrateUpArgs, MigrateDownArgs, sql } from '@payloadcms/db-postgres';
|
||||
|
||||
export async function up({ db }: MigrateUpArgs): Promise<void> {
|
||||
// Add featured_image_id to products and _products_v
|
||||
await db.execute(sql`
|
||||
ALTER TABLE "products" ADD COLUMN IF NOT EXISTS "featured_image_id" integer;
|
||||
`);
|
||||
await db.execute(sql`
|
||||
ALTER TABLE "_products_v" ADD COLUMN IF NOT EXISTS "version_featured_image_id" integer;
|
||||
`);
|
||||
|
||||
// Add foreign key constraints
|
||||
await db.execute(sql`
|
||||
DO $$ BEGIN
|
||||
ALTER TABLE "products" ADD CONSTRAINT "products_featured_image_id_media_id_fk" FOREIGN KEY ("featured_image_id") REFERENCES "public"."media"("id") ON DELETE set null ON UPDATE no action;
|
||||
EXCEPTION WHEN duplicate_object THEN null; END $$;
|
||||
`);
|
||||
await db.execute(sql`
|
||||
DO $$ BEGIN
|
||||
ALTER TABLE "_products_v" ADD CONSTRAINT "_products_v_version_featured_image_id_media_id_fk" FOREIGN KEY ("version_featured_image_id") REFERENCES "public"."media"("id") ON DELETE set null ON UPDATE no action;
|
||||
EXCEPTION WHEN duplicate_object THEN null; END $$;
|
||||
`);
|
||||
|
||||
// Add indexes
|
||||
await db.execute(sql`
|
||||
CREATE INDEX IF NOT EXISTS "products_featured_image_idx" ON "products" USING btree ("featured_image_id");
|
||||
`);
|
||||
await db.execute(sql`
|
||||
CREATE INDEX IF NOT EXISTS "_products_v_version_version_featured_image_idx" ON "_products_v" USING btree ("version_featured_image_id");
|
||||
`);
|
||||
}
|
||||
|
||||
export async function down({ db }: MigrateDownArgs): Promise<void> {
|
||||
await db.execute(sql`
|
||||
ALTER TABLE "products" DROP CONSTRAINT IF EXISTS "products_featured_image_id_media_id_fk";
|
||||
`);
|
||||
await db.execute(sql`
|
||||
ALTER TABLE "_products_v" DROP CONSTRAINT IF EXISTS "_products_v_version_featured_image_id_media_id_fk";
|
||||
`);
|
||||
await db.execute(sql`
|
||||
DROP INDEX IF EXISTS "products_featured_image_idx";
|
||||
`);
|
||||
await db.execute(sql`
|
||||
DROP INDEX IF EXISTS "_products_v_version_version_featured_image_idx";
|
||||
`);
|
||||
await db.execute(sql`
|
||||
ALTER TABLE "products" DROP COLUMN IF EXISTS "featured_image_id";
|
||||
`);
|
||||
await db.execute(sql`
|
||||
ALTER TABLE "_products_v" DROP COLUMN IF EXISTS "version_featured_image_id";
|
||||
`);
|
||||
}
|
||||
@@ -1,22 +0,0 @@
|
||||
import { MigrateUpArgs, MigrateDownArgs, sql } from '@payloadcms/db-postgres';
|
||||
|
||||
export async function up({ db }: MigrateUpArgs): Promise<void> {
|
||||
// redirect_permanent is a non-localized checkbox → stored on the main pages table
|
||||
await db.execute(sql`
|
||||
ALTER TABLE "pages" ADD COLUMN IF NOT EXISTS "redirect_permanent" boolean DEFAULT true;
|
||||
`);
|
||||
|
||||
// redirect_url is a localized text field → stored on the pages_locales table
|
||||
await db.execute(sql`
|
||||
ALTER TABLE "pages_locales" ADD COLUMN IF NOT EXISTS "redirect_url" varchar;
|
||||
`);
|
||||
}
|
||||
|
||||
export async function down({ db }: MigrateDownArgs): Promise<void> {
|
||||
await db.execute(sql`
|
||||
ALTER TABLE "pages" DROP COLUMN IF EXISTS "redirect_permanent";
|
||||
`);
|
||||
await db.execute(sql`
|
||||
ALTER TABLE "pages_locales" DROP COLUMN IF EXISTS "redirect_url";
|
||||
`);
|
||||
}
|
||||
@@ -2,8 +2,6 @@ import * as migration_20260223_195005_products_collection from './20260223_19500
|
||||
import * as migration_20260223_195151_remove_sku_unique from './20260223_195151_remove_sku_unique';
|
||||
import * as migration_20260225_003500_add_pages_collection from './20260225_003500_add_pages_collection';
|
||||
import * as migration_20260225_175000_native_localization from './20260225_175000_native_localization';
|
||||
import * as migration_20260305_215000_products_featured_image from './20260305_215000_products_featured_image';
|
||||
import * as migration_20260312_120000_pages_redirect_fields from './20260312_120000_pages_redirect_fields';
|
||||
|
||||
export const migrations = [
|
||||
{
|
||||
@@ -26,14 +24,4 @@ export const migrations = [
|
||||
down: migration_20260225_175000_native_localization.down,
|
||||
name: '20260225_175000_native_localization',
|
||||
},
|
||||
{
|
||||
up: migration_20260305_215000_products_featured_image.up,
|
||||
down: migration_20260305_215000_products_featured_image.down,
|
||||
name: '20260305_215000_products_featured_image',
|
||||
},
|
||||
{
|
||||
up: migration_20260312_120000_pages_redirect_fields.up,
|
||||
down: migration_20260312_120000_pages_redirect_fields.down,
|
||||
name: '20260312_120000_pages_redirect_fields',
|
||||
},
|
||||
];
|
||||
|
||||
@@ -1,30 +0,0 @@
|
||||
import { Block } from 'payload';
|
||||
|
||||
export const PDFDownload: Block = {
|
||||
slug: 'pdfDownload',
|
||||
labels: {
|
||||
singular: 'PDF Download',
|
||||
plural: 'PDF Downloads',
|
||||
},
|
||||
admin: {},
|
||||
fields: [
|
||||
{
|
||||
name: 'label',
|
||||
type: 'text',
|
||||
label: 'Button Beschriftung',
|
||||
required: true,
|
||||
localized: true,
|
||||
defaultValue: 'Als PDF herunterladen',
|
||||
},
|
||||
{
|
||||
name: 'style',
|
||||
type: 'select',
|
||||
defaultValue: 'primary',
|
||||
options: [
|
||||
{ label: 'Primary', value: 'primary' },
|
||||
{ label: 'Secondary', value: 'secondary' },
|
||||
{ label: 'Outline', value: 'outline' },
|
||||
],
|
||||
},
|
||||
],
|
||||
};
|
||||
@@ -16,7 +16,6 @@ import { StickyNarrative } from './StickyNarrative';
|
||||
import { TeamProfile } from './TeamProfile';
|
||||
import { TechnicalGrid } from './TechnicalGrid';
|
||||
import { VisualLinkPreview } from './VisualLinkPreview';
|
||||
import { PDFDownload } from './PDFDownload';
|
||||
import { homeBlocksArray } from './HomeBlocks';
|
||||
|
||||
export const payloadBlocks = [
|
||||
@@ -39,5 +38,4 @@ export const payloadBlocks = [
|
||||
TeamProfile,
|
||||
TechnicalGrid,
|
||||
VisualLinkPreview,
|
||||
PDFDownload,
|
||||
];
|
||||
|
||||
@@ -72,33 +72,6 @@ export const Pages: CollectionConfig = {
|
||||
position: 'sidebar',
|
||||
},
|
||||
},
|
||||
{
|
||||
type: 'collapsible',
|
||||
label: 'Redirect Settings',
|
||||
admin: {
|
||||
position: 'sidebar',
|
||||
},
|
||||
fields: [
|
||||
{
|
||||
name: 'redirectUrl',
|
||||
type: 'text',
|
||||
localized: true,
|
||||
admin: {
|
||||
description:
|
||||
'If set, visiting this page will immediately redirect the user to this URL (e.g. /de/terms).',
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'redirectPermanent',
|
||||
type: 'checkbox',
|
||||
defaultValue: true,
|
||||
admin: {
|
||||
description:
|
||||
'Check for a permanent (301) redirect. Uncheck for a temporary (302) redirect.',
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
name: 'content',
|
||||
type: 'richText',
|
||||
|
||||
@@ -37,6 +37,51 @@ export const Products: CollectionConfig = {
|
||||
};
|
||||
},
|
||||
},
|
||||
hooks: {
|
||||
afterChange: [
|
||||
async ({ doc, req, operation }) => {
|
||||
// Run index sync asynchronously to not block the CMS save operation
|
||||
setTimeout(async () => {
|
||||
try {
|
||||
const { upsertProductVector, deleteProductVector } = await import('../../lib/qdrant');
|
||||
|
||||
// Check if product is published
|
||||
if (doc._status !== 'published') {
|
||||
await deleteProductVector(doc.id);
|
||||
req.payload.logger.info(`Removed drafted product ${doc.sku} from Qdrant`);
|
||||
} else {
|
||||
// Serialize payload
|
||||
const contentText = `${doc.title} - SKU: ${doc.sku}\n${doc.description || ''}`;
|
||||
const payload = {
|
||||
id: doc.id,
|
||||
title: doc.title,
|
||||
sku: doc.sku,
|
||||
slug: doc.slug,
|
||||
description: doc.description,
|
||||
featuredImage: doc.featuredImage, // usually just ID or URL depending on depth
|
||||
};
|
||||
await upsertProductVector(doc.id, contentText, payload);
|
||||
req.payload.logger.info(`Upserted product ${doc.sku} to Qdrant`);
|
||||
}
|
||||
} catch (error) {
|
||||
req.payload.logger.error({ msg: 'Error syncing product to Qdrant', err: error, productId: doc.id });
|
||||
}
|
||||
}, 0);
|
||||
return doc;
|
||||
},
|
||||
],
|
||||
afterDelete: [
|
||||
async ({ id, req }) => {
|
||||
try {
|
||||
const { deleteProductVector } = await import('../../lib/qdrant');
|
||||
await deleteProductVector(id as string | number);
|
||||
req.payload.logger.info(`Deleted product ${id} from Qdrant`);
|
||||
} catch (error) {
|
||||
req.payload.logger.error({ msg: 'Error deleting product from Qdrant', err: error, productId: id });
|
||||
}
|
||||
},
|
||||
],
|
||||
},
|
||||
fields: [
|
||||
{
|
||||
name: 'title',
|
||||
|
||||
63
src/scripts/ingest-pdf.ts
Normal file
63
src/scripts/ingest-pdf.ts
Normal file
@@ -0,0 +1,63 @@
|
||||
import fs from 'fs';
|
||||
import path from 'path';
|
||||
import crypto from 'crypto';
|
||||
|
||||
// Override Qdrant URL for local script execution outside docker
|
||||
process.env.QDRANT_URL = process.env.QDRANT_URL || 'http://localhost:6333';
|
||||
|
||||
import { upsertProductVector } from '../lib/qdrant';
|
||||
|
||||
// Ingests the extracted Kabelhandbuch text into Qdrant as distinct knowledge topics.
|
||||
async function ingestPDF(txtPath: string) {
|
||||
if (!fs.existsSync(txtPath)) {
|
||||
console.error(`File not found: ${txtPath}`);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
try {
|
||||
const text = fs.readFileSync(txtPath, 'utf8');
|
||||
|
||||
// Simple sentence/paragraph chunking
|
||||
// We split by standard paragraph breaks (double newline) or large content blocks.
|
||||
const chunks = text
|
||||
.split(/\n\s*\n/)
|
||||
.map((c) => c.trim())
|
||||
.filter((c) => c.length > 50);
|
||||
|
||||
console.log(`Extracted ${text.length} characters from PDF.`);
|
||||
console.log(`Generated ${chunks.length} chunks for vector ingestion.\n`);
|
||||
|
||||
for (let i = 0; i < chunks.length; i++) {
|
||||
// We limit chuck sizes to ensure Openrouter embedding models don't timeout/fail,
|
||||
// stringing multiple paragraphs if they are short, or cutting them if too long.
|
||||
// For baseline, we'll index every chunk individually mapped as 'knowledge' with a unique ID
|
||||
|
||||
const chunkText = chunks[i];
|
||||
|
||||
// Generate a synthetic ID that won't collide with Payload Product IDs
|
||||
// Qdrant strictly requires UUID or unsigned int.
|
||||
const syntheticId = crypto.randomUUID();
|
||||
|
||||
const payloadData = {
|
||||
type: 'knowledge', // Custom flag to differentiate from 'product'
|
||||
title: `Kabelhandbuch Wissen - Bereich ${i + 1}`,
|
||||
content: chunkText,
|
||||
source: 'Kabelhandbuch KLZ.pdf',
|
||||
};
|
||||
|
||||
// Use the existing upsert function since it just embeds the text and stores the payload
|
||||
await upsertProductVector(syntheticId, chunkText, payloadData);
|
||||
console.log(`✅ Upserted chunk ${i + 1}/${chunks.length}`);
|
||||
}
|
||||
|
||||
console.log('🎉 PDF Ingestion Complete!');
|
||||
process.exit(0);
|
||||
} catch (err) {
|
||||
console.error('Failed to parse PDF:', err);
|
||||
process.exit(1);
|
||||
}
|
||||
}
|
||||
|
||||
// Run mapping
|
||||
const targetTxt = '/Users/marcmintel/Downloads/kabelhandbuch.txt';
|
||||
ingestPDF(targetTxt);
|
||||
20
test-chat2.mjs
Normal file
20
test-chat2.mjs
Normal file
@@ -0,0 +1,20 @@
|
||||
import fetch from 'node-fetch';
|
||||
|
||||
async function test() {
|
||||
const messages = [
|
||||
{ role: 'user', content: 'Ich will einen Windpark bauen' }
|
||||
];
|
||||
|
||||
console.log('Sending message:', messages[0].content);
|
||||
|
||||
const res = await fetch('http://localhost:3000/api/ai-search', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ messages })
|
||||
});
|
||||
|
||||
const data = await res.json();
|
||||
console.log('\nAI Response:', data);
|
||||
}
|
||||
|
||||
test().catch(console.error);
|
||||
@@ -1,19 +0,0 @@
|
||||
import React from 'react';
|
||||
import { renderToFile } from '@react-pdf/renderer';
|
||||
import { PDFPage } from './lib/pdf-page';
|
||||
import fs from 'fs';
|
||||
|
||||
const pageData = JSON.parse(fs.readFileSync('./lexical_avb.json', 'utf8'));
|
||||
const page = { title: "Test AVB", content: pageData };
|
||||
|
||||
async function run() {
|
||||
try {
|
||||
await renderToFile(<PDFPage page={page} locale="de" />, 'test-render.pdf');
|
||||
console.log('PDF rendered successfully offline!');
|
||||
} catch(e) {
|
||||
console.error('PDF Generation failed:');
|
||||
console.error(e);
|
||||
}
|
||||
}
|
||||
|
||||
run();
|
||||
BIN
test-render.pdf
BIN
test-render.pdf
Binary file not shown.
16
test-simple.mjs
Normal file
16
test-simple.mjs
Normal file
@@ -0,0 +1,16 @@
|
||||
import { generateText } from 'ai';
|
||||
import { createOpenAI } from '@ai-sdk/openai';
|
||||
|
||||
const openrouter = createOpenAI({
|
||||
baseURL: 'https://openrouter.ai/api/v1',
|
||||
apiKey: process.env.OPENROUTER_API_KEY,
|
||||
});
|
||||
|
||||
async function run() {
|
||||
const { text } = await generateText({
|
||||
model: openrouter('mistralai/mistral-large-2407'),
|
||||
prompt: 'Hello world! Reply in one word.',
|
||||
});
|
||||
console.log('Result:', text);
|
||||
}
|
||||
run();
|
||||
@@ -43,10 +43,6 @@
|
||||
"check:spell": {
|
||||
"inputs": ["content/**/*.{md,mdx}", "app/**/*.tsx", "components/**/*.tsx", "cspell.json"],
|
||||
"outputs": []
|
||||
},
|
||||
"check:mdx": {
|
||||
"inputs": ["content/**/*.{md,mdx}", "scripts/validate-mdx.mjs"],
|
||||
"outputs": []
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
170
update_avb.mjs
170
update_avb.mjs
@@ -1,170 +0,0 @@
|
||||
import fs from 'fs';
|
||||
|
||||
async function updateAVB() {
|
||||
|
||||
|
||||
// We need to construct the Lexical structure.
|
||||
|
||||
// Here is a helper to build lexical AST
|
||||
function p(text, format=0, type="text", mode="normal") {
|
||||
return {
|
||||
type: "paragraph",
|
||||
version: 1,
|
||||
format: "",
|
||||
indent: 0,
|
||||
direction: "ltr",
|
||||
children: [
|
||||
{ type, mode, text, style: "", detail: 0, format, version: 1 }
|
||||
]
|
||||
};
|
||||
}
|
||||
|
||||
function h(text, tag="h3") {
|
||||
return {
|
||||
type: "heading",
|
||||
tag,
|
||||
version: 1,
|
||||
format: "",
|
||||
indent: 0,
|
||||
direction: "ltr",
|
||||
children: [
|
||||
{ type: "text", mode: "normal", text, style: "", detail: 0, format: 0, version: 1 }
|
||||
]
|
||||
};
|
||||
}
|
||||
|
||||
const lexicalRawData = [
|
||||
h("Allgemeine Verkaufsbedingungen", "h2"),
|
||||
h("1 Geltungsbereich", "h3"),
|
||||
p("1.1 Ausschließlich die nachfolgenden Allgemeinen Verkaufsbedingungen (nachfolgend „AVB“) gelten für den gesamten Geschäftsverkehr (nachfolgend „Lieferung von Waren“) der KLZ Vertriebs GmbH, Raiffeisenstraße 22, 73630 Remshalden (nachfolgend „KLZ“) und Unternehmern in Ausübung ihrer gewerblichen oder selbständigen beruflichen Tätigkeit und juristischen Personen des öffentlichen Rechts (nachfolgend „Kunde“). Der Geltung von abweichenden, zusätzlichen oder entgegenstehenden allgemeinen Geschäftsbedingungen des Kunden wird hiermit widersprochen."),
|
||||
p("1.2 Diese AVB gelten auch dann, wenn KLZ die Lieferung von Waren an den Kunden vorbehaltlos in Kenntnis entgegenstehender oder von diesen AVB abweichenden allgemeinen Geschäftsbedingungen des Kunden ausführt."),
|
||||
p("1.3 Im Rahmen laufender Geschäftsbeziehungen gelten diese AVB auch dann für künftige Lieferungen von Waren, wenn sie nicht ausdrücklich erneut in den Vertrag einbezogen wurden."),
|
||||
p("1.4 Änderungen dieser AVB werden dem Kunden schriftlich, per Telefax oder per E-Mail mitgeteilt. Widerspricht der Kunde diesen Änderungen nicht innerhalb von vier (4) Wochen nach Zugang der Mitteilung, gelten die Änderungen als durch den Kunden anerkannt. Auf das Widerspruchsrecht und die Rechtsfolgen des Schweigens wird der Kunde im Rahmen der Mitteilung über die Änderung der Geschäftsbedingungen gesondert hingewiesen."),
|
||||
p("1.5 Hinweise auf die Geltung gesetzlicher Vorschriften im Rahmen dieser AVB haben nur klarstellende Bedeutung. Auch ohne eine derartige Klarstellung gelten daher die gesetzlichen Vorschriften, soweit sie in diesen AVB nicht unmittelbar abgeändert oder ausdrücklich ausgeschlossen werden."),
|
||||
h("2 Vertragsschluss", "h3"),
|
||||
p("2.1 Der Vertrag über die Lieferung von Waren kommt grundsätzlich durch ein Angebot des Kunden, z.B. über ein Auftragsformular, per E-Mail oder per Brief, und eine Annahme dieses Angebots durch KLZ in Textform z.B. durch eine Auftragsbestätigung per E-Mail oder Fax zustande. Der Annahme des Angebots durch KLZ steht es gleich, wenn KLZ den Auftrag oder die Bestellung binnen vierzehn (14) Kalendertagen ab Zugang des Angebots bei KLZ vorbehaltlos ausführt."),
|
||||
p("2.2 Alle Angebote von KLZ sind – insbesondere hinsichtlich Verfügbarkeit, angegebener Mengen, Lieferfristen und Nebenleistungen – freibleibend und unverbindlich, sofern sie nicht ausdrücklich in Textform als verbindlich gekennzeichnet wurden. Die Angebote stehen unter dem Vorbehalt der zeitgerechten, qualitativen und quantitativen Selbstbelieferung von KLZ."),
|
||||
p("2.3 Der Umfang der von KLZ zu liefernden Waren ergibt sich aus dem jeweiligen Angebot oder der jeweiligen Auftragsbestätigung von KLZ."),
|
||||
p("2.4 Technische Angaben, Muster, Preise, Abbildungen, Zeichnungen, Gewichts-, Maß-, Leistungs- und Gebrauchsangaben, sowie sonstige Beschreibungen der zu liefernden Ware in dem Angebot oder der Auftragsbestätigung sind nur annähernd maßgebend, soweit sie nicht ausdrücklich von KLZ in Textform als verbindlich gekennzeichnet wurden. Sie stellen keine Beschaffenheitsvereinbarung oder Garantie einer entsprechenden Beschaffenheit oder Haltbarkeit der zu liefernden Ware dar, es sei denn, sie wurden ausdrücklich in Textform als solche vereinbart. Entsprechendes gilt für Erwartungen des Kunden hinsichtlich der zu liefernden Ware oder deren Verwendung."),
|
||||
p("2.5 Handelsübliche und branchenübliche Abweichungen vom Leistungsumfang der zu liefernden Ware durch KLZ sind zulässig, soweit sie die Verwendbarkeit der zu liefernden Ware zum vertraglich vorgesehenen Zweck nicht beeinträchtigen. Dies umfasst auch Über- oder Unterlieferungen. Abweichungen der zu liefernden Ware, die durch zwingende rechtliche oder technische Normen, die nach Auftragsbestätigung in Kraft treten, bedingt sind, sind zulässig, soweit sie nicht erheblich und dem Kunden zumutbar sind. Änderungen, die zu einer technischen Verbesserung der zu liefernden Ware führen, sind stets zulässig."),
|
||||
p("2.6 Änderungen des Lieferumfangs durch den Kunden bedürfen zu ihrer Wirksamkeit stets der in Textform erklärten Einwilligung von KLZ. Mehraufwände und sonstige Kosten, die durch die Änderung des Lieferumfangs auf Wunsch des Kunden entstehen, sind von dem Kunden gesondert zu vergüten."),
|
||||
p("2.7 Soweit das Angebot oder die Auftragsbestätigung von KLZ offensichtliche Irrtümer, Schreib- oder Rechenfehler enthält, ist das Dokument für KLZ hinsichtlich der offensichtlichen Fehler nicht verbindlich. Der Kunde wird KLZ auf entsprechende Fehler hinweisen."),
|
||||
p("2.8 Verschlechtern sich die Vermögensverhältnisse des Kunden wesentlich oder wird der Antrag zur Eröffnung eines Insolvenz- oder vergleichbaren Verfahrens über das Vermögen des Kunden mangels Masse abgelehnt, ist KLZ berechtigt, ganz oder teilweise vom Vertrag zurückzutreten."),
|
||||
h("3 Abrufaufträge", "h3"),
|
||||
p("3.1 Erteilt der Kunde KLZ einen Abrufauftrag und werden über die Abruftermine keine gesonderten Vereinbarungen in Textform getroffen, ist der Kunde verpflichtet, KLZ die einzelnen Abruftermine so mitzuteilen, dass zwischen Eingang der Abrufmitteilung bei KLZ und Auslieferung der Ware mindestens vierzehn (14) Werktage und zwischen Eingang der Abrufmitteilung und der letzten Auslieferung der Ware maximal neunzig (90) Werktage liegen. Werktage sind dabei alle Kalendertage, bei denen es sich nicht um einen Sonntag oder einen gesetzlichen Feiertag am Sitz von KLZ handelt."),
|
||||
h("4 Preise und Zahlungsbedingungen", "h3"),
|
||||
p("4.1 Die in dem Angebot oder der Auftragsbestätigung von KLZ angegebenen Preise gelten für den in dem Angebot oder der Auftragsbestätigung von KLZ aufgeführten Lieferumfang und verstehen sich grundsätzlich in EURO und ohne Abzüge zuzüglich Versandkosten ab dem jeweiligen Werk des Herstellers der Ware, exklusive Verpackung und zuzüglich der jeweils geltenden gesetzlichen Umsatzsteuer, sofern nicht ausdrücklich etwas anderes in Textform vereinbart wurde. Die in dem Angebot oder der Auftragsbestätigung von KLZ ausgewiesenen Hohlpreise verstehen sich darüber hinaus zuzüglich Metallzuschlag und auftragsspezifischer Schnittkosten."),
|
||||
p("4.2 Sollte sich nach Vertragsschluss herausstellen, dass zur Durchführung des Vertrages bzw. zur Lieferung der Waren Mehrleistungen erforderlich sind, so werden diese Mehrleistungen gesondert abgerechnet und vergütet."),
|
||||
p("4.3 Hat sich der Preis für eine zu liefernde Ware, bzw. der vereinbarte Hohlpreis z.B. aufgrund gestiegener Preise für Energie, Transport, Arbeitskraft oder Material zwischen Vertragsschluss und Lieferdatum um mehr als fünf (5) % erhöht, werden die Parteien partnerschaftlich und konstruktiv über eine Anpassung des Preises bzw. des Hohlpreises diskutieren. KLZ ist zum Rücktritt von dem betroffenen Vertrag berechtigt, wenn innerhalb von vierzehn (14) Kalendertagen ab Anzeige der Preiserhöhung durch KLZ keine einvernehmliche Lösung gefunden wird."),
|
||||
p("4.4 Der Kaufpreis wird sofort mit Erhalt der Rechnung oder Lieferung der Ware, je nachdem welcher Zeitpunkt früher eintritt, zur Zahlung fällig, sofern keine anderen Zahlungskonditionen in Textform vereinbart sind. Der Kunde hat den ausstehenden Kaufpreis innerhalb von zehn (10) Kalendertagen nach Erhalt der Rechnung oder Erhalt der Ware, je nachdem welcher Zeitpunkt früher eintritt, frei Konto von KLZ und ohne Abzug zu bezahlen. Die Zahlungsfrist gilt mit vollständiger Gutschrift des ausstehenden Kaufpreises auf dem Konto von KLZ als gewahrt. Nach Ablauf der Zahlungsfrist kommt der Kunde automatisch mit der Zahlung des ausstehenden Kaufpreises in Verzug, ohne dass es hierfür einer gesonderten Mahnung von KLZ bedarf."),
|
||||
p("4.5 Befindet sich der Kunde mit einer Zahlung in Verzug, ist KLZ berechtigt, die zu liefernde Ware nur Zug-um-Zug gegen Zahlung der Summe, mit der sich der Kunde in Verzug befindet, zu liefern. Dies gilt auch für alle künftigen Lieferungen von Waren von KLZ an den Kunden. Darüber hinaus stehen KLZ sämtliche Ansprüche gegen den Kunden aufgrund dessen Zahlungsverzug uneingeschränkt zu."),
|
||||
p("4.6 Befindet sich der Kunde seit mehr als vierzehn (14) Kalendertagen in Zahlungsverzug, ist KLZ dazu berechtigt, vom Vertrag zurückzutreten und die gelieferte Ware unverzüglich zurück zu verlangen, ohne dass dem Kunden hieraus Ansprüche entstehen."),
|
||||
p("4.7 Das Recht des Kunden, Zahlungen zurückzuhalten oder mit Gegenansprüchen aufzurechnen, steht dem Kunden nur insoweit zu, als seine Gegenansprüche unbestritten oder rechtskräftig festgestellt sind, oder wenn die jeweiligen Ansprüche in einem Gegenseitigkeitsverhältnis zueinanderstehen."),
|
||||
h("5 Metallnotierung", "h3"),
|
||||
p("5.1 Basis zur Berechnung des Kupferpreises ist der Durchschnitt des Liefervormonats der Notierung „LME Copper official price cash offer“, zuzüglich der jeweils aktuellen von KLZ benannten Kupfer-Prämie."),
|
||||
p("5.2 Basis zur Berechnung des Aluminiumpreises ist der Durchschnitt des Liefervormonats der Notierung „LME Aluminium official price cash offer\" zuzüglich der jeweils von KLZ benannten Aluminium-Prämie."),
|
||||
p("5.3 USD werden auf Basis des EUR/USD LME-FX-Rate (MTLE) in EUR umgerechnet. Die entsprechenden Notierungen können der Webseite www.westmetall.com entnommen werden. Die Prämienzuschläge können stark variieren und KLZ behält sich das Recht vor, diese fristgerecht anzupassen, ungeachtet der Angebotslegung."),
|
||||
h("6 Metallzahl", "h3"),
|
||||
p("6.1 Die von KLZ in dem Angebot oder der Auftragsbestätigung ausgewiesene Metallzahl ist eine rein kaufmännische Berechnungsgröße für den Metallinhalt, der in die Berechnung des Gesamtpreises eines Kabels eingeht."),
|
||||
p("6.2 Die Angabe der Metallzahl dient ausschließlich dazu, dem Kunden eine Vergleichbarkeit auf Grundlage der Holpreisbasis zu ermöglichen. Die Metallzahl definiert nicht das Gewicht des tatsächlich im Kabel enthaltenen Leitermetalls. Die Metallzahl ist ein rein kalkulatorischer Berechnungsfaktor, der keine unmittelbaren Rückschlüsse auf die im Kabel verwendeten Kupfer- bzw. Aluminiummengen zulässt. KLZ weist den Kunden hiermit ausdrücklich darauf hin, final nur den Vollpreis für Vergleichszwecke heranzuziehen."),
|
||||
p("6.3 Auf Anfrage des Kunden in Textform ist KLZ grundsätzlich dazu bereit, andere Metallzahlen zu berechnen und auszuweisen. Bei jeglicher Änderung bleibt der Vollpreis der gleiche Betrag. Ein Anspruch des Kunden auf Berechnung und Angabe anderer Metallzahlen besteht nicht."),
|
||||
h("7 Lieferung", "h3"),
|
||||
p("7.1 Lieferungen erfolgen grundsätzlich DAP (INCOTERMS® 2020) sofern die Parteien keine anderweitige Vereinbarung in Textform getroffen haben."),
|
||||
p("7.2 Die Lieferung der Ware wird nach Möglichkeit geschlossen vorgenommen. KLZ ist jedoch berechtigt, Teillieferungen vorzunehmen, wenn dies für den Kunden zumutbar ist, insbesondere wenn wegen Fehlens von Waren eine erhebliche Verzögerung der gesamten Lieferung eintreten würde."),
|
||||
p("7.3 Lieferfristen aus Angeboten und Auftragsbestätigungen sind grundsätzlich für KLZ unverbindlich. Eine verbindliche Lieferfrist muss ausdrücklich in Textform als solche gekennzeichnet werden."),
|
||||
p("7.4 Die Lieferfrist beginnt mit dem Zugang der Auftragsbestätigung bei dem Kunden. Die Einhaltung der Lieferfrist setzt die rechtzeitige Klärung aller kaufmännischen und technischen Fragen, sowie die Einhaltung der vereinbarten Zahlungsbedingungen und sonstigen Verpflichtungen des Kunden voraus. Werden diese Voraussetzungen nicht rechtzeitig erfüllt, so verlängern sich die Fristen angemessen; dies gilt nicht, wenn ausschließlich KLZ die Verzögerung zu vertreten hat."),
|
||||
p("7.5 Die Einhaltung auch einer verbindlichen Lieferfrist steht unter dem Vorbehalt ordnungsgemäßer, insbesondere rechtzeitiger quantitativer und qualitativer Selbstbelieferung von KLZ durch dessen Vorlieferanten. KLZ ist im Fall der nicht ordnungsgemäßen Selbstbelieferung zum Rücktritt vom Vertrag berechtigt. KLZ informiert den Kunden unverzüglich, wenn und soweit KLZ von seinem Recht zum Rücktritt vom Vertrag Gebrauch macht und gewährt etwa erbrachte Vorleistungen des Kunden zurück."),
|
||||
p("7.6 Die Lieferfrist gilt als gewahrt, wenn KLZ die zu liefernde Ware dem Kunden bis zum Ablauf der Lieferfrist an dem benannten Lieferort innerhalb der üblichen Geschäftszeiten zur Annahme erstmalig angeboten hat."),
|
||||
p("7.7 Auch wenn eine verbindliche Lieferfrist vereinbart wurde, kommt KLZ mit der Lieferung der Ware nur dann in Verzug, wenn der Kunde KLZ nach Ablauf der Lieferfrist eine angemessene Nachfrist zur Lieferung der Ware in Textform gesetzt hat und diese Nachfrist erfolglos verstrichen ist."),
|
||||
p("7.8 Wird die Lieferung der Ware, oder das Abladen der Ware, auf Wunsch des Kunden verzögert, oder verzögert sich die Lieferung der Ware, oder das Abladen der Ware, aus sonstigen Gründen, die der Kunde zu vertreten hat, ist KLZ berechtigt, dem Kunden nach Ablauf der vertraglich vereinbarten Lieferfrist ein Lagergeld in Höhe von zwei (2) % des jeweiligen Nettoauftragswertes für jeden angefangenen Monat, maximal jedoch fünf (5) % des jeweiligen Nettoauftragswertes in Rechnung zu stellen. Das Lagergeld ist der Höhe nach auf maximal fünf (5) % des Nettoauftragswertes pro Vertrag beschränkt."),
|
||||
p("7.9 KLZ behält sich die Geltendmachung aller darüberhinausgehenden Ansprüche gegenüber dem Kunden aufgrund dessen verzögerter Übernahme der Ware uneingeschränkt vor. Das Lagergeld ist auf weitergehende Zahlungsansprüche von KLZ gegenüber dem Kunden aufgrund dessen verzögerter Übernahme der Ware anzurechnen. Dem Kunden bleibt der Nachweis gestattet, dass KLZ überhaupt kein oder nur ein geringerer Schaden entstanden ist."),
|
||||
p("7.10 Kabel werden von KLZ auf stabilen Vollholztrommeln geliefert. Auf Wunsch vermittelt KLZ dem Kunden Partner, die diese Trommeln gegen eine Gebühr abholen. Bei Lagerzeiten über drei (3) Monaten ist zu beachten, dass die Trommel regelmäßig bewegt wird, um eine einseitige Belastung zu vermeiden. Folgeschäden bei Nichtbeachtung wie Instabilität schließt KLZ aus."),
|
||||
p("7.11 Rücksendungen von Waren an KLZ, die nicht vorher von KLZ in Textform bestätigt wurden, erfolgen auf alleinige Gefahr des Kunden."),
|
||||
h("8 Gefahrübergang", "h3"),
|
||||
p("8.1 Die Gefahr des zufälligen Untergangs und der zufälligen Verschlechterung der zu liefernden Ware geht grundsätzlich DAP (INCOTERMS® 2020) auf den Kunden über, sofern die Parteien keine anderweitige Vereinbarung in Textform getroffen haben."),
|
||||
p("8.2 Verzögert sich die Versendung bzw. Übergabe der Ware aus Gründen, die der Kunde zu vertreten hat, geht die Gefahr des zufälligen Untergangs und der zufälligen Verschlechterung der Ware mit der Durchführung des ersten Zustellversuchs innerhalb der gewöhnlichen Öffnungszeiten des Kunden durch KLZ auf den Kunden über."),
|
||||
h("9 Eigentumsvorbehalt", "h3"),
|
||||
p("9.1 Die jeweils gelieferten Waren bleiben bis zur vollständigen Bezahlung sämtlicher fälliger Forderungen aus der Geschäftsbeziehung zwischen dem Kunden und KLZ in dem Eigentum von KLZ (nachfolgend „Vorbehaltsware“). Dabei ist unerheblich, aus welchem Rechtsgrund die Forderungen von KLZ gegen den Kunden bestehen. Erfasst sind insbesondere auch Saldoforderungen aus laufender Rechnung."),
|
||||
p("9.2 Der Kunde ist berechtigt, die gelieferte Ware im Rahmen des ordnungsgemäßen Geschäftsbetriebes zu veräußern, umzubilden oder weiterzuverarbeiten."),
|
||||
p("9.3 Die Verarbeitung oder Umbildung der Vorbehaltsware durch den Kunden erfolgt jedoch stets für KLZ und KLZ erwirbt Eigentum an der neuen Sache. Wird die Vorbehaltsware mit anderen Sachen zu einer einheitlichen Sache verbunden, verarbeitet oder untrennbar vermischt oder vermengt oder in sonstiger Weise verbunden, erwirbt KLZ Miteigentum an der neuen Sache im Verhältnis des Wertes der Vorbehaltsware zu den anderen Gegenständen zur Zeit der Verarbeitung bzw. Vermischung, Vermengung oder sonstigen Verbindung. Erfolgt die Vermischung, Vermengung oder sonstige Verbindung dergestalt, dass die Sache des Kunden als Hauptsache anzusehen ist, so willigt der Kunde bereits jetzt dazu ein, KLZ anteiliges Miteigentum an der neuen Sache, in dem Mengenverhältnis der Vorbehaltsware zu den fremden Sachen, zu übertragen. Für die so entstandenen Sachen gelten die Bestimmungen für Vorbehaltsware."),
|
||||
p("9.4 Der Kunde hat die Vorbehaltsware mit kaufmännischer Sorgfalt für KLZ zu verwahren, pfleglich zu behandeln und auf seine Kosten gegen Feuer, Wasser und Diebstahl zum Neuwert zu versichern. Der Kunde tritt seine entsprechenden Ansprüche aus den Versicherungsverträgen an KLZ ab; KLZ nimmt die Abtretung an."),
|
||||
p("9.5 Der Kunde darf die Vorbehaltsware nur im ordnungsgemäßen Geschäftsgang bis zum Eintritt des Verwertungsfalles veräußern. Verpfändungen und Sicherungsübereignungen hinsichtlich der Vorbehaltsware sind unzulässig. Eingriffe Dritter, wie etwa die Pfändung oder Zwangsvollstreckung, durch welche Rechte von KLZ beeinträchtigt werden, hat der Kunde KLZ unverzüglich in Textform anzuzeigen. Der Kunde hat den Dritten darüber hinaus unverzüglich in Textform darauf hinzuweisen, dass KLZ Eigentümer der Vorbehaltsware ist. Soweit der Dritte nicht in der Lage ist, KLZ die gerichtlichen und außergerichtlichen Kosten einer Klage nach § 771 ZPO zu erstatten, haftet der Kunde für den dadurch entstandenen Ausfall. Weitergehende Ansprüche gegen den Kunden bleiben unberührt."),
|
||||
p("9.6 Für den Fall der Weiterveräußerung der Vorbehaltsware tritt der Kunde die ihm daraus seinerseits erwachsenden Forderungen gegen seinen Abnehmer bereits jetzt an KLZ ab; KLZ nimmt die Abtretung an. Gleiches gilt für sonstige Forderungen, die an die Stelle der Vorbehaltsware treten oder sonst hinsichtlich der Vorbehaltsware entstehen, wie z. B. Versicherungsansprüche oder Ansprüche aus unerlaubter Handlung bei Verlust oder Zerstörung."),
|
||||
p("9.7 Wird die Vorbehaltsware nach Verarbeitung oder zusammen mit anderen Waren, die KLZ nicht gehören, weiterveräußert oder wird sie mit einem Grundstück oder mit beweglichen Sachen verbunden, so erklärt der Kunde bereits jetzt die Abtretung der Forderung des Kunden gegen seine Abnehmer in Höhe des zwischen dem Kunden und KLZ vereinbarten Kaufpreises für die Vorbehaltsware; KLZ nimmt die Abtretung an."),
|
||||
p("9.8 Der Kunde wird ermächtigt, die abgetretenen Forderungen für KLZ einzuziehen. KLZ bleibt auch weiterhin zur Einziehung der abgetretenen Forderungen berechtigt, wird die abgetretenen Forderungen jedoch nicht selbst einziehen, solange kein Grund nach Ziffer 9.9 vorliegt, der KLZ zum Widerruf der Einziehungsermächtigung berechtigt. Zur Abtretung der Forderung an Dritte, einschließlich Forderungsverkauf an Factoring Banken, ist der Kunde nur mit vorheriger schriftlicher Zustimmung von KLZ berechtigt. KLZ wird die Zustimmung nicht verweigern, sofern eine gleichwertige Sicherheit gestellt wird."),
|
||||
p("9.9 KLZ kann die Ermächtigung zur Weiterveräußerung und Einziehung widerrufen, sobald sich der Kunde gegenüber KLZ im Zahlungsverzug befindet, oder das Insolvenzverfahren oder ein vergleichbares Verfahren über das Vermögen des Kunden eröffnet oder mangels Masse abgewiesen wurde."),
|
||||
p("9.10 Der Kunde hat die eingezogenen Forderungen binnen zehn (10) Werktagen auf ein von KLZ benanntes Konto zu überweisen."),
|
||||
p("9.11 KLZ ist zur Offenlegung der Abtretung berechtigt und kann von dem Kunden verlangen, dass dieser die Abtretung seinen Abnehmern anzeigt. Auf Verlangen ist der Kunde verpflichtet, KLZ die Namen seiner Abnehmer bekanntzugeben und alle erforderlichen Unterlagen herauszugeben, die für die Einziehung der Forderung durch KLZ selbst erforderlich sind. Auch ist der Kunde ab Verzugseintritt verpflichtet, die Abtretung der Forderung an KLZ seinem Abnehmer gegenüber schriftlich mitzuteilen."),
|
||||
p("9.12 KLZ wird die Vorbehaltsware sowie die an ihre Stelle tretenden Sachen oder Forderungen freigeben, soweit ihr Wert die Höhe der gesicherten Forderungen um mehr als zehn (10) % übersteigt. Die Auswahl der danach freizugebenden Gegenstände liegt bei KLZ."),
|
||||
p("9.13 Tritt KLZ bei vertragswidrigem Verhalten des Kunden, insbesondere Zahlungsverzug, vom Vertrag zurück (Verwertungsfall), ist KLZ berechtigt, die Vorbehaltsware herauszuverlangen. Der Kunde hat in diesem Fall KLZ alle Informationen und Unterlagen zu übergeben, die erforderlich sind, um die Vorbehaltsware herauszuverlangen und die an KLZ abgetretenen Ansprüche des Kunden gegenüber seinen Abnehmern geltend zu machen, mindestens jedoch eine Aufstellung über die bei dem Kunden oder bei den Abnehmern befindliche Vorbehaltsware, auch soweit sie bereits verarbeitet ist, und eine Aufstellung aller abgetretenen Forderungen nebst Rechnungsgutschriften."),
|
||||
h("10 Höhere Gewalt", "h3"),
|
||||
p("10.1 „Höhere Gewalt“ bedeutet das Eintreten eines Ereignisses oder Umstands, welcher eine Partei daran hindert, eine oder mehrere ihrer vertraglichen Verpflichtungen zu erfüllen, wenn und soweit die von dem Hindernis betroffene Partei nachweist, dass (a) dieses Hindernis außerhalb der ihr zumutbaren Kontrolle liegt und (b) es zu dem Zeitpunkt des Vertragsschlusses nicht in zumutbarer Weise vorhersehbar war und (c) die Auswirkungen des Hindernisses von der betroffenen Partei nicht in zumutbarer Weise hätte vermieden oder überwunden werden können."),
|
||||
p("10.2 Bei dem Beweis des Gegenteils wird insbesondere, aber nicht abschließend, bei den folgenden Ereignissen ein Fall Höherer Gewalt im Sinne des vorstehenden Absatzes vermutet: Krieg, Terrorakte, Währungs- und Handelsbeschränkungen, Embargo, Sanktionen, Amtshandlungen, Befolgung von Gesetzen oder Regierungsanordnungen, staatliche Ausreise- und Exportverbote oder staatliche Einreise- und Importverbote, Enteignung, Epidemie, Naturkatastrophe, extremes Naturereignis, Explosionen, Feuer, Zerstörung von Ausrüstungen, Demonstrationen oder Versammlungen, Ereignisse, die das Passieren von wichtigen Transportrouten verhindern, allgemeine Arbeitsunruhen, insbesondere Boykott, Streik und Aussperrungen, Energieknappheit oder Beeinträchtigung von Transportmitteln."),
|
||||
p("10.3 Eine Partei, die sich mit Erfolg auf diese Klausel beruft, ist ab dem Zeitpunkt, zu dem das Hindernis ihr die Leistungserbringung unmöglich macht, von ihrer Pflicht zur Erfüllung ihrer vertraglichen Verpflichtungen und von jeder Schadensersatzpflicht oder jedem anderen vertraglichen Rechtsbehelf wegen Vertragsverletzung suspendiert, sofern der Eintritt eines Ereignisses Höherer Gewalt der anderen Partei unverzüglich mitgeteilt wird. Ist die Auswirkung des geltend gemachten Hindernisses oder Ereignisses vorübergehend, so gelten die eben dargelegten Folgen nur so lange, wie das geltend gemachte Hindernis die Vertragserfüllung durch die betroffene Partei verhindert sowie einer angemessenen Anlaufzeit. Dauert das Hindernis oder Ereignis höherer Gewalt bei der betroffenen Partei länger als vier (4) Monate an, ist die andere Partei zur ganzen oder teilweisen außerordentlichen Kündigung bzw. zum Rücktritt vom Vertrag berechtigt. Wechselseitige Schadensersatzansprüche wegen dieser Kündigung bzw. dieses Rücktritts sind ausgeschlossen."),
|
||||
p("10.4 Der Kunde ist verpflichtet, auf Verlangen von KLZ innerhalb einer angemessenen Frist zu erklären, ob er wegen einem bei KLZ eingetretenen Ereignis Höherer Gewalt den Vertrag kündigt bzw. von diesem Zurücktritt oder weiterhin auf der Durchführung der Lieferung der Ware besteht."),
|
||||
p("10.5 Sofern sich aufgrund eines Ereignisses Höherer Gewalt die wirtschaftliche Bedeutung oder der Inhalt der Lieferung der Ware erheblich verändert oder auf den Betrieb von KLZ erheblich einwirkt, werden die Parteien den zugrundeliegenden Vertrag unter Beachtung von Treu und Glauben angemessen anpassen. Soweit dies wirtschaftlich nicht vertretbar ist, steht KLZ das Recht zu, von dem Vertrag zurückzutreten. Will KLZ von diesem Rücktrittsrecht Gebrauch machen, so hat er dies nach Erkenntnis der Tragweite des Ereignisses Höherer Gewalt unverzüglich dem Kunden mitzuteilen, und zwar auch dann, wenn zunächst mit dem Kunden eine Verlängerung der Lieferzeit vereinbart war."),
|
||||
h("11 Gewährleistung und Mängelrüge", "h3"),
|
||||
p("11.1 Mängelansprüche bestehen nicht bei nur unerheblicher Abweichung der gelieferten Ware von den vereinbarten Spezifikationen, bei unerheblicher Beeinträchtigung der von KLZ vorgegebenen Brauchbarkeit, bei natürlicher Abnutzung, übermäßiger Beanspruchung, Verwendung ungeeigneter Betriebsmittel, oder bei Mängeln, die aufgrund besonderer äußerer Einflüsse entstehen, sowie bei nicht reproduzierbaren Softwarefehlern. Werden vom Kunden oder von Dritten unsachgemäß Änderungen oder Instandsetzungsarbeiten vorgenommen, so bestehen für diese und die daraus entstehenden Folgen ebenfalls keine Mängelansprüche."),
|
||||
p("11.2 Eine Gewährleistungsverpflichtung von KLZ besteht nur, wenn der Kunde den ihm obliegenden Untersuchungs- und Rügepflichten fristgerecht nachgekommen ist. Eingehende Waren hat der Kunde unverzüglich auf äußerlich erkennbare Transportschäden und Identität der gelieferten Ware mit der Bestellung zu überprüfen. Äußerlich erkennbare Mängel sind unverzüglich, spätestens jedoch innerhalb von drei (3) Werktagen ab Übergabe, sonstige Mängel innerhalb von fünf (5) Werktagen nach ihrer Entdeckung gegenüber KLZ im Rahmen einer Mängelrüge mindestens in Textform geltend zu machen."),
|
||||
p("11.3 Mängelrügen müssen eine genaue Beschreibung der aufgetretenen Mängel, der betroffenen Lieferungen und wenn möglich, auch eine bildhafte Dokumentation des Mangels enthalten. KLZ ist berechtigt, mangelhafte Teile auf eigene Kosten zur Übersendung und Untersuchung anzufordern."),
|
||||
p("11.4 Beim Verlegen von Kabeln in Gräben oder Rohren, bzw. in Bauwerken ist eine ständige Sichtkontrolle durch den Kabelverleger vorzunehmen. Der Kabelverleger hat laufend zu überprüfen, ob Auffälligkeiten zu vermerken sind."),
|
||||
p("11.5 KLZ ist berechtigt, innerhalb einer angemessenen Frist, anerkannte Mängel nach eigener Wahl durch Beseitigung (Nachbesserung) oder durch Lieferung mangelfreier Ware (Ersatzlieferung) zu beheben. Der Kunde wird KLZ bei der Beseitigung von Mängeln in zumutbarem Umfang unterstützen."),
|
||||
p("11.6 Die Kosten der Nacherfüllung und Mangeluntersuchung werden bei berechtigten Beanstandungen von KLZ getragen. Dies gilt nicht für Aufwendungen, die dadurch entstehen, dass die gelieferten Waren durch den Kunden an einen anderen als den Ort der gewerblichen Niederlassung des Kunden verbracht worden sind, es sei denn, das Verbringen entspricht dem vertraglich vorgesehenen Verwendungszweck der gelieferten Ware. Satz 1 gilt ebenfalls nicht für Mehraufwendungen, die dadurch entstehen, dass der Kunde die gelieferte Ware ohne Zustimmung von KLZ verändert hat."),
|
||||
p("11.7 Hat der Kunde die Ware verbaut, obwohl er positive Kenntnis oder grob fahrlässige Unkenntnis des Mangels hatte, ist KLZ nicht dazu verpflichtet, die Ein- und Ausbaukosten der Ware zu erstatten."),
|
||||
p("11.8 Der Kunde kann erst nach zweimaligem Fehlschlagen der Mängelbeseitigung vom Vertrag zurücktreten oder den vereinbarten Kaufpreis angemessen mindern. Bei nur unerheblicher Minderung des Werts oder der Tauglichkeit der gelieferten Waren ist der Rücktritt ausgeschlossen, ebenso wenn der Kunde sich im Annahmeverzug befindet oder wenn der Kunde den Mangel zu vertreten hat. Solange der Kunde KLZ gegenüber nicht den Rücktritt vom Vertrag erklärt oder Schadensersatz wegen Nichterfüllung verlangt hat, ist KLZ auch nach Ablauf der vom Kunden gesetzten Frist zur Nacherfüllung berechtigt, es sei denn, der Kunde hat zuvor schriftlich die Ablehnung der Nacherfüllung angezeigt."),
|
||||
p("11.9 Beruht ein Mangel auf dem Verschulden von KLZ, kann der Kunde unter den in Ziffer 12 bestimmten Voraussetzungen Schadensersatz verlangen. Ziffer 12 gilt entsprechend für den Ersatz vergeblicher Aufwendungen, den der Kunde anstelle eines Schadensersatzes statt der Leistung verlangen kann."),
|
||||
p("11.10 Besteht Uneinigkeit zwischen den Parteien in Bezug auf Mängel an den Waren, ist ein Gutachten bei dem VDE-Institut über die streitige Frage einzuholen. Das Ergebnis des Gutachtens des VDE-Instituts ist für die Feststellung der Mangelfreiheit oder eines Mangels maßgebend. KLZ ist nur dann verpflichtet, die Kosten des Gutachtens des VDE-Instituts zu tragen, wenn das Gutachten einen Mangel feststellt."),
|
||||
p("11.11 Die gesetzlichen Mängelgewährleistungsansprüche des Kunden verjähren innerhalb von zwölf (12) Monaten ab Gefahrübergang. §§ 438 Abs. 1 Nr. 2, 479 Abs. 1 und 634a Abs. 1 Nr. 2 BGB bleiben hiervon unberührt. Diese Verkürzung der Verjährungsfrist gilt nicht bei Vorsatz, grober Fahrlässigkeit, der Verletzung von Leben, Körper, Gesundheit, oder jedweder wesentlicher Vertragspflichtverletzung, zwingender gesetzlicher Haftungsnormen wie denen des ProdHaftG, sowie bei Nichteinhaltung einer Beschaffenheitsgarantie oder dem arglistigen Verschweigen eines Mangels. Die gesetzlichen Regelungen über Ablaufhemmung, Hemmung und Neubeginn der Fristen bleiben unberührt."),
|
||||
h("12 Haftung", "h3"),
|
||||
p("12.1 KLZ haftet uneingeschränkt für Vorsatz und grobe Fahrlässigkeit. KLZ haftet ferner uneingeschränkt für Schäden aus der Verletzung des Lebens, des Körpers oder der Gesundheit, sowie für arglistiges Verschweigen eines Mangels und Verletzung von garantierten Beschaffenheitsmerkmalen, sowie im Rahmen zwingender gesetzlicher Vorschriften, wie etwa denen des Produkthaftungsgesetzes."),
|
||||
p("12.2 Bei der Verletzung von wesentlichen Vertragspflichten haftet KLZ im Übrigen der Höhe nach begrenzt auf den bei Vertragsschluss für KLZ typischerweise vorhersehbaren Schaden bzw. die typischerweise vorhersehbaren Aufwendungen. Vertragswesentlich sind die Verpflichtungen, deren Erfüllung die ordnungsgemäße Vertragsdurchführung erst ermöglichen und auf deren Einhaltung der Kunde vertraut und auch vertrauen darf. Im Übrigen haftet KLZ bei leichter Fahrlässigkeit nicht auf Schadensersatz."),
|
||||
p("12.3 Eine Haftung für mittelbare Schäden und Folgeschäden, die Folge von Mängeln der gelieferten Waren sind, besteht nur für solche Schäden, die bei bestimmungsgemäßer Verwendung der gelieferten Ware typischerweise zu erwarten sind."),
|
||||
p("12.4 Die Haftung entfällt für Schäden, die auf Reparaturen, Änderungen oder anderen Eingriffen oder auf die Verwendung von Nicht-Originalteilen von KLZ im Rahmen der Reparatur oder Änderung bzw. des Eingriffs zurückzuführen sind, die der Kunde ohne Zustimmung von KLZ vorgenommen hat."),
|
||||
p("12.5 Soweit die Haftung von KLZ ausgeschlossen oder beschränkt ist, gilt dies auch für die Haftung der Angestellten, Arbeitnehmer, Mitarbeiter, Vertreter und Erfüllungsgehilfen von KLZ."),
|
||||
h("13 Produkthaftung", "h3"),
|
||||
p("13.1 Der Kunde wird die gelieferten Waren ausschließlich vertragsgemäß und entsprechend den verfügbaren Anleitungen von KLZ verwenden, insbesondere wird er vorhandene Warnhinweise über Gefahren bei Gebrauch der gelieferten Waren nicht verändern oder entfernen. Bei Verletzung dieser Pflicht stellt der Kunde KLZ im Innenverhältnis von Produkthaftungsansprüchen Dritter frei, es sei denn der Kunde ist für den die Haftung auslösenden Fehler nicht verantwortlich."),
|
||||
p("13.2 Wird KLZ aufgrund eines Produktfehlers zu einem Produktrückruf oder einer -warnung veranlasst, so wird der Kunde nach besten Kräften bei den Maßnahmen mitwirken, die KLZ für erforderlich und zweckmäßig hält. KLZ ist verpflichtet, die Kosten des Produktrückrufs oder der -warnung zu tragen, es sei denn KLZ ist für den Produktfehler und den eingetretenen Schaden nach produkthaftungsrechtlichen Grundsätzen nicht verantwortlich."),
|
||||
p("13.3 Der Kunde wird KLZ unverzüglich über ihm bekanntwerdende Risiken bei der Verwendung der gelieferten Ware und mögliche Produktfehler in Textform informieren."),
|
||||
h("14 Exportkontrolle", "h3"),
|
||||
p("14.1 Lieferungen von Waren von KLZ stehen unter dem Vorbehalt, dass der Vertragserfüllung keine Hindernisse aufgrund von nationalen oder internationalen exportkontrollrechtlichen Vorschriften entgegenstehen. Sofern sich die Durchführung des Vertrages aufgrund von Exportkontrollprüfungen oder Genehmigungsverfahren verzögert, werden Fristen und Lieferzeiten außer Kraft gesetzt. KLZ ist berechtigt, von der Einzelbestellung zurückzutreten und den Liefervertrag fristlos zu kündigen, wenn ein solcher Rücktritt oder eine Kündigung zur Einhaltung nationaler oder internationalen Exportkontrollbestimmung erforderlich ist. Die Geltendmachung von Schadensersatzansprüchen oder anderen Rechten durch den Kunden wegen vorgenannter Kündigung oder Verzögerung ist ausgeschlossen."),
|
||||
p("14.2 Der Kunde verpflichtet sich zur Einhaltung des nationalen und internationalen Exportkontrollrechts, insbesondere bei Weitergabe der gelieferten Ware an Dritte."),
|
||||
h("15 Urheberrechte und Datenschutz", "h3"),
|
||||
p("15.1 An Kostenvoranschlägen, Zeichnungen, Modellen, Teilen, Schablonen, Berechnungen, Beschreibungen, Mustern und anderen Unterlagen (im Folgenden: „Unterlagen“ genannt) behält sich KLZ seine eigentums- und urheberrechtlichen Verwertungsrechte uneingeschränkt vor. Die Unterlagen dürfen nur nach vorheriger schriftlicher Zustimmung von KLZ Dritten zugänglich gemacht werden und sind KLZ auf Verlangen unverzüglich zurückzugeben."),
|
||||
p("15.2 Ohne ausdrückliche Zustimmung von KLZ in Textform ist der Kunde nicht berechtigt, die gelieferte Ware zu Werbezwecken zu verwenden."),
|
||||
p("15.3 KLZ erhebt und verwendet personenbezogenen Daten ausschließlich gemäß den Bestimmungen der Datenschutzgrundverordnung und des Bundesdatenschutzgesetzes."),
|
||||
p("15.4 Personenbezogenen Daten, die für die Begründung, inhaltliche Ausgestaltung, Abwicklung oder Änderung des Vertragsverhältnisses erforderlich sind (Bestandsdaten), werden ausschließlich zur Durchführung des zwischen KLZ und dem Kunden abgeschlossenen Vertrages verwendet. Zur Abwicklung des Vertragsverhältnisses müssen diese Daten im erforderlichen Umfang an Dritte (z.B. Transporteure oder Spediteure) weitergegeben werden. Der Kunde erteilt für die vorbeschriebene Verwendung seiner personenbezogenen Daten hiermit eine entsprechende Einwilligung."),
|
||||
p("15.5 Diese Einwilligungserklärung ist jederzeit -ohne Angabe von Gründen- widerruflich. Der Widerruf ist zu richten an info@klz-cables.com ."),
|
||||
p("15.6 Eine darüberhinausgehende Nutzung der Bestandsdaten des Kunden für Zwecke der Werbung, der Marktforschung oder zur bedarfsgerechten Gestaltung der Angebote von KLZ bedarf der gesonderten ausdrücklichen Einwilligung des Kunden."),
|
||||
p("15.7 Auf Anordnung einer zuständigen Stelle darf KLZ im Einzelfall Auskunft über personenbezogene Daten des Kunden im Rahmen der Anordnung an die zuständige Stelle erteilen, soweit dies für Zwecke der Strafverfolgung, zur Gefahrenabwehr, zur Erfüllung der gesetzlichen Aufgaben der Verfassungsschutzbehörden oder zur Durchsetzung der Rechte am geistigen Eigentum erforderlich ist."),
|
||||
h("16 Geheimhaltung", "h3"),
|
||||
p("16.1 „Vertrauliche Informationen“ sind\n(i) alle Informationen, Daten, Zeichnungen, Unterlagen, Materialien, Know-how oder sonstige Geschäftsgeheimnisse im Sinne von § 2 Nr. 1 des Gesetzes zum Schutz von Geschäftsgeheimnissen (GeschGehG), insbesondere alle Entwürfe, Skizzen, Zeichnungen, technischen Protokolle, Modelle oder elektronische Daten, gleich in welcher Verkörperung,\n(ii) sowie alle nicht offenkundigen kaufmännischen, betrieblichen oder technischen Informationen oder Gegenstände, gleich in welcher Verkörperung, die dem Kunden im Rahmen der Geschäftsbeziehung mit KLZ offengelegt oder anderweitig bekannt werden,\n(iii) die entweder direkt als vertraulich gekennzeichnet wurden oder nachträglich schriftlich oder in Textform als vertraulich gekennzeichnet werden. Des Weiteren gelten auch solche Informationen als vertraulich, welche der Natur nach bzw. aus den Umständen heraus oder aus anderen Gründen erkennbar als vertraulich anzusehen sind, soweit die Parteien nichts anderes in Schriftform vereinbart haben."),
|
||||
p("16.2 Vertrauliche Informationen im Sinne von Ziffer 16.1 sind jedoch nicht solche Informationen,\n(i) die der Kunde von öffentlich zugänglichen Quellen bezogen hat und die allgemein bekannt oder auf legalem Wege zugänglich sind und diese Quellen die Informationen ebenfalls auf legalem Wege erlangt haben,\n(ii) die der Kunde von einem berechtigten Dritten rechtmäßig erhalten hat,\n(iii) die der Kunde eigenständig entwickelt hat,\n(iv) die kraft Gesetzes oder aufgrund gerichtlicher oder behördlicher Anordnung offengelegt wurden oder offenzulegen sind. Der Kunde ist in diesem Fall verpflichtet, KLZ vor einer solchen Offenlegung der Informationen unverzüglich zu informieren und zu versuchen, die Offenlegung zu verhindern, soweit dies mit angemessenen und gesetzlich zulässigen Mitteln möglich ist."),
|
||||
p("16.3 Die Darlegungs- und Beweislast für das Vorliegen einer in Ziffer 16.2 benannten Ausnahmetatbestände trägt diejenige Partei, die sich auf das Vorliegen einer oder mehrerer dieser Ausnahmetatbestände beruft."),
|
||||
p("16.4 Der Kunde verpflichtet sich, die Vertraulichen Informationen strikt vertraulich zu behandeln und ausschließlich zur Erfüllung des Vertragszwecks zu verwenden. Die Vertraulichen Informationen dürfen durch den Kunden nur an solche Mitarbeiter oder andere Personen weitergegeben werden, die sie zur Erreichung des Vertragszwecks zwingend erhalten müssen. Sämtliche Mitarbeiter des Kunden, welche Kenntnis von Vertraulichen Informationen erhalten, müssen durch den Kunden ebenfalls zur Geheimhaltung hinsichtlich der Vertraulichen Informationen verpflichtet sein oder werden. Diese Geheimhaltungspflicht soll auch für die Zeit nach einem etwaigen Ausscheiden des Mitarbeiters aus dem Unternehmen des Kunden für weitere fünf (5) Jahre gelten, soweit dies rechtlich möglich ist."),
|
||||
p("16.5 Der Kunde verpflichtet sich, auf erstes Anfordern von KLZ und/oder nach Beendigung der Geschäftsbeziehung oder Beendigung des zugrundeliegenden Vertrage, gleich aus welchem Rechtsgrund, alle unter diese Geheimhaltungsvereinbarung fallenden Vertraulichen Informationen und davon angefertigte Kopien an KLZ herauszugeben oder sachgemäß zu zerstören bzw. sicher und dauerhaft zu löschen und die vollständige Herausgabe, vollständige sachgemäße Zerstörung oder vollständige sichere und dauerhafte Löschung KLZ gegenüber schriftlich zu bestätigen."),
|
||||
p("16.6 Ziffer 16.5 gilt nicht für Vertrauliche Informationen, die zwingenden gesetzlichen Aufbewahrungspflichten unterliegen oder in automatisierten Back-Up Dateien im Rahmen des ordnungsgemäßen Sicherungsprozesses sicher gespeichert wurden. Für diese Vertraulichen Informationen gelten die Bestimmungen dieser Geheimhaltungsvereinbarung bis zu ihrer Herausgabe, sicheren und dauerhaften Löschung oder vollständigen sachgemäßen Zerstörung fort."),
|
||||
p("16.7 Die Geheimhaltungsverpflichtungen gemäß dieser Ziffer 16 gelten für einen Zeitraum von fünf (5) Jahren nach Beendigung des zugrundeliegenden Vertrages, gleich aus welchem Rechtsgrund, fort."),
|
||||
p("16.8 Für jeden Einzelfall eines Verstoßes des Kunden gegen die Pflichten aus dieser Geheimhaltungsvereinbarung ist der Kunde zur Zahlung einer Vertragsstrafe an KLZ verpflichtet, deren Höhe in das billige Ermessen von KLZ gestellt wird und im Streitfall durch das zuständige Gericht überprüft werden kann. Durch die Geltendmachung der Vertragsstrafe werden weitergehende Schadensersatzansprüche von KLZ nicht ausgeschlossen."),
|
||||
h("17 Schlussbestimmungen", "h3"),
|
||||
p("17.1 Änderungen oder Ergänzungen dieser AVB bedürfen der Textform. Dies gilt auch für die Abänderung des Textformerfordernisses selbst."),
|
||||
p("17.2 Auf Verträge zwischen KLZ und dem Kunden, der Auslegung dieser AVB, sowie die gesamten Rechtsbeziehungen zwischen KLZ und dem Kunden gilt ausschließlich das Recht der Bundesrepublik Deutschland unter Ausschluss des UN-Kaufrechts und des Kollisionsrechts. Erfüllungsort sowie ausschließlicher Gerichtsstand für alle Streitigkeiten aus oder im Zusammenhang mit der Rechtsbeziehung zwischen KLZ und dem Kunden ist Stuttgart."),
|
||||
p("17.3 Sollten einzelne Bestimmungen dieser AVB unwirksam oder undurchführbar sein oder werden, so wird hierdurch die Wirksamkeit aller sonstigen Bestimmungen dieser AVB im Übrigen nicht berührt. Die unwirksame oder undurchführbare Bestimmung wird von den Vertragsparteien einvernehmlich durch eine rechtswirksame Regelung ersetzt, welche dem wirtschaftlichen Erfolg und Zweck der unwirksamen oder undurchführbaren Bestimmung am nächsten kommt. Dies gilt gleichermaßen für den Fall einer Regelungslücke."),
|
||||
p("Stand: April 2026")
|
||||
];
|
||||
|
||||
const ast = {
|
||||
root: {
|
||||
type: "root",
|
||||
format: "",
|
||||
indent: 0,
|
||||
version: 1,
|
||||
children: lexicalRawData,
|
||||
direction: "ltr"
|
||||
}
|
||||
};
|
||||
|
||||
fs.writeFileSync('ast.json', JSON.stringify(ast, null, 2));
|
||||
console.log("Written to ast.json");
|
||||
}
|
||||
|
||||
updateAVB().catch(console.error);
|
||||
Reference in New Issue
Block a user