Compare commits

..

25 Commits

Author SHA1 Message Date
8ba1c7ea38 style(pdf): align AGB layout with technical datasheet hero and spacing
All checks were successful
Build & Deploy / 🔍 Prepare (push) Successful in 6s
Build & Deploy / 🧪 QA (push) Successful in 53s
Build & Deploy / 🏗️ Build (push) Successful in 2m13s
Build & Deploy / 🚀 Deploy (push) Successful in 15s
Build & Deploy / 🧪 Post-Deploy Verification (push) Successful in 4m16s
Build & Deploy / 🔔 Notify (push) Successful in 1s
2026-03-16 07:53:29 +01:00
a546ffe69c fix(pdf): remove broken helvetica font registration causing 500 error
Some checks failed
Build & Deploy / 🔍 Prepare (push) Successful in 6s
Build & Deploy / 🧪 QA (push) Successful in 56s
Build & Deploy / 🏗️ Build (push) Successful in 2m29s
Build & Deploy / 🚀 Deploy (push) Failing after 15s
Build & Deploy / 🧪 Post-Deploy Verification (push) Has been skipped
Build & Deploy / 🔔 Notify (push) Successful in 2s
2026-03-16 07:47:47 +01:00
15740db51e chore(ci): re-trigger pipeline after testing db schema hotfix
All checks were successful
Build & Deploy / 🔍 Prepare (push) Successful in 6s
Build & Deploy / 🧪 QA (push) Successful in 51s
Build & Deploy / 🏗️ Build (push) Successful in 2m20s
Build & Deploy / 🚀 Deploy (push) Successful in 15s
Build & Deploy / 🧪 Post-Deploy Verification (push) Successful in 4m7s
Build & Deploy / 🔔 Notify (push) Successful in 1s
2026-03-16 07:38:54 +01:00
13ab755857 fix(docker): bypass internal registry for base images to prevent 404s
Some checks failed
Build & Deploy / 🔍 Prepare (push) Successful in 6s
Build & Deploy / 🧪 QA (push) Successful in 51s
Build & Deploy / 🏗️ Build (push) Successful in 2m23s
Build & Deploy / 🚀 Deploy (push) Successful in 18s
Build & Deploy / 🧪 Post-Deploy Verification (push) Successful in 3m33s
Build & Deploy / 🔔 Notify (push) Successful in 2s
Nightly QA / 🔗 Links & Deps (push) Successful in 2m57s
Nightly QA / 🎭 Lighthouse (push) Successful in 3m32s
Nightly QA / 🔍 Static Analysis (push) Failing after 4m49s
Nightly QA / ♿ Accessibility (push) Successful in 5m12s
Nightly QA / 🔔 Notify (push) Successful in 19s
2026-03-15 23:39:22 +01:00
1a68af0eec fix(pdf): align AGB page PDF layout with datasheet design tokens
Some checks failed
Nightly QA / 🔗 Links & Deps (push) Successful in 2m21s
Nightly QA / 🎭 Lighthouse (push) Successful in 4m15s
Nightly QA / 🔍 Static Analysis (push) Failing after 4m45s
Nightly QA / ♿ Accessibility (push) Successful in 5m16s
Nightly QA / 🔔 Notify (push) Successful in 3s
Build & Deploy / 🔍 Prepare (push) Successful in 7s
Build & Deploy / 🧪 QA (push) Successful in 57s
Build & Deploy / 🏗️ Build (push) Failing after 18s
Build & Deploy / 🚀 Deploy (push) Has been skipped
Build & Deploy / 🧪 Post-Deploy Verification (push) Has been skipped
Build & Deploy / 🔔 Notify (push) Successful in 2s
2026-03-13 22:24:33 +01:00
275784745d feat(db): add migration for pages redirect fields
Some checks failed
Build & Deploy / 🔍 Prepare (push) Successful in 6s
Build & Deploy / 🧪 QA (push) Successful in 54s
Build & Deploy / 🏗️ Build (push) Failing after 15s
Build & Deploy / 🚀 Deploy (push) Has been skipped
Build & Deploy / 🧪 Post-Deploy Verification (push) Has been skipped
Build & Deploy / 🔔 Notify (push) Successful in 2s
2026-03-13 22:19:29 +01:00
4aef49cf2c fix: remove agbs rewrite rules that conflict with slug-mapping (redirect loop)
Some checks failed
Build & Deploy / 🔍 Prepare (push) Successful in 6s
Build & Deploy / 🧪 QA (push) Successful in 56s
Build & Deploy / 🏗️ Build (push) Failing after 16s
Build & Deploy / 🚀 Deploy (push) Has been skipped
Build & Deploy / 🧪 Post-Deploy Verification (push) Has been skipped
Build & Deploy / 🔔 Notify (push) Successful in 2s
2026-03-13 15:35:01 +01:00
8ad3abb6f3 fix(docker): restore valid v1.8.20 base image tag
Some checks failed
Build & Deploy / 🔍 Prepare (push) Successful in 6s
Build & Deploy / 🧪 QA (push) Successful in 1m0s
Build & Deploy / 🏗️ Build (push) Failing after 15s
Build & Deploy / 🚀 Deploy (push) Has been skipped
Build & Deploy / 🧪 Post-Deploy Verification (push) Has been skipped
Build & Deploy / 🔔 Notify (push) Successful in 1s
Nightly QA / 🔗 Links & Deps (push) Successful in 2m32s
Nightly QA / 🎭 Lighthouse (push) Successful in 3m3s
Nightly QA / 🔍 Static Analysis (push) Failing after 4m43s
Nightly QA / ♿ Accessibility (push) Successful in 5m20s
Nightly QA / 🔔 Notify (push) Successful in 14s
2026-03-12 19:12:56 +01:00
1d75b60236 fix(docker): use correctly versioned mintel v2 base images
Some checks failed
Build & Deploy / 🔍 Prepare (push) Successful in 5s
Build & Deploy / 🧪 QA (push) Successful in 53s
Build & Deploy / 🏗️ Build (push) Failing after 15s
Build & Deploy / 🚀 Deploy (push) Has been skipped
Build & Deploy / 🧪 Post-Deploy Verification (push) Has been skipped
Build & Deploy / 🔔 Notify (push) Successful in 1s
2026-03-12 14:39:05 +01:00
3dff19eca2 chore: update auto-generated types
Some checks failed
Build & Deploy / 🔍 Prepare (push) Successful in 11s
Build & Deploy / 🧪 QA (push) Successful in 2m9s
Build & Deploy / 🏗️ Build (push) Successful in 5m38s
Build & Deploy / 🚀 Deploy (push) Successful in 27s
Build & Deploy / 🧪 Post-Deploy Verification (push) Failing after 5m12s
Build & Deploy / 🔔 Notify (push) Successful in 2s
2026-03-12 13:41:23 +01:00
07b01c622a fix(deps): update pnpm-lock.yaml to fix CI registry checksums
Some checks failed
Build & Deploy / 🔍 Prepare (push) Successful in 14s
Build & Deploy / 🏗️ Build (push) Has been cancelled
Build & Deploy / 🚀 Deploy (push) Has been cancelled
Build & Deploy / 🧪 Post-Deploy Verification (push) Has been cancelled
Build & Deploy / 🔔 Notify (push) Has been cancelled
Build & Deploy / 🧪 QA (push) Has been cancelled
2026-03-12 13:34:32 +01:00
50de18c09c fix(docker): use latest tags for base images
Some checks failed
Build & Deploy / 🔍 Prepare (push) Successful in 5s
Build & Deploy / 🧪 QA (push) Successful in 2m7s
Build & Deploy / 🚀 Deploy (push) Has been cancelled
Build & Deploy / 🧪 Post-Deploy Verification (push) Has been cancelled
Build & Deploy / 🔔 Notify (push) Has been cancelled
Build & Deploy / 🏗️ Build (push) Has been cancelled
2026-03-12 13:29:45 +01:00
dbee0cd8bc fix(docker): use correct mmintel namespace for base images
Some checks failed
Build & Deploy / 🔍 Prepare (push) Successful in 5s
Build & Deploy / 🧪 QA (push) Successful in 2m12s
Build & Deploy / 🏗️ Build (push) Failing after 14s
Build & Deploy / 🚀 Deploy (push) Has been skipped
Build & Deploy / 🧪 Post-Deploy Verification (push) Has been skipped
Build & Deploy / 🔔 Notify (push) Successful in 1s
2026-03-12 13:24:33 +01:00
f30f8ddd8d fix(docker): migrate base image to git.infra.mintel.me registry
Some checks failed
Build & Deploy / 🔍 Prepare (push) Successful in 6s
Build & Deploy / 🧪 QA (push) Successful in 2m11s
Build & Deploy / 🏗️ Build (push) Failing after 14s
Build & Deploy / 🚀 Deploy (push) Has been skipped
Build & Deploy / 🧪 Post-Deploy Verification (push) Has been skipped
Build & Deploy / 🔔 Notify (push) Successful in 1s
2026-03-12 13:20:48 +01:00
bb9fd65dbb fix(og): convert font buffers to ArrayBuffer for Satori compatibility
Some checks failed
Build & Deploy / 🔍 Prepare (push) Successful in 6s
Build & Deploy / 🧪 QA (push) Successful in 2m8s
Build & Deploy / 🏗️ Build (push) Failing after 14s
Build & Deploy / 🚀 Deploy (push) Has been skipped
Build & Deploy / 🧪 Post-Deploy Verification (push) Has been skipped
Build & Deploy / 🔔 Notify (push) Successful in 1s
2026-03-12 13:17:18 +01:00
036fba8b53 feat(payload): add redirect settings to pages
Some checks failed
Build & Deploy / 🔍 Prepare (push) Successful in 5s
Build & Deploy / 🧪 QA (push) Successful in 2m6s
Build & Deploy / 🏗️ Build (push) Failing after 15s
Build & Deploy / 🚀 Deploy (push) Has been skipped
Build & Deploy / 🧪 Post-Deploy Verification (push) Has been skipped
Build & Deploy / 🔔 Notify (push) Successful in 1s
2026-03-12 13:12:13 +01:00
3e8d5ad8b6 chore: backup script
Some checks failed
Build & Deploy / 🔍 Prepare (push) Successful in 6s
Build & Deploy / 🧪 QA (push) Successful in 2m13s
Build & Deploy / 🏗️ Build (push) Failing after 15s
Build & Deploy / 🚀 Deploy (push) Has been skipped
Build & Deploy / 🧪 Post-Deploy Verification (push) Has been skipped
Build & Deploy / 🔔 Notify (push) Successful in 1s
2026-03-12 13:05:19 +01:00
70ad2e3041 fix(build): remove swcMinify and fix staleTimes/serverActions config object to pass Next.js validation
Some checks failed
Build & Deploy / 🔍 Prepare (push) Successful in 5s
Build & Deploy / 🧪 QA (push) Successful in 2m14s
Build & Deploy / 🏗️ Build (push) Failing after 14s
Build & Deploy / 🚀 Deploy (push) Has been skipped
Build & Deploy / 🧪 Post-Deploy Verification (push) Has been skipped
Build & Deploy / 🔔 Notify (push) Successful in 1s
2026-03-12 12:52:51 +01:00
5376b939d5 fix(cache): disable client router cache and fix terms routing
Some checks failed
Build & Deploy / 🔍 Prepare (push) Successful in 6s
Build & Deploy / 🧪 QA (push) Successful in 2m8s
Build & Deploy / 🏗️ Build (push) Failing after 16s
Build & Deploy / 🚀 Deploy (push) Has been skipped
Build & Deploy / 🧪 Post-Deploy Verification (push) Has been skipped
Build & Deploy / 🔔 Notify (push) Successful in 1s
2026-03-12 12:44:42 +01:00
6f80e72c1d style: align PDF Page component with KLZ brand Design System
Some checks failed
Build & Deploy / 🔍 Prepare (push) Successful in 12s
Build & Deploy / 🚀 Deploy (push) Has been skipped
Build & Deploy / 🧪 Post-Deploy Verification (push) Has been skipped
Build & Deploy / 🔔 Notify (push) Successful in 4s
Build & Deploy / 🧪 QA (push) Failing after 1m15s
Build & Deploy / 🏗️ Build (push) Has been skipped
Nightly QA / 🔗 Links & Deps (push) Successful in 3m32s
Nightly QA / 🎭 Lighthouse (push) Successful in 5m5s
Nightly QA / ♿ Accessibility (push) Successful in 5m28s
Nightly QA / 🔍 Static Analysis (push) Failing after 6m0s
Nightly QA / 🔔 Notify (push) Successful in 2s
2026-03-05 22:57:16 +01:00
d9334f558d fix(cms): add missing featured_image_id column to products via migration 2026-03-05 22:04:14 +01:00
cb436d31d0 fix(cms): disable migrationDir in production to prevent runtime TS import crashes 2026-03-05 21:51:55 +01:00
4b3ef49522 feat: register PDF download block and fix gotify notifications
All checks were successful
Build & Deploy / 🔍 Prepare (push) Successful in 7s
Build & Deploy / 🧪 QA (push) Successful in 2m24s
Build & Deploy / 🏗️ Build (push) Successful in 4m3s
Build & Deploy / 🚀 Deploy (push) Successful in 17s
Build & Deploy / 🧪 Post-Deploy Verification (push) Successful in 9m3s
Build & Deploy / 🔔 Notify (push) Successful in 2s
2026-03-05 16:56:09 +01:00
301e112488 fix(workflow): remove push trigger from qa.yml to prevent race conditions with deploy 2026-03-05 16:56:09 +01:00
2d4919cc1f feat: add modular dynamic PDF generation for Payload pages
Some checks failed
Build & Deploy / 🔍 Prepare (push) Successful in 6s
Build & Deploy / 🧪 QA (push) Has been cancelled
Build & Deploy / 🏗️ Build (push) Has been cancelled
Build & Deploy / 🚀 Deploy (push) Has been cancelled
Build & Deploy / 🧪 Post-Deploy Verification (push) Has been cancelled
Build & Deploy / 🔔 Notify (push) Has been cancelled
Nightly QA / 🔍 Static Analysis (push) Failing after 3m14s
Nightly QA / 🔗 Links & Deps (push) Successful in 3m59s
Nightly QA / 🎭 Lighthouse (push) Successful in 4m44s
Nightly QA / ♿ Accessibility (push) Successful in 5m48s
Nightly QA / 🔔 Notify (push) Successful in 3s
2026-03-05 13:53:59 +01:00
59 changed files with 2766 additions and 6650 deletions

38
.env Normal file
View File

@@ -0,0 +1,38 @@
# 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

View File

@@ -48,12 +48,6 @@ 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)
# ────────────────────────────────────────────────────────────────────────────

51
.gitea/workflows/ci.yml Normal file
View File

@@ -0,0 +1,51 @@
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

View File

@@ -14,8 +14,8 @@ on:
default: 'false'
env:
PUPPETEER_SKIP_DOWNLOAD: 'true'
COREPACK_NPM_REGISTRY: 'https://registry.npmmirror.com'
PUPPETEER_SKIP_DOWNLOAD: "true"
COREPACK_NPM_REGISTRY: "https://registry.npmmirror.com"
concurrency:
group: deploy-pipeline
@@ -29,16 +29,14 @@ jobs:
name: 🔍 Prepare
runs-on: docker
outputs:
target: ${{ steps.determine.outputs.target }}
image_tag: ${{ steps.determine.outputs.image_tag }}
env_file: ${{ steps.determine.outputs.env_file }}
traefik_host: ${{ steps.determine.outputs.traefik_host }}
traefik_rule: ${{ steps.determine.outputs.traefik_rule }}
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 }}
target: ${{ steps.determine.outputs.target }}
image_tag: ${{ steps.determine.outputs.image_tag }}
env_file: ${{ steps.determine.outputs.env_file }}
traefik_host: ${{ steps.determine.outputs.traefik_host }}
traefik_rule: ${{ steps.determine.outputs.traefik_rule }}
next_public_url: ${{ steps.determine.outputs.next_public_url }}
project_name: ${{ steps.determine.outputs.project_name }}
short_sha: ${{ steps.determine.outputs.short_sha }}
container:
image: catthehacker/ubuntu:act-latest
steps:
@@ -85,7 +83,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.klz-cables.com"
TRAEFIK_HOST="${SLUG}.branch.mintel.me"
fi
# Standardize Traefik Rule (escaped backticks for Traefik v3)
@@ -96,7 +94,7 @@ jobs:
TRAEFIK_RULE='Host(`'"$TRAEFIK_HOST"'`)'
PRIMARY_HOST="$TRAEFIK_HOST"
fi
GATEKEEPER_HOST="gatekeeper.$PRIMARY_HOST"
{
@@ -115,7 +113,6 @@ jobs:
echo "project_name=$PRJ-$TARGET"
fi
echo "short_sha=$SHORT_SHA"
echo "slug=$SLUG"
} >> "$GITHUB_OUTPUT"
# ⏳ Wait for Upstream Packages/Images if Tagged
@@ -159,8 +156,6 @@ 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:
@@ -186,15 +181,12 @@ jobs:
- name: 🔒 Security Audit
run: pnpm audit --audit-level high || echo "⚠️ Audit found vulnerabilities (non-blocking)"
- name: 🧹 Clean Workspace
run: rm -rf .next .turbo || true
- name: 🧪 QA Checks
if: github.event.inputs.skip_checks != 'true'
env:
TURBO_TELEMETRY_DISABLED: '1'
TURBO_TELEMETRY_DISABLED: "1"
run: npx turbo run lint typecheck test --cache-dir=".turbo"
# ──────────────────────────────────────────────────────────────────────────────
# JOB 3: Build & Push
# ──────────────────────────────────────────────────────────────────────────────
@@ -211,8 +203,7 @@ 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:
@@ -228,22 +219,7 @@ 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 }}
- 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 }}
"NPM_TOKEN=${{ secrets.NPM_TOKEN }}"
# ──────────────────────────────────────────────────────────────────────────────
# JOB 4: Deploy
@@ -255,54 +231,44 @@ jobs:
container:
image: catthehacker/ubuntu:act-latest
env:
TARGET: ${{ needs.prepare.outputs.target }}
IMAGE_TAG: ${{ needs.prepare.outputs.image_tag }}
PROJECT_NAME: ${{ needs.prepare.outputs.project_name }}
TARGET: ${{ needs.prepare.outputs.target }}
IMAGE_TAG: ${{ needs.prepare.outputs.image_tag }}
PROJECT_NAME: ${{ needs.prepare.outputs.project_name }}
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 }}
TRAEFIK_HOST: ${{ needs.prepare.outputs.traefik_host }}
GATEKEEPER_HOST: ${{ needs.prepare.outputs.gatekeeper_host }}
# Secrets mapping (Payload CMS)
PAYLOAD_SECRET: ${{ secrets.PAYLOAD_SECRET || vars.PAYLOAD_SECRET || 'you-need-to-set-a-payload-secret' }}
PAYLOAD_DB_NAME: ${{ secrets.PAYLOAD_DB_NAME || vars.PAYLOAD_DB_NAME || 'payload' }}
PAYLOAD_DB_USER: ${{ secrets.PAYLOAD_DB_USER || vars.PAYLOAD_DB_USER || 'payload' }}
PAYLOAD_DB_PASSWORD: ${{ (needs.prepare.outputs.target == 'testing' && secrets.TESTING_PAYLOAD_DB_PASSWORD) || (needs.prepare.outputs.target == 'staging' && secrets.STAGING_PAYLOAD_DB_PASSWORD) || secrets.PAYLOAD_DB_PASSWORD || vars.PAYLOAD_DB_PASSWORD || 'payload' }}
PAYLOAD_SECRET: ${{ secrets.PAYLOAD_SECRET || vars.PAYLOAD_SECRET || 'you-need-to-set-a-payload-secret' }}
PAYLOAD_DB_NAME: ${{ secrets.PAYLOAD_DB_NAME || vars.PAYLOAD_DB_NAME || 'payload' }}
PAYLOAD_DB_USER: ${{ secrets.PAYLOAD_DB_USER || vars.PAYLOAD_DB_USER || 'payload' }}
PAYLOAD_DB_PASSWORD: ${{ (needs.prepare.outputs.target == 'testing' && secrets.TESTING_PAYLOAD_DB_PASSWORD) || (needs.prepare.outputs.target == 'staging' && secrets.STAGING_PAYLOAD_DB_PASSWORD) || secrets.PAYLOAD_DB_PASSWORD || vars.PAYLOAD_DB_PASSWORD || 'payload' }}
# Secrets mapping (Mail)
MAIL_HOST: ${{ secrets.SMTP_HOST || vars.SMTP_HOST }}
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 }}
MAIL_RECIPIENTS: ${{ secrets.CONTACT_RECIPIENT || vars.CONTACT_RECIPIENT }}
MAIL_HOST: ${{ secrets.SMTP_HOST || vars.SMTP_HOST }}
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 }}
MAIL_RECIPIENTS: ${{ secrets.CONTACT_RECIPIENT || vars.CONTACT_RECIPIENT }}
# Monitoring
SENTRY_DSN: ${{ secrets.SENTRY_DSN || vars.SENTRY_DSN }}
SENTRY_DSN: ${{ secrets.SENTRY_DSN || vars.SENTRY_DSN }}
# Gatekeeper
GATEKEEPER_PASSWORD: ${{ secrets.GATEKEEPER_PASSWORD || 'klz2026' }}
# 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 }}
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' }}
steps:
- name: Checkout repository
uses: actions/checkout@v4
- name: 📝 Generate Environment
shell: bash
env:
TRAEFIK_RULE: ${{ needs.prepare.outputs.traefik_rule }}
ENV_FILE: ${{ needs.prepare.outputs.env_file }}
TRAEFIK_RULE: ${{ needs.prepare.outputs.traefik_rule }}
ENV_FILE: ${{ needs.prepare.outputs.env_file }}
run: |
# Middleware Selection Logic
# Regular app routes get auth on non-production
@@ -310,7 +276,7 @@ jobs:
LOG_LEVEL=$( [[ "$TARGET" == "testing" || "$TARGET" == "development" ]] && echo "debug" || echo "info" )
COOKIE_DOMAIN=.$(echo $NEXT_PUBLIC_BASE_URL | sed 's|https://||')
STD_MW="${PROJECT_NAME}-ratelimit,${PROJECT_NAME}-forward,${PROJECT_NAME}-compress"
if [[ "$TARGET" == "production" ]]; then
AUTH_MIDDLEWARE="$STD_MW"
COMPOSE_PROFILES=""
@@ -353,12 +319,6 @@ 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"
@@ -378,45 +338,16 @@ 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:
ENV_FILE: ${{ needs.prepare.outputs.env_file }}
ENV_FILE: ${{ needs.prepare.outputs.env_file }}
run: |
mkdir -p ~/.ssh
echo "${{ secrets.ALPHA_SSH_KEY }}" > ~/.ssh/id_ed25519
chmod 600 ~/.ssh/id_ed25519
ssh-keyscan -H alpha.mintel.me >> ~/.ssh/known_hosts 2>/dev/null
# Determine deployment paths
echo "Preparing deployment for $TARGET..."
# Transfer and Restart
if [[ "$TARGET" == "production" ]]; then
SITE_DIR="/home/deploy/sites/klz-cables.com"
@@ -425,16 +356,17 @@ 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"
SITE_DIR="/home/deploy/sites/branch.klz-cables.com/${SLUG:-unknown}"
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
# 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"
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"
# Sanitize Payload Migrations: Replace 'dev' push entries with proper migration names.
# Without this, Payload prompts interactively for confirmation and blocks forever in Docker.
DB_CONTAINER="${{ needs.prepare.outputs.project_name }}-klz-db-1"
@@ -447,32 +379,40 @@ jobs:
echo " Attempt $i/15..."
sleep 2
done
echo "🔧 Sanitizing payload_migrations table (if exists)..."
REMOTE_DB_USER=$(ssh root@alpha.mintel.me "grep -h '^PAYLOAD_DB_USER=' $SITE_DIR/.env* 2>/dev/null | tail -1 | cut -d= -f2" || echo "payload")
REMOTE_DB_NAME=$(ssh root@alpha.mintel.me "grep -h '^PAYLOAD_DB_NAME=' $SITE_DIR/.env* 2>/dev/null | tail -1 | cut -d= -f2" || echo "payload")
REMOTE_DB_USER="${REMOTE_DB_USER:-payload}"
REMOTE_DB_NAME="${REMOTE_DB_NAME:-payload}"
# 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.'
"
# 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
# Restart app to pick up clean migration state
APP_CONTAINER="${PROJECT_NAME}-klz-app-1"
APP_CONTAINER="${{ needs.prepare.outputs.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)
@@ -521,7 +461,7 @@ jobs:
with:
path: /usr/bin/chromium
key: ${{ runner.os }}-chromium-native-${{ hashFiles('package.json') }}
- name: 🔍 Install Chromium (Native & ARM64)
if: steps.cache-chromium.outputs.cache-hit != 'true'
run: |
@@ -631,8 +571,15 @@ jobs:
STATUS_LINE="All checks passed"
fi
TITLE="$EMOJI klz-cables.com $VERSION -> $TARGET"
MESSAGE="$STATUS_LINE | Deploy: $DEPLOY | Smoke: $SMOKE | $URL"
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
curl -s -k -X POST "${{ secrets.GOTIFY_URL }}/message?token=${{ secrets.GOTIFY_TOKEN }}" \
-F "title=$TITLE" \

View File

@@ -1,8 +1,6 @@
name: Nightly QA
on:
push:
branches: [main]
schedule:
- cron: '0 3 * * *'
workflow_dispatch:
@@ -227,6 +225,11 @@ 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" \

6
.gitignore vendored
View File

@@ -28,9 +28,3 @@ html-errors*.json
reference/
# Database backups
backups/
.env
# Payload CMS auto-generated
# Knowledge base source files
kabelhandbuch.txt

1
.npmrc
View File

@@ -1 +1,2 @@
@mintel:registry=https://git.infra.mintel.me/api/packages/mmintel/npm/
//git.infra.mintel.me/api/packages/mmintel/npm/:_authToken=${NPM_TOKEN}

View File

@@ -1,16 +1,22 @@
# Stage 1: Builder
FROM git.infra.mintel.me/mmintel/nextjs:latest AS base
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
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
@@ -18,7 +24,6 @@ ENV CI=true
# Copy lockfile and manifest for dependency installation caching
COPY pnpm-lock.yaml package.json .npmrc* ./
COPY patches* ./patches/
# Configure private registry and install dependencies
RUN --mount=type=cache,id=pnpm,target=/pnpm/store \
@@ -38,11 +43,6 @@ FROM base AS development
ENV NODE_ENV=development
CMD ["pnpm", "dev:local"]
# Stage: Migrator
FROM base AS migrator
ENV NODE_ENV=production
CMD ["pnpm", "cms:migrate"]
# Build application
# Stage 3: Builder (Production)
FROM base AS builder
@@ -56,9 +56,19 @@ ENV UV_THREADPOOL_SIZE=3
RUN pnpm build
# Stage 2: Runner
FROM git.infra.mintel.me/mmintel/runtime:latest AS runner
FROM node:20-alpine 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

View File

@@ -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++ curl
RUN apk add --no-cache libc6-compat python3 make g++
WORKDIR /app

View File

@@ -1,84 +1,57 @@
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';
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'
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,
'@mintel/payload-ai/components/ChatWindow#ChatWindowProvider':
ChatWindowProvider_d32a660df96f186e48bfc5b31626ccf5,
'@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,
"@payloadcms/next/rsc#CollectionCards": CollectionCards_f9c02e79a4aed9a3924487c0cd4cafb1
}

View File

@@ -1,4 +1,4 @@
import { notFound, redirect } from 'next/navigation';
import { notFound, redirect, permanentRedirect } from 'next/navigation';
import { Container, Badge, Heading } from '@/components/ui';
import { getTranslations, setRequestLocale } from 'next-intl/server';
import { Metadata } from 'next';
@@ -62,6 +62,15 @@ 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);

View File

@@ -134,13 +134,13 @@ export default async function BlogPost({ params }: BlogPostProps) {
<span>{getReadingTime(rawTextContent)} min read</span>
{(new Date(post.frontmatter.date) > new Date() ||
post.frontmatter.public === false) && (
<>
<span className="w-1 h-1 bg-white/30 rounded-full" />
<span className="px-2 py-0.5 border border-white/40 text-white/80 rounded uppercase tracking-widest text-[10px] md:text-xs font-bold">
Draft Preview
</span>
</>
)}
<>
<span className="w-1 h-1 bg-white/30 rounded-full" />
<span className="px-2 py-0.5 border border-white/40 text-white/80 rounded uppercase tracking-widest text-[10px] md:text-xs font-bold">
Draft Preview
</span>
</>
)}
</div>
</div>
</div>
@@ -171,13 +171,13 @@ export default async function BlogPost({ params }: BlogPostProps) {
<span>{getReadingTime(rawTextContent)} min read</span>
{(new Date(post.frontmatter.date) > new Date() ||
post.frontmatter.public === false) && (
<>
<span className="w-1 h-1 bg-neutral-300 rounded-full" />
<span className="px-2 py-0.5 border border-orange-500/50 text-orange-600 rounded uppercase tracking-widest text-[10px] md:text-xs font-bold">
Draft Preview
</span>
</>
)}
<>
<span className="w-1 h-1 bg-neutral-300 rounded-full" />
<span className="px-2 py-0.5 border border-orange-500/50 text-orange-600 rounded uppercase tracking-widest text-[10px] md:text-xs font-bold">
Draft Preview
</span>
</>
)}
</div>
</div>
</header>

View File

@@ -132,11 +132,7 @@ export default async function Layout(props: {
const feedbackEnabled = process.env.NEXT_PUBLIC_FEEDBACK_ENABLED === 'true';
return (
<html
lang={safeLocale}
className={`scroll-smooth overflow-x-hidden ${inter.variable}`}
data-scroll-behavior="smooth"
>
<html lang={safeLocale} className={`overflow-x-hidden ${inter.variable}`}>
<head>
<link rel="preconnect" href="https://fonts.googleapis.com" />
<link rel="preconnect" href="https://fonts.gstatic.com" crossOrigin="anonymous" />

View File

@@ -1,223 +0,0 @@
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 },
);
}
}

View File

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

View File

@@ -0,0 +1,64 @@
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 });
}
}

View File

@@ -1,165 +0,0 @@
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}`);
}
}
// ── Kabelhandbuch (Static Text) ──
const os = require('os');
const path = require('path');
const fs = require('fs');
const crypto = await import('crypto');
const txtPath = path.join(process.cwd(), 'kabelhandbuch.txt');
let manualChunks = 0;
if (fs.existsSync(txtPath)) {
try {
const text = fs.readFileSync(txtPath, 'utf8');
const chunks = text
.split(/\n\s*\n/)
.map((c: string) => c.trim())
.filter((c: string) => c.length > 50);
for (let i = 0; i < chunks.length; i++) {
const chunkText = chunks[i];
const syntheticId = crypto.randomUUID();
await upsertProductVector(syntheticId, chunkText, {
type: 'knowledge',
content: chunkText,
data: {
title: `Kabelhandbuch Wissen - Bereich ${i + 1}`,
source: 'Kabelhandbuch KLZ.pdf',
},
});
manualChunks++;
}
console.log(`[Qdrant Sync] ✅ ${manualChunks} Kabelhandbuch-Chunks synced`);
} catch (e: any) {
results.errors.push(`kabelhandbuch: ${e.message}`);
}
} else {
console.log(`[Qdrant Sync] ⚠️ skipped Kabelhandbuch: ${txtPath} not found`);
}
console.log(
`[Qdrant Sync] ✅ ${results.products} products, ${results.posts} posts, ${results.pages} pages synced, ${manualChunks} manual chunks 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 });
}
}

View File

@@ -15,12 +15,10 @@ 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, Testing, or Branch preview)
// Only proceed with check if it's developer context (Local or Testing)
// Staging and Production should NEVER see this unless forced with ?cms_debug
if (!isLocal && !isTesting && !isBranch && !isDebug) return;
if (!isLocal && !isTesting && !isDebug) return;
try {
const response = await fetch('/api/health/cms');
@@ -60,8 +58,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 run migrations for this environment.'
: 'A content service is unavailable. Check the deployment logs for details.'}
? 'The database schema is missing. Please sync your local data to this environment.'
: errorMsg || 'The application cannot connect to the Directus CMS.'}
</p>
<div className="flex gap-2">
<button

View File

@@ -3,7 +3,6 @@
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';
@@ -277,48 +276,6 @@ 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>
);

View File

@@ -9,8 +9,6 @@ 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');
@@ -18,7 +16,6 @@ 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
@@ -276,19 +273,6 @@ 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'}`}
@@ -483,8 +467,6 @@ export default function Header() {
</div>
</nav>
</div>
<AISearchResults isOpen={isSearchOpen} onClose={() => setIsSearchOpen(false)} />
</>
);
}

View File

@@ -0,0 +1,34 @@
'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>
);
};

View File

@@ -37,6 +37,7 @@ 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.
@@ -429,6 +430,12 @@ 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;

View File

@@ -1,221 +1,98 @@
'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-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"
<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"
>
<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
/>
{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">
<div>
<Button
type="submit"
href="/contact"
variant="accent"
size="lg"
className="rounded-xl px-6 py-4 shrink-0 flex items-center shadow-md font-bold cursor-pointer hover:bg-accent hover:brightness-110"
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',
})
}
>
Fragen
<ChevronRight className="w-5 h-5 ml-2 -mr-1" />
{data?.ctaLabel || t('cta')}
<span className="transition-transform group-hover/btn:translate-x-1 ml-2">
&rarr;
</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')}
</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">
&rarr;
</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>
</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>
</Container>
<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 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>
</Section>
<AISearchResults
isOpen={isSearchOpen}
onClose={() => setIsSearchOpen(false)}
initialQuery={searchQuery}
triggerSearch={true}
/>
</>
</div>
</Section>
);
}

View File

@@ -74,14 +74,11 @@ export default async function RecentPosts({ locale, data }: RecentPostsProps) {
suppressHydrationWarning
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?.length === 2 ? locale : 'de',
{
year: 'numeric',
month: 'short',
day: 'numeric',
},
)}
{new Date(post.frontmatter.date).toLocaleDateString(locale || 'de', {
year: 'numeric',
month: 'short',
day: 'numeric',
})}
</time>
{(new Date(post.frontmatter.date) > new Date() ||
post.frontmatter.public === false) && (

View File

@@ -1,371 +0,0 @@
'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>
);
}

View File

@@ -1,646 +0,0 @@
'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>
);
}

View File

@@ -8,7 +8,7 @@ services:
- infra
labels:
- "caddy=http://${TRAEFIK_HOST:-klz.localhost}"
- "caddy.reverse_proxy=http://klz-app:3000"
- "caddy.reverse_proxy=host.docker.internal:3100"
# Full Docker dev (use with `pnpm run dev:docker`)
klz-app:
@@ -26,20 +26,13 @@ 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}
UV_THREADPOOL_SIZE: "1"
RAYON_NUM_THREADS: "1"
NEXT_PRIVATE_WORKER_THREADS: "false"
NODE_OPTIONS: "--max-old-space-size=8192"
UV_THREADPOOL_SIZE: "4"
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
@@ -49,34 +42,19 @@ services:
- /app/.git
- /app/reference
- /app/data
deploy:
resources:
limits:
cpus: '4'
memory: 8G
command: >
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"
sh -c "pnpm install && pnpm next dev --webpack --hostname 0.0.0.0"
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
@@ -97,24 +75,6 @@ services:
ports:
- "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:
name: ${PROJECT_NAME:-klz-cables}-internal
@@ -124,8 +84,6 @@ networks:
volumes:
klz_db_data:
external: false
klz_qdrant_data:
external: false
klz_node_modules:
klz_next_cache:
klz_turbo_cache:

View File

@@ -100,25 +100,6 @@ 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
@@ -130,5 +111,3 @@ volumes:
external: false
klz_media_data:
external: false
klz_qdrant_data:
external: false

View File

@@ -23,9 +23,7 @@ export default [
"tests/**",
"next-env.d.ts",
"reference/**",
"data/**",
"remotion/**",
"components/record-mode/**"
"data/**"
],
},

View File

@@ -11,10 +11,21 @@ export async function getOgFonts() {
try {
console.log(`[OG] Loading fonts: bold=${boldFontPath}, regular=${regularFontPath}`);
const boldFont = readFileSync(boldFontPath);
const regularFont = readFileSync(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,
);
console.log(
`[OG] Fonts loaded successfully (${boldFont.length} and ${regularFont.length} bytes)`,
`[OG] Fonts loaded successfully (${boldFont.byteLength} and ${regularFont.byteLength} bytes)`,
);
return [

View File

@@ -15,6 +15,8 @@ export interface PageFrontmatter {
export interface PageData {
slug: string;
redirectUrl?: string;
redirectPermanent?: boolean;
frontmatter: PageFrontmatter;
content: any; // Lexical AST Document
}
@@ -96,6 +98,8 @@ 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 || '',

View File

@@ -1,22 +1,8 @@
import * as React from 'react';
import {
Document,
Page,
View,
Text,
Image,
StyleSheet,
Font,
} from '@react-pdf/renderer';
import { Document, Page, View, Text, Image, StyleSheet, Font } from '@react-pdf/renderer';
// 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 },
],
});
// 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.
// Industrial/technical/restrained design - STYLEGUIDE.md compliant
const styles = StyleSheet.create({
@@ -302,10 +288,7 @@ 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 (
@@ -317,9 +300,7 @@ export const PDFDatasheet: React.FC<PDFDatasheetProps> = ({
<View>
<Text style={styles.logoText}>KLZ</Text>
</View>
<Text style={styles.docTitle}>
{labels.productDatasheet}
</Text>
<Text style={styles.docTitle}>{labels.productDatasheet}</Text>
</View>
<View style={styles.productRow}>
@@ -328,7 +309,8 @@ export const PDFDatasheet: React.FC<PDFDatasheetProps> = ({
<View style={styles.categories}>
{product.categories.map((cat, index) => (
<Text key={index} style={styles.productMeta}>
{cat.name}{index < product.categories.length - 1 ? ' • ' : ''}
{cat.name}
{index < product.categories.length - 1 ? ' • ' : ''}
</Text>
))}
</View>
@@ -337,12 +319,8 @@ export const PDFDatasheet: React.FC<PDFDatasheetProps> = ({
</View>
<View style={styles.productImageCol}>
{product.featuredImage ? (
<Image
src={product.featuredImage}
style={styles.heroImage}
/>
<Image src={product.featuredImage} style={styles.heroImage} />
) : (
<Text style={styles.noImage}>{labels.noImage}</Text>
)}
</View>
@@ -356,7 +334,11 @@ export const PDFDatasheet: React.FC<PDFDatasheetProps> = ({
<Text style={styles.sectionTitle}>{labels.description}</Text>
<View style={styles.sectionAccent} />
<Text style={styles.description}>
{stripHtml(product.applicationHtml || product.shortDescriptionHtml || product.descriptionHtml)}
{stripHtml(
product.applicationHtml ||
product.shortDescriptionHtml ||
product.descriptionHtml,
)}
</Text>
</View>
)}
@@ -372,17 +354,14 @@ export const PDFDatasheet: React.FC<PDFDatasheetProps> = ({
key={index}
style={[
styles.specsTableRow,
index === product.attributes.length - 1 &&
styles.specsTableRowLast,
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>
<Text style={styles.specsTableValueText}>{attr.options.join(', ')}</Text>
</View>
</View>
))}

328
lib/pdf-page.tsx Normal file
View File

@@ -0,0 +1,328 @@
import * as React from 'react';
import { Document, Page, View, Text, StyleSheet, Font, Link } 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.5,
backgroundColor: C.white,
paddingTop: 0,
paddingBottom: 100,
fontFamily: 'Helvetica',
},
// Hero-style header
hero: {
backgroundColor: C.white,
paddingTop: 24,
paddingBottom: 20,
paddingHorizontal: MARGIN,
marginBottom: 20,
position: 'relative',
borderBottomWidth: 1,
borderBottomColor: C.gray200,
},
header: {
flexDirection: 'row',
justifyContent: 'space-between',
alignItems: 'center',
marginBottom: 16,
},
logoText: {
fontSize: 24,
fontWeight: 700,
color: C.navyDeep,
letterSpacing: 1,
textTransform: 'uppercase',
},
docTitle: {
fontSize: 10,
fontWeight: 700,
color: C.navy,
letterSpacing: 2,
textTransform: 'uppercase',
},
productHero: {
marginTop: 0,
},
pageTitle: {
fontSize: 24,
fontWeight: 700,
color: C.navyDeep,
marginBottom: 0,
textTransform: 'uppercase',
letterSpacing: -0.5,
},
accentBar: {
width: 30,
height: 3,
backgroundColor: C.accent,
marginTop: 8,
borderRadius: 1.5,
},
// Content Area
content: {
paddingHorizontal: MARGIN,
},
// Lexical Elements
paragraph: {
fontSize: 10,
color: C.gray600,
lineHeight: 1.7,
marginBottom: 12,
},
heading1: {
fontSize: 16,
fontWeight: 700,
color: C.navyDeep,
marginTop: 20,
marginBottom: 10,
textTransform: 'uppercase',
letterSpacing: 0.5,
},
heading2: {
fontSize: 12,
fontWeight: 700,
color: C.navyDeep,
marginTop: 16,
marginBottom: 8,
},
heading3: {
fontSize: 10,
fontWeight: 700,
color: C.navyDeep,
marginTop: 12,
marginBottom: 6,
},
list: {
marginBottom: 12,
marginLeft: 8,
},
listItem: {
flexDirection: 'row',
marginBottom: 4,
},
listItemBullet: {
width: 12,
fontSize: 10,
color: C.accent,
fontWeight: 700,
},
listItemContent: {
flex: 1,
fontSize: 10,
color: C.gray600,
lineHeight: 1.7,
},
link: {
color: C.accent,
textDecoration: 'none',
},
textBold: {
fontWeight: 700,
fontFamily: 'Helvetica-Bold',
color: C.navyDeep,
},
textItalic: {
fontStyle: 'italic',
},
// Footer — matches brochure style
footer: {
position: 'absolute',
bottom: 40,
left: MARGIN,
right: MARGIN,
flexDirection: 'row',
justifyContent: 'space-between',
alignItems: 'center',
paddingTop: 24,
borderTopWidth: 1,
borderTopColor: C.gray200,
},
footerText: {
fontSize: 8,
color: C.gray400,
fontWeight: 500,
textTransform: 'uppercase',
letterSpacing: 1,
},
footerBrand: {
fontSize: 10,
fontWeight: 700,
color: C.navyDeep,
textTransform: 'uppercase',
letterSpacing: 1,
},
});
// ─── 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': {
return (
<Text key={idx} style={styles.paragraph}>
{node.children?.map((child: any, i: number) => renderLexicalNode(child, i))}
</Text>
);
}
case 'heading': {
let hStyle = styles.heading3;
if (node.tag === 'h1') hStyle = styles.heading1;
if (node.tag === 'h2') hStyle = styles.heading2;
return (
<Text key={idx} style={hStyle}>
{node.children?.map((child: any, i: number) => renderLexicalNode(child, i))}
</Text>
);
}
case 'list': {
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',
});
return (
<Document>
<Page size="A4" style={styles.page}>
{/* Hero Header */}
<View style={styles.hero} fixed>
<View style={styles.header}>
<View>
<Text style={styles.logoText}>KLZ</Text>
</View>
<Text style={styles.docTitle}>{locale === 'en' ? 'Document' : 'Dokument'}</Text>
</View>
<View style={styles.productHero}>
<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>
{/* Minimal footer */}
<View style={styles.footer} fixed>
<Text style={styles.footerBrand}>KLZ CABLES</Text>
<Text style={styles.footerText}>{dateStr}</Text>
</View>
</Page>
</Document>
);
};

View File

@@ -3,7 +3,7 @@
"pages": {
"impressum": "impressum",
"datenschutz": "datenschutz",
"agbs": "agbs",
"agbs": "terms",
"kontakt": "contact",
"team": "team",
"blog": "blog",
@@ -74,7 +74,7 @@
"privacyPolicy": "Datenschutz",
"privacyPolicySlug": "datenschutz",
"terms": "AGB",
"termsSlug": "agbs",
"termsSlug": "terms",
"products": "Produkte",
"lowVoltage": "Niederspannungskabel",
"mediumVoltage": "Mittelspannungskabel",

View File

@@ -3,7 +3,7 @@
"pages": {
"legal-notice": "impressum",
"privacy-policy": "datenschutz",
"terms": "agbs",
"terms": "terms",
"contact": "contact",
"team": "team",
"blog": "blog",
@@ -396,4 +396,4 @@
"cta": "Back to Safety"
}
}
}
}

View File

@@ -7,26 +7,21 @@ 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: {
// 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,
// Make sure entries are not disposed too quickly
maxInactiveAge: 60 * 1000,
},
experimental: {
optimizePackageImports: [
'lucide-react',
'framer-motion',
'@/components/ui',
'@sentry/nextjs',
'@payloadcms/richtext-lexical',
'react-hook-form',
'zod',
'date-fns',
],
staleTimes: {
dynamic: 0,
static: 30,
},
optimizePackageImports: ['lucide-react', 'framer-motion', '@/components/ui'],
cpus: 3,
workerThreads: false,
memoryBasedWorkersCount: true,
serverActions: {
allowedOrigins: ["*.klz-cables.com", "*.branch.klz-cables.com", "localhost:3000", "klz.localhost"],
},
},
reactStrictMode: false,
productionBrowserSourceMaps: false,
@@ -36,21 +31,6 @@ 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;
@@ -73,7 +53,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} https://raw.githack.com https://raw.githubusercontent.com;
connect-src 'self' ${umamiDomain} ${glitchtipDomain};
frame-src 'self';
object-src 'none';
base-uri 'self';
@@ -419,7 +399,6 @@ const nextConfig = {
];
},
images: {
qualities: [25, 50, 75, 100],
formats: ['image/webp'],
deviceSizes: [640, 750, 828, 1080, 1200, 1920, 2048, 3840],
remotePatterns: [
@@ -485,10 +464,6 @@ const nextConfig = {
source: '/en/datenschutz',
destination: '/en/privacy-policy',
},
{
source: '/en/agbs',
destination: '/en/terms',
},
],
afterFiles: [],
fallback: [],

View File

@@ -4,26 +4,19 @@
"private": true,
"packageManager": "pnpm@10.18.3",
"dependencies": {
"@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",
"@mintel/mail": "^1.8.21",
"@mintel/next-config": "^1.8.21",
"@mintel/next-feedback": "^1.8.21",
"@mintel/next-utils": "^1.8.21",
"@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",
"@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",
@@ -31,7 +24,6 @@
"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",
@@ -46,17 +38,13 @@
"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"
},
@@ -65,8 +53,8 @@
"@commitlint/config-conventional": "^20.4.0",
"@cspell/dict-de-de": "^4.1.2",
"@lhci/cli": "^0.15.1",
"@mintel/eslint-config": "^1.9.0",
"@mintel/tsconfig": "^1.9.0",
"@mintel/eslint-config": "1.8.21",
"@mintel/tsconfig": "^1.8.21",
"@next/bundle-analyzer": "^16.1.6",
"@tailwindcss/cli": "^4.1.18",
"@tailwindcss/postcss": "^4.1.18",
@@ -92,7 +80,6 @@
"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",
@@ -105,8 +92,8 @@
"vitest": "^4.0.16"
},
"scripts": {
"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": "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: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",
@@ -164,9 +151,6 @@
"overrides": {
"next": "16.1.6",
"minimatch": ">=10.2.2"
},
"patchedDependencies": {
"@mintel/payload-ai@1.9.15": "patches/@mintel__payload-ai@1.9.15.patch"
}
},
"browserslist": [

View File

@@ -1,131 +0,0 @@
diff --git a/dist/components/ChatWindow/index.js b/dist/components/ChatWindow/index.js
index 90c65bae4abb78beec98d8308e808e8ba341dcc2..f675dbc69ff82b64438288f53599c93a56391b64 100644
--- a/dist/components/ChatWindow/index.js
+++ b/dist/components/ChatWindow/index.js
@@ -2,7 +2,6 @@
import { jsx as _jsx, Fragment as _Fragment, jsxs as _jsxs } from "react/jsx-runtime";
import { useState } from 'react';
import { useChat } from '@ai-sdk/react';
-import './ChatWindow.scss';
export const ChatWindowProvider = ({ children }) => {
return (_jsxs(_Fragment, { children: [children, _jsx(ChatWindow, {})] }));
};
@@ -14,47 +13,63 @@ const ChatWindow = () => {
initialMessages: []
});
// Basic implementation to toggle chat window and submit messages
- return (_jsxs("div", { className: "payload-mcp-chat-container", children: [_jsx("button", { className: "payload-mcp-chat-toggle", onClick: () => setIsOpen(!isOpen), style: {
- position: 'fixed',
- bottom: '20px',
- right: '20px',
- zIndex: 9999,
- padding: '12px 24px',
- backgroundColor: '#000',
- color: '#fff',
- borderRadius: '8px',
- border: 'none',
- cursor: 'pointer',
- fontWeight: 'bold'
- }, children: isOpen ? 'Close AI Chat' : 'Ask AI' }), isOpen && (_jsxs("div", { className: "payload-mcp-chat-window", style: {
- position: 'fixed',
- bottom: '80px',
- right: '20px',
- width: '400px',
- height: '600px',
- backgroundColor: '#fff',
- border: '1px solid #eaeaea',
- borderRadius: '12px',
- zIndex: 9999,
- display: 'flex',
- flexDirection: 'column',
- boxShadow: '0 10px 40px rgba(0,0,0,0.1)'
- }, children: [_jsx("div", { className: "chat-header", style: { padding: '16px', borderBottom: '1px solid #eaeaea', backgroundColor: '#f9f9f9', borderTopLeftRadius: '12px', borderTopRightRadius: '12px' }, children: _jsx("h3", { style: { margin: 0, fontSize: '16px' }, children: "Payload MCP Chat" }) }), _jsx("div", { className: "chat-messages", style: { flex: 1, padding: '16px', overflowY: 'auto' }, children: messages.map((m) => (_jsx("div", { style: {
- marginBottom: '12px',
- textAlign: m.role === 'user' ? 'right' : 'left'
- }, children: _jsxs("div", { style: {
- display: 'inline-block',
- padding: '8px 12px',
- borderRadius: '8px',
- backgroundColor: m.role === 'user' ? '#000' : '#f0f0f0',
- color: m.role === 'user' ? '#fff' : '#000',
- maxWidth: '80%'
- }, children: [m.role === 'user' ? 'G: ' : 'AI: ', m.content] }) }, m.id))) }), _jsx("form", { onSubmit: handleSubmit, style: { padding: '16px', borderTop: '1px solid #eaeaea' }, children: _jsx("input", { value: input, placeholder: "Ask me anything or use /commands...", onChange: handleInputChange, style: {
- width: '100%',
- padding: '12px',
- borderRadius: '8px',
- border: '1px solid #eaeaea',
- boxSizing: 'border-box'
- } }) })] }))] }));
+ return (_jsxs("div", {
+ className: "payload-mcp-chat-container", children: [_jsx("button", {
+ className: "payload-mcp-chat-toggle", onClick: () => setIsOpen(!isOpen), style: {
+ position: 'fixed',
+ bottom: '20px',
+ right: '20px',
+ zIndex: 9999,
+ padding: '12px 24px',
+ backgroundColor: '#000',
+ color: '#fff',
+ borderRadius: '8px',
+ border: 'none',
+ cursor: 'pointer',
+ fontWeight: 'bold'
+ }, children: isOpen ? 'Close AI Chat' : 'Ask AI'
+ }), isOpen && (_jsxs("div", {
+ className: "payload-mcp-chat-window", style: {
+ position: 'fixed',
+ bottom: '80px',
+ right: '20px',
+ width: '400px',
+ height: '600px',
+ backgroundColor: '#fff',
+ border: '1px solid #eaeaea',
+ borderRadius: '12px',
+ zIndex: 9999,
+ display: 'flex',
+ flexDirection: 'column',
+ boxShadow: '0 10px 40px rgba(0,0,0,0.1)'
+ }, children: [_jsx("div", { className: "chat-header", style: { padding: '16px', borderBottom: '1px solid #eaeaea', backgroundColor: '#f9f9f9', borderTopLeftRadius: '12px', borderTopRightRadius: '12px' }, children: _jsx("h3", { style: { margin: 0, fontSize: '16px' }, children: "Payload MCP Chat" }) }), _jsx("div", {
+ className: "chat-messages", style: { flex: 1, padding: '16px', overflowY: 'auto' }, children: messages.map((m) => (_jsx("div", {
+ style: {
+ marginBottom: '12px',
+ textAlign: m.role === 'user' ? 'right' : 'left'
+ }, children: _jsxs("div", {
+ style: {
+ display: 'inline-block',
+ padding: '8px 12px',
+ borderRadius: '8px',
+ backgroundColor: m.role === 'user' ? '#000' : '#f0f0f0',
+ color: m.role === 'user' ? '#fff' : '#000',
+ maxWidth: '80%'
+ }, children: [m.role === 'user' ? 'G: ' : 'AI: ', m.content]
+ })
+ }, m.id)))
+ }), _jsx("form", {
+ onSubmit: handleSubmit, style: { padding: '16px', borderTop: '1px solid #eaeaea' }, children: _jsx("input", {
+ value: input, placeholder: "Ask me anything or use /commands...", onChange: handleInputChange, style: {
+ width: '100%',
+ padding: '12px',
+ borderRadius: '8px',
+ border: '1px solid #eaeaea',
+ boxSizing: 'border-box'
+ }
+ })
+ })]
+ }))]
+ }));
};
//# sourceMappingURL=index.js.map
\ No newline at end of file
diff --git a/src/components/ChatWindow/index.tsx b/src/components/ChatWindow/index.tsx
index 9081ae77d4eae53ce660e285c1a6babde99ceaab..f262f1dd0fd1199734024cc27905d956e31900a2 100644
--- a/src/components/ChatWindow/index.tsx
+++ b/src/components/ChatWindow/index.tsx
@@ -2,7 +2,6 @@
import React, { useState } from 'react'
import { useChat } from '@ai-sdk/react'
-import './ChatWindow.scss'
export const ChatWindowProvider: React.FC<{ children: React.ReactNode }> = ({ children }) => {
return (

View File

@@ -87,7 +87,9 @@ export interface Config {
products: ProductsSelect<false> | ProductsSelect<true>;
pages: PagesSelect<false> | PagesSelect<true>;
'payload-kv': PayloadKvSelect<false> | PayloadKvSelect<true>;
'payload-locked-documents': PayloadLockedDocumentsSelect<false> | PayloadLockedDocumentsSelect<true>;
'payload-locked-documents':
| PayloadLockedDocumentsSelect<false>
| PayloadLockedDocumentsSelect<true>;
'payload-preferences': PayloadPreferencesSelect<false> | PayloadPreferencesSelect<true>;
'payload-migrations': PayloadMigrationsSelect<false> | PayloadMigrationsSelect<true>;
};
@@ -98,6 +100,9 @@ export interface Config {
globals: {};
globalsSelect: {};
locale: 'de' | 'en';
widgets: {
collections: CollectionsWidget;
};
user: User;
jobs: {
tasks: unknown;
@@ -328,6 +333,14 @@ 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;
@@ -574,6 +587,8 @@ export interface PagesSelect<T extends boolean = true> {
layout?: T;
excerpt?: T;
featuredImage?: T;
redirectUrl?: T;
redirectPermanent?: T;
content?: T;
updatedAt?: T;
createdAt?: T;
@@ -619,6 +634,16 @@ 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".
@@ -957,7 +982,6 @@ export interface Auth {
[k: string]: unknown;
}
declare module 'payload' {
export interface GeneratedTypes extends Config {}
}
}

View File

@@ -23,13 +23,6 @@ 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);
@@ -78,6 +71,8 @@ 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 ||
@@ -105,14 +100,5 @@ export default buildConfig({
})
: undefined,
sharp,
plugins: [
...(chatPlugin
? [
chatPlugin({
enabled: true,
mcpServers: [{ name: 'klz-qdrant-mcp' }],
}),
]
: []),
],
plugins: [],
});

5375
pnpm-lock.yaml generated

File diff suppressed because it is too large Load Diff

View File

@@ -13,7 +13,6 @@ 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"
@@ -21,20 +20,21 @@ BACKUP_FILE="${BACKUP_DIR}/payload_${TIMESTAMP}.sql.gz"
# Ensure backup directory exists
mkdir -p "$BACKUP_DIR"
# 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
# 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
fi
echo "📦 Backing up Payload database..."
echo " Container: $DB_CONTAINER"
echo " Service: klz-db"
echo " Database: $DB_NAME"
echo " Output: $BACKUP_FILE"
# Run pg_dump inside the container and compress
docker exec "$DB_CONTAINER" pg_dump -U "$DB_USER" -d "$DB_NAME" --clean --if-exists | gzip > "$BACKUP_FILE"
docker compose exec -T klz-db pg_dump -U "$DB_USER" -d "$DB_NAME" --clean --if-exists | gzip > "$BACKUP_FILE"
# Show result
SIZE=$(du -h "$BACKUP_FILE" | cut -f1)

View File

@@ -1,220 +0,0 @@
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 = 1024; // Mistral mistral-embed
// 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);
}
}
/**
* Hash text for cache key
*/
function hashKey(text: string): string {
const { createHash } = require('crypto');
return createHash('sha256').update(text).digest('hex').slice(0, 32);
}
/**
* Generate embedding using Mistral API (EU/DSGVO-compliant)
*/
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 mistralKey = process.env.MISTRAL_API_KEY;
if (!mistralKey) {
throw new Error('MISTRAL_API_KEY is not set');
}
const response = await fetch('https://api.mistral.ai/v1/embeddings', {
method: 'POST',
headers: {
Authorization: `Bearer ${mistralKey}`,
'Content-Type': 'application/json',
},
body: JSON.stringify({
model: 'mistral-embed',
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);
}
}
/**
* Delete knowledge chunks by their source Media ID
*/
export async function deleteKnowledgeByMediaId(mediaId: string | number) {
try {
await ensureCollection();
await qdrant.delete(COLLECTION_NAME, {
wait: true,
filter: {
must: [
{
key: 'mediaId',
match: {
value: mediaId,
},
},
],
},
});
console.log(`Successfully deleted Qdrant chunks for Media ID: ${mediaId}`);
} catch (error) {
console.error('Error deleting knowledge by Media ID 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 [];
}
}

View File

@@ -1,22 +0,0 @@
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;

View File

@@ -55,29 +55,10 @@ 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`
@@ -221,63 +202,9 @@ 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`
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"`,
);
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`

View File

@@ -0,0 +1,52 @@
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";
`);
}

View File

@@ -0,0 +1,22 @@
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";
`);
}

View File

@@ -2,6 +2,8 @@ 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 = [
{
@@ -24,4 +26,14 @@ 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',
},
];

View File

@@ -0,0 +1,30 @@
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' },
],
},
],
};

View File

@@ -16,6 +16,7 @@ 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 = [
@@ -38,4 +39,5 @@ export const payloadBlocks = [
TeamProfile,
TechnicalGrid,
VisualLinkPreview,
PDFDownload,
];

View File

@@ -45,81 +45,4 @@ export const Media: CollectionConfig = {
type: 'text',
},
],
hooks: {
afterChange: [
async ({ doc, req }) => {
// Only process PDF files
if (doc.mimeType === 'application/pdf') {
try {
const fs = require('fs');
const path = require('path');
const crypto = require('crypto');
const pdfParse = require('pdf-parse');
const { upsertProductVector, deleteKnowledgeByMediaId } = require('../../lib/qdrant');
const filePath = path.join(process.cwd(), 'public/media', doc.filename);
if (fs.existsSync(filePath)) {
req.payload.logger.info(`Extracting text from PDF: ${doc.filename}`);
const dataBuffer = fs.readFileSync(filePath);
const data = await pdfParse(dataBuffer);
// Clear any previously indexed chunks for this file just in case it's an update
await deleteKnowledgeByMediaId(doc.id);
// Chunk the text like we did in the ingest script
const chunks = data.text
.split(/\n\s*\n/)
.map((c: string) => c.trim())
.filter((c: string) => c.length > 50);
let successCount = 0;
for (let i = 0; i < chunks.length; i++) {
// Generate a deterministic UUID based on doc ID and chunk index
const hash = crypto.createHash('md5').update(`${doc.id}-${i}`).digest('hex');
// Qdrant strictly requires UUID: 8-4-4-4-12
const uuid = [
hash.substring(0, 8),
hash.substring(8, 12),
hash.substring(12, 16),
hash.substring(16, 20),
hash.substring(20, 32),
].join('-');
await upsertProductVector(uuid, chunks[i], {
type: 'knowledge',
title: `${doc.filename} - Teil ${i + 1}`,
content: chunks[i],
source: doc.filename,
mediaId: doc.id,
});
successCount++;
}
req.payload.logger.info(
`Successfully ingested ${successCount} chunks from ${doc.filename} into Qdrant`,
);
}
} catch (e: any) {
req.payload.logger.error(`Error parsing PDF ${doc.filename}: ${e.message}`);
}
}
},
],
afterDelete: [
async ({ id, doc, req }) => {
if (doc.mimeType === 'application/pdf') {
try {
const { deleteKnowledgeByMediaId } = require('../../lib/qdrant');
await deleteKnowledgeByMediaId(id);
req.payload.logger.info(`Removed Qdrant chunks for deleted PDF: ${doc.filename}`);
} catch (e: any) {
req.payload.logger.error(
`Error removing Qdrant chunks for ${doc.filename}: ${e.message}`,
);
}
}
},
],
},
};

View File

@@ -26,66 +26,6 @@ export const Pages: CollectionConfig = {
};
},
},
hooks: {
afterChange: [
async ({ doc, req }) => {
// Run index sync asynchronously to not block the CMS save operation
setTimeout(async () => {
try {
const { upsertProductVector, deleteProductVector } = await import('../../lib/qdrant');
// Check if page is published
if (doc._status !== 'published') {
await deleteProductVector(`page_${doc.id}`);
req.payload.logger.info(`Removed drafted page ${doc.slug} from Qdrant`);
} else {
// Serialize payload
const contentText = [
`Seite: ${doc.title}`,
doc.excerpt ? `Beschreibung: ${doc.excerpt}` : '',
]
.filter(Boolean)
.join('\n');
const payload = {
type: 'knowledge',
content: contentText,
data: {
title: doc.title,
slug: doc.slug,
},
};
await upsertProductVector(`page_${doc.id}`, contentText, payload);
req.payload.logger.info(`Upserted page ${doc.slug} to Qdrant`);
}
} catch (error) {
req.payload.logger.error({
msg: 'Error syncing page to Qdrant',
err: error,
pageId: doc.id,
});
}
}, 0);
return doc;
},
],
afterDelete: [
async ({ id, req }) => {
try {
const { deleteProductVector } = await import('../../lib/qdrant');
await deleteProductVector(`page_${id}`);
req.payload.logger.info(`Deleted page ${id} from Qdrant`);
} catch (error) {
req.payload.logger.error({
msg: 'Error deleting page from Qdrant',
err: error,
pageId: id,
});
}
},
],
},
fields: [
{
name: 'title',
@@ -132,6 +72,33 @@ 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',

View File

@@ -45,67 +45,6 @@ export const Posts: CollectionConfig = {
};
},
},
hooks: {
afterChange: [
async ({ doc, req }) => {
// Run index sync asynchronously to not block the CMS save operation
setTimeout(async () => {
try {
const { upsertProductVector, deleteProductVector } = await import('../../lib/qdrant');
// Check if post is published
if (doc._status !== 'published') {
await deleteProductVector(`post_${doc.id}`);
req.payload.logger.info(`Removed drafted post ${doc.slug} from Qdrant`);
} else {
// Serialize payload
const contentText = [
`Blog-Artikel: ${doc.title}`,
doc.excerpt ? `Zusammenfassung: ${doc.excerpt}` : '',
doc.category ? `Kategorie: ${doc.category}` : '',
]
.filter(Boolean)
.join('\n');
const payload = {
type: 'knowledge',
content: contentText,
data: {
title: doc.title,
slug: doc.slug,
},
};
await upsertProductVector(`post_${doc.id}`, contentText, payload);
req.payload.logger.info(`Upserted post ${doc.slug} to Qdrant`);
}
} catch (error) {
req.payload.logger.error({
msg: 'Error syncing post to Qdrant',
err: error,
postId: doc.id,
});
}
}, 0);
return doc;
},
],
afterDelete: [
async ({ id, req }) => {
try {
const { deleteProductVector } = await import('../../lib/qdrant');
await deleteProductVector(`post_${id}`);
req.payload.logger.info(`Deleted post ${id} from Qdrant`);
} catch (error) {
req.payload.logger.error({
msg: 'Error deleting post from Qdrant',
err: error,
postId: id,
});
}
},
],
},
fields: [
{
name: 'title',

View File

@@ -37,51 +37,6 @@ 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',

View File

@@ -1,64 +0,0 @@
import fs from 'fs';
import path from 'path';
import crypto from 'crypto';
import 'dotenv/config';
// 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);

View File

@@ -1,58 +0,0 @@
import fs from 'fs';
import path from 'path';
import 'dotenv/config';
import { getPayload } from 'payload';
import configPromise from '@payload-config';
async function uploadPDFs() {
const payload = await getPayload({ config: configPromise });
const downloadDir = '/Users/marcmintel/Downloads';
const files = fs.readdirSync(downloadDir).filter((f) => f.endsWith('.pdf'));
console.log(`Found ${files.length} PDFs in Downloads folder.`);
for (const file of files) {
const filePath = path.join(downloadDir, file);
try {
const stats = fs.statSync(filePath);
// Check if it already exists
const existing = await payload.find({
collection: 'media',
where: {
filename: {
equals: file,
},
},
});
if (existing.docs.length > 0) {
console.log(`Skipping ${file} - already exists in CMS`);
continue;
}
console.log(`Uploading ${file}...`);
await payload.create({
collection: 'media',
data: {
alt: file,
},
file: {
data: fs.readFileSync(filePath),
mimetype: 'application/pdf',
name: file,
size: stats.size,
},
});
console.log(`✅ Uploaded ${file}`);
} catch (err) {
console.error(`❌ Failed to upload ${file}:`, err);
}
}
console.log('Done uploading PDFs to Payload CMS. Payload hooks have synced them to Qdrant.');
process.exit(0);
}
uploadPDFs();

View File

@@ -1,20 +0,0 @@
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);

View File

@@ -1,16 +0,0 @@
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();

View File

@@ -43,6 +43,10 @@
"check:spell": {
"inputs": ["content/**/*.{md,mdx}", "app/**/*.tsx", "components/**/*.tsx", "cspell.json"],
"outputs": []
},
"check:mdx": {
"inputs": ["content/**/*.{md,mdx}", "scripts/validate-mdx.mjs"],
"outputs": []
}
}
}