Compare commits

..

166 Commits

Author SHA1 Message Date
18556c818d fix(mail): harden mailer and fix missing notification recipients
Some checks failed
Build & Deploy / 🧪 QA (push) Failing after 1m24s
Build & Deploy / 🔍 Prepare (push) Successful in 16s
Build & Deploy / 🏗️ Build (push) Has been skipped
Build & Deploy / 🚀 Deploy (push) Has been skipped
Build & Deploy / 🧪 Post-Deploy Verification (push) Has been skipped
Build & Deploy / 🔔 Notify (push) Successful in 4s
2026-04-10 12:58:04 +02:00
1a5dfc96c1 fix(patch): updated @mintel/payload-ai exports to allow directory components
All checks were successful
Build & Deploy / 🔍 Prepare (push) Successful in 20s
Build & Deploy / 🧪 QA (push) Successful in 1m28s
Build & Deploy / 🏗️ Build (push) Successful in 2m57s
Build & Deploy / 🚀 Deploy (push) Successful in 31s
Build & Deploy / 🧪 Post-Deploy Verification (push) Successful in 4m27s
Build & Deploy / 🔔 Notify (push) Successful in 2s
2026-04-09 23:50:50 +02:00
ad9f6e1a64 fix(ai-search): remove redundant @ts-expect-error causing typecheck failure
Some checks failed
Build & Deploy / 🔍 Prepare (push) Successful in 24s
Build & Deploy / 🧪 QA (push) Successful in 2m58s
Build & Deploy / 🏗️ Build (push) Failing after 5m12s
Build & Deploy / 🚀 Deploy (push) Has been skipped
Build & Deploy / 🧪 Post-Deploy Verification (push) Has been skipped
Build & Deploy / 🔔 Notify (push) Successful in 3s
2026-04-09 23:26:57 +02:00
10ac7b4877 fix(deps): regenerate patch for @mintel/payload-ai@1.9.18
Some checks failed
Build & Deploy / 🔍 Prepare (push) Successful in 13s
Build & Deploy / 🧪 QA (push) Failing after 2m49s
Build & Deploy / 🏗️ Build (push) Has been skipped
Build & Deploy / 🚀 Deploy (push) Has been skipped
Build & Deploy / 🧪 Post-Deploy Verification (push) Has been skipped
Build & Deploy / 🔔 Notify (push) Successful in 4s
2026-04-09 23:09:39 +02:00
a662c6bfe8 fix(deps): update @mintel/payload-ai to 1.9.18 and migrate patch
Some checks failed
Build & Deploy / 🔍 Prepare (push) Successful in 12s
Build & Deploy / 🧪 QA (push) Failing after 47s
Build & Deploy / 🏗️ Build (push) Has been skipped
Build & Deploy / 🚀 Deploy (push) Has been skipped
Build & Deploy / 🧪 Post-Deploy Verification (push) Has been skipped
Build & Deploy / 🔔 Notify (push) Successful in 2s
2026-04-09 22:59:16 +02:00
55a03daf2f fix(deps): add pnpm overrides to bypass broken workspace dependency in @mintel/payload-ai
Some checks failed
Build & Deploy / 🔍 Prepare (push) Successful in 14s
Build & Deploy / 🧪 QA (push) Failing after 23s
Build & Deploy / 🏗️ Build (push) Has been skipped
Build & Deploy / 🚀 Deploy (push) Has been skipped
Build & Deploy / 🧪 Post-Deploy Verification (push) Has been skipped
Build & Deploy / 🔔 Notify (push) Successful in 2s
2026-04-09 22:53:40 +02:00
564b77a2f3 fix(deps): aligned @mintel versions with registry availability (1.9.11) and bumped to 2.3.11
Some checks failed
Build & Deploy / 🔍 Prepare (push) Successful in 13s
Build & Deploy / 🧪 QA (push) Failing after 40s
Build & Deploy / 🏗️ Build (push) Has been skipped
Build & Deploy / 🚀 Deploy (push) Has been skipped
Build & Deploy / 🧪 Post-Deploy Verification (push) Has been skipped
Build & Deploy / 🔔 Notify (push) Successful in 2s
2026-04-09 22:19:38 +02:00
6d109a7636 chore: fix pnpm patch path and bump version to 2.3.10
Some checks failed
Build & Deploy / 🔍 Prepare (push) Successful in 12s
Build & Deploy / 🧪 QA (push) Failing after 26s
Build & Deploy / 🏗️ Build (push) Has been skipped
Build & Deploy / 🚀 Deploy (push) Has been skipped
Build & Deploy / 🧪 Post-Deploy Verification (push) Has been skipped
Build & Deploy / 🔔 Notify (push) Successful in 2s
2026-04-09 22:09:19 +02:00
849c4808b8 chore: bump version to 2.3.9 and harden deploy pipeline
Some checks failed
Build & Deploy / 🔍 Prepare (push) Successful in 20s
Build & Deploy / 🧪 QA (push) Failing after 31s
Build & Deploy / 🏗️ Build (push) Has been skipped
Build & Deploy / 🚀 Deploy (push) Has been skipped
Build & Deploy / 🧪 Post-Deploy Verification (push) Has been skipped
Build & Deploy / 🔔 Notify (push) Successful in 3s
2026-04-09 22:00:05 +02:00
40e35cd013 fix(build): transpile @mintel/payload-ai and bump version to 2.3.8 to resolve @payload-config resolution error
Some checks failed
Build & Deploy / 🧪 QA (push) Failing after 18s
Build & Deploy / 🔍 Prepare (push) Successful in 18s
Build & Deploy / 🏗️ Build (push) Has been skipped
Build & Deploy / 🚀 Deploy (push) Has been skipped
Build & Deploy / 🧪 Post-Deploy Verification (push) Has been skipped
Build & Deploy / 🔔 Notify (push) Successful in 2s
2026-04-09 21:50:06 +02:00
ec6379c582 chore: align @mintel dependencies to 1.9.11 and fix AI model patch
Some checks failed
Build & Deploy / 🔍 Prepare (push) Successful in 17s
Build & Deploy / 🧪 QA (push) Successful in 2m48s
Build & Deploy / 🏗️ Build (push) Failing after 5m42s
Build & Deploy / 🚀 Deploy (push) Has been skipped
Build & Deploy / 🧪 Post-Deploy Verification (push) Has been skipped
Build & Deploy / 🔔 Notify (push) Successful in 3s
2026-04-09 13:22:49 +02:00
9623ca4b0d chore: deploy v2.3.6 (sync deps to 1.9.17, root v2.3.6, middleware fix)
Some checks failed
Build & Deploy / 🔍 Prepare (push) Failing after 12s
Build & Deploy / 🧪 QA (push) Has been skipped
Build & Deploy / 🏗️ Build (push) Has been skipped
Build & Deploy / 🚀 Deploy (push) Has been skipped
Build & Deploy / 🧪 Post-Deploy Verification (push) Has been skipped
Build & Deploy / 🔔 Notify (push) Successful in 2s
2026-04-09 12:30:38 +02:00
49f5c67aa2 fix(prod): resolve middleware piping error and AI model ID
Some checks failed
Build & Deploy / 🧪 QA (push) Failing after 1m44s
Build & Deploy / 🔍 Prepare (push) Successful in 16s
Build & Deploy / 🏗️ Build (push) Has been skipped
Build & Deploy / 🚀 Deploy (push) Has been skipped
Build & Deploy / 🧪 Post-Deploy Verification (push) Has been skipped
Build & Deploy / 🔔 Notify (push) Successful in 3s
2026-04-09 11:33:35 +02:00
1ad12a9818 Merge branch 'feature/excel' into feature-ai-search, resolve conflicts
All checks were successful
Build & Deploy / 🔍 Prepare (push) Successful in 10s
Build & Deploy / 🧪 QA (push) Successful in 1m28s
Build & Deploy / 🏗️ Build (push) Successful in 4m11s
Build & Deploy / 🚀 Deploy (push) Successful in 45s
Build & Deploy / 🧪 Post-Deploy Verification (push) Successful in 4m2s
Build & Deploy / 🔔 Notify (push) Successful in 2s
2026-03-21 15:46:12 +01:00
9ba1ddf2a6 chore: trigger deployment for payload-ai v1.9.18 MCP tools fix
All checks were successful
Build & Deploy / 🔍 Prepare (push) Successful in 5s
Build & Deploy / 🧪 QA (push) Successful in 1m10s
Build & Deploy / 🏗️ Build (push) Successful in 5m6s
Build & Deploy / 🚀 Deploy (push) Successful in 1m31s
Build & Deploy / 🧪 Post-Deploy Verification (push) Has been skipped
Build & Deploy / 🔔 Notify (push) Successful in 1s
2026-03-18 21:51:04 +01:00
a558b18cd8 fix: switch AI model to openai/gpt-4o-mini for reliable MCP tool calling
All checks were successful
Build & Deploy / 🔍 Prepare (push) Successful in 6s
Build & Deploy / 🧪 QA (push) Successful in 1m7s
Build & Deploy / 🏗️ Build (push) Successful in 5m28s
Build & Deploy / 🚀 Deploy (push) Successful in 1m29s
Build & Deploy / 🧪 Post-Deploy Verification (push) Has been skipped
Build & Deploy / 🔔 Notify (push) Successful in 1s
2026-03-18 21:39:35 +01:00
482460b336 fix: add missing MISTRAL_API_KEY to deploy env
All checks were successful
Build & Deploy / 🔍 Prepare (push) Successful in 6s
Build & Deploy / 🧪 QA (push) Successful in 1m9s
Build & Deploy / 🏗️ Build (push) Successful in 5m17s
Build & Deploy / 🚀 Deploy (push) Successful in 1m32s
Build & Deploy / 🧪 Post-Deploy Verification (push) Has been skipped
Build & Deploy / 🔔 Notify (push) Successful in 1s
2026-03-18 18:56:00 +01:00
943c6ed09d fix: ai model id and remove klz-kabelfachmann public port
All checks were successful
Build & Deploy / 🔍 Prepare (push) Successful in 5s
Build & Deploy / 🧪 QA (push) Successful in 1m6s
Build & Deploy / 🏗️ Build (push) Successful in 5m17s
Build & Deploy / 🚀 Deploy (push) Successful in 1m32s
Build & Deploy / 🧪 Post-Deploy Verification (push) Has been skipped
Build & Deploy / 🔔 Notify (push) Successful in 1s
2026-03-18 17:25:04 +01:00
c45a90082b chore: trigger deploy after pushing missing kabelfachmann-mcp image
All checks were successful
Build & Deploy / 🔍 Prepare (push) Successful in 5s
Build & Deploy / 🧪 QA (push) Successful in 1m20s
Build & Deploy / 🏗️ Build (push) Successful in 5m21s
Build & Deploy / 🚀 Deploy (push) Successful in 1m20s
Build & Deploy / 🧪 Post-Deploy Verification (push) Has been skipped
Build & Deploy / 🔔 Notify (push) Successful in 1s
2026-03-18 16:13:27 +01:00
fb98c8237e feat(qdrant-sync): add gzip compression before transfer to reduce upload size
Some checks failed
Build & Deploy / 🔍 Prepare (push) Successful in 5s
Build & Deploy / 🧪 QA (push) Successful in 1m8s
Build & Deploy / 🏗️ Build (push) Successful in 5m32s
Build & Deploy / 🚀 Deploy (push) Failing after 9s
Build & Deploy / 🧪 Post-Deploy Verification (push) Has been skipped
Build & Deploy / 🔔 Notify (push) Successful in 2s
2026-03-18 10:32:54 +01:00
b84e0d782d fix(deploy): use :latest tag for kabelfachmann-mcp image (pre-built external image)
Some checks failed
Build & Deploy / 🔍 Prepare (push) Successful in 6s
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-18 10:32:42 +01:00
ef4f0e7ace fix(qdrant-sync): use rsync with SSH keepalive to prevent broken pipe on large transfers
Some checks failed
Build & Deploy / 🔍 Prepare (push) Successful in 6s
Build & Deploy / 🧪 QA (push) Successful in 1m11s
Build & Deploy / 🏗️ Build (push) Successful in 5m40s
Build & Deploy / 🚀 Deploy (push) Failing after 12s
Build & Deploy / 🧪 Post-Deploy Verification (push) Has been skipped
Build & Deploy / 🔔 Notify (push) Successful in 3s
2026-03-17 23:51:02 +01:00
d6bdd28b30 fix(ai-search): fix linting errors for deployment
Some checks failed
Build & Deploy / 🔍 Prepare (push) Successful in 11s
Build & Deploy / 🧪 QA (push) Successful in 1m30s
Build & Deploy / 🏗️ Build (push) Successful in 6m41s
Build & Deploy / 🚀 Deploy (push) Failing after 11s
Build & Deploy / 🧪 Post-Deploy Verification (push) Has been skipped
Build & Deploy / 🔔 Notify (push) Successful in 2s
2026-03-17 23:03:48 +01:00
289e41a040 feat: integrate and deploy kabelfachmann mcp
Some checks failed
Build & Deploy / 🔍 Prepare (push) Successful in 6s
Build & Deploy / 🧪 QA (push) Failing after 1m10s
Build & Deploy / 🏗️ Build (push) Has been skipped
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-17 22:09:13 +01:00
b0d66c4192 chore: Pin @react-email/components to 1.0.8 to fix broken upstream 1.0.9 release
All checks were successful
Build & Deploy / 🔍 Prepare (push) Successful in 10s
Build & Deploy / 🧪 QA (push) Successful in 2m29s
Build & Deploy / 🏗️ Build (push) Successful in 3m50s
Build & Deploy / 🚀 Deploy (push) Successful in 31s
Build & Deploy / 🧪 Post-Deploy Verification (push) Successful in 5m29s
Build & Deploy / 🔔 Notify (push) Successful in 1s
2026-03-11 15:00:15 +01:00
040809812a test: Fix form E2E timeouts by using JS native click to bypass overlays
Some checks failed
Build & Deploy / 🔍 Prepare (push) Successful in 26s
Build & Deploy / 🧪 QA (push) Failing after 2m24s
Build & Deploy / 🏗️ Build (push) Has been skipped
Build & Deploy / 🚀 Deploy (push) Has been skipped
Build & Deploy / 🧪 Post-Deploy Verification (push) Has been skipped
Build & Deploy / 🔔 Notify (push) Successful in 6s
2026-03-11 14:54:21 +01:00
678ca784a1 test: robust E2E form verification with direct Gatekeeper auth and verbose logging
Some checks failed
Build & Deploy / 🔍 Prepare (push) Successful in 10s
Build & Deploy / 🧪 QA (push) Successful in 2m19s
Build & Deploy / 🏗️ Build (push) Successful in 3m37s
Build & Deploy / 🚀 Deploy (push) Successful in 22s
Build & Deploy / 🧪 Post-Deploy Verification (push) Failing after 5m15s
Build & Deploy / 🔔 Notify (push) Successful in 2s
2026-03-11 10:05:32 +01:00
03d10f9a83 test: refine E2E form verification with German error keyword support
Some checks failed
Build & Deploy / 🔍 Prepare (push) Successful in 10s
Build & Deploy / 🧪 QA (push) Successful in 2m23s
Build & Deploy / 🏗️ Build (push) Successful in 3m49s
Build & Deploy / 🚀 Deploy (push) Successful in 23s
Build & Deploy / 🧪 Post-Deploy Verification (push) Failing after 5m17s
Build & Deploy / 🔔 Notify (push) Successful in 2s
2026-03-11 09:45:10 +01:00
4f464f8bb7 fix(next-config): restore missing datasheet and brochure rewrites and fix naming
Some checks failed
Build & Deploy / 🔍 Prepare (push) Successful in 10s
Build & Deploy / 🧪 QA (push) Successful in 4m53s
Build & Deploy / 🏗️ Build (push) Successful in 5m0s
Build & Deploy / 🚀 Deploy (push) Successful in 23s
Build & Deploy / 🧪 Post-Deploy Verification (push) Failing after 5m15s
Build & Deploy / 🔔 Notify (push) Successful in 2s
2026-03-11 00:54:27 +01:00
975ac79059 fix(pipeline): allow more response codes for datasheet accessibility check
Some checks failed
Build & Deploy / 🔍 Prepare (push) Successful in 24s
Build & Deploy / 🧪 QA (push) Successful in 3m11s
Build & Deploy / 🏗️ Build (push) Successful in 6m29s
Build & Deploy / 🚀 Deploy (push) Successful in 54s
Build & Deploy / 🧪 Post-Deploy Verification (push) Failing after 7m13s
Build & Deploy / 🔔 Notify (push) Successful in 3s
2026-03-11 00:32:01 +01:00
eae46d3048 fix(excel): move datasheet generation to post-deploy and add persistent volume
Some checks failed
Build & Deploy / 🔍 Prepare (push) Successful in 10s
Build & Deploy / 🧪 QA (push) Successful in 4m40s
Build & Deploy / 🏗️ Build (push) Successful in 4m55s
Build & Deploy / 🚀 Deploy (push) Successful in 22s
Build & Deploy / 🧪 Post-Deploy Verification (push) Failing after 6m28s
Build & Deploy / 🔔 Notify (push) Successful in 4s
2026-03-11 00:11:00 +01:00
7e0e01ecac fix(e2e): improve form test reliability with scoped selectors and integrate excel datasheet generation
Some checks failed
Build & Deploy / 🔍 Prepare (push) Successful in 32s
Build & Deploy / 🧪 QA (push) Successful in 3m27s
Build & Deploy / 🏗️ Build (push) Failing after 3m47s
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-10 23:32:21 +01:00
a5db900d3f fix(config): add serverActions.allowedOrigins to fix Gatekeeper login on branch deploys
Some checks failed
Build & Deploy / 🔍 Prepare (push) Successful in 10s
Build & Deploy / 🧪 QA (push) Successful in 2m18s
Build & Deploy / 🏗️ Build (push) Successful in 3m42s
Build & Deploy / 🚀 Deploy (push) Successful in 32s
Build & Deploy / 🧪 Post-Deploy Verification (push) Failing after 5m22s
Build & Deploy / 🔔 Notify (push) Successful in 1s
2026-03-10 13:51:31 +01:00
dd27f77c71 feat: include full product info and tech specs in excel datasheets
Some checks failed
Build & Deploy / 🔍 Prepare (push) Successful in 10s
Build & Deploy / 🧪 QA (push) Successful in 2m21s
Build & Deploy / 🏗️ Build (push) Successful in 3m43s
Build & Deploy / 🚀 Deploy (push) Successful in 32s
Build & Deploy / 🧪 Post-Deploy Verification (push) Failing after 5m19s
Build & Deploy / 🔔 Notify (push) Successful in 1s
2026-03-10 11:36:59 +01:00
5144378c7e fix: routes 2026-03-10 11:34:01 +01:00
53d1e62b42 fix(cms): use tsx for migrations to support .ts files in development/branch deploys
Some checks failed
Build & Deploy / 🔍 Prepare (push) Successful in 10s
Build & Deploy / 🧪 QA (push) Successful in 2m20s
Build & Deploy / 🏗️ Build (push) Successful in 3m39s
Build & Deploy / 🚀 Deploy (push) Successful in 31s
Build & Deploy / 🧪 Post-Deploy Verification (push) Failing after 4m27s
Build & Deploy / 🔔 Notify (push) Successful in 2s
2026-03-08 13:00:54 +01:00
3f6bbff409 fix(mail): add overrideAccess to form submissions and mail fallbacks for branch deploys
Some checks failed
Build & Deploy / 🔍 Prepare (push) Successful in 10s
Build & Deploy / 🧪 QA (push) Successful in 2m20s
Build & Deploy / 🏗️ Build (push) Successful in 3m46s
Build & Deploy / 🚀 Deploy (push) Successful in 22s
Build & Deploy / 🧪 Post-Deploy Verification (push) Failing after 4m26s
Build & Deploy / 🔔 Notify (push) Successful in 2s
2026-03-08 10:30:27 +01:00
d575e5924a fix(pdf): fix missing logos and product images in datasheets, update pipeline
Some checks failed
Build & Deploy / 🔍 Prepare (push) Successful in 10s
Build & Deploy / 🧪 QA (push) Successful in 2m20s
Build & Deploy / 🏗️ Build (push) Successful in 3m56s
Build & Deploy / 🚀 Deploy (push) Successful in 22s
Build & Deploy / 🧪 Post-Deploy Verification (push) Failing after 4m30s
Build & Deploy / 🔔 Notify (push) Successful in 2s
2026-03-08 01:55:52 +01:00
c804b051e0 fix: qdrant 2026-03-08 01:36:14 +01:00
1dc52da677 feat: Automate Qdrant PDF ingestion via Media hooks
All checks were successful
Build & Deploy / 🔍 Prepare (push) Successful in 7s
Build & Deploy / 🧪 QA (push) Successful in 1m1s
Build & Deploy / 🏗️ Build (push) Successful in 4m22s
Build & Deploy / 🚀 Deploy (push) Successful in 1m41s
Build & Deploy / 🧪 Post-Deploy Verification (push) Has been skipped
Build & Deploy / 🔔 Notify (push) Successful in 2s
2026-03-08 01:08:55 +01:00
7f1aeaee7e ci: add migrator target to Dockerfile for deployment pipeline
All checks were successful
Build & Deploy / 🔍 Prepare (push) Successful in 9s
Build & Deploy / 🧪 QA (push) Successful in 1m55s
Build & Deploy / 🏗️ Build (push) Successful in 8m40s
Build & Deploy / 🚀 Deploy (push) Successful in 1m23s
Build & Deploy / 🧪 Post-Deploy Verification (push) Has been skipped
Build & Deploy / 🔔 Notify (push) Successful in 2s
2026-03-07 23:41:42 +01:00
590c542b73 ci: fix Docker build missing patches directory
Some checks failed
Build & Deploy / 🔍 Prepare (push) Successful in 6s
Build & Deploy / 🧪 QA (push) Successful in 58s
Build & Deploy / 🏗️ Build (push) Failing after 4m38s
Build & Deploy / 🚀 Deploy (push) Has been skipped
Build & Deploy / 🧪 Post-Deploy Verification (push) Has been skipped
Build & Deploy / 🔔 Notify (push) Successful in 4s
2026-03-07 23:34:26 +01:00
ab477e8d8e chore: commit Payload importMap to fix CI typecheck
Some checks failed
Build & Deploy / 🔍 Prepare (push) Successful in 6s
Build & Deploy / 🧪 QA (push) Successful in 59s
Build & Deploy / 🏗️ Build (push) Failing after 24s
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-07 23:30:59 +01:00
d83ab182db ci: ignore dirty workspace files from eslint to fix QA
Some checks failed
Build & Deploy / 🔍 Prepare (push) Successful in 6s
Build & Deploy / 🧪 QA (push) Failing after 55s
Build & Deploy / 🏗️ Build (push) Has been skipped
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-07 23:26:01 +01:00
159ee66f55 fix: patch @mintel/payload-ai to remove missing SCSS import
Some checks failed
Build & Deploy / 🔍 Prepare (push) Successful in 6s
Build & Deploy / 🧪 QA (push) Failing after 58s
Build & Deploy / 🏗️ Build (push) Has been skipped
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-07 22:43:38 +01:00
45c385d62e ci: fix typecheck by clearing stale .next cache before QA
Some checks failed
Build & Deploy / 🔍 Prepare (push) Successful in 7s
Build & Deploy / 🧪 QA (push) Failing after 57s
Build & Deploy / 🏗️ Build (push) Has been skipped
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-07 22:34:22 +01:00
8e99c9d121 feat: automated Qdrant sync with Mistral embeddings + Kabelhandbuch ingestion
Some checks failed
Build & Deploy / 🔍 Prepare (push) Successful in 6s
Build & Deploy / 🧪 QA (push) Failing after 55s
Build & Deploy / 🏗️ Build (push) Has been skipped
Build & Deploy / 🚀 Deploy (push) Has been skipped
Build & Deploy / 🧪 Post-Deploy Verification (push) Has been skipped
Build & Deploy / 🔔 Notify (push) Successful in 2s
- Switch embedding API from OpenRouter to Mistral mistral-embed (1024-dim, EU/DSGVO)
- Add afterChange/afterDelete hooks to Posts.ts and Pages.ts for live sync
- Integrate kabelhandbuch.txt parsing into /api/sync-qdrant boot route
- Add .gitignore entries for kabelhandbuch.txt
2026-03-07 15:39:10 +01:00
7583540de2 fix: regenerate all PDFs with restored DenseTable and KeyValueGrid components
Some checks failed
Build & Deploy / 🔍 Prepare (push) Successful in 9s
Build & Deploy / 🧪 QA (push) Successful in 2m16s
Build & Deploy / 🏗️ Build (push) Successful in 3m34s
Build & Deploy / 🚀 Deploy (push) Successful in 49s
Build & Deploy / 🧪 Post-Deploy Verification (push) Failing after 4m25s
Build & Deploy / 🔔 Notify (push) Successful in 2s
2026-03-07 14:43:08 +01:00
c979647287 fix: restore complex table components in lib/pdf-datasheet.tsx
Some checks failed
Build & Deploy / 🔍 Prepare (push) Successful in 8s
Build & Deploy / 🧪 QA (push) Successful in 2m16s
Build & Deploy / 🏗️ Build (push) Successful in 3m41s
Build & Deploy / 🚀 Deploy (push) Successful in 1m14s
Build & Deploy / 🧪 Post-Deploy Verification (push) Failing after 4m25s
Build & Deploy / 🔔 Notify (push) Successful in 2s
2026-03-07 11:18:01 +01:00
20051244d9 feat: unify pdf datasheet architecture and regenerate all products
Some checks failed
Build & Deploy / 🔍 Prepare (push) Successful in 8s
Build & Deploy / 🧪 QA (push) Successful in 2m18s
Build & Deploy / 🏗️ Build (push) Successful in 3m43s
Build & Deploy / 🚀 Deploy (push) Successful in 19s
Build & Deploy / 🧪 Post-Deploy Verification (push) Failing after 4m54s
Build & Deploy / 🔔 Notify (push) Successful in 1s
2026-03-06 23:42:46 +01:00
b80136894c fix(ci): robust database ready check for branch deployments
Some checks failed
Build & Deploy / 🔍 Prepare (push) Successful in 8s
Build & Deploy / 🧪 QA (push) Successful in 2m35s
Build & Deploy / 🏗️ Build (push) Successful in 7m22s
Build & Deploy / 🚀 Deploy (push) Successful in 31s
Build & Deploy / 🧪 Post-Deploy Verification (push) Failing after 7m38s
Build & Deploy / 🔔 Notify (push) Successful in 1s
2026-03-06 22:42:19 +01:00
3acf0c3740 chore: ignore auto-generated payload importMap.js
Some checks failed
Build & Deploy / 🔍 Prepare (push) Successful in 9s
Build & Deploy / 🧪 QA (push) Failing after 1m58s
Build & Deploy / 🏗️ Build (push) Has been skipped
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-06 22:37:59 +01:00
4dcdb717f0 feat(ai-search): optimize dev server, add qdrant boot sync, fix orb overflow
Some checks failed
Build & Deploy / 🔍 Prepare (push) Successful in 6s
Build & Deploy / 🧪 QA (push) Successful in 1m0s
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-06 22:35:48 +01:00
81ce3a4588 fix(cms): defensive versioned table creation in localization migration 2026-03-06 11:16:42 +01:00
8b2e82888c fix(cms): add missing enum types to localization migration 2026-03-06 11:16:42 +01:00
da637d4a74 feat: integrate Payload CMS context-aware Agent (@mintel/payload-ai) natively into payload.config.ts 2026-03-06 01:50:30 +01:00
9c4c8e28e9 style: align PDF Page component with KLZ brand Design System
Some checks failed
Build & Deploy / 🔍 Prepare (push) Successful in 29s
Build & Deploy / 🧪 QA (push) Successful in 4m36s
Build & Deploy / 🏗️ Build (push) Failing after 6m0s
Build & Deploy / 🚀 Deploy (push) Has been skipped
Build & Deploy / 🧪 Post-Deploy Verification (push) Has been skipped
Build & Deploy / 🔔 Notify (push) Successful in 3s
2026-03-05 22:56:53 +01:00
761c6be80a fix(ui): global modal scroll locks and ci branch db seeding
Some checks failed
Build & Deploy / 🔍 Prepare (push) Successful in 13s
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-05 22:54:44 +01:00
663aaefc4f fix(cms): add missing tablet sizes to media collection via migration
Some checks failed
Build & Deploy / 🔍 Prepare (push) Successful in 13s
Build & Deploy / 🧪 QA (push) Successful in 2m12s
Build & Deploy / 🏗️ Build (push) Successful in 3m23s
Build & Deploy / 🚀 Deploy (push) Successful in 25s
Build & Deploy / 🧪 Post-Deploy Verification (push) Failing after 4m2s
Build & Deploy / 🔔 Notify (push) Successful in 1s
2026-03-05 22:17:13 +01:00
7d3737a88d fix(cms): add missing featured_image_id column to products via migration
Some checks failed
Build & Deploy / 🔍 Prepare (push) Successful in 13s
Build & Deploy / 🧪 QA (push) Successful in 2m32s
Build & Deploy / 🏗️ Build (push) Successful in 5m12s
Build & Deploy / 🚀 Deploy (push) Successful in 39s
Build & Deploy / 🔔 Notify (push) Has been cancelled
Build & Deploy / 🧪 Post-Deploy Verification (push) Has been cancelled
2026-03-05 22:04:55 +01:00
e32446fedb feat: register PDF download block and fix gotify notifications
Some checks failed
Build & Deploy / 🔍 Prepare (push) Successful in 13s
Build & Deploy / 🧪 QA (push) Successful in 3m2s
Build & Deploy / 🏗️ Build (push) Successful in 5m58s
Build & Deploy / 🚀 Deploy (push) Successful in 27s
Build & Deploy / 🔔 Notify (push) Has been cancelled
Build & Deploy / 🧪 Post-Deploy Verification (push) Has been cancelled
2026-03-05 16:28:27 +01:00
5552d952aa fix(workflow): remove push trigger from qa.yml to prevent race conditions with deploy
Some checks failed
Build & Deploy / 🔍 Prepare (push) Successful in 24s
Build & Deploy / 🧪 QA (push) Successful in 4m16s
Build & Deploy / 🏗️ Build (push) Successful in 7m28s
Build & Deploy / 🚀 Deploy (push) Successful in 23s
Build & Deploy / 🧪 Post-Deploy Verification (push) Failing after 5m33s
Build & Deploy / 🔔 Notify (push) Successful in 3s
2026-03-05 14:03:59 +01:00
3f67e1c333 feat: add modular dynamic PDF generation for Payload pages
Some checks failed
Build & Deploy / 🔍 Prepare (push) Successful in 14s
Build & Deploy / 🧪 QA (push) Failing after 40s
Build & Deploy / 🏗️ Build (push) Has been skipped
Build & Deploy / 🚀 Deploy (push) Has been skipped
Build & Deploy / 🧪 Post-Deploy Verification (push) Has been skipped
Build & Deploy / 🔔 Notify (push) Successful in 7s
2026-03-05 13:39:45 +01:00
ca2839017e ci: Update Gitea QA workflow configuration.
Some checks failed
Build & Deploy / 🔍 Prepare (push) Successful in 8s
Build & Deploy / 🧪 QA (push) Successful in 3m16s
Build & Deploy / 🏗️ Build (push) Successful in 3m52s
Build & Deploy / 🚀 Deploy (push) Successful in 25s
Build & Deploy / 🧪 Post-Deploy Verification (push) Failing after 4m53s
Build & Deploy / 🔔 Notify (push) Successful in 4s
2026-03-05 13:12:39 +01:00
e6c4af1606 fix(cms): defensive versioned table creation in localization migration
Some checks failed
Build & Deploy / 🔍 Prepare (push) Successful in 17s
Build & Deploy / 🧪 QA (push) Successful in 2m28s
Build & Deploy / 🏗️ Build (push) Successful in 3m59s
Build & Deploy / 🚀 Deploy (push) Successful in 26s
Build & Deploy / 🧪 Post-Deploy Verification (push) Failing after 4m32s
Build & Deploy / 🔔 Notify (push) Successful in 2s
2026-03-05 13:00:27 +01:00
34d341f5ae fix(cms): add missing enum types to localization migration
Some checks failed
Build & Deploy / 🔍 Prepare (push) Successful in 28s
Build & Deploy / 🧪 QA (push) Successful in 3m22s
Build & Deploy / 🏗️ Build (push) Successful in 4m57s
Build & Deploy / 🚀 Deploy (push) Successful in 30s
Build & Deploy / 🔔 Notify (push) Has been cancelled
Build & Deploy / 🧪 Post-Deploy Verification (push) Has been cancelled
2026-03-05 12:43:59 +01:00
42c287f519 fix(ci): enable branch smoke tests and shift migration trigger to health check
Some checks failed
Build & Deploy / 🔍 Prepare (push) Successful in 16s
Build & Deploy / 🧪 QA (push) Successful in 3m22s
Build & Deploy / 🏗️ Build (push) Successful in 5m2s
Build & Deploy / 🚀 Deploy (push) Successful in 28s
Build & Deploy / 🧪 Post-Deploy Verification (push) Failing after 4m14s
Build & Deploy / 🔔 Notify (push) Successful in 2s
2026-03-05 12:08:49 +01:00
fe3cb37351 fix(cms): add logs to onInit to diagnose migration issue
Some checks failed
Build & Deploy / 🔍 Prepare (push) Successful in 8s
Build & Deploy / 🧪 QA (push) Successful in 2m14s
Build & Deploy / 🚀 Deploy (push) Has been cancelled
Build & Deploy / 🧪 Post-Deploy Verification (push) Has been cancelled
Build & Deploy / 🏗️ Build (push) Has been cancelled
Build & Deploy / 🔔 Notify (push) Has been cancelled
2026-03-05 12:05:51 +01:00
c614cf9867 fix(cms): remove unused @ts-expect-error
All checks were successful
Build & Deploy / 🔍 Prepare (push) Successful in 8s
Build & Deploy / 🧪 QA (push) Successful in 2m12s
Build & Deploy / 🏗️ Build (push) Successful in 3m39s
Build & Deploy / 🚀 Deploy (push) Successful in 26s
Build & Deploy / 🧪 Post-Deploy Verification (push) Has been skipped
Build & Deploy / 🔔 Notify (push) Successful in 2s
2026-03-05 11:56:52 +01:00
a32fff7d20 fix(cms): use correct migrate() method on database adapter
Some checks failed
Build & Deploy / 🔍 Prepare (push) Successful in 8s
Build & Deploy / 🧪 QA (push) Failing after 2m13s
Build & Deploy / 🏗️ Build (push) Has been skipped
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-05 11:54:10 +01:00
ce7fefd99f fix(cms): automate migrations on startup and clean up deploy pipeline
Some checks failed
Build & Deploy / 🔍 Prepare (push) Successful in 26s
Build & Deploy / 🧪 QA (push) Failing after 2m54s
Build & Deploy / 🏗️ Build (push) Has been skipped
Build & Deploy / 🚀 Deploy (push) Has been skipped
Build & Deploy / 🧪 Post-Deploy Verification (push) Has been skipped
Build & Deploy / 🔔 Notify (push) Successful in 3s
2026-03-05 11:15:04 +01:00
2d2958301a fix(ci): solve build failure by syncing manual configs, restoring runner as default stage, and fixing pnpm lockfile integrity
Some checks failed
Build & Deploy / 🔍 Prepare (push) Successful in 22s
Build & Deploy / 🧪 QA (push) Successful in 3m31s
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
2026-03-05 11:07:09 +01:00
27aaf3b0ca chore: update pnpm lockfile (fix @mintel tarball integrity checksums)
Some checks failed
Build & Deploy / 🔍 Prepare (push) Successful in 11s
Build & Deploy / 🧪 QA (push) Successful in 2m37s
Build & Deploy / 🏗️ Build (push) Failing after 5m2s
Build & Deploy / 🚀 Deploy (push) Has been skipped
Build & Deploy / 🧪 Post-Deploy Verification (push) Has been skipped
Build & Deploy / 🔔 Notify (push) Successful in 3s
2026-03-05 10:43:45 +01:00
a2729689d5 fix(dockerfile): remove migrator stage that was overriding default CMD to payload migrate
Some checks failed
Build & Deploy / 🔍 Prepare (push) Successful in 11s
Build & Deploy / 🧪 QA (push) Successful in 2m49s
Build & Deploy / 🏗️ Build (push) Failing after 4m56s
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-04 22:10:41 +01:00
1640b57c87 fix(dockerfile): remove migrator stage that was overriding default CMD to payload migrate 2026-03-04 22:10:24 +01:00
57d624839d fix(deploy): auto-run payload migrations on deploy, hide cms error details from visitors
All checks were successful
Build & Deploy / 🔍 Prepare (push) Successful in 6s
Build & Deploy / 🧪 QA (push) Successful in 2m46s
Build & Deploy / 🏗️ Build (push) Successful in 16m16s
Build & Deploy / 🚀 Deploy (push) Successful in 1m23s
Build & Deploy / 🧪 Post-Deploy Verification (push) Has been skipped
Build & Deploy / 🔔 Notify (push) Successful in 2s
2026-03-04 18:39:56 +01:00
e3393d73c3 fix(ci): fix Notify step multi-line MESSAGE bash syntax error 2026-03-04 18:39:56 +01:00
94e15656a4 chore(ci): migrate docker registry from Gitea to standalone registry.infra.mintel.me 2026-03-04 18:39:56 +01:00
d41ec9b66b fix(ci): fix base64 portability (remove -w 0 flag) and add REGISTRY debug output 2026-03-04 18:39:09 +01:00
68380a3af9 fix(ci): use SCP credentials file for docker auth on remote server 2026-03-04 18:39:09 +01:00
3496720e22 fix(ci): fix heredoc variable expansion for SSH docker login 2026-03-04 18:39:09 +01:00
8162a8b1cb fix(ci): use heredoc for SSH docker login to avoid token escaping issues 2026-03-04 18:39:09 +01:00
6729d53c78 fix(ci): restore NPM_TOKEN in build-args and secrets for docker build 2026-03-04 18:39:09 +01:00
884adeabaf fix(ci): apply NPM_TOKEN string redaction bypass and native bash variables 2026-03-04 18:39:09 +01:00
a66674fcdf fix(deploy): use native actions interpretation for SLUG and TARGET to avoid empty SSH deployment paths 2026-03-04 18:39:09 +01:00
ee7a40d39b fix(build): add token discovery to prevent secret redaction breaking pnpm install 2026-03-04 18:38:26 +01:00
79bbf852a1 fix(deploy): remove redundant a11y tests breaking pre-build qa job 2026-03-04 18:37:52 +01:00
8adc3c5b51 fix(ci): remove invalid yaml heredocs breaking pipeline parser 2026-03-04 18:37:52 +01:00
d89b7930c2 chore(ci): merge ci checks into deploy and fix branch preview domain 2026-03-04 18:37:51 +01:00
6cea2f0c45 fix(deploy): finalize puppeteer env in qa job 2026-03-04 18:37:12 +01:00
9be7cb7ac9 fix(deploy): add puppeteer env and refine ssh heredocs 2026-03-04 18:37:12 +01:00
e7116f75fc fix(ci): remove non-existent check:mdx and set global puppeteer env 2026-03-04 18:37:12 +01:00
dacb100218 fix(ci): fix npm token secret format and unify chromium install 2026-03-04 18:37:11 +01:00
1862b54540 fix(ci): use dynamic codename for chromium ppa 2026-03-04 18:36:39 +01:00
df2514e461 fix(ci): add chromium installation to CI workflow 2026-03-04 18:36:39 +01:00
1434569dd8 fix(ci): use standardized registry secrets for remote deploy login 2026-03-04 18:36:15 +01:00
7bab6a55db fix(ci): use login-action for reliable buildx authentication on Gitea registry 2026-03-04 18:35:33 +01:00
5797261d1a fix(ci): use latest tag for nextjs and runtime base images 2026-03-04 18:34:43 +01:00
88dfeba502 fix(docker): bump base images to v1.8.21 to prevent build metadata registry failures 2026-03-04 18:34:43 +01:00
f1d0227260 fix(deploy): auto-run payload migrations on deploy, hide cms error details from visitors
All checks were successful
Build & Deploy / 🔍 Prepare (push) Successful in 9s
Build & Deploy / 🧪 QA (push) Successful in 3m3s
Build & Deploy / 🏗️ Build (push) Successful in 19m36s
Build & Deploy / 🚀 Deploy (push) Successful in 1m25s
Build & Deploy / 🧪 Post-Deploy Verification (push) Has been skipped
Build & Deploy / 🔔 Notify (push) Successful in 21s
2026-03-04 17:57:01 +01:00
69b8ae9067 fix(ci): fix Notify step multi-line MESSAGE bash syntax error
All checks were successful
Build & Deploy / 🔍 Prepare (push) Successful in 12s
Build & Deploy / 🧪 QA (push) Successful in 3m22s
Build & Deploy / 🏗️ Build (push) Successful in 5m21s
Build & Deploy / 🚀 Deploy (push) Successful in 40s
Build & Deploy / 🧪 Post-Deploy Verification (push) Has been skipped
Build & Deploy / 🔔 Notify (push) Successful in 4s
2026-03-04 17:23:19 +01:00
49d9902dc3 chore(ci): migrate docker registry from Gitea to standalone registry.infra.mintel.me
Some checks failed
Build & Deploy / 🔍 Prepare (push) Successful in 12s
Build & Deploy / 🧪 QA (push) Successful in 3m12s
Build & Deploy / 🏗️ Build (push) Successful in 5m34s
Build & Deploy / 🚀 Deploy (push) Successful in 22s
Build & Deploy / 🧪 Post-Deploy Verification (push) Has been skipped
Build & Deploy / 🔔 Notify (push) Failing after 10m46s
2026-03-04 16:53:45 +01:00
4ff50603e4 fix(ci): fix base64 portability (remove -w 0 flag) and add REGISTRY debug output
Some checks failed
Build & Deploy / 🔍 Prepare (push) Successful in 9s
Build & Deploy / 🧪 QA (push) Successful in 2m56s
Build & Deploy / 🏗️ Build (push) Successful in 5m6s
Build & Deploy / 🚀 Deploy (push) Failing after 12m7s
Build & Deploy / 🧪 Post-Deploy Verification (push) Has been cancelled
Build & Deploy / 🔔 Notify (push) Has been cancelled
2026-03-04 16:23:45 +01:00
cb47add128 fix(ci): use SCP credentials file for docker auth on remote server
Some checks failed
Build & Deploy / 🔍 Prepare (push) Successful in 24s
Build & Deploy / 🧪 QA (push) Successful in 2m29s
Build & Deploy / 🏗️ Build (push) Successful in 6m0s
Build & Deploy / 🚀 Deploy (push) Failing after 32s
Build & Deploy / 🧪 Post-Deploy Verification (push) Has been skipped
Build & Deploy / 🔔 Notify (push) Successful in 2s
2026-03-04 16:10:24 +01:00
35db587a0d fix(ci): fix heredoc variable expansion for SSH docker login
Some checks failed
Build & Deploy / 🔍 Prepare (push) Successful in 10s
Build & Deploy / 🧪 QA (push) Successful in 3m19s
Build & Deploy / 🏗️ Build (push) Successful in 4m59s
Build & Deploy / 🚀 Deploy (push) Failing after 27s
Build & Deploy / 🧪 Post-Deploy Verification (push) Has been skipped
Build & Deploy / 🔔 Notify (push) Successful in 2s
2026-03-04 15:57:45 +01:00
6e80c91f7d fix(ci): use heredoc for SSH docker login to avoid token escaping issues
Some checks failed
Build & Deploy / 🔍 Prepare (push) Successful in 13s
Build & Deploy / 🧪 QA (push) Successful in 2m34s
Build & Deploy / 🏗️ Build (push) Successful in 5m16s
Build & Deploy / 🚀 Deploy (push) Failing after 15s
Build & Deploy / 🧪 Post-Deploy Verification (push) Has been skipped
Build & Deploy / 🔔 Notify (push) Successful in 3s
2026-03-04 15:47:04 +01:00
2e706b1946 fix(ci): restore NPM_TOKEN in build-args and secrets for docker build
Some checks failed
Build & Deploy / 🔍 Prepare (push) Successful in 24s
Build & Deploy / 🧪 QA (push) Successful in 3m7s
Build & Deploy / 🏗️ Build (push) Successful in 6m3s
Build & Deploy / 🚀 Deploy (push) Failing after 33s
Build & Deploy / 🧪 Post-Deploy Verification (push) Has been skipped
Build & Deploy / 🔔 Notify (push) Successful in 3s
2026-03-04 15:31:47 +01:00
90542c9388 fix(ci): apply NPM_TOKEN string redaction bypass and native bash variables
Some checks failed
Build & Deploy / 🔍 Prepare (push) Successful in 16s
Build & Deploy / 🧪 QA (push) Successful in 2m41s
Build & Deploy / 🏗️ Build (push) Failing after 1m1s
Build & Deploy / 🚀 Deploy (push) Has been skipped
Build & Deploy / 🧪 Post-Deploy Verification (push) Has been skipped
Build & Deploy / 🔔 Notify (push) Successful in 4s
2026-03-04 15:21:49 +01:00
ce8829ece5 fix(build): eliminate string masking corruption by exposing NPM_TOKEN seamlessly via GITHUB_ENV for Docker Build
Some checks failed
Build & Deploy / 🔍 Prepare (push) Successful in 6s
Build & Deploy / 🧪 QA (push) Successful in 57s
Build & Deploy / 🏗️ Build (push) Failing after 25s
Build & Deploy / 🚀 Deploy (push) Has been skipped
Build & Deploy / 🧪 Post-Deploy Verification (push) Has been skipped
Build & Deploy / 🔔 Notify (push) Successful in 4s
2026-03-04 15:16:50 +01:00
6f393fbc59 chore: retrigger ci to evaluate npm token fix
Some checks failed
Build & Deploy / 🔍 Prepare (push) Successful in 5s
Build & Deploy / 🧪 QA (push) Successful in 1m2s
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-04 15:07:44 +01:00
b1363d9d52 fix(build): restore NPM_TOKEN string redaction bash workaround to fix intermittent docker build failures
Some checks failed
Build & Deploy / 🔍 Prepare (push) Successful in 6s
Build & Deploy / 🧪 QA (push) Successful in 1m10s
Build & Deploy / 🏗️ Build (push) Failing after 17s
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-04 14:58:16 +01:00
0bd55c1dee fix(deploy): revert to bash variables for SITE_DIR calculation to prevent gitea actions parser crash
Some checks failed
Build & Deploy / 🔍 Prepare (push) Successful in 5s
Build & Deploy / 🧪 QA (push) Successful in 1m7s
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-04 12:33:08 +01:00
bb7c50c780 fix(deploy): use native actions interpretation for SLUG and TARGET to avoid empty SSH deployment paths
Some checks failed
Build & Deploy / 🔍 Prepare (push) Successful in 6s
Build & Deploy / 🧪 QA (push) Successful in 1m13s
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-04 12:13:57 +01:00
296ead2c74 fix(deploy): use native actions interpretation for SLUG and TARGET to avoid empty SSH deployment paths
Some checks failed
Build & Deploy / 🔍 Prepare (push) Successful in 9s
Build & Deploy / 🧪 QA (push) Successful in 2m38s
Build & Deploy / 🏗️ Build (push) Successful in 3m55s
Build & Deploy / 🚀 Deploy (push) Failing after 11s
Build & Deploy / 🧪 Post-Deploy Verification (push) Has been skipped
Build & Deploy / 🔔 Notify (push) Successful in 1s
2026-03-04 12:13:40 +01:00
a1a6992f8e fix(deploy): export missing slug variable to fix branch preview directory generation
Some checks failed
Build & Deploy / 🔍 Prepare (push) Successful in 12s
Build & Deploy / 🧪 QA (push) Successful in 2m32s
Build & Deploy / 🏗️ Build (push) Successful in 3m52s
Build & Deploy / 🚀 Deploy (push) Failing after 11s
Build & Deploy / 🧪 Post-Deploy Verification (push) Has been skipped
Build & Deploy / 🔔 Notify (push) Successful in 2s
2026-03-04 11:57:39 +01:00
3c89c50a88 fix(deploy): export missing slug variable to fix branch preview directory generation
Some checks failed
Build & Deploy / 🔍 Prepare (push) Successful in 6s
Build & Deploy / 🧪 QA (push) Successful in 1m3s
Build & Deploy / 🏗️ Build (push) Failing after 21s
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-04 11:56:56 +01:00
118cecf423 fix(build): simplify token discovery to ensure NPM_TOKEN is passed to pnpm install
Some checks failed
Build & Deploy / 🧪 QA (push) Blocked by required conditions
Build & Deploy / 🔍 Prepare (push) Successful in 6s
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
2026-03-04 11:44:23 +01:00
9a72306227 fix(build): add token discovery to prevent secret redaction breaking pnpm install
Some checks failed
Build & Deploy / 🔍 Prepare (push) Successful in 8s
Build & Deploy / 🧪 QA (push) Successful in 2m16s
Build & Deploy / 🏗️ Build (push) Successful in 3m55s
Build & Deploy / 🚀 Deploy (push) Failing after 53s
Build & Deploy / 🧪 Post-Deploy Verification (push) Has been skipped
Build & Deploy / 🔔 Notify (push) Successful in 2s
2026-03-04 11:36:09 +01:00
4ca0b53bd9 fix(ci): migrate docker base images to git.infra.mintel.me registry
Some checks failed
Build & Deploy / 🔍 Prepare (push) Successful in 6s
Build & Deploy / 🧪 QA (push) Successful in 55s
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-04 11:29:13 +01:00
c8286f4f67 fix(ci): remove quotes around NPM_TOKEN in docker build secrets to fix 401
Some checks failed
Build & Deploy / 🔍 Prepare (push) Successful in 6s
Build & Deploy / 🧪 QA (push) Successful in 55s
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 2s
2026-03-04 11:17:30 +01:00
73c8b4dcc2 chore(ci): retry pipeline verification
Some checks failed
Build & Deploy / 🔍 Prepare (push) Successful in 6s
Build & Deploy / 🧪 QA (push) Successful in 1m1s
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 2s
2026-03-04 11:11:56 +01:00
7ca1b9c143 fix(ci): use robust gitea registry auth and migrate to git.infra.mintel.me
Some checks failed
Build & Deploy / 🔍 Prepare (push) Successful in 5s
Build & Deploy / 🧪 QA (push) Successful in 1m1s
Build & Deploy / 🏗️ Build (push) Failing after 17s
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-04 11:05:42 +01:00
3570e766f6 chore(ci): delete redundant ci workflow
Some checks failed
Build & Deploy / 🔍 Prepare (push) Successful in 5s
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-04 11:05:25 +01:00
ec0abffc55 Merge remote-tracking branch 'origin/main' into feature/ai-search
Some checks failed
Build & Deploy / 🔍 Prepare (push) Successful in 12s
Build & Deploy / 🧪 QA (push) Successful in 2m8s
CI - Lint, Typecheck & Test / quality-assurance (pull_request) Failing after 4m42s
Build & Deploy / 🏗️ Build (push) Successful in 3m9s
Build & Deploy / 🚀 Deploy (push) Successful in 19s
Build & Deploy / 🧪 Post-Deploy Verification (push) Has been skipped
Build & Deploy / 🔔 Notify (push) Successful in 1s
# Conflicts:
#	.env
#	components/Header.tsx
#	components/home/Hero.tsx
2026-03-04 10:56:41 +01:00
4aa179df4c fix(deploy): remove redundant a11y tests breaking pre-build qa job
Some checks failed
Build & Deploy / 🔍 Prepare (push) Successful in 7s
Build & Deploy / 🧪 QA (push) Successful in 2m25s
Build & Deploy / 🏗️ Build (push) Successful in 3m43s
Build & Deploy / 🚀 Deploy (push) Failing after 11s
Build & Deploy / 🧪 Post-Deploy Verification (push) Has been skipped
Build & Deploy / 🔔 Notify (push) Successful in 3s
2026-03-04 10:47:30 +01:00
b248af400b fix(ci): remove invalid yaml heredocs breaking pipeline parser
Some checks failed
Build & Deploy / 🔍 Prepare (push) Successful in 10s
Build & Deploy / 🧪 QA (push) Failing after 3m25s
Build & Deploy / 🏗️ Build (push) Has been skipped
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-04 10:08:25 +01:00
63884ff258 chore(ci): merge ci checks into deploy and fix branch preview domain 2026-03-04 09:59:47 +01:00
6d0d086622 fix(deploy): finalize puppeteer env in qa job
All checks were successful
CI - Lint, Typecheck & Test / quality-assurance (pull_request) Successful in 8m20s
2026-03-03 20:43:34 +01:00
561d1938c5 fix(deploy): add puppeteer env and refine ssh heredocs
All checks were successful
CI - Lint, Typecheck & Test / quality-assurance (pull_request) Successful in 7m24s
2026-03-03 20:42:50 +01:00
949cac8bf8 fix(ci): remove non-existent check:mdx and set global puppeteer env
Some checks failed
Build & Deploy / 🔍 Prepare (push) Successful in 9s
Build & Deploy / 🧪 QA (push) Failing after 5s
Build & Deploy / 🏗️ Build (push) Has been skipped
Build & Deploy / 🚀 Deploy (push) Has been skipped
Build & Deploy / 🧪 Post-Deploy Verification (push) Has been skipped
Build & Deploy / 🔔 Notify (push) Successful in 1s
CI - Lint, Typecheck & Test / quality-assurance (pull_request) Successful in 7m43s
2026-03-03 20:37:17 +01:00
c16b0e01cb fix(ci): fix npm token secret format and unify chromium install
Some checks failed
Build & Deploy / 🔍 Prepare (push) Successful in 9s
Build & Deploy / 🧪 QA (push) Successful in 2m54s
CI - Lint, Typecheck & Test / quality-assurance (pull_request) Failing after 5m46s
Build & Deploy / 🏗️ Build (push) Successful in 6m26s
Build & Deploy / 🚀 Deploy (push) Failing after 11s
Build & Deploy / 🧪 Post-Deploy Verification (push) Has been skipped
Build & Deploy / 🔔 Notify (push) Has been cancelled
2026-03-03 20:26:58 +01:00
99a0e05499 fix(ci): use dynamic codename for chromium ppa
Some checks failed
Build & Deploy / 🔍 Prepare (push) Successful in 33s
CI - Lint, Typecheck & Test / quality-assurance (pull_request) Failing after 2m51s
Build & Deploy / 🧪 QA (push) Successful in 2m43s
Build & Deploy / 🏗️ Build (push) Failing after 13m3s
Build & Deploy / 🚀 Deploy (push) Has been cancelled
Build & Deploy / 🧪 Post-Deploy Verification (push) Has been cancelled
Build & Deploy / 🔔 Notify (push) Has been cancelled
2026-03-03 20:08:08 +01:00
bbbad1fbc7 fix(ci): add chromium installation to CI workflow
Some checks failed
Build & Deploy / 🔍 Prepare (push) Successful in 43s
Build & Deploy / 🏗️ Build (push) Has been cancelled
Build & Deploy / 🧪 QA (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
CI - Lint, Typecheck & Test / quality-assurance (pull_request) Failing after 13m38s
2026-03-03 20:05:38 +01:00
21c1c6282f fix(ci): use standardized registry secrets for remote deploy login
Some checks failed
Build & Deploy / 🔍 Prepare (push) Successful in 8s
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
CI - Lint, Typecheck & Test / quality-assurance (pull_request) Failing after 4m35s
2026-03-03 20:04:01 +01:00
371e835853 fix(ci): use login-action for reliable buildx authentication on Gitea registry
Some checks failed
Build & Deploy / 🔍 Prepare (push) Successful in 10s
Build & Deploy / 🧪 QA (push) Successful in 3m36s
CI - Lint, Typecheck & Test / quality-assurance (pull_request) Failing after 6m4s
Build & Deploy / 🏗️ Build (push) Successful in 5m11s
Build & Deploy / 🚀 Deploy (push) Failing after 52s
Build & Deploy / 🧪 Post-Deploy Verification (push) Has been skipped
Build & Deploy / 🔔 Notify (push) Successful in 1s
2026-03-03 19:49:46 +01:00
001ebe28ef fix(ci): use latest tag for nextjs and runtime base images
Some checks failed
Build & Deploy / 🔍 Prepare (push) Successful in 9s
Build & Deploy / 🧪 QA (push) Successful in 3m9s
Build & Deploy / 🏗️ Build (push) Failing after 41s
Build & Deploy / 🚀 Deploy (push) Has been skipped
Build & Deploy / 🧪 Post-Deploy Verification (push) Has been skipped
Build & Deploy / 🔔 Notify (push) Successful in 2s
CI - Lint, Typecheck & Test / quality-assurance (pull_request) Failing after 4m47s
2026-03-03 17:36:19 +01:00
a670c5fd65 fix(docker): bump base images to v1.8.21 to prevent build metadata registry failures 2026-03-03 17:25:48 +01:00
70f189b0c9 test(e2e): isolate puppeteer from vitest to prevent CI pipeline crash
Some checks failed
Build & Deploy / 🔍 Prepare (push) Successful in 9s
Build & Deploy / 🧪 QA (push) Successful in 2m41s
Build & Deploy / 🏗️ Build (push) Failing after 21s
Build & Deploy / 🚀 Deploy (push) Has been skipped
Build & Deploy / 🧪 Post-Deploy Verification (push) Has been skipped
Build & Deploy / 🔔 Notify (push) Successful in 2s
CI - Lint, Typecheck & Test / quality-assurance (pull_request) Failing after 3m40s
2026-03-03 14:19:36 +01:00
d5dd66b832 chore(qa): resolve all lingering eslint warnings after branch merge
Some checks failed
Build & Deploy / 🔍 Prepare (push) Successful in 9s
CI - Lint, Typecheck & Test / quality-assurance (pull_request) Failing after 1m57s
Build & Deploy / 🧪 QA (push) Failing after 2m3s
Build & Deploy / 🏗️ Build (push) Has been skipped
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-03 13:54:04 +01:00
f8fc6fcbbe Merge branch 'main' into feature/excel
Some checks failed
Build & Deploy / 🔍 Prepare (push) Successful in 9s
CI - Lint, Typecheck & Test / quality-assurance (pull_request) Failing after 1m57s
Build & Deploy / 🧪 QA (push) Failing after 2m6s
Build & Deploy / 🏗️ Build (push) Has been skipped
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-03 13:34:57 +01:00
4e0d8a0f3a fix(pdf): drop product title down 10pt on page 2 to eliminate clipping and center grid values 2026-03-03 13:29:00 +01:00
11723bf184 fix(pdf): fix header overlap on new pages, force pagebreak per voltage table, and center grid titles 2026-03-03 13:22:54 +01:00
1756b630ef fix(pdf): push pdf header and footer closer to page edge and force payload image extraction fallback 2026-03-03 13:05:23 +01:00
daabf8bb63 fix(pdf): correct logo-black path, fix image fetch logic, and resolve irregular cross-section table spacing 2026-03-03 12:58:00 +01:00
e524c9faf6 fix(pdf): fix missing logo/images and irregular table row heights in pdf generation 2026-03-03 12:55:45 +01:00
15279c8be1 fix(ci): use explicit registry token instead of GITHUB_TOKEN for docker login 2026-03-03 12:54:45 +01:00
583a3797f3 fix(ci): use explicit registry token instead of GITHUB_TOKEN for docker login 2026-03-03 12:54:17 +01:00
655f33091f fix: resolve layout, pdf datasheets data generation and excel 404 routing 2026-03-03 12:38:55 +01:00
34bb91c04b fix(ui): Re-architecture of CTA Modal to eliminate hydration mismatch overhead, enhance modal accessibility with focus trap and scroll lock, and add a dedicated inline form component for the footer. Fixed Payload CMS collection schema to register brochure downlod types.
Some checks failed
Build & Deploy / 🔍 Prepare (push) Successful in 8s
Build & Deploy / 🧪 QA (push) Failing after 1m53s
Build & Deploy / 🏗️ Build (push) Has been skipped
Build & Deploy / 🚀 Deploy (push) Has been skipped
Build & Deploy / 🧪 Post-Deploy Verification (push) Has been skipped
Build & Deploy / 🔔 Notify (push) Successful in 1s
CI - Lint, Typecheck & Test / quality-assurance (pull_request) Failing after 2m3s
2026-03-03 12:23:15 +01:00
449b7bc8aa chore(ci): migrate docker registry publishers to git.infra.mintel.me 2026-03-03 12:13:43 +01:00
b033142599 fix: locale parsing error in RecentPosts
Some checks failed
Build & Deploy / 🔍 Prepare (push) Successful in 8s
Build & Deploy / 🧪 QA (push) Failing after 1m52s
Build & Deploy / 🏗️ Build (push) Has been skipped
Build & Deploy / 🚀 Deploy (push) Has been skipped
Build & Deploy / 🧪 Post-Deploy Verification (push) Has been skipped
Build & Deploy / 🔔 Notify (push) Successful in 1s
- safely parse locale string for toLocaleDateString

- add 100 to images.qualities in next.config.mjs to suppress warnings
2026-03-03 11:38:01 +01:00
02be8e59b2 feat: auto-opening brochure modal with mintel/mail delivery
Some checks failed
Build & Deploy / 🔍 Prepare (push) Successful in 8s
Build & Deploy / 🧪 QA (push) Failing after 1m53s
Build & Deploy / 🏗️ Build (push) Has been skipped
Build & Deploy / 🚀 Deploy (push) Has been skipped
Build & Deploy / 🧪 Post-Deploy Verification (push) Has been skipped
Build & Deploy / 🔔 Notify (push) Successful in 2s
- implemented BrochureDeliveryEmail template

- created AutoBrochureModal wrapper with 5s delay

- updated layout.tsx and BrochureCTA to use new success state

- added tests/brochure-modal.test.ts e2e test
2026-03-02 23:08:05 +01:00
d2418b5720 feat: product catalog 2026-03-02 16:23:09 +01:00
501f9659a1 feat: product catalog 2026-03-02 16:20:54 +01:00
e9ceae3989 feat: product catalog 2026-03-02 15:41:26 +01:00
ec3f2cf8c9 style: design refinements — reduce title/heading sizes, remove Scribble decorations, add image quality
Some checks failed
Build & Deploy / 🔍 Prepare (push) Successful in 10s
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
- Hero title: text-7xl → text-5xl, removed text-shadow
- Removed all Scribble decorative strokes from Hero, VideoSection, products page
- PayloadRichText headings reduced by one size step
- Team page: harmonized Michael/Klaus heading sizes (both text-4xl)
- Product overview: removed min-height from hero, reduced CTA heading
- Added quality={100} to team photos, Experience and MeetTheTeam backgrounds
- Cleaned up unused Scribble imports
2026-03-02 01:11:22 +01:00
3a61d01384 feat: product catalog
Some checks failed
Build & Deploy / 🔍 Prepare (push) Successful in 8s
Build & Deploy / 🧪 QA (push) Failing after 2m2s
Build & Deploy / 🏗️ Build (push) Has been skipped
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-01 23:14:25 +01:00
17ebde407e feat: product catalog
Some checks failed
Build & Deploy / 🔍 Prepare (push) Successful in 8s
Build & Deploy / 🧪 QA (push) Failing after 2m0s
Build & Deploy / 🏗️ Build (push) Has been skipped
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-01 22:35:49 +01:00
56cd1fb1ba fix(blog): restore table of contents, fix list item styling, resolve dynamic og image generation 2026-03-01 13:12:07 +01:00
437dd35c9c feat: product catalog
Some checks failed
Build & Deploy / 🔍 Prepare (push) Successful in 7s
Build & Deploy / 🧪 QA (push) Failing after 2m20s
Build & Deploy / 🏗️ Build (push) Has been skipped
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-01 13:07:13 +01:00
0cb96dfbac feat: product catalog
Some checks failed
Build & Deploy / 🔍 Prepare (push) Successful in 7s
Build & Deploy / 🧪 QA (push) Failing after 2m15s
Build & Deploy / 🏗️ Build (push) Has been skipped
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-01 10:19:13 +01:00
f1a28b9db2 feat(ai-search): add interactive WebGL Orb, Markdown support, and Sentry tracking
Some checks failed
Build & Deploy / 🔍 Prepare (push) Successful in 11s
Build & Deploy / 🧪 QA (push) Successful in 1m18s
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
CI - Lint, Typecheck & Test / quality-assurance (pull_request) Failing after 3m55s
2026-02-28 00:03:39 +01:00
7fb1945ce5 Merge branch 'main' into feature/ai-search 2026-02-27 19:08:50 +01:00
ec013a32a2 Merge main into feature/ai-search and resolve conflicts 2026-02-27 18:45:34 +01:00
40e26117bd chore: remove env 2026-02-26 11:27:39 +01:00
20fd889751 feat: ai search 2026-02-26 03:10:15 +01:00
310 changed files with 36904 additions and 11539 deletions

38
.env
View File

@@ -1,38 +0,0 @@
# Application
NODE_ENV=production
NEXT_PUBLIC_BASE_URL=https://klz-cables.com
UMAMI_WEBSITE_ID=e4a2cd1c-59fb-4e5b-bac5-9dfd1d02dd81
UMAMI_API_ENDPOINT=https://analytics.infra.mintel.me
SENTRY_DSN=https://c10957d0182245b1a2a806ac2d34a197@errors.infra.mintel.me/1
LOG_LEVEL=info
NEXT_PUBLIC_FEEDBACK_ENABLED=false
NEXT_PUBLIC_RECORD_MODE_ENABLED=false
NPM_TOKEN=263e7f75d8ada27f3a2e71fd6bd9d95298d48a4d
# SMTP Configuration
MAIL_HOST=smtp.eu.mailgun.org
MAIL_PORT=587
MAIL_USERNAME=postmaster@mg.mintel.me
MAIL_PASSWORD=4592fcb94599ee1a45b4ac2386fd0a64-102c75d8-ca2870e6
MAIL_FROM="KLZ Cables <postmaster@mg.mintel.me>"
MAIL_RECIPIENTS=marc@cablecreations.de,info@klz-cables.com
# ────────────────────────────────────────────────────────────────────────────
# Payload Infrastructure (Dockerized)
# ────────────────────────────────────────────────────────────────────────────
# The POSTGRES_URI and PAYLOAD_SECRET are automatically constructed and injected
# by docker-compose.yml using these base DB credentials, so you don't need to
# manually write the connection strings here.
PAYLOAD_DB_NAME=payload
PAYLOAD_DB_USER=payload
PAYLOAD_DB_PASSWORD=120in09oenaoinsd9iaidon
# ────────────────────────────────────────────────────────────────────────────
# Hetzner S3 Object Storage
# ────────────────────────────────────────────────────────────────────────────
S3_ENDPOINT=https://fsn1.your-objectstorage.com
S3_ACCESS_KEY=ROB3MSWMEIGRL7N94ZKS
S3_SECRET_KEY=9QJV3NE8xeLxhyufhNU7lsUB0RffJxPhGuEuFSH3
S3_BUCKET=mintel
S3_REGION=fsn1
S3_PREFIX=klz-cables

111
.env.bak Normal file
View File

@@ -0,0 +1,111 @@
# ============================================================================
# KLZ Cables - Environment Configuration
# ============================================================================
# Copy this file to .env for local development
# For production, use .env.production as a template
# ============================================================================
# ────────────────────────────────────────────────────────────────────────────
# Application Configuration
# ────────────────────────────────────────────────────────────────────────────
NODE_ENV=development
NEXT_PUBLIC_BASE_URL=http://localhost:3000
# TARGET is used to differentiate between environments (testing, staging, production)
# NEXT_PUBLIC_TARGET makes this information available to the frontend
TARGET=development
NEXT_PUBLIC_FEEDBACK_ENABLED=false
NEXT_PUBLIC_RECORD_MODE_ENABLED=true
# ────────────────────────────────────────────────────────────────────────────
# Analytics (Umami)
# ────────────────────────────────────────────────────────────────────────────
# Optional: Leave empty to disable analytics
UMAMI_WEBSITE_ID=
UMAMI_API_ENDPOINT=https://analytics.infra.mintel.me
# ────────────────────────────────────────────────────────────────────────────
# Error Tracking (GlitchTip/Sentry)
# ────────────────────────────────────────────────────────────────────────────
# Optional: Leave empty to disable error tracking
SENTRY_DSN=
# ────────────────────────────────────────────────────────────────────────────
# Email Configuration (SMTP)
# ────────────────────────────────────────────────────────────────────────────
# Required for contact form functionality
MAIL_HOST=smtp.eu.mailgun.org
MAIL_PORT=587
MAIL_USERNAME=
MAIL_PASSWORD=
MAIL_FROM=KLZ Cables <noreply@klz-cables.com>
MAIL_RECIPIENTS=info@klz-cables.com
# ────────────────────────────────────────────────────────────────────────────
# Logging
# ────────────────────────────────────────────────────────────────────────────
LOG_LEVEL=info
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)
# ────────────────────────────────────────────────────────────────────────────
# 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
# ────────────────────────────────────────────────────────────────────────────
# Deployment Configuration (CI/CD only)
# ────────────────────────────────────────────────────────────────────────────
# These are typically set by the CI/CD workflow
IMAGE_TAG=latest
TRAEFIK_HOST=klz-cables.com
ENV_FILE=.env
# IMGPROXY_URL: The backend URL of the imgproxy instance (e.g. img.infra.mintel.me)
# Next.js will proxy requests from /_img to this URL.
IMGPROXY_URL=https://img.infra.mintel.me
# ────────────────────────────────────────────────────────────────────────────
# Varnish Configuration
# ────────────────────────────────────────────────────────────────────────────
VARNISH_CACHE_SIZE=256M
# ============================================================================
# IMPORTANT NOTES
# ============================================================================
#
# BUILD-TIME vs RUNTIME Variables:
# ─────────────────────────────────
# • NEXT_PUBLIC_* variables are baked into the client bundle at BUILD time
# They must be provided as --build-arg when building the Docker image
#
# • All other variables are used at RUNTIME only
# They are loaded from the .env file by docker-compose
#
# Docker Deployment:
# ──────────────────
# 1. Build-time: Only NEXT_PUBLIC_* vars are needed (via --build-arg)
# 2. Runtime: All vars are loaded from .env file on the server
# 3. Branch Deployments:
# - main branch uses .env.prod
# - staging branch uses .env.staging
# - CI/CD supports STAGING_ prefix for all secrets to override defaults
# - TRAEFIK_HOST is automatically derived from NEXT_PUBLIC_BASE_URL
#
# Security:
# ─────────
# • NEVER commit .env files with real credentials to git
# • Use Gitea/GitHub secrets for CI/CD workflows
# • Store production .env file securely on the server only
#
# ============================================================================

View File

@@ -48,6 +48,12 @@ GATEKEEPER_PASSWORD=klz2026
SENTRY_DSN=
# SENTRY_ENVIRONMENT is set automatically by CI
# ────────────────────────────────────────────────────────────────────────────
# AI Agent (Payload CMS Agent via OpenRouter)
# ────────────────────────────────────────────────────────────────────────────
# Required for the Payload CMS AI Chat Agent
MISTRAL_API_KEY=
# ────────────────────────────────────────────────────────────────────────────
# Payload Infrastructure (Dockerized)
# ────────────────────────────────────────────────────────────────────────────

View File

@@ -1,51 +0,0 @@
name: CI - Lint, Typecheck & Test
on:
pull_request:
concurrency:
group: deploy-pipeline
cancel-in-progress: true
jobs:
quality-assurance:
runs-on: docker
steps:
- name: Checkout repository
uses: actions/checkout@v4
- name: Setup pnpm
uses: pnpm/action-setup@v3
with:
version: 10
run_install: false
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: 20
- name: 🔐 Configure Private Registry
run: |
echo "@mintel:registry=https://git.infra.mintel.me/api/packages/mmintel/npm" > .npmrc
echo "//git.infra.mintel.me/api/packages/mmintel/npm/:_authToken=${{ secrets.NPM_TOKEN }}" >> .npmrc
- name: Install dependencies
run: pnpm install --no-frozen-lockfile
env:
NPM_TOKEN: ${{ secrets.NPM_TOKEN }}
- name: 🧪 QA Checks
env:
TURBO_TELEMETRY_DISABLED: "1"
run: npx turbo run check:mdx lint typecheck test --cache-dir=".turbo"
- name: 🏗️ Build
run: pnpm build
- name: ♿ Accessibility Check
run: pnpm start-server-and-test start http://localhost:3000 "pnpm check:a11y http://localhost:3000"
- name: ♿ WCAG Sitemap Audit
run: pnpm start-server-and-test start http://localhost:3000 "pnpm run check:wcag http://localhost:3000"
# monitor trigger

View File

@@ -37,6 +37,8 @@ jobs:
next_public_url: ${{ steps.determine.outputs.next_public_url }}
project_name: ${{ steps.determine.outputs.project_name }}
short_sha: ${{ steps.determine.outputs.short_sha }}
slug: ${{ steps.determine.outputs.slug }}
gatekeeper_host: ${{ steps.determine.outputs.gatekeeper_host }}
container:
image: catthehacker/ubuntu:act-latest
steps:
@@ -83,7 +85,7 @@ jobs:
SLUG=$(echo "$REF" | tr '[:upper:]' '[:lower:]' | sed 's/[^a-z0-9]/-/g' | sed 's/--*/-/g' | sed 's/^-//;s/-$//')
IMAGE_TAG="branch-${SLUG}-${SHORT_SHA}"
ENV_FILE=".env.branch-${SLUG}"
TRAEFIK_HOST="${SLUG}.branch.mintel.me"
TRAEFIK_HOST="${SLUG}.branch.klz-cables.com"
fi
# Standardize Traefik Rule (escaped backticks for Traefik v3)
@@ -113,24 +115,32 @@ jobs:
echo "project_name=$PRJ-$TARGET"
fi
echo "short_sha=$SHORT_SHA"
echo "slug=$SLUG"
} >> "$GITHUB_OUTPUT"
# ⏳ Wait for Upstream Packages/Images if Tagged
if [[ "${{ github.ref_type }}" == "tag" ]]; then
echo "🔎 Checking for @mintel dependencies in package.json..."
# Extract any @mintel/ version (they should be synced in monorepo)
UPSTREAM_VERSION=$(grep -o '"@mintel/.*": "[^"]*"' package.json | grep -v "next-utils" | cut -d'"' -f4 | sed 's/\^//; s/\~//' | sort -V | tail -1)
TAG_TO_WAIT="v$UPSTREAM_VERSION"
# Extract @mintel versions, cleaning caret/tilde, and filtering out workspace refs
VERSIONS=$(grep "@mintel/" package.json | sed -E 's/.*: "[^0-9]*([0-9]+\.[0-9]+\.[0-9]+).*/\1/' | sort -u)
echo "Found @mintel versions in package.json: $VERSIONS"
if [[ -n "$UPSTREAM_VERSION" && "$UPSTREAM_VERSION" != "workspace:"* ]]; then
UPSTREAM_VERSION=$(echo "$VERSIONS" | sort -V | tail -1)
if [[ -n "$UPSTREAM_VERSION" ]]; then
TAG_TO_WAIT="v$UPSTREAM_VERSION"
echo "🔎 Verifying tag $TAG_TO_WAIT in mmintel/at-mintel..."
# 1. Discovery (Works without token for public repositories)
UPSTREAM_SHA=$(git ls-remote --tags https://git.infra.mintel.me/mmintel/at-mintel.git "$TAG_TO_WAIT" 2>/dev/null | grep "$TAG_TO_WAIT" | awk '{print $1}' | tail -n1 || echo "")
UPSTREAM_SHA=$(git ls-remote --tags https://git.infra.mintel.me/mmintel/at-mintel.git "$TAG_TO_WAIT" | grep "$TAG_TO_WAIT" | tail -n1 | awk '{print $1}')
if [[ -z "$UPSTREAM_SHA" ]]; then
echo "⚠️ Warning: Tag $TAG_TO_WAIT not found in mmintel/at-mintel."
else
echo "✅ Tag verified: Found upstream SHA $UPSTREAM_SHA for $TAG_TO_WAIT"
echo "❌ Error: Tag $TAG_TO_WAIT not found in mmintel/at-mintel."
echo "Full @mintel dependencies in package.json:"
grep "@mintel/" package.json
exit 1
fi
echo "✅ Tag verified: Found upstream SHA $UPSTREAM_SHA for $TAG_TO_WAIT"
# 2. Status Check (Requires GITEA_PAT for cross-repo API access)
POLL_TOKEN="${{ secrets.GITEA_PAT || secrets.MINTEL_PRIVATE_TOKEN }}"
@@ -156,6 +166,8 @@ jobs:
needs: prepare
if: needs.prepare.outputs.target != 'skip'
runs-on: docker
env:
PUPPETEER_EXECUTABLE_PATH: /usr/bin/chromium
container:
image: catthehacker/ubuntu:act-latest
steps:
@@ -181,12 +193,12 @@ jobs:
- name: 🔒 Security Audit
run: pnpm audit --audit-level high || echo "⚠️ Audit found vulnerabilities (non-blocking)"
- name: 🧪 QA Checks
if: github.event.inputs.skip_checks != 'true'
env:
TURBO_TELEMETRY_DISABLED: "1"
run: npx turbo run lint typecheck test --cache-dir=".turbo"
# ──────────────────────────────────────────────────────────────────────────────
# JOB 3: Build & Push
# ──────────────────────────────────────────────────────────────────────────────
@@ -203,7 +215,8 @@ jobs:
- name: 🐳 Set up Docker Buildx
uses: docker/setup-buildx-action@v3
- name: 🔐 Registry Login
run: echo "${{ secrets.REGISTRY_PASS }}" | docker login registry.infra.mintel.me -u "${{ secrets.REGISTRY_USER }}" --password-stdin
run: |
echo "${{ secrets.REGISTRY_PASS }}" | docker login registry.infra.mintel.me -u "${{ secrets.REGISTRY_USER }}" --password-stdin
- name: 🏗️ Build and Push
uses: docker/build-push-action@v5
with:
@@ -219,7 +232,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 }}"
NPM_TOKEN=${{ secrets.NPM_TOKEN }}
# ──────────────────────────────────────────────────────────────────────────────
# JOB 4: Deploy
@@ -237,6 +250,7 @@ jobs:
NEXT_PUBLIC_BASE_URL: ${{ needs.prepare.outputs.next_public_url }}
TRAEFIK_HOST: ${{ needs.prepare.outputs.traefik_host }}
GATEKEEPER_HOST: ${{ needs.prepare.outputs.gatekeeper_host }}
SLUG: ${{ needs.prepare.outputs.slug }}
# Secrets mapping (Payload CMS)
PAYLOAD_SECRET: ${{ secrets.PAYLOAD_SECRET || vars.PAYLOAD_SECRET || 'you-need-to-set-a-payload-secret' }}
@@ -261,6 +275,17 @@ jobs:
# Analytics
UMAMI_WEBSITE_ID: ${{ secrets.UMAMI_WEBSITE_ID || vars.UMAMI_WEBSITE_ID }}
UMAMI_API_ENDPOINT: ${{ secrets.UMAMI_API_ENDPOINT || vars.UMAMI_API_ENDPOINT || 'https://analytics.infra.mintel.me' }}
# Search & AI
OPENROUTER_API_KEY: ${{ secrets.OPENROUTER_API_KEY || vars.OPENROUTER_API_KEY }}
MISTRAL_API_KEY: ${{ secrets.MISTRAL_API_KEY || vars.MISTRAL_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' }}
KABELFACHMANN_MCP_URL: ${{ secrets.KABELFACHMANN_MCP_URL || vars.KABELFACHMANN_MCP_URL || 'http://klz-kabelfachmann:3007/sse' }}
# Container Registry (standalone)
REGISTRY_USER: ${{ secrets.REGISTRY_USER }}
REGISTRY_PASS: ${{ secrets.REGISTRY_PASS }}
steps:
- name: Checkout repository
uses: actions/checkout@v4
@@ -319,6 +344,14 @@ 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 "MISTRAL_API_KEY=$MISTRAL_API_KEY"
echo "QDRANT_URL=$QDRANT_URL"
echo "QDRANT_API_KEY=$QDRANT_API_KEY"
echo "REDIS_URL=$REDIS_URL"
echo "KABELFACHMANN_MCP_URL=$KABELFACHMANN_MCP_URL"
echo ""
echo "TARGET=$TARGET"
echo "SENTRY_ENVIRONMENT=$TARGET"
echo "PROJECT_NAME=$PROJECT_NAME"
@@ -338,9 +371,39 @@ 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:
TARGET: ${{ needs.prepare.outputs.target }}
SLUG: ${{ needs.prepare.outputs.slug }}
IMAGE_TAG: ${{ needs.prepare.outputs.image_tag }}
PROJECT_NAME: ${{ needs.prepare.outputs.project_name }}
ENV_FILE: ${{ needs.prepare.outputs.env_file }}
run: |
mkdir -p ~/.ssh
@@ -348,6 +411,9 @@ jobs:
chmod 600 ~/.ssh/id_ed25519
ssh-keyscan -H alpha.mintel.me >> ~/.ssh/known_hosts 2>/dev/null
# Determine deployment paths
echo "Preparing deployment for $TARGET..."
# Transfer and Restart
if [[ "$TARGET" == "production" ]]; then
SITE_DIR="/home/deploy/sites/klz-cables.com"
@@ -356,63 +422,55 @@ jobs:
elif [[ "$TARGET" == "staging" ]]; then
SITE_DIR="/home/deploy/sites/staging.klz-cables.com"
else
SITE_DIR="/home/deploy/sites/branch.klz-cables.com/${SLUG:-unknown}"
SITE_DIR="/home/deploy/sites/branch.klz-cables.com/$SLUG"
fi
# Transfer files
ssh root@alpha.mintel.me "mkdir -p $SITE_DIR"
scp .env.deploy root@alpha.mintel.me:$SITE_DIR/$ENV_FILE
scp docker-compose.yml root@alpha.mintel.me:$SITE_DIR/docker-compose.yml
ssh root@alpha.mintel.me "cd $SITE_DIR && echo '${{ secrets.REGISTRY_PASS }}' | docker login registry.infra.mintel.me -u '${{ secrets.REGISTRY_USER }}' --password-stdin"
ssh root@alpha.mintel.me "cd $SITE_DIR && docker compose -p '${{ needs.prepare.outputs.project_name }}' --env-file '$ENV_FILE' pull"
ssh root@alpha.mintel.me "cd $SITE_DIR && docker compose -p '${{ needs.prepare.outputs.project_name }}' --env-file '$ENV_FILE' up -d --remove-orphans"
# Sanitize Payload Migrations: Replace 'dev' push entries with proper migration names.
# Without this, Payload prompts interactively for confirmation and blocks forever in Docker.
APP_CONTAINER="${{ needs.prepare.outputs.project_name }}-klz-app-1"
DB_CONTAINER="${{ needs.prepare.outputs.project_name }}-klz-db-1"
echo "⏳ Waiting for database container to be ready..."
for i in $(seq 1 15); do
if ssh root@alpha.mintel.me "docker exec $DB_CONTAINER pg_isready -U payload -q 2>/dev/null"; then
echo "✅ Database is ready."
break
fi
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}"
# 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)"
# Branch Seeding Logic (Production -> Branch)
if [[ "$TARGET" == "branch" ]]; then
echo "🌱 Seeding Branch Environment from Production Database..."
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 klz-db"
# Wait for DB to be healthy with a 60s timeout
echo "⏳ Waiting for branch database to be ready..."
ssh root@alpha.mintel.me "
for i in {1..30}; do
if docker exec $DB_CONTAINER pg_isready -U payload >/dev/null 2>&1; then
exit 0
fi
sleep 2
done
echo '❌ Database failed to become ready after 60 seconds'
exit 1
" || exit 1
# Copy Production Payload DB to Branch Payload DB & ensure media is copied
echo "📦 Syncing Production DB into Branch DB..."
ssh root@alpha.mintel.me "
set -e -o pipefail
docker exec klz-cablescom-klz-db-1 pg_dump -U payload -d payload --clean --if-exists | docker exec -i $DB_CONTAINER psql -U payload -d payload --quiet
rsync -a --delete /var/lib/docker/volumes/klz-cablescom_klz_media_data/_data/ /var/lib/docker/volumes/${{ needs.prepare.outputs.project_name }}_klz_media_data/_data/
" || exit 1
echo "✅ Branch database and media synced successfully."
fi
# 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"
# Restart app to pick up clean migration state
APP_CONTAINER="${{ needs.prepare.outputs.project_name }}-klz-app-1"
ssh root@alpha.mintel.me "docker restart $APP_CONTAINER"
# Generate Excel Datasheets
echo "📊 Generating Excel Datasheets on live container..."
ssh root@alpha.mintel.me "docker exec $APP_CONTAINER pnpm run excel:datasheets" || echo "⚠️ Excel generation failed (non-blocking)"
ssh root@alpha.mintel.me "docker system prune -f --filter 'until=24h'"
- name: 🧹 Post-Deploy Cleanup (Runner)
@@ -425,7 +483,7 @@ jobs:
post_deploy_checks:
name: 🧪 Post-Deploy Verification
needs: [prepare, deploy]
if: needs.deploy.result == 'success' && needs.prepare.outputs.target != 'branch'
if: needs.deploy.result == 'success' && true
runs-on: docker
container:
image: catthehacker/ubuntu:act-latest
@@ -486,33 +544,25 @@ jobs:
# ── Critical Smoke Tests (MUST pass) ──────────────────────────────────
- name: 🏥 CMS Deep Health Check
continue-on-error: true
env:
DEPLOY_URL: ${{ needs.prepare.outputs.next_public_url }}
GK_PASS: ${{ secrets.GATEKEEPER_PASSWORD || 'klz2026' }}
run: |
echo "Waiting 60s for app to fully start (cold start / migrations / hydration)..."
sleep 60
echo "Waiting 10s for app to fully start..."
sleep 10
echo "Checking basic health..."
curl -sfL --retry 5 --retry-delay 10 "$DEPLOY_URL/health" || { echo "❌ Basic health check failed after retries"; exit 1; }
curl -sf "$DEPLOY_URL/health" || { echo "❌ Basic health check failed"; exit 1; }
echo "✅ Basic health OK"
echo "Checking CMS DB connectivity..."
# We use -v to see if we hit Gatekeeper (307) or 503/504
URL="$DEPLOY_URL/api/health/cms?gk_bypass=$GK_PASS&cb=$(date +%s)"
RESPONSE=$(curl -sfL --retry 5 --retry-delay 10 "$URL" 2>&1) || {
echo "⚠️ CMS health check failed, but site might still be fine."
echo "--- DEBUG INFO ---"
curl -svI "$URL"
echo "--- RESPONSE BODY ---"
curl -sL "$URL" | head -c 1000
RESPONSE=$(curl -sf "$DEPLOY_URL/api/health/cms?gk_bypass=$GK_PASS" 2>&1) || {
echo "❌ CMS health check failed!"
echo "$RESPONSE"
echo ""
echo "Proceeding to smoke tests to confirm actual site functionality."
echo "This usually means Payload CMS migrations failed or DB tables are missing."
echo "Check: docker logs \$APP_CONTAINER | grep -i error"
exit 1
}
echo "✅ CMS health output: $RESPONSE"
echo "✅ CMS health: $RESPONSE"
- name: 🚀 OG Image Check
if: always() && steps.deps.outcome == 'success'
env:
@@ -526,6 +576,15 @@ jobs:
GATEKEEPER_PASSWORD: ${{ secrets.GATEKEEPER_PASSWORD || 'klz2026' }}
UMAMI_API_ENDPOINT: ${{ secrets.UMAMI_API_ENDPOINT || vars.UMAMI_API_ENDPOINT || 'https://analytics.infra.mintel.me' }}
SENTRY_DSN: ${{ secrets.SENTRY_DSN || vars.SENTRY_DSN }}
- name: 📊 Excel Datasheet Accessibility Check
if: always() && steps.deps.outcome == 'success'
env:
TEST_URL: ${{ needs.prepare.outputs.next_public_url }}
run: |
echo "Checking if datasheets directory is reachable..."
# This checks if the /datasheets/ directory returns a valid response (200, 403, or 404 is technically reachable, but we'd prefer 200/403)
# Since the files are in public/datasheets/products/, we check that path.
curl -I -L -s -o /dev/null -w "%{http_code}" "$TEST_URL/datasheets/products/" | grep -E "200|301|302|403|404"
- name: 📝 E2E Form Submission Test
if: always() && steps.deps.outcome == 'success'
@@ -579,7 +638,7 @@ jobs:
STATUS_LINE="All checks passed"
fi
TITLE="$EMOJI klz-cables.com $VERSION $TARGET"
TITLE="$EMOJI klz-cables.com $VERSION -> $TARGET"
MESSAGE="$STATUS_LINE
Deploy: $DEPLOY | Smoke: $SMOKE | Perf: $PERF
$URL"

View File

@@ -198,7 +198,7 @@ jobs:
notify:
name: 🔔 Notify
needs: [static, a11y, lighthouse, links]
if: always()
if: failure()
runs-on: docker
container:
image: catthehacker/ubuntu:act-latest

6
.gitignore vendored
View File

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

1
.npmrc
View File

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

View File

@@ -1,648 +0,0 @@
<h1 id="allgemeine-verkaufsbedingungen">Allgemeine
Verkaufsbedingungen</h1>
<h3 id="geltungsbereich">1 Geltungsbereich</h3>
<p>1.1 Ausschließlich die nachfolgenden Allgemeinen Verkaufsbedingungen
(nachfolgend „AVB“) gelten für den gesamten Geschäftsverkehr
(nachfolgend „Lieferung von Waren“) der KLZ Vertriebs GmbH,
Raiffeisenstraße 22, 73630 Remshalden (nachfolgend „KLZ“) und
Unternehmern in Ausübung ihrer gewerblichen oder selbständigen
beruflichen Tätigkeit und juristischen Personen des öffentlichen Rechts
(nachfolgend „Kunde“). Der Geltung von abweichenden, zusätzlichen oder
entgegenstehenden allgemeinen Geschäftsbedingungen des Kunden wird
hiermit widersprochen.</p>
<p>1.2 Diese AVB gelten auch dann, wenn KLZ die Lieferung von Waren an
den Kunden vorbehaltlos in Kenntnis entgegenstehender oder von diesen
AVB abweichenden allgemeinen Geschäftsbedingungen des Kunden
ausführt.</p>
<p>1.3 Im Rahmen laufender Geschäftsbeziehungen gelten diese AVB auch
dann für künftige Lieferungen von Waren, wenn sie nicht ausdrücklich
erneut in den Vertrag einbezogen wurden.</p>
<p>1.4 Änderungen dieser AVB werden dem Kunden schriftlich, per Telefax
oder per E-Mail mitgeteilt. Widerspricht der Kunde diesen Änderungen
nicht innerhalb von vier (4) Wochen nach Zugang der Mitteilung, gelten
die Änderungen als durch den Kunden anerkannt. Auf das Widerspruchsrecht
und die Rechtsfolgen des Schweigens wird der Kunde im Rahmen der
Mitteilung über die Änderung der Geschäftsbedingungen gesondert
hingewiesen.</p>
<p>1.5 Hinweise auf die Geltung gesetzlicher Vorschriften im Rahmen
dieser AVB haben nur klarstellende Bedeutung. Auch ohne eine derartige
Klarstellung gelten daher die gesetzlichen Vorschriften, soweit sie in
diesen AVB nicht unmittelbar abgeändert oder ausdrücklich ausgeschlossen
werden.</p>
<h3 id="vertragsschluss">2 Vertragsschluss</h3>
<p>2.1 Der Vertrag über die Lieferung von Waren kommt grundsätzlich
durch ein Angebot des Kunden, z.B. über ein Auftragsformular, per E-Mail
oder per Brief, und eine Annahme dieses Angebots durch KLZ in Textform
z.B. durch eine Auftragsbestätigung per E-Mail oder Fax zustande. Der
Annahme des Angebots durch KLZ steht es gleich, wenn KLZ den Auftrag
oder die Bestellung binnen vierzehn (14) Kalendertagen ab Zugang des
Angebots bei KLZ vorbehaltlos ausführt.</p>
<p>2.2 Alle Angebote von KLZ sind insbesondere hinsichtlich
Verfügbarkeit, angegebener Mengen, Lieferfristen und Nebenleistungen
freibleibend und unverbindlich, sofern sie nicht ausdrücklich in
Textform als verbindlich gekennzeichnet wurden. Die Angebote stehen
unter dem Vorbehalt der zeitgerechten, qualitativen und quantitativen
Selbstbelieferung von KLZ.</p>
<p>2.3 Der Umfang der von KLZ zu liefernden Waren ergibt sich aus dem
jeweiligen Angebot oder der jeweiligen Auftragsbestätigung von KLZ.</p>
<p>2.4 Technische Angaben, Muster, Preise, Abbildungen, Zeichnungen,
Gewichts-, Maß-, Leistungs- und Gebrauchsangaben, sowie sonstige
Beschreibungen der zu liefernden Ware in dem Angebot oder der
Auftragsbestätigung sind nur annähernd maßgebend, soweit sie nicht
ausdrücklich von KLZ in Textform als verbindlich gekennzeichnet wurden.
Sie stellen keine Beschaffenheitsvereinbarung oder Garantie einer
entsprechenden Beschaffenheit oder Haltbarkeit der zu liefernden Ware
dar, es sei denn, sie wurden ausdrücklich in Textform als solche
vereinbart. Entsprechendes gilt für Erwartungen des Kunden hinsichtlich
der zu liefernden Ware oder deren Verwendung.</p>
<p>2.5 Handelsübliche und branchenübliche Abweichungen vom
Leistungsumfang der zu liefernden Ware durch KLZ sind zulässig, soweit
sie die Verwendbarkeit der zu liefernden Ware zum vertraglich
vorgesehenen Zweck nicht beeinträchtigen. Dies umfasst auch Über- oder
Unterlieferungen. Abweichungen der zu liefernden Ware, die durch
zwingende rechtliche oder technische Normen, die nach
Auftragsbestätigung in Kraft treten, bedingt sind, sind zulässig, soweit
sie nicht erheblich und dem Kunden zumutbar sind. Änderungen, die zu
einer technischen Verbesserung der zu liefernden Ware führen, sind stets
zulässig.</p>
<p>2.6 Änderungen des Lieferumfangs durch den Kunden bedürfen zu ihrer
Wirksamkeit stets der in Textform erklärten Einwilligung von KLZ.
Mehraufwände und sonstige Kosten, die durch die Änderung des
Lieferumfangs auf Wunsch des Kunden entstehen, sind von dem Kunden
gesondert zu vergüten.</p>
<p>2.7 Soweit das Angebot oder die Auftragsbestätigung von KLZ
offensichtliche Irrtümer, Schreib- oder Rechenfehler enthält, ist das
Dokument für KLZ hinsichtlich der offensichtlichen Fehler nicht
verbindlich. Der Kunde wird KLZ auf entsprechende Fehler hinweisen.</p>
<p>2.8 Verschlechtern sich die Vermögensverhältnisse des Kunden
wesentlich oder wird der Antrag zur Eröffnung eines Insolvenz- oder
vergleichbaren Verfahrens über das Vermögen des Kunden mangels Masse
abgelehnt, ist KLZ berechtigt, ganz oder teilweise vom Vertrag
zurückzutreten.</p>
<h3 id="abrufaufträge">3 Abrufaufträge</h3>
<p>3.1 Erteilt der Kunde KLZ einen Abrufauftrag und werden über die
Abruftermine keine gesonderten Vereinbarungen in Textform getroffen, ist
der Kunde verpflichtet, KLZ die einzelnen Abruftermine so mitzuteilen,
dass zwischen Eingang der Abrufmitteilung bei KLZ und Auslieferung der
Ware mindestens vierzehn (14) Werktage und zwischen Eingang der
Abrufmitteilung und der letzten Auslieferung der Ware maximal neunzig
(90) Werktage liegen. Werktage sind dabei alle Kalendertage, bei denen
es sich nicht um einen Sonntag oder einen gesetzlichen Feiertag am Sitz
von KLZ handelt.</p>
<h3 id="preise-und-zahlungsbedingungen">4 Preise und
Zahlungsbedingungen</h3>
<p>4.1 Die in dem Angebot oder der Auftragsbestätigung von KLZ
angegebenen Preise gelten für den in dem Angebot oder der
Auftragsbestätigung von KLZ aufgeführten Lieferumfang und verstehen sich
grundsätzlich in EURO und ohne Abzüge zuzüglich Versandkosten ab dem
jeweiligen Werk des Herstellers der Ware, exklusive Verpackung und
zuzüglich der jeweils geltenden gesetzlichen Umsatzsteuer, sofern nicht
ausdrücklich etwas anderes in Textform vereinbart wurde. Die in dem
Angebot oder der Auftragsbestätigung von KLZ ausgewiesenen Hohlpreise
verstehen sich darüber hinaus zuzüglich Metallzuschlag und
auftragsspezifischer Schnittkosten.</p>
<p>4.2 Sollte sich nach Vertragsschluss herausstellen, dass zur
Durchführung des Vertrages bzw. zur Lieferung der Waren Mehrleistungen
erforderlich sind, so werden diese Mehrleistungen gesondert abgerechnet
und vergütet.</p>
<p>4.3 Hat sich der Preis für eine zu liefernde Ware, bzw. der
vereinbarte Hohlpreis z.B. aufgrund gestiegener Preise für Energie,
Transport, Arbeitskraft oder Material zwischen Vertragsschluss und
Lieferdatum um mehr als fünf (5) % erhöht, werden die Parteien
partnerschaftlich und konstruktiv über eine Anpassung des Preises bzw.
des Hohlpreises diskutieren. KLZ ist zum Rücktritt von dem betroffenen
Vertrag berechtigt, wenn innerhalb von vierzehn (14) Kalendertagen ab
Anzeige der Preiserhöhung durch KLZ keine einvernehmliche Lösung
gefunden wird.</p>
<p>4.4 Der Kaufpreis wird sofort mit Erhalt der Rechnung oder Lieferung
der Ware, je nachdem welcher Zeitpunkt früher eintritt, zur Zahlung
fällig, sofern keine anderen Zahlungskonditionen in Textform vereinbart
sind. Der Kunde hat den ausstehenden Kaufpreis innerhalb von zehn (10)
Kalendertagen nach Erhalt der Rechnung oder Erhalt der Ware, je nachdem
welcher Zeitpunkt früher eintritt, frei Konto von KLZ und ohne Abzug zu
bezahlen. Die Zahlungsfrist gilt mit vollständiger Gutschrift des
ausstehenden Kaufpreises auf dem Konto von KLZ als gewahrt. Nach Ablauf
der Zahlungsfrist kommt der Kunde automatisch mit der Zahlung des
ausstehenden Kaufpreises in Verzug, ohne dass es hierfür einer
gesonderten Mahnung von KLZ bedarf.</p>
<p>4.5 Befindet sich der Kunde mit einer Zahlung in Verzug, ist KLZ
berechtigt, die zu liefernde Ware nur Zug-um-Zug gegen Zahlung der
Summe, mit der sich der Kunde in Verzug befindet, zu liefern. Dies gilt
auch für alle künftigen Lieferungen von Waren von KLZ an den Kunden.
Darüber hinaus stehen KLZ sämtliche Ansprüche gegen den Kunden aufgrund
dessen Zahlungsverzug uneingeschränkt zu.</p>
<p>4.6 Befindet sich der Kunde seit mehr als vierzehn (14) Kalendertagen
in Zahlungsverzug, ist KLZ dazu berechtigt, vom Vertrag zurückzutreten
und die gelieferte Ware unverzüglich zurück zu verlangen, ohne dass dem
Kunden hieraus Ansprüche entstehen.</p>
<p>4.7 Das Recht des Kunden, Zahlungen zurückzuhalten oder mit
Gegenansprüchen aufzurechnen, steht dem Kunden nur insoweit zu, als
seine Gegenansprüche unbestritten oder rechtskräftig festgestellt sind,
oder wenn die jeweiligen Ansprüche in einem Gegenseitigkeitsverhältnis
zueinanderstehen.</p>
<h3 id="metallnotierung">5 Metallnotierung</h3>
<p>5.1 Basis zur Berechnung des Kupferpreises ist der Durchschnitt des
Liefervormonats der Notierung „LME Copper official price cash offer“,
zuzüglich der jeweils aktuellen von KLZ benannten Kupfer-Prämie.</p>
<p>5.2 Basis zur Berechnung des Aluminiumpreises ist der Durchschnitt
des Liefervormonats der Notierung „LME Aluminium official price cash
offer” zuzüglich der jeweils von KLZ benannten Aluminium-Prämie.</p>
<p>5.3 USD werden auf Basis des EUR/USD LME-FX-Rate (MTLE) in EUR
umgerechnet. Die entsprechenden Notierungen können der Webseite
www.westmetall.com entnommen werden. Die Prämienzuschläge können stark
variieren und KLZ behält sich das Recht vor, diese fristgerecht
anzupassen, ungeachtet der Angebotslegung.</p>
<h3 id="metallzahl">6 Metallzahl</h3>
<p>6.1 Die von KLZ in dem Angebot oder der Auftragsbestätigung
ausgewiesene Metallzahl ist eine rein kaufmännische Berechnungsgröße für
den Metallinhalt, der in die Berechnung des Gesamtpreises eines Kabels
eingeht.</p>
<p>6.2 Die Angabe der Metallzahl dient ausschließlich dazu, dem Kunden
eine Vergleichbarkeit auf Grundlage der Holpreisbasis zu ermöglichen.
Die Metallzahl definiert nicht das Gewicht des tatsächlich im Kabel
enthaltenen Leitermetalls. Die Metallzahl ist ein rein kalkulatorischer
Berechnungsfaktor, der keine unmittelbaren Rückschlüsse auf die im Kabel
verwendeten Kupfer- bzw. Aluminiummengen zulässt. KLZ weist den Kunden
hiermit ausdrücklich darauf hin, final nur den Vollpreis für
Vergleichszwecke heranzuziehen.</p>
<p>6.3 Auf Anfrage des Kunden in Textform ist KLZ grundsätzlich dazu
bereit, andere Metallzahlen zu berechnen und auszuweisen. Bei jeglicher
Änderung bleibt der Vollpreis der gleiche Betrag. Ein Anspruch des
Kunden auf Berechnung und Angabe anderer Metallzahlen besteht nicht.</p>
<h3 id="lieferung">7 Lieferung</h3>
<p>7.1 Lieferungen erfolgen grundsätzlich DAP (INCOTERMS® 2020) sofern
die Parteien keine anderweitige Vereinbarung in Textform getroffen
haben.</p>
<p>7.2 Die Lieferung der Ware wird nach Möglichkeit geschlossen
vorgenommen. KLZ ist jedoch berechtigt, Teillieferungen vorzunehmen,
wenn dies für den Kunden zumutbar ist, insbesondere wenn wegen Fehlens
von Waren eine erhebliche Verzögerung der gesamten Lieferung eintreten
würde.</p>
<p>7.3 Lieferfristen aus Angeboten und Auftragsbestätigungen sind
grundsätzlich für KLZ unverbindlich. Eine verbindliche Lieferfrist muss
ausdrücklich in Textform als solche gekennzeichnet werden.</p>
<p>7.4 Die Lieferfrist beginnt mit dem Zugang der Auftragsbestätigung
bei dem Kunden. Die Einhaltung der Lieferfrist setzt die rechtzeitige
Klärung aller kaufmännischen und technischen Fragen, sowie die
Einhaltung der vereinbarten Zahlungsbedingungen und sonstigen
Verpflichtungen des Kunden voraus. Werden diese Voraussetzungen nicht
rechtzeitig erfüllt, so verlängern sich die Fristen angemessen; dies
gilt nicht, wenn ausschließlich KLZ die Verzögerung zu vertreten
hat.</p>
<p>7.5 Die Einhaltung auch einer verbindlichen Lieferfrist steht unter
dem Vorbehalt ordnungsgemäßer, insbesondere rechtzeitiger quantitativer
und qualitativer Selbstbelieferung von KLZ durch dessen Vorlieferanten.
KLZ ist im Fall der nicht ordnungsgemäßen Selbstbelieferung zum
Rücktritt vom Vertrag berechtigt. KLZ informiert den Kunden
unverzüglich, wenn und soweit KLZ von seinem Recht zum Rücktritt vom
Vertrag Gebrauch macht und gewährt etwa erbrachte Vorleistungen des
Kunden zurück.</p>
<p>7.6 Die Lieferfrist gilt als gewahrt, wenn KLZ die zu liefernde Ware
dem Kunden bis zum Ablauf der Lieferfrist an dem benannten Lieferort
innerhalb der üblichen Geschäftszeiten zur Annahme erstmalig angeboten
hat.</p>
<p>7.7 Auch wenn eine verbindliche Lieferfrist vereinbart wurde, kommt
KLZ mit der Lieferung der Ware nur dann in Verzug, wenn der Kunde KLZ
nach Ablauf der Lieferfrist eine angemessene Nachfrist zur Lieferung der
Ware in Textform gesetzt hat und diese Nachfrist erfolglos verstrichen
ist.</p>
<p>7.8 Wird die Lieferung der Ware, oder das Abladen der Ware, auf
Wunsch des Kunden verzögert, oder verzögert sich die Lieferung der Ware,
oder das Abladen der Ware, aus sonstigen Gründen, die der Kunde zu
vertreten hat, ist KLZ berechtigt, dem Kunden nach Ablauf der
vertraglich vereinbarten Lieferfrist ein Lagergeld in Höhe von zwei (2)
% des jeweiligen Nettoauftragswertes für jeden angefangenen Monat,
maximal jedoch fünf (5) % des jeweiligen Nettoauftragswertes in Rechnung
zu stellen. Das Lagergeld ist der Höhe nach auf maximal fünf (5) % des
Nettoauftragswertes pro Vertrag beschränkt.</p>
<p>7.9 KLZ behält sich die Geltendmachung aller darüberhinausgehenden
Ansprüche gegenüber dem Kunden aufgrund dessen verzögerter Übernahme der
Ware uneingeschränkt vor. Das Lagergeld ist auf weitergehende
Zahlungsansprüche von KLZ gegenüber dem Kunden aufgrund dessen
verzögerter Übernahme der Ware anzurechnen. Dem Kunden bleibt der
Nachweis gestattet, dass KLZ überhaupt kein oder nur ein geringerer
Schaden entstanden ist.</p>
<p>7.10 Kabel werden von KLZ auf stabilen Vollholztrommeln geliefert.
Auf Wunsch vermittelt KLZ dem Kunden Partner, die diese Trommeln gegen
eine Gebühr abholen. Bei Lagerzeiten über drei (3) Monaten ist zu
beachten, dass die Trommel regelmäßig bewegt wird, um eine einseitige
Belastung zu vermeiden. Folgeschäden bei Nichtbeachtung wie Instabilität
schließt KLZ aus.</p>
<p>7.11 Rücksendungen von Waren an KLZ, die nicht vorher von KLZ in
Textform bestätigt wurden, erfolgen auf alleinige Gefahr des Kunden.</p>
<h3 id="gefahrübergang">8 Gefahrübergang</h3>
<p>8.1 Die Gefahr des zufälligen Untergangs und der zufälligen
Verschlechterung der zu liefernden Ware geht grundsätzlich DAP
(INCOTERMS® 2020) auf den Kunden über, sofern die Parteien keine
anderweitige Vereinbarung in Textform getroffen haben.</p>
<p>8.2 Verzögert sich die Versendung bzw. Übergabe der Ware aus Gründen,
die der Kunde zu vertreten hat, geht die Gefahr des zufälligen
Untergangs und der zufälligen Verschlechterung der Ware mit der
Durchführung des ersten Zustellversuchs innerhalb der gewöhnlichen
Öffnungszeiten des Kunden durch KLZ auf den Kunden über.</p>
<h3 id="eigentumsvorbehalt">9 Eigentumsvorbehalt</h3>
<p>9.1 Die jeweils gelieferten Waren bleiben bis zur vollständigen
Bezahlung sämtlicher fälliger Forderungen aus der Geschäftsbeziehung
zwischen dem Kunden und KLZ in dem Eigentum von KLZ (nachfolgend
„Vorbehaltsware“). Dabei ist unerheblich, aus welchem Rechtsgrund die
Forderungen von KLZ gegen den Kunden bestehen. Erfasst sind insbesondere
auch Saldoforderungen aus laufender Rechnung.</p>
<p>9.2 Der Kunde ist berechtigt, die gelieferte Ware im Rahmen des
ordnungsgemäßen Geschäftsbetriebes zu veräußern, umzubilden oder
weiterzuverarbeiten.</p>
<p>9.3 Die Verarbeitung oder Umbildung der Vorbehaltsware durch den
Kunden erfolgt jedoch stets für KLZ und KLZ erwirbt Eigentum an der
neuen Sache. Wird die Vorbehaltsware mit anderen Sachen zu einer
einheitlichen Sache verbunden, verarbeitet oder untrennbar vermischt
oder vermengt oder in sonstiger Weise verbunden, erwirbt KLZ Miteigentum
an der neuen Sache im Verhältnis des Wertes der Vorbehaltsware zu den
anderen Gegenständen zur Zeit der Verarbeitung bzw. Vermischung,
Vermengung oder sonstigen Verbindung. Erfolgt die Vermischung,
Vermengung oder sonstige Verbindung dergestalt, dass die Sache des
Kunden als Hauptsache anzusehen ist, so willigt der Kunde bereits jetzt
dazu ein, KLZ anteiliges Miteigentum an der neuen Sache, in dem
Mengenverhältnis der Vorbehaltsware zu den fremden Sachen, zu
übertragen. Für die so entstandenen Sachen gelten die Bestimmungen für
Vorbehaltsware.</p>
<p>9.4 Der Kunde hat die Vorbehaltsware mit kaufmännischer Sorgfalt für
KLZ zu verwahren, pfleglich zu behandeln und auf seine Kosten gegen
Feuer, Wasser und Diebstahl zum Neuwert zu versichern. Der Kunde tritt
seine entsprechenden Ansprüche aus den Versicherungsverträgen an KLZ ab;
KLZ nimmt die Abtretung an.</p>
<p>9.5 Der Kunde darf die Vorbehaltsware nur im ordnungsgemäßen
Geschäftsgang bis zum Eintritt des Verwertungsfalles veräußern.
Verpfändungen und Sicherungsübereignungen hinsichtlich der
Vorbehaltsware sind unzulässig. Eingriffe Dritter, wie etwa die Pfändung
oder Zwangsvollstreckung, durch welche Rechte von KLZ beeinträchtigt
werden, hat der Kunde KLZ unverzüglich in Textform anzuzeigen. Der Kunde
hat den Dritten darüber hinaus unverzüglich in Textform darauf
hinzuweisen, dass KLZ Eigentümer der Vorbehaltsware ist. Soweit der
Dritte nicht in der Lage ist, KLZ die gerichtlichen und
außergerichtlichen Kosten einer Klage nach § 771 ZPO zu erstatten,
haftet der Kunde für den dadurch entstandenen Ausfall. Weitergehende
Ansprüche gegen den Kunden bleiben unberührt.</p>
<p>9.6 Für den Fall der Weiterveräußerung der Vorbehaltsware tritt der
Kunde die ihm daraus seinerseits erwachsenden Forderungen gegen seinen
Abnehmer bereits jetzt an KLZ ab; KLZ nimmt die Abtretung an. Gleiches
gilt für sonstige Forderungen, die an die Stelle der Vorbehaltsware
treten oder sonst hinsichtlich der Vorbehaltsware entstehen, wie z. B.
Versicherungsansprüche oder Ansprüche aus unerlaubter Handlung bei
Verlust oder Zerstörung.</p>
<p>9.7 Wird die Vorbehaltsware nach Verarbeitung oder zusammen mit
anderen Waren, die KLZ nicht gehören, weiterveräußert oder wird sie mit
einem Grundstück oder mit beweglichen Sachen verbunden, so erklärt der
Kunde bereits jetzt die Abtretung der Forderung des Kunden gegen seine
Abnehmer in Höhe des zwischen dem Kunden und KLZ vereinbarten
Kaufpreises für die Vorbehaltsware; KLZ nimmt die Abtretung an.</p>
<p>9.8 Der Kunde wird ermächtigt, die abgetretenen Forderungen für KLZ
einzuziehen. KLZ bleibt auch weiterhin zur Einziehung der abgetretenen
Forderungen berechtigt, wird die abgetretenen Forderungen jedoch nicht
selbst einziehen, solange kein Grund nach Ziffer 9.9 vorliegt, der KLZ
zum Widerruf der Einziehungsermächtigung berechtigt. Zur Abtretung der
Forderung an Dritte, einschließlich Forderungsverkauf an Factoring
Banken, ist der Kunde nur mit vorheriger schriftlicher Zustimmung von
KLZ berechtigt. KLZ wird die Zustimmung nicht verweigern, sofern eine
gleichwertige Sicherheit gestellt wird.</p>
<p>9.9 KLZ kann die Ermächtigung zur Weiterveräußerung und Einziehung
widerrufen, sobald sich der Kunde gegenüber KLZ im Zahlungsverzug
befindet, oder das Insolvenzverfahren oder ein vergleichbares Verfahren
über das Vermögen des Kunden eröffnet oder mangels Masse abgewiesen
wurde.</p>
<p>9.10 Der Kunde hat die eingezogenen Forderungen binnen zehn (10)
Werktagen auf ein von KLZ benanntes Konto zu überweisen.</p>
<p>9.11 KLZ ist zur Offenlegung der Abtretung berechtigt und kann von
dem Kunden verlangen, dass dieser die Abtretung seinen Abnehmern
anzeigt. Auf Verlangen ist der Kunde verpflichtet, KLZ die Namen seiner
Abnehmer bekanntzugeben und alle erforderlichen Unterlagen
herauszugeben, die für die Einziehung der Forderung durch KLZ selbst
erforderlich sind. Auch ist der Kunde ab Verzugseintritt verpflichtet,
die Abtretung der Forderung an KLZ seinem Abnehmer gegenüber schriftlich
mitzuteilen.</p>
<p>9.12 KLZ wird die Vorbehaltsware sowie die an ihre Stelle tretenden
Sachen oder Forderungen freigeben, soweit ihr Wert die Höhe der
gesicherten Forderungen um mehr als zehn (10) % übersteigt. Die Auswahl
der danach freizugebenden Gegenstände liegt bei KLZ.</p>
<p>9.13 Tritt KLZ bei vertragswidrigem Verhalten des Kunden,
insbesondere Zahlungsverzug, vom Vertrag zurück (Verwertungsfall), ist
KLZ berechtigt, die Vorbehaltsware herauszuverlangen. Der Kunde hat in
diesem Fall KLZ alle Informationen und Unterlagen zu übergeben, die
erforderlich sind, um die Vorbehaltsware herauszuverlangen und die an
KLZ abgetretenen Ansprüche des Kunden gegenüber seinen Abnehmern geltend
zu machen, mindestens jedoch eine Aufstellung über die bei dem Kunden
oder bei den Abnehmern befindliche Vorbehaltsware, auch soweit sie
bereits verarbeitet ist, und eine Aufstellung aller abgetretenen
Forderungen nebst Rechnungsgutschriften.</p>
<h3 id="höhere-gewalt">10 Höhere Gewalt</h3>
<p>10.1 „Höhere Gewalt“ bedeutet das Eintreten eines Ereignisses oder
Umstands, welcher eine Partei daran hindert, eine oder mehrere ihrer
vertraglichen Verpflichtungen zu erfüllen, wenn und soweit die von dem
Hindernis betroffene Partei nachweist, dass (a) dieses Hindernis
außerhalb der ihr zumutbaren Kontrolle liegt und (b) es zu dem Zeitpunkt
des Vertragsschlusses nicht in zumutbarer Weise vorhersehbar war und (c)
die Auswirkungen des Hindernisses von der betroffenen Partei nicht in
zumutbarer Weise hätte vermieden oder überwunden werden können.</p>
<p>10.2 Bei dem Beweis des Gegenteils wird insbesondere, aber nicht
abschließend, bei den folgenden Ereignissen ein Fall Höherer Gewalt im
Sinne des vorstehenden Absatzes vermutet: Krieg, Terrorakte, Währungs-
und Handelsbeschränkungen, Embargo, Sanktionen, Amtshandlungen,
Befolgung von Gesetzen oder Regierungsanordnungen, staatliche Ausreise-
und Exportverbote oder staatliche Einreise- und Importverbote,
Enteignung, Epidemie, Naturkatastrophe, extremes Naturereignis,
Explosionen, Feuer, Zerstörung von Ausrüstungen, Demonstrationen oder
Versammlungen, Ereignisse, die das Passieren von wichtigen
Transportrouten verhindern, allgemeine Arbeitsunruhen, insbesondere
Boykott, Streik und Aussperrungen, Energieknappheit oder
Beeinträchtigung von Transportmitteln.</p>
<p>10.3 Eine Partei, die sich mit Erfolg auf diese Klausel beruft, ist
ab dem Zeitpunkt, zu dem das Hindernis ihr die Leistungserbringung
unmöglich macht, von ihrer Pflicht zur Erfüllung ihrer vertraglichen
Verpflichtungen und von jeder Schadensersatzpflicht oder jedem anderen
vertraglichen Rechtsbehelf wegen Vertragsverletzung suspendiert, sofern
der Eintritt eines Ereignisses Höherer Gewalt der anderen Partei
unverzüglich mitgeteilt wird. Ist die Auswirkung des geltend gemachten
Hindernisses oder Ereignisses vorübergehend, so gelten die eben
dargelegten Folgen nur so lange, wie das geltend gemachte Hindernis die
Vertragserfüllung durch die betroffene Partei verhindert sowie einer
angemessenen Anlaufzeit. Dauert das Hindernis oder Ereignis höherer
Gewalt bei der betroffenen Partei länger als vier (4) Monate an, ist die
andere Partei zur ganzen oder teilweisen außerordentlichen Kündigung
bzw. zum Rücktritt vom Vertrag berechtigt. Wechselseitige
Schadensersatzansprüche wegen dieser Kündigung bzw. dieses Rücktritts
sind ausgeschlossen.</p>
<p>10.4 Der Kunde ist verpflichtet, auf Verlangen von KLZ innerhalb
einer angemessenen Frist zu erklären, ob er wegen einem bei KLZ
eingetretenen Ereignis Höherer Gewalt den Vertrag kündigt bzw. von
diesem Zurücktritt oder weiterhin auf der Durchführung der Lieferung der
Ware besteht.</p>
<p>10.5 Sofern sich aufgrund eines Ereignisses Höherer Gewalt die
wirtschaftliche Bedeutung oder der Inhalt der Lieferung der Ware
erheblich verändert oder auf den Betrieb von KLZ erheblich einwirkt,
werden die Parteien den zugrundeliegenden Vertrag unter Beachtung von
Treu und Glauben angemessen anpassen. Soweit dies wirtschaftlich nicht
vertretbar ist, steht KLZ das Recht zu, von dem Vertrag zurückzutreten.
Will KLZ von diesem Rücktrittsrecht Gebrauch machen, so hat er dies nach
Erkenntnis der Tragweite des Ereignisses Höherer Gewalt unverzüglich dem
Kunden mitzuteilen, und zwar auch dann, wenn zunächst mit dem Kunden
eine Verlängerung der Lieferzeit vereinbart war.</p>
<h3 id="gewährleistung-und-mängelrüge">11 Gewährleistung und
Mängelrüge</h3>
<p>11.1 Mängelansprüche bestehen nicht bei nur unerheblicher Abweichung
der gelieferten Ware von den vereinbarten Spezifikationen, bei
unerheblicher Beeinträchtigung der von KLZ vorgegebenen Brauchbarkeit,
bei natürlicher Abnutzung, übermäßiger Beanspruchung, Verwendung
ungeeigneter Betriebsmittel, oder bei Mängeln, die aufgrund besonderer
äußerer Einflüsse entstehen, sowie bei nicht reproduzierbaren
Softwarefehlern. Werden vom Kunden oder von Dritten unsachgemäß
Änderungen oder Instandsetzungsarbeiten vorgenommen, so bestehen für
diese und die daraus entstehenden Folgen ebenfalls keine
Mängelansprüche.</p>
<p>11.2 Eine Gewährleistungsverpflichtung von KLZ besteht nur, wenn der
Kunde den ihm obliegenden Untersuchungs- und Rügepflichten fristgerecht
nachgekommen ist. Eingehende Waren hat der Kunde unverzüglich auf
äußerlich erkennbare Transportschäden und Identität der gelieferten Ware
mit der Bestellung zu überprüfen. Äußerlich erkennbare Mängel sind
unverzüglich, spätestens jedoch innerhalb von drei (3) Werktagen ab
Übergabe, sonstige Mängel innerhalb von fünf (5) Werktagen nach ihrer
Entdeckung gegenüber KLZ im Rahmen einer Mängelrüge mindestens in
Textform geltend zu machen.</p>
<p>11.3 Mängelrügen müssen eine genaue Beschreibung der aufgetretenen
Mängel, der betroffenen Lieferungen und wenn möglich, auch eine
bildhafte Dokumentation des Mangels enthalten. KLZ ist berechtigt,
mangelhafte Teile auf eigene Kosten zur Übersendung und Untersuchung
anzufordern.</p>
<p>11.4 Beim Verlegen von Kabeln in Gräben oder Rohren, bzw. in
Bauwerken ist eine ständige Sichtkontrolle durch den Kabelverleger
vorzunehmen. Der Kabelverleger hat laufend zu überprüfen, ob
Auffälligkeiten zu vermerken sind.</p>
<p>11.5 KLZ ist berechtigt, innerhalb einer angemessenen Frist,
anerkannte Mängel nach eigener Wahl durch Beseitigung (Nachbesserung)
oder durch Lieferung mangelfreier Ware (Ersatzlieferung) zu beheben. Der
Kunde wird KLZ bei der Beseitigung von Mängeln in zumutbarem Umfang
unterstützen.</p>
<p>11.6 Die Kosten der Nacherfüllung und Mangeluntersuchung werden bei
berechtigten Beanstandungen von KLZ getragen. Dies gilt nicht für
Aufwendungen, die dadurch entstehen, dass die gelieferten Waren durch
den Kunden an einen anderen als den Ort der gewerblichen Niederlassung
des Kunden verbracht worden sind, es sei denn, das Verbringen entspricht
dem vertraglich vorgesehenen Verwendungszweck der gelieferten Ware. Satz
1 gilt ebenfalls nicht für Mehraufwendungen, die dadurch entstehen, dass
der Kunde die gelieferte Ware ohne Zustimmung von KLZ verändert hat.</p>
<p>11.7 Hat der Kunde die Ware verbaut, obwohl er positive Kenntnis oder
grob fahrlässige Unkenntnis des Mangels hatte, ist KLZ nicht dazu
verpflichtet, die Ein- und Ausbaukosten der Ware zu erstatten.</p>
<p>11.8 Der Kunde kann erst nach zweimaligem Fehlschlagen der
Mängelbeseitigung vom Vertrag zurücktreten oder den vereinbarten
Kaufpreis angemessen mindern. Bei nur unerheblicher Minderung des Werts
oder der Tauglichkeit der gelieferten Waren ist der Rücktritt
ausgeschlossen, ebenso wenn der Kunde sich im Annahmeverzug befindet
oder wenn der Kunde den Mangel zu vertreten hat. Solange der Kunde KLZ
gegenüber nicht den Rücktritt vom Vertrag erklärt oder Schadensersatz
wegen Nichterfüllung verlangt hat, ist KLZ auch nach Ablauf der vom
Kunden gesetzten Frist zur Nacherfüllung berechtigt, es sei denn, der
Kunde hat zuvor schriftlich die Ablehnung der Nacherfüllung
angezeigt.</p>
<p>11.9 Beruht ein Mangel auf dem Verschulden von KLZ, kann der Kunde
unter den in Ziffer 12 bestimmten Voraussetzungen Schadensersatz
verlangen. Ziffer 12 gilt entsprechend für den Ersatz vergeblicher
Aufwendungen, den der Kunde anstelle eines Schadensersatzes statt der
Leistung verlangen kann.</p>
<p>11.10 Besteht Uneinigkeit zwischen den Parteien in Bezug auf Mängel
an den Waren, ist ein Gutachten bei dem VDE-Institut über die streitige
Frage einzuholen. Das Ergebnis des Gutachtens des VDE-Instituts ist für
die Feststellung der Mangelfreiheit oder eines Mangels maßgebend. KLZ
ist nur dann verpflichtet, die Kosten des Gutachtens des VDE-Instituts
zu tragen, wenn das Gutachten einen Mangel feststellt.</p>
<p>11.11 Die gesetzlichen Mängelgewährleistungsansprüche des Kunden
verjähren innerhalb von zwölf (12) Monaten ab Gefahrübergang. §§ 438
Abs. 1 Nr. 2, 479 Abs. 1 und 634a Abs. 1 Nr. 2 BGB bleiben hiervon
unberührt. Diese Verkürzung der Verjährungsfrist gilt nicht bei Vorsatz,
grober Fahrlässigkeit, der Verletzung von Leben, Körper, Gesundheit,
oder jedweder wesentlicher Vertragspflichtverletzung, zwingender
gesetzlicher Haftungsnormen wie denen des ProdHaftG, sowie bei
Nichteinhaltung einer Beschaffenheitsgarantie oder dem arglistigen
Verschweigen eines Mangels. Die gesetzlichen Regelungen über
Ablaufhemmung, Hemmung und Neubeginn der Fristen bleiben unberührt.</p>
<h3 id="haftung">12 Haftung</h3>
<p>12.1 KLZ haftet uneingeschränkt für Vorsatz und grobe Fahrlässigkeit.
KLZ haftet ferner uneingeschränkt für Schäden aus der Verletzung des
Lebens, des Körpers oder der Gesundheit, sowie für arglistiges
Verschweigen eines Mangels und Verletzung von garantierten
Beschaffenheitsmerkmalen, sowie im Rahmen zwingender gesetzlicher
Vorschriften, wie etwa denen des Produkthaftungsgesetzes.</p>
<p>12.2 Bei der Verletzung von wesentlichen Vertragspflichten haftet KLZ
im Übrigen der Höhe nach begrenzt auf den bei Vertragsschluss für KLZ
typischerweise vorhersehbaren Schaden bzw. die typischerweise
vorhersehbaren Aufwendungen. Vertragswesentlich sind die
Verpflichtungen, deren Erfüllung die ordnungsgemäße Vertragsdurchführung
erst ermöglichen und auf deren Einhaltung der Kunde vertraut und auch
vertrauen darf. Im Übrigen haftet KLZ bei leichter Fahrlässigkeit nicht
auf Schadensersatz.</p>
<p>12.3 Eine Haftung für mittelbare Schäden und Folgeschäden, die Folge
von Mängeln der gelieferten Waren sind, besteht nur für solche Schäden,
die bei bestimmungsgemäßer Verwendung der gelieferten Ware
typischerweise zu erwarten sind.</p>
<p>12.4 Die Haftung entfällt für Schäden, die auf Reparaturen,
Änderungen oder anderen Eingriffen oder auf die Verwendung von
Nicht-Originalteilen von KLZ im Rahmen der Reparatur oder Änderung bzw.
des Eingriffs zurückzuführen sind, die der Kunde ohne Zustimmung von KLZ
vorgenommen hat.</p>
<p>12.5 Soweit die Haftung von KLZ ausgeschlossen oder beschränkt ist,
gilt dies auch für die Haftung der Angestellten, Arbeitnehmer,
Mitarbeiter, Vertreter und Erfüllungsgehilfen von KLZ.</p>
<h3 id="produkthaftung">13 Produkthaftung</h3>
<p>13.1 Der Kunde wird die gelieferten Waren ausschließlich
vertragsgemäß und entsprechend den verfügbaren Anleitungen von KLZ
verwenden, insbesondere wird er vorhandene Warnhinweise über Gefahren
bei Gebrauch der gelieferten Waren nicht verändern oder entfernen. Bei
Verletzung dieser Pflicht stellt der Kunde KLZ im Innenverhältnis von
Produkthaftungsansprüchen Dritter frei, es sei denn der Kunde ist für
den die Haftung auslösenden Fehler nicht verantwortlich.</p>
<p>13.2 Wird KLZ aufgrund eines Produktfehlers zu einem Produktrückruf
oder einer -warnung veranlasst, so wird der Kunde nach besten Kräften
bei den Maßnahmen mitwirken, die KLZ für erforderlich und zweckmäßig
hält. KLZ ist verpflichtet, die Kosten des Produktrückrufs oder der
-warnung zu tragen, es sei denn KLZ ist für den Produktfehler und den
eingetretenen Schaden nach produkthaftungsrechtlichen Grundsätzen nicht
verantwortlich.</p>
<p>13.3 Der Kunde wird KLZ unverzüglich über ihm bekanntwerdende Risiken
bei der Verwendung der gelieferten Ware und mögliche Produktfehler in
Textform informieren.</p>
<h3 id="exportkontrolle">14 Exportkontrolle</h3>
<p>14.1 Lieferungen von Waren von KLZ stehen unter dem Vorbehalt, dass
der Vertragserfüllung keine Hindernisse aufgrund von nationalen oder
internationalen exportkontrollrechtlichen Vorschriften entgegenstehen.
Sofern sich die Durchführung des Vertrages aufgrund von
Exportkontrollprüfungen oder Genehmigungsverfahren verzögert, werden
Fristen und Lieferzeiten außer Kraft gesetzt. KLZ ist berechtigt, von
der Einzelbestellung zurückzutreten und den Liefervertrag fristlos zu
kündigen, wenn ein solcher Rücktritt oder eine Kündigung zur Einhaltung
nationaler oder internationalen Exportkontrollbestimmung erforderlich
ist. Die Geltendmachung von Schadensersatzansprüchen oder anderen
Rechten durch den Kunden wegen vorgenannter Kündigung oder Verzögerung
ist ausgeschlossen.</p>
<p>14.2 Der Kunde verpflichtet sich zur Einhaltung des nationalen und
internationalen Exportkontrollrechts, insbesondere bei Weitergabe der
gelieferten Ware an Dritte.</p>
<h3 id="urheberrechte-und-datenschutz">15 Urheberrechte und
Datenschutz</h3>
<p>15.1 An Kostenvoranschlägen, Zeichnungen, Modellen, Teilen,
Schablonen, Berechnungen, Beschreibungen, Mustern und anderen Unterlagen
(im Folgenden: „Unterlagen“ genannt) behält sich KLZ seine eigentums-
und urheberrechtlichen Verwertungsrechte uneingeschränkt vor. Die
Unterlagen dürfen nur nach vorheriger schriftlicher Zustimmung von KLZ
Dritten zugänglich gemacht werden und sind KLZ auf Verlangen
unverzüglich zurückzugeben.</p>
<p>15.2 Ohne ausdrückliche Zustimmung von KLZ in Textform ist der Kunde
nicht berechtigt, die gelieferte Ware zu Werbezwecken zu verwenden.</p>
<p>15.3 KLZ erhebt und verwendet personenbezogenen Daten ausschließlich
gemäß den Bestimmungen der Datenschutzgrundverordnung und des
Bundesdatenschutzgesetzes.</p>
<p>15.4 Personenbezogenen Daten, die für die Begründung, inhaltliche
Ausgestaltung, Abwicklung oder Änderung des Vertragsverhältnisses
erforderlich sind (Bestandsdaten), werden ausschließlich zur
Durchführung des zwischen KLZ und dem Kunden abgeschlossenen Vertrages
verwendet. Zur Abwicklung des Vertragsverhältnisses müssen diese Daten
im erforderlichen Umfang an Dritte (z.B. Transporteure oder Spediteure)
weitergegeben werden. Der Kunde erteilt für die vorbeschriebene
Verwendung seiner personenbezogenen Daten hiermit eine entsprechende
Einwilligung.</p>
<p>15.5 Diese Einwilligungserklärung ist jederzeit -ohne Angabe von
Gründen- widerruflich. Der Widerruf ist zu richten an
info@klz-cables.com .</p>
<p>15.6 Eine darüberhinausgehende Nutzung der Bestandsdaten des Kunden
für Zwecke der Werbung, der Marktforschung oder zur bedarfsgerechten
Gestaltung der Angebote von KLZ bedarf der gesonderten ausdrücklichen
Einwilligung des Kunden.</p>
<p>15.7 Auf Anordnung einer zuständigen Stelle darf KLZ im Einzelfall
Auskunft über personenbezogene Daten des Kunden im Rahmen der Anordnung
an die zuständige Stelle erteilen, soweit dies für Zwecke der
Strafverfolgung, zur Gefahrenabwehr, zur Erfüllung der gesetzlichen
Aufgaben der Verfassungsschutzbehörden oder zur Durchsetzung der Rechte
am geistigen Eigentum erforderlich ist.</p>
<h3 id="geheimhaltung">16 Geheimhaltung</h3>
<p>16.1 „Vertrauliche Informationen“ sind (i) alle Informationen, Daten,
Zeichnungen, Unterlagen, Materialien, Know-how oder sonstige
Geschäftsgeheimnisse im Sinne von § 2 Nr. 1 des Gesetzes zum Schutz von
Geschäftsgeheimnissen (GeschGehG), insbesondere alle Entwürfe, Skizzen,
Zeichnungen, technischen Protokolle, Modelle oder elektronische Daten,
gleich in welcher Verkörperung, (ii) sowie alle nicht offenkundigen
kaufmännischen, betrieblichen oder technischen Informationen oder
Gegenstände, gleich in welcher Verkörperung, die dem Kunden im Rahmen
der Geschäftsbeziehung mit KLZ offengelegt oder anderweitig bekannt
werden, (iii) die entweder direkt als vertraulich gekennzeichnet wurden
oder nachträglich schriftlich oder in Textform als vertraulich
gekennzeichnet werden. Des Weiteren gelten auch solche Informationen als
vertraulich, welche der Natur nach bzw. aus den Umständen heraus oder
aus anderen Gründen erkennbar als vertraulich anzusehen sind, soweit die
Parteien nichts anderes in Schriftform vereinbart haben.</p>
<p>16.2 Vertrauliche Informationen im Sinne von Ziffer 16.1 sind jedoch
nicht solche Informationen, (i) die der Kunde von öffentlich
zugänglichen Quellen bezogen hat und die allgemein bekannt oder auf
legalem Wege zugänglich sind und diese Quellen die Informationen
ebenfalls auf legalem Wege erlangt haben, (ii) die der Kunde von einem
berechtigten Dritten rechtmäßig erhalten hat, (iii) die der Kunde
eigenständig entwickelt hat, (iv) die kraft Gesetzes oder aufgrund
gerichtlicher oder behördlicher Anordnung offengelegt wurden oder
offenzulegen sind. Der Kunde ist in diesem Fall verpflichtet, KLZ vor
einer solchen Offenlegung der Informationen unverzüglich zu informieren
und zu versuchen, die Offenlegung zu verhindern, soweit dies mit
angemessenen und gesetzlich zulässigen Mitteln möglich ist.</p>
<p>16.3 Die Darlegungs- und Beweislast für das Vorliegen einer in Ziffer
16.2 benannten Ausnahmetatbestände trägt diejenige Partei, die sich auf
das Vorliegen einer oder mehrerer dieser Ausnahmetatbestände beruft.</p>
<p>16.4 Der Kunde verpflichtet sich, die Vertraulichen Informationen
strikt vertraulich zu behandeln und ausschließlich zur Erfüllung des
Vertragszwecks zu verwenden. Die Vertraulichen Informationen dürfen
durch den Kunden nur an solche Mitarbeiter oder andere Personen
weitergegeben werden, die sie zur Erreichung des Vertragszwecks zwingend
erhalten müssen. Sämtliche Mitarbeiter des Kunden, welche Kenntnis von
Vertraulichen Informationen erhalten, müssen durch den Kunden ebenfalls
zur Geheimhaltung hinsichtlich der Vertraulichen Informationen
verpflichtet sein oder werden. Diese Geheimhaltungspflicht soll auch für
die Zeit nach einem etwaigen Ausscheiden des Mitarbeiters aus dem
Unternehmen des Kunden für weitere fünf (5) Jahre gelten, soweit dies
rechtlich möglich ist.</p>
<p>16.5 Der Kunde verpflichtet sich, auf erstes Anfordern von KLZ
und/oder nach Beendigung der Geschäftsbeziehung oder Beendigung des
zugrundeliegenden Vertrage, gleich aus welchem Rechtsgrund, alle unter
diese Geheimhaltungsvereinbarung fallenden Vertraulichen Informationen
und davon angefertigte Kopien an KLZ herauszugeben oder sachgemäß zu
zerstören bzw. sicher und dauerhaft zu löschen und die vollständige
Herausgabe, vollständige sachgemäße Zerstörung oder vollständige sichere
und dauerhafte Löschung KLZ gegenüber schriftlich zu bestätigen.</p>
<p>16.6 Ziffer 16.5 gilt nicht für Vertrauliche Informationen, die
zwingenden gesetzlichen Aufbewahrungspflichten unterliegen oder in
automatisierten Back-Up Dateien im Rahmen des ordnungsgemäßen
Sicherungsprozesses sicher gespeichert wurden. Für diese Vertraulichen
Informationen gelten die Bestimmungen dieser Geheimhaltungsvereinbarung
bis zu ihrer Herausgabe, sicheren und dauerhaften Löschung oder
vollständigen sachgemäßen Zerstörung fort.</p>
<p>16.7 Die Geheimhaltungsverpflichtungen gemäß dieser Ziffer 16 gelten
für einen Zeitraum von fünf (5) Jahren nach Beendigung des
zugrundeliegenden Vertrages, gleich aus welchem Rechtsgrund, fort.</p>
<p>16.8 Für jeden Einzelfall eines Verstoßes des Kunden gegen die
Pflichten aus dieser Geheimhaltungsvereinbarung ist der Kunde zur
Zahlung einer Vertragsstrafe an KLZ verpflichtet, deren Höhe in das
billige Ermessen von KLZ gestellt wird und im Streitfall durch das
zuständige Gericht überprüft werden kann. Durch die Geltendmachung der
Vertragsstrafe werden weitergehende Schadensersatzansprüche von KLZ
nicht ausgeschlossen.</p>
<h3 id="schlussbestimmungen">17 Schlussbestimmungen</h3>
<p>17.1 Änderungen oder Ergänzungen dieser AVB bedürfen der Textform.
Dies gilt auch für die Abänderung des Textformerfordernisses selbst.</p>
<p>17.2 Auf Verträge zwischen KLZ und dem Kunden, der Auslegung dieser
AVB, sowie die gesamten Rechtsbeziehungen zwischen KLZ und dem Kunden
gilt ausschließlich das Recht der Bundesrepublik Deutschland unter
Ausschluss des UN-Kaufrechts und des Kollisionsrechts. Erfüllungsort
sowie ausschließlicher Gerichtsstand für alle Streitigkeiten aus oder im
Zusammenhang mit der Rechtsbeziehung zwischen KLZ und dem Kunden ist
Stuttgart.</p>
<p>17.3 Sollten einzelne Bestimmungen dieser AVB unwirksam oder
undurchführbar sein oder werden, so wird hierdurch die Wirksamkeit aller
sonstigen Bestimmungen dieser AVB im Übrigen nicht berührt. Die
unwirksame oder undurchführbare Bestimmung wird von den Vertragsparteien
einvernehmlich durch eine rechtswirksame Regelung ersetzt, welche dem
wirtschaftlichen Erfolg und Zweck der unwirksamen oder undurchführbaren
Bestimmung am nächsten kommt. Dies gilt gleichermaßen für den Fall einer
Regelungslücke.</p>
<p>Stand: April 2026</p>

View File

@@ -1,238 +0,0 @@
# Allgemeine Verkaufsbedingungen
### 1 Geltungsbereich
1.1 Ausschließlich die nachfolgenden Allgemeinen Verkaufsbedingungen (nachfolgend „AVB“) gelten für den gesamten Geschäftsverkehr (nachfolgend „Lieferung von Waren“) der KLZ Vertriebs GmbH, Raiffeisenstraße 22, 73630 Remshalden (nachfolgend „KLZ“) und Unternehmern in Ausübung ihrer gewerblichen oder selbständigen beruflichen Tätigkeit und juristischen Personen des öffentlichen Rechts (nachfolgend „Kunde“). Der Geltung von abweichenden, zusätzlichen oder entgegenstehenden allgemeinen Geschäftsbedingungen des Kunden wird hiermit widersprochen.
1.2 Diese AVB gelten auch dann, wenn KLZ die Lieferung von Waren an den Kunden vorbehaltlos in Kenntnis entgegenstehender oder von diesen AVB abweichenden allgemeinen Geschäftsbedingungen des Kunden ausführt.
1.3 Im Rahmen laufender Geschäftsbeziehungen gelten diese AVB auch dann für künftige Lieferungen von Waren, wenn sie nicht ausdrücklich erneut in den Vertrag einbezogen wurden.
1.4 Änderungen dieser AVB werden dem Kunden schriftlich, per Telefax oder per E-Mail mitgeteilt. Widerspricht der Kunde diesen Änderungen nicht innerhalb von vier (4) Wochen nach Zugang der Mitteilung, gelten die Änderungen als durch den Kunden anerkannt. Auf das Widerspruchsrecht und die Rechtsfolgen des Schweigens wird der Kunde im Rahmen der Mitteilung über die Änderung der Geschäftsbedingungen gesondert hingewiesen.
1.5 Hinweise auf die Geltung gesetzlicher Vorschriften im Rahmen dieser AVB haben nur klarstellende Bedeutung. Auch ohne eine derartige Klarstellung gelten daher die gesetzlichen Vorschriften, soweit sie in diesen AVB nicht unmittelbar abgeändert oder ausdrücklich ausgeschlossen werden.
### 2 Vertragsschluss
2.1 Der Vertrag über die Lieferung von Waren kommt grundsätzlich durch ein Angebot des Kunden, z.B. über ein Auftragsformular, per E-Mail oder per Brief, und eine Annahme dieses Angebots durch KLZ in Textform z.B. durch eine Auftragsbestätigung per E-Mail oder Fax zustande. Der Annahme des Angebots durch KLZ steht es gleich, wenn KLZ den Auftrag oder die Bestellung binnen vierzehn (14) Kalendertagen ab Zugang des Angebots bei KLZ vorbehaltlos ausführt.
2.2 Alle Angebote von KLZ sind insbesondere hinsichtlich Verfügbarkeit, angegebener Mengen, Lieferfristen und Nebenleistungen freibleibend und unverbindlich, sofern sie nicht ausdrücklich in Textform als verbindlich gekennzeichnet wurden. Die Angebote stehen unter dem Vorbehalt der zeitgerechten, qualitativen und quantitativen Selbstbelieferung von KLZ.
2.3 Der Umfang der von KLZ zu liefernden Waren ergibt sich aus dem jeweiligen Angebot oder der jeweiligen Auftragsbestätigung von KLZ.
2.4 Technische Angaben, Muster, Preise, Abbildungen, Zeichnungen, Gewichts-, Maß-, Leistungs- und Gebrauchsangaben, sowie sonstige Beschreibungen der zu liefernden Ware in dem Angebot oder der Auftragsbestätigung sind nur annähernd maßgebend, soweit sie nicht ausdrücklich von KLZ in Textform als verbindlich gekennzeichnet wurden. Sie stellen keine Beschaffenheitsvereinbarung oder Garantie einer entsprechenden Beschaffenheit oder Haltbarkeit der zu liefernden Ware dar, es sei denn, sie wurden ausdrücklich in Textform als solche vereinbart. Entsprechendes gilt für Erwartungen des Kunden hinsichtlich der zu liefernden Ware oder deren Verwendung.
2.5 Handelsübliche und branchenübliche Abweichungen vom Leistungsumfang der zu liefernden Ware durch KLZ sind zulässig, soweit sie die Verwendbarkeit der zu liefernden Ware zum vertraglich vorgesehenen Zweck nicht beeinträchtigen. Dies umfasst auch Über- oder Unterlieferungen. Abweichungen der zu liefernden Ware, die durch zwingende rechtliche oder technische Normen, die nach Auftragsbestätigung in Kraft treten, bedingt sind, sind zulässig, soweit sie nicht erheblich und dem Kunden zumutbar sind. Änderungen, die zu einer technischen Verbesserung der zu liefernden Ware führen, sind stets zulässig.
2.6 Änderungen des Lieferumfangs durch den Kunden bedürfen zu ihrer Wirksamkeit stets der in Textform erklärten Einwilligung von KLZ. Mehraufwände und sonstige Kosten, die durch die Änderung des Lieferumfangs auf Wunsch des Kunden entstehen, sind von dem Kunden gesondert zu vergüten.
2.7 Soweit das Angebot oder die Auftragsbestätigung von KLZ offensichtliche Irrtümer, Schreib- oder Rechenfehler enthält, ist das Dokument für KLZ hinsichtlich der offensichtlichen Fehler nicht verbindlich. Der Kunde wird KLZ auf entsprechende Fehler hinweisen.
2.8 Verschlechtern sich die Vermögensverhältnisse des Kunden wesentlich oder wird der Antrag zur Eröffnung eines Insolvenz- oder vergleichbaren Verfahrens über das Vermögen des Kunden mangels Masse abgelehnt, ist KLZ berechtigt, ganz oder teilweise vom Vertrag zurückzutreten.
### 3 Abrufaufträge
3.1 Erteilt der Kunde KLZ einen Abrufauftrag und werden über die Abruftermine keine gesonderten Vereinbarungen in Textform getroffen, ist der Kunde verpflichtet, KLZ die einzelnen Abruftermine so mitzuteilen, dass zwischen Eingang der Abrufmitteilung bei KLZ und Auslieferung der Ware mindestens vierzehn (14) Werktage und zwischen Eingang der Abrufmitteilung und der letzten Auslieferung der Ware maximal neunzig (90) Werktage liegen. Werktage sind dabei alle Kalendertage, bei denen es sich nicht um einen Sonntag oder einen gesetzlichen Feiertag am Sitz von KLZ handelt.
### 4 Preise und Zahlungsbedingungen
4.1 Die in dem Angebot oder der Auftragsbestätigung von KLZ angegebenen Preise gelten für den in dem Angebot oder der Auftragsbestätigung von KLZ aufgeführten Lieferumfang und verstehen sich grundsätzlich in EURO und ohne Abzüge zuzüglich Versandkosten ab dem jeweiligen Werk des Herstellers der Ware, exklusive Verpackung und zuzüglich der jeweils geltenden gesetzlichen Umsatzsteuer, sofern nicht ausdrücklich etwas anderes in Textform vereinbart wurde. Die in dem Angebot oder der Auftragsbestätigung von KLZ ausgewiesenen Hohlpreise verstehen sich darüber hinaus zuzüglich Metallzuschlag und auftragsspezifischer Schnittkosten.
4.2 Sollte sich nach Vertragsschluss herausstellen, dass zur Durchführung des Vertrages bzw. zur Lieferung der Waren Mehrleistungen erforderlich sind, so werden diese Mehrleistungen gesondert abgerechnet und vergütet.
4.3 Hat sich der Preis für eine zu liefernde Ware, bzw. der vereinbarte Hohlpreis z.B. aufgrund gestiegener Preise für Energie, Transport, Arbeitskraft oder Material zwischen Vertragsschluss und Lieferdatum um mehr als fünf (5) % erhöht, werden die Parteien partnerschaftlich und konstruktiv über eine Anpassung des Preises bzw. des Hohlpreises diskutieren. KLZ ist zum Rücktritt von dem betroffenen Vertrag berechtigt, wenn innerhalb von vierzehn (14) Kalendertagen ab Anzeige der Preiserhöhung durch KLZ keine einvernehmliche Lösung gefunden wird.
4.4 Der Kaufpreis wird sofort mit Erhalt der Rechnung oder Lieferung der Ware, je nachdem welcher Zeitpunkt früher eintritt, zur Zahlung fällig, sofern keine anderen Zahlungskonditionen in Textform vereinbart sind. Der Kunde hat den ausstehenden Kaufpreis innerhalb von zehn (10) Kalendertagen nach Erhalt der Rechnung oder Erhalt der Ware, je nachdem welcher Zeitpunkt früher eintritt, frei Konto von KLZ und ohne Abzug zu bezahlen. Die Zahlungsfrist gilt mit vollständiger Gutschrift des ausstehenden Kaufpreises auf dem Konto von KLZ als gewahrt. Nach Ablauf der Zahlungsfrist kommt der Kunde automatisch mit der Zahlung des ausstehenden Kaufpreises in Verzug, ohne dass es hierfür einer gesonderten Mahnung von KLZ bedarf.
4.5 Befindet sich der Kunde mit einer Zahlung in Verzug, ist KLZ berechtigt, die zu liefernde Ware nur Zug-um-Zug gegen Zahlung der Summe, mit der sich der Kunde in Verzug befindet, zu liefern. Dies gilt auch für alle künftigen Lieferungen von Waren von KLZ an den Kunden. Darüber hinaus stehen KLZ sämtliche Ansprüche gegen den Kunden aufgrund dessen Zahlungsverzug uneingeschränkt zu.
4.6 Befindet sich der Kunde seit mehr als vierzehn (14) Kalendertagen in Zahlungsverzug, ist KLZ dazu berechtigt, vom Vertrag zurückzutreten und die gelieferte Ware unverzüglich zurück zu verlangen, ohne dass dem Kunden hieraus Ansprüche entstehen.
4.7 Das Recht des Kunden, Zahlungen zurückzuhalten oder mit Gegenansprüchen aufzurechnen, steht dem Kunden nur insoweit zu, als seine Gegenansprüche unbestritten oder rechtskräftig festgestellt sind, oder wenn die jeweiligen Ansprüche in einem Gegenseitigkeitsverhältnis zueinanderstehen.
### 5 Metallnotierung
5.1 Basis zur Berechnung des Kupferpreises ist der Durchschnitt des Liefervormonats der Notierung „LME Copper official price cash offer“, zuzüglich der jeweils aktuellen von KLZ benannten Kupfer-Prämie.
5.2 Basis zur Berechnung des Aluminiumpreises ist der Durchschnitt des Liefervormonats der Notierung „LME Aluminium official price cash offer" zuzüglich der jeweils von KLZ benannten Aluminium-Prämie.
5.3 USD werden auf Basis des EUR/USD LME-FX-Rate (MTLE) in EUR umgerechnet. Die entsprechenden Notierungen können der Webseite www.westmetall.com entnommen werden. Die Prämienzuschläge können stark variieren und KLZ behält sich das Recht vor, diese fristgerecht anzupassen, ungeachtet der Angebotslegung.
### 6 Metallzahl
6.1 Die von KLZ in dem Angebot oder der Auftragsbestätigung ausgewiesene Metallzahl ist eine rein kaufmännische Berechnungsgröße für den Metallinhalt, der in die Berechnung des Gesamtpreises eines Kabels eingeht.
6.2 Die Angabe der Metallzahl dient ausschließlich dazu, dem Kunden eine Vergleichbarkeit auf Grundlage der Holpreisbasis zu ermöglichen. Die Metallzahl definiert nicht das Gewicht des tatsächlich im Kabel enthaltenen Leitermetalls. Die Metallzahl ist ein rein kalkulatorischer Berechnungsfaktor, der keine unmittelbaren Rückschlüsse auf die im Kabel verwendeten Kupfer- bzw. Aluminiummengen zulässt. KLZ weist den Kunden hiermit ausdrücklich darauf hin, final nur den Vollpreis für Vergleichszwecke heranzuziehen.
6.3 Auf Anfrage des Kunden in Textform ist KLZ grundsätzlich dazu bereit, andere Metallzahlen zu berechnen und auszuweisen. Bei jeglicher Änderung bleibt der Vollpreis der gleiche Betrag. Ein Anspruch des Kunden auf Berechnung und Angabe anderer Metallzahlen besteht nicht.
### 7 Lieferung
7.1 Lieferungen erfolgen grundsätzlich DAP (INCOTERMS® 2020) sofern die Parteien keine anderweitige Vereinbarung in Textform getroffen haben.
7.2 Die Lieferung der Ware wird nach Möglichkeit geschlossen vorgenommen. KLZ ist jedoch berechtigt, Teillieferungen vorzunehmen, wenn dies für den Kunden zumutbar ist, insbesondere wenn wegen Fehlens von Waren eine erhebliche Verzögerung der gesamten Lieferung eintreten würde.
7.3 Lieferfristen aus Angeboten und Auftragsbestätigungen sind grundsätzlich für KLZ unverbindlich. Eine verbindliche Lieferfrist muss ausdrücklich in Textform als solche gekennzeichnet werden.
7.4 Die Lieferfrist beginnt mit dem Zugang der Auftragsbestätigung bei dem Kunden. Die Einhaltung der Lieferfrist setzt die rechtzeitige Klärung aller kaufmännischen und technischen Fragen, sowie die Einhaltung der vereinbarten Zahlungsbedingungen und sonstigen Verpflichtungen des Kunden voraus. Werden diese Voraussetzungen nicht rechtzeitig erfüllt, so verlängern sich die Fristen angemessen; dies gilt nicht, wenn ausschließlich KLZ die Verzögerung zu vertreten hat.
7.5 Die Einhaltung auch einer verbindlichen Lieferfrist steht unter dem Vorbehalt ordnungsgemäßer, insbesondere rechtzeitiger quantitativer und qualitativer Selbstbelieferung von KLZ durch dessen Vorlieferanten. KLZ ist im Fall der nicht ordnungsgemäßen Selbstbelieferung zum Rücktritt vom Vertrag berechtigt. KLZ informiert den Kunden unverzüglich, wenn und soweit KLZ von seinem Recht zum Rücktritt vom Vertrag Gebrauch macht und gewährt etwa erbrachte Vorleistungen des Kunden zurück.
7.6 Die Lieferfrist gilt als gewahrt, wenn KLZ die zu liefernde Ware dem Kunden bis zum Ablauf der Lieferfrist an dem benannten Lieferort innerhalb der üblichen Geschäftszeiten zur Annahme erstmalig angeboten hat.
7.7 Auch wenn eine verbindliche Lieferfrist vereinbart wurde, kommt KLZ mit der Lieferung der Ware nur dann in Verzug, wenn der Kunde KLZ nach Ablauf der Lieferfrist eine angemessene Nachfrist zur Lieferung der Ware in Textform gesetzt hat und diese Nachfrist erfolglos verstrichen ist.
7.8 Wird die Lieferung der Ware, oder das Abladen der Ware, auf Wunsch des Kunden verzögert, oder verzögert sich die Lieferung der Ware, oder das Abladen der Ware, aus sonstigen Gründen, die der Kunde zu vertreten hat, ist KLZ berechtigt, dem Kunden nach Ablauf der vertraglich vereinbarten Lieferfrist ein Lagergeld in Höhe von zwei (2) % des jeweiligen Nettoauftragswertes für jeden angefangenen Monat, maximal jedoch fünf (5) % des jeweiligen Nettoauftragswertes in Rechnung zu stellen. Das Lagergeld ist der Höhe nach auf maximal fünf (5) % des Nettoauftragswertes pro Vertrag beschränkt.
7.9 KLZ behält sich die Geltendmachung aller darüberhinausgehenden Ansprüche gegenüber dem Kunden aufgrund dessen verzögerter Übernahme der Ware uneingeschränkt vor. Das Lagergeld ist auf weitergehende Zahlungsansprüche von KLZ gegenüber dem Kunden aufgrund dessen verzögerter Übernahme der Ware anzurechnen. Dem Kunden bleibt der Nachweis gestattet, dass KLZ überhaupt kein oder nur ein geringerer Schaden entstanden ist.
7.10 Kabel werden von KLZ auf stabilen Vollholztrommeln geliefert. Auf Wunsch vermittelt KLZ dem Kunden Partner, die diese Trommeln gegen eine Gebühr abholen. Bei Lagerzeiten über drei (3) Monaten ist zu beachten, dass die Trommel regelmäßig bewegt wird, um eine einseitige Belastung zu vermeiden. Folgeschäden bei Nichtbeachtung wie Instabilität schließt KLZ aus.
7.11 Rücksendungen von Waren an KLZ, die nicht vorher von KLZ in Textform bestätigt wurden, erfolgen auf alleinige Gefahr des Kunden.
### 8 Gefahrübergang
8.1 Die Gefahr des zufälligen Untergangs und der zufälligen Verschlechterung der zu liefernden Ware geht grundsätzlich DAP (INCOTERMS® 2020) auf den Kunden über, sofern die Parteien keine anderweitige Vereinbarung in Textform getroffen haben.
8.2 Verzögert sich die Versendung bzw. Übergabe der Ware aus Gründen, die der Kunde zu vertreten hat, geht die Gefahr des zufälligen Untergangs und der zufälligen Verschlechterung der Ware mit der Durchführung des ersten Zustellversuchs innerhalb der gewöhnlichen Öffnungszeiten des Kunden durch KLZ auf den Kunden über.
### 9 Eigentumsvorbehalt
9.1 Die jeweils gelieferten Waren bleiben bis zur vollständigen Bezahlung sämtlicher fälliger Forderungen aus der Geschäftsbeziehung zwischen dem Kunden und KLZ in dem Eigentum von KLZ (nachfolgend „Vorbehaltsware“). Dabei ist unerheblich, aus welchem Rechtsgrund die Forderungen von KLZ gegen den Kunden bestehen. Erfasst sind insbesondere auch Saldoforderungen aus laufender Rechnung.
9.2 Der Kunde ist berechtigt, die gelieferte Ware im Rahmen des ordnungsgemäßen Geschäftsbetriebes zu veräußern, umzubilden oder weiterzuverarbeiten.
9.3 Die Verarbeitung oder Umbildung der Vorbehaltsware durch den Kunden erfolgt jedoch stets für KLZ und KLZ erwirbt Eigentum an der neuen Sache. Wird die Vorbehaltsware mit anderen Sachen zu einer einheitlichen Sache verbunden, verarbeitet oder untrennbar vermischt oder vermengt oder in sonstiger Weise verbunden, erwirbt KLZ Miteigentum an der neuen Sache im Verhältnis des Wertes der Vorbehaltsware zu den anderen Gegenständen zur Zeit der Verarbeitung bzw. Vermischung, Vermengung oder sonstigen Verbindung. Erfolgt die Vermischung, Vermengung oder sonstige Verbindung dergestalt, dass die Sache des Kunden als Hauptsache anzusehen ist, so willigt der Kunde bereits jetzt dazu ein, KLZ anteiliges Miteigentum an der neuen Sache, in dem Mengenverhältnis der Vorbehaltsware zu den fremden Sachen, zu übertragen. Für die so entstandenen Sachen gelten die Bestimmungen für Vorbehaltsware.
9.4 Der Kunde hat die Vorbehaltsware mit kaufmännischer Sorgfalt für KLZ zu verwahren, pfleglich zu behandeln und auf seine Kosten gegen Feuer, Wasser und Diebstahl zum Neuwert zu versichern. Der Kunde tritt seine entsprechenden Ansprüche aus den Versicherungsverträgen an KLZ ab; KLZ nimmt die Abtretung an.
9.5 Der Kunde darf die Vorbehaltsware nur im ordnungsgemäßen Geschäftsgang bis zum Eintritt des Verwertungsfalles veräußern. Verpfändungen und Sicherungsübereignungen hinsichtlich der Vorbehaltsware sind unzulässig. Eingriffe Dritter, wie etwa die Pfändung oder Zwangsvollstreckung, durch welche Rechte von KLZ beeinträchtigt werden, hat der Kunde KLZ unverzüglich in Textform anzuzeigen. Der Kunde hat den Dritten darüber hinaus unverzüglich in Textform darauf hinzuweisen, dass KLZ Eigentümer der Vorbehaltsware ist. Soweit der Dritte nicht in der Lage ist, KLZ die gerichtlichen und außergerichtlichen Kosten einer Klage nach § 771 ZPO zu erstatten, haftet der Kunde für den dadurch entstandenen Ausfall. Weitergehende Ansprüche gegen den Kunden bleiben unberührt.
9.6 Für den Fall der Weiterveräußerung der Vorbehaltsware tritt der Kunde die ihm daraus seinerseits erwachsenden Forderungen gegen seinen Abnehmer bereits jetzt an KLZ ab; KLZ nimmt die Abtretung an. Gleiches gilt für sonstige Forderungen, die an die Stelle der Vorbehaltsware treten oder sonst hinsichtlich der Vorbehaltsware entstehen, wie z. B. Versicherungsansprüche oder Ansprüche aus unerlaubter Handlung bei Verlust oder Zerstörung.
9.7 Wird die Vorbehaltsware nach Verarbeitung oder zusammen mit anderen Waren, die KLZ nicht gehören, weiterveräußert oder wird sie mit einem Grundstück oder mit beweglichen Sachen verbunden, so erklärt der Kunde bereits jetzt die Abtretung der Forderung des Kunden gegen seine Abnehmer in Höhe des zwischen dem Kunden und KLZ vereinbarten Kaufpreises für die Vorbehaltsware; KLZ nimmt die Abtretung an.
9.8 Der Kunde wird ermächtigt, die abgetretenen Forderungen für KLZ einzuziehen. KLZ bleibt auch weiterhin zur Einziehung der abgetretenen Forderungen berechtigt, wird die abgetretenen Forderungen jedoch nicht selbst einziehen, solange kein Grund nach Ziffer 9.9 vorliegt, der KLZ zum Widerruf der Einziehungsermächtigung berechtigt. Zur Abtretung der Forderung an Dritte, einschließlich Forderungsverkauf an Factoring Banken, ist der Kunde nur mit vorheriger schriftlicher Zustimmung von KLZ berechtigt. KLZ wird die Zustimmung nicht verweigern, sofern eine gleichwertige Sicherheit gestellt wird.
9.9 KLZ kann die Ermächtigung zur Weiterveräußerung und Einziehung widerrufen, sobald sich der Kunde gegenüber KLZ im Zahlungsverzug befindet, oder das Insolvenzverfahren oder ein vergleichbares Verfahren über das Vermögen des Kunden eröffnet oder mangels Masse abgewiesen wurde.
9.10 Der Kunde hat die eingezogenen Forderungen binnen zehn (10) Werktagen auf ein von KLZ benanntes Konto zu überweisen.
9.11 KLZ ist zur Offenlegung der Abtretung berechtigt und kann von dem Kunden verlangen, dass dieser die Abtretung seinen Abnehmern anzeigt. Auf Verlangen ist der Kunde verpflichtet, KLZ die Namen seiner Abnehmer bekanntzugeben und alle erforderlichen Unterlagen herauszugeben, die für die Einziehung der Forderung durch KLZ selbst erforderlich sind. Auch ist der Kunde ab Verzugseintritt verpflichtet, die Abtretung der Forderung an KLZ seinem Abnehmer gegenüber schriftlich mitzuteilen.
9.12 KLZ wird die Vorbehaltsware sowie die an ihre Stelle tretenden Sachen oder Forderungen freigeben, soweit ihr Wert die Höhe der gesicherten Forderungen um mehr als zehn (10) % übersteigt. Die Auswahl der danach freizugebenden Gegenstände liegt bei KLZ.
9.13 Tritt KLZ bei vertragswidrigem Verhalten des Kunden, insbesondere Zahlungsverzug, vom Vertrag zurück (Verwertungsfall), ist KLZ berechtigt, die Vorbehaltsware herauszuverlangen. Der Kunde hat in diesem Fall KLZ alle Informationen und Unterlagen zu übergeben, die erforderlich sind, um die Vorbehaltsware herauszuverlangen und die an KLZ abgetretenen Ansprüche des Kunden gegenüber seinen Abnehmern geltend zu machen, mindestens jedoch eine Aufstellung über die bei dem Kunden oder bei den Abnehmern befindliche Vorbehaltsware, auch soweit sie bereits verarbeitet ist, und eine Aufstellung aller abgetretenen Forderungen nebst Rechnungsgutschriften.
### 10 Höhere Gewalt
10.1 „Höhere Gewalt“ bedeutet das Eintreten eines Ereignisses oder Umstands, welcher eine Partei daran hindert, eine oder mehrere ihrer vertraglichen Verpflichtungen zu erfüllen, wenn und soweit die von dem Hindernis betroffene Partei nachweist, dass (a) dieses Hindernis außerhalb der ihr zumutbaren Kontrolle liegt und (b) es zu dem Zeitpunkt des Vertragsschlusses nicht in zumutbarer Weise vorhersehbar war und (c) die Auswirkungen des Hindernisses von der betroffenen Partei nicht in zumutbarer Weise hätte vermieden oder überwunden werden können.
10.2 Bei dem Beweis des Gegenteils wird insbesondere, aber nicht abschließend, bei den folgenden Ereignissen ein Fall Höherer Gewalt im Sinne des vorstehenden Absatzes vermutet: Krieg, Terrorakte, Währungs- und Handelsbeschränkungen, Embargo, Sanktionen, Amtshandlungen, Befolgung von Gesetzen oder Regierungsanordnungen, staatliche Ausreise- und Exportverbote oder staatliche Einreise- und Importverbote, Enteignung, Epidemie, Naturkatastrophe, extremes Naturereignis, Explosionen, Feuer, Zerstörung von Ausrüstungen, Demonstrationen oder Versammlungen, Ereignisse, die das Passieren von wichtigen Transportrouten verhindern, allgemeine Arbeitsunruhen, insbesondere Boykott, Streik und Aussperrungen, Energieknappheit oder Beeinträchtigung von Transportmitteln.
10.3 Eine Partei, die sich mit Erfolg auf diese Klausel beruft, ist ab dem Zeitpunkt, zu dem das Hindernis ihr die Leistungserbringung unmöglich macht, von ihrer Pflicht zur Erfüllung ihrer vertraglichen Verpflichtungen und von jeder Schadensersatzpflicht oder jedem anderen vertraglichen Rechtsbehelf wegen Vertragsverletzung suspendiert, sofern der Eintritt eines Ereignisses Höherer Gewalt der anderen Partei unverzüglich mitgeteilt wird. Ist die Auswirkung des geltend gemachten Hindernisses oder Ereignisses vorübergehend, so gelten die eben dargelegten Folgen nur so lange, wie das geltend gemachte Hindernis die Vertragserfüllung durch die betroffene Partei verhindert sowie einer angemessenen Anlaufzeit. Dauert das Hindernis oder Ereignis höherer Gewalt bei der betroffenen Partei länger als vier (4) Monate an, ist die andere Partei zur ganzen oder teilweisen außerordentlichen Kündigung bzw. zum Rücktritt vom Vertrag berechtigt. Wechselseitige Schadensersatzansprüche wegen dieser Kündigung bzw. dieses Rücktritts sind ausgeschlossen.
10.4 Der Kunde ist verpflichtet, auf Verlangen von KLZ innerhalb einer angemessenen Frist zu erklären, ob er wegen einem bei KLZ eingetretenen Ereignis Höherer Gewalt den Vertrag kündigt bzw. von diesem Zurücktritt oder weiterhin auf der Durchführung der Lieferung der Ware besteht.
10.5 Sofern sich aufgrund eines Ereignisses Höherer Gewalt die wirtschaftliche Bedeutung oder der Inhalt der Lieferung der Ware erheblich verändert oder auf den Betrieb von KLZ erheblich einwirkt, werden die Parteien den zugrundeliegenden Vertrag unter Beachtung von Treu und Glauben angemessen anpassen. Soweit dies wirtschaftlich nicht vertretbar ist, steht KLZ das Recht zu, von dem Vertrag zurückzutreten. Will KLZ von diesem Rücktrittsrecht Gebrauch machen, so hat er dies nach Erkenntnis der Tragweite des Ereignisses Höherer Gewalt unverzüglich dem Kunden mitzuteilen, und zwar auch dann, wenn zunächst mit dem Kunden eine Verlängerung der Lieferzeit vereinbart war.
### 11 Gewährleistung und Mängelrüge
11.1 Mängelansprüche bestehen nicht bei nur unerheblicher Abweichung der gelieferten Ware von den vereinbarten Spezifikationen, bei unerheblicher Beeinträchtigung der von KLZ vorgegebenen Brauchbarkeit, bei natürlicher Abnutzung, übermäßiger Beanspruchung, Verwendung ungeeigneter Betriebsmittel, oder bei Mängeln, die aufgrund besonderer äußerer Einflüsse entstehen, sowie bei nicht reproduzierbaren Softwarefehlern. Werden vom Kunden oder von Dritten unsachgemäß Änderungen oder Instandsetzungsarbeiten vorgenommen, so bestehen für diese und die daraus entstehenden Folgen ebenfalls keine Mängelansprüche.
11.2 Eine Gewährleistungsverpflichtung von KLZ besteht nur, wenn der Kunde den ihm obliegenden Untersuchungs- und Rügepflichten fristgerecht nachgekommen ist. Eingehende Waren hat der Kunde unverzüglich auf äußerlich erkennbare Transportschäden und Identität der gelieferten Ware mit der Bestellung zu überprüfen. Äußerlich erkennbare Mängel sind unverzüglich, spätestens jedoch innerhalb von drei (3) Werktagen ab Übergabe, sonstige Mängel innerhalb von fünf (5) Werktagen nach ihrer Entdeckung gegenüber KLZ im Rahmen einer Mängelrüge mindestens in Textform geltend zu machen.
11.3 Mängelrügen müssen eine genaue Beschreibung der aufgetretenen Mängel, der betroffenen Lieferungen und wenn möglich, auch eine bildhafte Dokumentation des Mangels enthalten. KLZ ist berechtigt, mangelhafte Teile auf eigene Kosten zur Übersendung und Untersuchung anzufordern.
11.4 Beim Verlegen von Kabeln in Gräben oder Rohren, bzw. in Bauwerken ist eine ständige Sichtkontrolle durch den Kabelverleger vorzunehmen. Der Kabelverleger hat laufend zu überprüfen, ob Auffälligkeiten zu vermerken sind.
11.5 KLZ ist berechtigt, innerhalb einer angemessenen Frist, anerkannte Mängel nach eigener Wahl durch Beseitigung (Nachbesserung) oder durch Lieferung mangelfreier Ware (Ersatzlieferung) zu beheben. Der Kunde wird KLZ bei der Beseitigung von Mängeln in zumutbarem Umfang unterstützen.
11.6 Die Kosten der Nacherfüllung und Mangeluntersuchung werden bei berechtigten Beanstandungen von KLZ getragen. Dies gilt nicht für Aufwendungen, die dadurch entstehen, dass die gelieferten Waren durch den Kunden an einen anderen als den Ort der gewerblichen Niederlassung des Kunden verbracht worden sind, es sei denn, das Verbringen entspricht dem vertraglich vorgesehenen Verwendungszweck der gelieferten Ware. Satz 1 gilt ebenfalls nicht für Mehraufwendungen, die dadurch entstehen, dass der Kunde die gelieferte Ware ohne Zustimmung von KLZ verändert hat.
11.7 Hat der Kunde die Ware verbaut, obwohl er positive Kenntnis oder grob fahrlässige Unkenntnis des Mangels hatte, ist KLZ nicht dazu verpflichtet, die Ein- und Ausbaukosten der Ware zu erstatten.
11.8 Der Kunde kann erst nach zweimaligem Fehlschlagen der Mängelbeseitigung vom Vertrag zurücktreten oder den vereinbarten Kaufpreis angemessen mindern. Bei nur unerheblicher Minderung des Werts oder der Tauglichkeit der gelieferten Waren ist der Rücktritt ausgeschlossen, ebenso wenn der Kunde sich im Annahmeverzug befindet oder wenn der Kunde den Mangel zu vertreten hat. Solange der Kunde KLZ gegenüber nicht den Rücktritt vom Vertrag erklärt oder Schadensersatz wegen Nichterfüllung verlangt hat, ist KLZ auch nach Ablauf der vom Kunden gesetzten Frist zur Nacherfüllung berechtigt, es sei denn, der Kunde hat zuvor schriftlich die Ablehnung der Nacherfüllung angezeigt.
11.9 Beruht ein Mangel auf dem Verschulden von KLZ, kann der Kunde unter den in Ziffer 12 bestimmten Voraussetzungen Schadensersatz verlangen. Ziffer 12 gilt entsprechend für den Ersatz vergeblicher Aufwendungen, den der Kunde anstelle eines Schadensersatzes statt der Leistung verlangen kann.
11.10 Besteht Uneinigkeit zwischen den Parteien in Bezug auf Mängel an den Waren, ist ein Gutachten bei dem VDE-Institut über die streitige Frage einzuholen. Das Ergebnis des Gutachtens des VDE-Instituts ist für die Feststellung der Mangelfreiheit oder eines Mangels maßgebend. KLZ ist nur dann verpflichtet, die Kosten des Gutachtens des VDE-Instituts zu tragen, wenn das Gutachten einen Mangel feststellt.
11.11 Die gesetzlichen Mängelgewährleistungsansprüche des Kunden verjähren innerhalb von zwölf (12) Monaten ab Gefahrübergang. §§ 438 Abs. 1 Nr. 2, 479 Abs. 1 und 634a Abs. 1 Nr. 2 BGB bleiben hiervon unberührt. Diese Verkürzung der Verjährungsfrist gilt nicht bei Vorsatz, grober Fahrlässigkeit, der Verletzung von Leben, Körper, Gesundheit, oder jedweder wesentlicher Vertragspflichtverletzung, zwingender gesetzlicher Haftungsnormen wie denen des ProdHaftG, sowie bei Nichteinhaltung einer Beschaffenheitsgarantie oder dem arglistigen Verschweigen eines Mangels. Die gesetzlichen Regelungen über Ablaufhemmung, Hemmung und Neubeginn der Fristen bleiben unberührt.
### 12 Haftung
12.1 KLZ haftet uneingeschränkt für Vorsatz und grobe Fahrlässigkeit. KLZ haftet ferner uneingeschränkt für Schäden aus der Verletzung des Lebens, des Körpers oder der Gesundheit, sowie für arglistiges Verschweigen eines Mangels und Verletzung von garantierten Beschaffenheitsmerkmalen, sowie im Rahmen zwingender gesetzlicher Vorschriften, wie etwa denen des Produkthaftungsgesetzes.
12.2 Bei der Verletzung von wesentlichen Vertragspflichten haftet KLZ im Übrigen der Höhe nach begrenzt auf den bei Vertragsschluss für KLZ typischerweise vorhersehbaren Schaden bzw. die typischerweise vorhersehbaren Aufwendungen. Vertragswesentlich sind die Verpflichtungen, deren Erfüllung die ordnungsgemäße Vertragsdurchführung erst ermöglichen und auf deren Einhaltung der Kunde vertraut und auch vertrauen darf. Im Übrigen haftet KLZ bei leichter Fahrlässigkeit nicht auf Schadensersatz.
12.3 Eine Haftung für mittelbare Schäden und Folgeschäden, die Folge von Mängeln der gelieferten Waren sind, besteht nur für solche Schäden, die bei bestimmungsgemäßer Verwendung der gelieferten Ware typischerweise zu erwarten sind.
12.4 Die Haftung entfällt für Schäden, die auf Reparaturen, Änderungen oder anderen Eingriffen oder auf die Verwendung von Nicht-Originalteilen von KLZ im Rahmen der Reparatur oder Änderung bzw. des Eingriffs zurückzuführen sind, die der Kunde ohne Zustimmung von KLZ vorgenommen hat.
12.5 Soweit die Haftung von KLZ ausgeschlossen oder beschränkt ist, gilt dies auch für die Haftung der Angestellten, Arbeitnehmer, Mitarbeiter, Vertreter und Erfüllungsgehilfen von KLZ.
### 13 Produkthaftung
13.1 Der Kunde wird die gelieferten Waren ausschließlich vertragsgemäß und entsprechend den verfügbaren Anleitungen von KLZ verwenden, insbesondere wird er vorhandene Warnhinweise über Gefahren bei Gebrauch der gelieferten Waren nicht verändern oder entfernen. Bei Verletzung dieser Pflicht stellt der Kunde KLZ im Innenverhältnis von Produkthaftungsansprüchen Dritter frei, es sei denn der Kunde ist für den die Haftung auslösenden Fehler nicht verantwortlich.
13.2 Wird KLZ aufgrund eines Produktfehlers zu einem Produktrückruf oder einer -warnung veranlasst, so wird der Kunde nach besten Kräften bei den Maßnahmen mitwirken, die KLZ für erforderlich und zweckmäßig hält. KLZ ist verpflichtet, die Kosten des Produktrückrufs oder der -warnung zu tragen, es sei denn KLZ ist für den Produktfehler und den eingetretenen Schaden nach produkthaftungsrechtlichen Grundsätzen nicht verantwortlich.
13.3 Der Kunde wird KLZ unverzüglich über ihm bekanntwerdende Risiken bei der Verwendung der gelieferten Ware und mögliche Produktfehler in Textform informieren.
### 14 Exportkontrolle
14.1 Lieferungen von Waren von KLZ stehen unter dem Vorbehalt, dass der Vertragserfüllung keine Hindernisse aufgrund von nationalen oder internationalen exportkontrollrechtlichen Vorschriften entgegenstehen. Sofern sich die Durchführung des Vertrages aufgrund von Exportkontrollprüfungen oder Genehmigungsverfahren verzögert, werden Fristen und Lieferzeiten außer Kraft gesetzt. KLZ ist berechtigt, von der Einzelbestellung zurückzutreten und den Liefervertrag fristlos zu kündigen, wenn ein solcher Rücktritt oder eine Kündigung zur Einhaltung nationaler oder internationalen Exportkontrollbestimmung erforderlich ist. Die Geltendmachung von Schadensersatzansprüchen oder anderen Rechten durch den Kunden wegen vorgenannter Kündigung oder Verzögerung ist ausgeschlossen.
14.2 Der Kunde verpflichtet sich zur Einhaltung des nationalen und internationalen Exportkontrollrechts, insbesondere bei Weitergabe der gelieferten Ware an Dritte.
### 15 Urheberrechte und Datenschutz
15.1 An Kostenvoranschlägen, Zeichnungen, Modellen, Teilen, Schablonen, Berechnungen, Beschreibungen, Mustern und anderen Unterlagen (im Folgenden: „Unterlagen“ genannt) behält sich KLZ seine eigentums- und urheberrechtlichen Verwertungsrechte uneingeschränkt vor. Die Unterlagen dürfen nur nach vorheriger schriftlicher Zustimmung von KLZ Dritten zugänglich gemacht werden und sind KLZ auf Verlangen unverzüglich zurückzugeben.
15.2 Ohne ausdrückliche Zustimmung von KLZ in Textform ist der Kunde nicht berechtigt, die gelieferte Ware zu Werbezwecken zu verwenden.
15.3 KLZ erhebt und verwendet personenbezogenen Daten ausschließlich gemäß den Bestimmungen der Datenschutzgrundverordnung und des Bundesdatenschutzgesetzes.
15.4 Personenbezogenen Daten, die für die Begründung, inhaltliche Ausgestaltung, Abwicklung oder Änderung des Vertragsverhältnisses erforderlich sind (Bestandsdaten), werden ausschließlich zur Durchführung des zwischen KLZ und dem Kunden abgeschlossenen Vertrages verwendet. Zur Abwicklung des Vertragsverhältnisses müssen diese Daten im erforderlichen Umfang an Dritte (z.B. Transporteure oder Spediteure) weitergegeben werden. Der Kunde erteilt für die vorbeschriebene Verwendung seiner personenbezogenen Daten hiermit eine entsprechende Einwilligung.
15.5 Diese Einwilligungserklärung ist jederzeit -ohne Angabe von Gründen- widerruflich. Der Widerruf ist zu richten an info@klz-cables.com .
15.6 Eine darüberhinausgehende Nutzung der Bestandsdaten des Kunden für Zwecke der Werbung, der Marktforschung oder zur bedarfsgerechten Gestaltung der Angebote von KLZ bedarf der gesonderten ausdrücklichen Einwilligung des Kunden.
15.7 Auf Anordnung einer zuständigen Stelle darf KLZ im Einzelfall Auskunft über personenbezogene Daten des Kunden im Rahmen der Anordnung an die zuständige Stelle erteilen, soweit dies für Zwecke der Strafverfolgung, zur Gefahrenabwehr, zur Erfüllung der gesetzlichen Aufgaben der Verfassungsschutzbehörden oder zur Durchsetzung der Rechte am geistigen Eigentum erforderlich ist.
### 16 Geheimhaltung
16.1 „Vertrauliche Informationen“ sind
(i) alle Informationen, Daten, Zeichnungen, Unterlagen, Materialien, Know-how oder sonstige Geschäftsgeheimnisse im Sinne von § 2 Nr. 1 des Gesetzes zum Schutz von Geschäftsgeheimnissen (GeschGehG), insbesondere alle Entwürfe, Skizzen, Zeichnungen, technischen Protokolle, Modelle oder elektronische Daten, gleich in welcher Verkörperung,
(ii) sowie alle nicht offenkundigen kaufmännischen, betrieblichen oder technischen Informationen oder Gegenstände, gleich in welcher Verkörperung, die dem Kunden im Rahmen der Geschäftsbeziehung mit KLZ offengelegt oder anderweitig bekannt werden,
(iii) die entweder direkt als vertraulich gekennzeichnet wurden oder nachträglich schriftlich oder in Textform als vertraulich gekennzeichnet werden. Des Weiteren gelten auch solche Informationen als vertraulich, welche der Natur nach bzw. aus den Umständen heraus oder aus anderen Gründen erkennbar als vertraulich anzusehen sind, soweit die Parteien nichts anderes in Schriftform vereinbart haben.
16.2 Vertrauliche Informationen im Sinne von Ziffer 16.1 sind jedoch nicht solche Informationen,
(i) die der Kunde von öffentlich zugänglichen Quellen bezogen hat und die allgemein bekannt oder auf legalem Wege zugänglich sind und diese Quellen die Informationen ebenfalls auf legalem Wege erlangt haben,
(ii) die der Kunde von einem berechtigten Dritten rechtmäßig erhalten hat,
(iii) die der Kunde eigenständig entwickelt hat,
(iv) die kraft Gesetzes oder aufgrund gerichtlicher oder behördlicher Anordnung offengelegt wurden oder offenzulegen sind. Der Kunde ist in diesem Fall verpflichtet, KLZ vor einer solchen Offenlegung der Informationen unverzüglich zu informieren und zu versuchen, die Offenlegung zu verhindern, soweit dies mit angemessenen und gesetzlich zulässigen Mitteln möglich ist.
16.3 Die Darlegungs- und Beweislast für das Vorliegen einer in Ziffer 16.2 benannten Ausnahmetatbestände trägt diejenige Partei, die sich auf das Vorliegen einer oder mehrerer dieser Ausnahmetatbestände beruft.
16.4 Der Kunde verpflichtet sich, die Vertraulichen Informationen strikt vertraulich zu behandeln und ausschließlich zur Erfüllung des Vertragszwecks zu verwenden. Die Vertraulichen Informationen dürfen durch den Kunden nur an solche Mitarbeiter oder andere Personen weitergegeben werden, die sie zur Erreichung des Vertragszwecks zwingend erhalten müssen. Sämtliche Mitarbeiter des Kunden, welche Kenntnis von Vertraulichen Informationen erhalten, müssen durch den Kunden ebenfalls zur Geheimhaltung hinsichtlich der Vertraulichen Informationen verpflichtet sein oder werden. Diese Geheimhaltungspflicht soll auch für die Zeit nach einem etwaigen Ausscheiden des Mitarbeiters aus dem Unternehmen des Kunden für weitere fünf (5) Jahre gelten, soweit dies rechtlich möglich ist.
16.5 Der Kunde verpflichtet sich, auf erstes Anfordern von KLZ und/oder nach Beendigung der Geschäftsbeziehung oder Beendigung des zugrundeliegenden Vertrage, gleich aus welchem Rechtsgrund, alle unter diese Geheimhaltungsvereinbarung fallenden Vertraulichen Informationen und davon angefertigte Kopien an KLZ herauszugeben oder sachgemäß zu zerstören bzw. sicher und dauerhaft zu löschen und die vollständige Herausgabe, vollständige sachgemäße Zerstörung oder vollständige sichere und dauerhafte Löschung KLZ gegenüber schriftlich zu bestätigen.
16.6 Ziffer 16.5 gilt nicht für Vertrauliche Informationen, die zwingenden gesetzlichen Aufbewahrungspflichten unterliegen oder in automatisierten Back-Up Dateien im Rahmen des ordnungsgemäßen Sicherungsprozesses sicher gespeichert wurden. Für diese Vertraulichen Informationen gelten die Bestimmungen dieser Geheimhaltungsvereinbarung bis zu ihrer Herausgabe, sicheren und dauerhaften Löschung oder vollständigen sachgemäßen Zerstörung fort.
16.7 Die Geheimhaltungsverpflichtungen gemäß dieser Ziffer 16 gelten für einen Zeitraum von fünf (5) Jahren nach Beendigung des zugrundeliegenden Vertrages, gleich aus welchem Rechtsgrund, fort.
16.8 Für jeden Einzelfall eines Verstoßes des Kunden gegen die Pflichten aus dieser Geheimhaltungsvereinbarung ist der Kunde zur Zahlung einer Vertragsstrafe an KLZ verpflichtet, deren Höhe in das billige Ermessen von KLZ gestellt wird und im Streitfall durch das zuständige Gericht überprüft werden kann. Durch die Geltendmachung der Vertragsstrafe werden weitergehende Schadensersatzansprüche von KLZ nicht ausgeschlossen.
### 17 Schlussbestimmungen
17.1 Änderungen oder Ergänzungen dieser AVB bedürfen der Textform. Dies gilt auch für die Abänderung des Textformerfordernisses selbst.
17.2 Auf Verträge zwischen KLZ und dem Kunden, der Auslegung dieser AVB, sowie die gesamten Rechtsbeziehungen zwischen KLZ und dem Kunden gilt ausschließlich das Recht der Bundesrepublik Deutschland unter Ausschluss des UN-Kaufrechts und des Kollisionsrechts. Erfüllungsort sowie ausschließlicher Gerichtsstand für alle Streitigkeiten aus oder im Zusammenhang mit der Rechtsbeziehung zwischen KLZ und dem Kunden ist Stuttgart.
17.3 Sollten einzelne Bestimmungen dieser AVB unwirksam oder undurchführbar sein oder werden, so wird hierdurch die Wirksamkeit aller sonstigen Bestimmungen dieser AVB im Übrigen nicht berührt. Die unwirksame oder undurchführbare Bestimmung wird von den Vertragsparteien einvernehmlich durch eine rechtswirksame Regelung ersetzt, welche dem wirtschaftlichen Erfolg und Zweck der unwirksamen oder undurchführbaren Bestimmung am nächsten kommt. Dies gilt gleichermaßen für den Fall einer Regelungslücke.
Stand: April 2026

View File

@@ -1,22 +1,16 @@
# Stage 1: Builder
FROM node:20-alpine AS base
RUN apk add --no-cache libc6-compat curl
# Enable pnpm
RUN corepack enable && corepack prepare pnpm@10.3.0 --activate
FROM git.infra.mintel.me/mmintel/nextjs:latest AS base
WORKDIR /app
# Arguments for build-time configuration
ARG NEXT_PUBLIC_BASE_URL
ARG NEXT_PUBLIC_TARGET
ARG DIRECTUS_URL
ARG UMAMI_WEBSITE_ID
ARG UMAMI_API_ENDPOINT
# Environment variables for Next.js build
ENV NEXT_PUBLIC_BASE_URL=$NEXT_PUBLIC_BASE_URL
ENV NEXT_PUBLIC_TARGET=$NEXT_PUBLIC_TARGET
ENV DIRECTUS_URL=$DIRECTUS_URL
ENV UMAMI_WEBSITE_ID=$UMAMI_WEBSITE_ID
ENV UMAMI_API_ENDPOINT=$UMAMI_API_ENDPOINT
ENV SKIP_RUNTIME_ENV_VALIDATION=true
@@ -24,6 +18,7 @@ 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 \
@@ -43,6 +38,11 @@ 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
@@ -54,21 +54,12 @@ ENV RAYON_NUM_THREADS=3
ENV UV_THREADPOOL_SIZE=3
RUN pnpm build
# Excel generation moved to post-deploy
# Stage 2: Runner
FROM node:20-alpine AS runner
FROM git.infra.mintel.me/mmintel/runtime:latest AS runner
WORKDIR /app
# Install curl for health checks
RUN apk add --no-cache curl
# Create nextjs user and group (standardized in runtime image but ensuring local ownership)
RUN addgroup --system --gid 1001 nodejs && \
adduser --system --uid 1001 nextjs && \
chown -R nextjs:nodejs /app
USER nextjs
ENV HOSTNAME="0.0.0.0"
ENV PORT=3000
ENV NODE_ENV=production
@@ -80,3 +71,4 @@ COPY --from=builder --chown=nextjs:nodejs /app/.next/static ./.next/static
COPY --from=builder --chown=nextjs:nodejs /app/.next/cache ./.next/cache
CMD ["node", "server.js"]

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

Binary file not shown.

Binary file not shown.

View File

@@ -1,57 +1,84 @@
import { RscEntryLexicalCell as RscEntryLexicalCell_44fe37237e0ebf4470c9990d8cb7b07e } from '@payloadcms/richtext-lexical/rsc'
import { RscEntryLexicalField as RscEntryLexicalField_44fe37237e0ebf4470c9990d8cb7b07e } from '@payloadcms/richtext-lexical/rsc'
import { LexicalDiffComponent as LexicalDiffComponent_44fe37237e0ebf4470c9990d8cb7b07e } from '@payloadcms/richtext-lexical/rsc'
import { BlocksFeatureClient as BlocksFeatureClient_e70f5e05f09f93e00b997edb1ef0c864 } from '@payloadcms/richtext-lexical/client'
import { InlineToolbarFeatureClient as InlineToolbarFeatureClient_e70f5e05f09f93e00b997edb1ef0c864 } from '@payloadcms/richtext-lexical/client'
import { HorizontalRuleFeatureClient as HorizontalRuleFeatureClient_e70f5e05f09f93e00b997edb1ef0c864 } from '@payloadcms/richtext-lexical/client'
import { UploadFeatureClient as UploadFeatureClient_e70f5e05f09f93e00b997edb1ef0c864 } from '@payloadcms/richtext-lexical/client'
import { BlockquoteFeatureClient as BlockquoteFeatureClient_e70f5e05f09f93e00b997edb1ef0c864 } from '@payloadcms/richtext-lexical/client'
import { RelationshipFeatureClient as RelationshipFeatureClient_e70f5e05f09f93e00b997edb1ef0c864 } from '@payloadcms/richtext-lexical/client'
import { LinkFeatureClient as LinkFeatureClient_e70f5e05f09f93e00b997edb1ef0c864 } from '@payloadcms/richtext-lexical/client'
import { ChecklistFeatureClient as ChecklistFeatureClient_e70f5e05f09f93e00b997edb1ef0c864 } from '@payloadcms/richtext-lexical/client'
import { OrderedListFeatureClient as OrderedListFeatureClient_e70f5e05f09f93e00b997edb1ef0c864 } from '@payloadcms/richtext-lexical/client'
import { UnorderedListFeatureClient as UnorderedListFeatureClient_e70f5e05f09f93e00b997edb1ef0c864 } from '@payloadcms/richtext-lexical/client'
import { IndentFeatureClient as IndentFeatureClient_e70f5e05f09f93e00b997edb1ef0c864 } from '@payloadcms/richtext-lexical/client'
import { AlignFeatureClient as AlignFeatureClient_e70f5e05f09f93e00b997edb1ef0c864 } from '@payloadcms/richtext-lexical/client'
import { HeadingFeatureClient as HeadingFeatureClient_e70f5e05f09f93e00b997edb1ef0c864 } from '@payloadcms/richtext-lexical/client'
import { ParagraphFeatureClient as ParagraphFeatureClient_e70f5e05f09f93e00b997edb1ef0c864 } from '@payloadcms/richtext-lexical/client'
import { InlineCodeFeatureClient as InlineCodeFeatureClient_e70f5e05f09f93e00b997edb1ef0c864 } from '@payloadcms/richtext-lexical/client'
import { SuperscriptFeatureClient as SuperscriptFeatureClient_e70f5e05f09f93e00b997edb1ef0c864 } from '@payloadcms/richtext-lexical/client'
import { SubscriptFeatureClient as SubscriptFeatureClient_e70f5e05f09f93e00b997edb1ef0c864 } from '@payloadcms/richtext-lexical/client'
import { StrikethroughFeatureClient as StrikethroughFeatureClient_e70f5e05f09f93e00b997edb1ef0c864 } from '@payloadcms/richtext-lexical/client'
import { UnderlineFeatureClient as UnderlineFeatureClient_e70f5e05f09f93e00b997edb1ef0c864 } from '@payloadcms/richtext-lexical/client'
import { BoldFeatureClient as BoldFeatureClient_e70f5e05f09f93e00b997edb1ef0c864 } from '@payloadcms/richtext-lexical/client'
import { ItalicFeatureClient as ItalicFeatureClient_e70f5e05f09f93e00b997edb1ef0c864 } from '@payloadcms/richtext-lexical/client'
import { default as default_9ed509b5e5f7d08a16335393f27586cc } from '../../../src/payload/components/Icon'
import { default as default_5470ea90f7a8fd882c2fe59ff2b1c5b9 } from '../../../src/payload/components/Logo'
import { CollectionCards as CollectionCards_f9c02e79a4aed9a3924487c0cd4cafb1 } from '@payloadcms/next/rsc'
import { RscEntryLexicalCell as RscEntryLexicalCell_44fe37237e0ebf4470c9990d8cb7b07e } from '@payloadcms/richtext-lexical/rsc';
import { RscEntryLexicalField as RscEntryLexicalField_44fe37237e0ebf4470c9990d8cb7b07e } from '@payloadcms/richtext-lexical/rsc';
import { LexicalDiffComponent as LexicalDiffComponent_44fe37237e0ebf4470c9990d8cb7b07e } from '@payloadcms/richtext-lexical/rsc';
import { BlocksFeatureClient as BlocksFeatureClient_e70f5e05f09f93e00b997edb1ef0c864 } from '@payloadcms/richtext-lexical/client';
import { InlineToolbarFeatureClient as InlineToolbarFeatureClient_e70f5e05f09f93e00b997edb1ef0c864 } from '@payloadcms/richtext-lexical/client';
import { HorizontalRuleFeatureClient as HorizontalRuleFeatureClient_e70f5e05f09f93e00b997edb1ef0c864 } from '@payloadcms/richtext-lexical/client';
import { UploadFeatureClient as UploadFeatureClient_e70f5e05f09f93e00b997edb1ef0c864 } from '@payloadcms/richtext-lexical/client';
import { BlockquoteFeatureClient as BlockquoteFeatureClient_e70f5e05f09f93e00b997edb1ef0c864 } from '@payloadcms/richtext-lexical/client';
import { RelationshipFeatureClient as RelationshipFeatureClient_e70f5e05f09f93e00b997edb1ef0c864 } from '@payloadcms/richtext-lexical/client';
import { LinkFeatureClient as LinkFeatureClient_e70f5e05f09f93e00b997edb1ef0c864 } from '@payloadcms/richtext-lexical/client';
import { ChecklistFeatureClient as ChecklistFeatureClient_e70f5e05f09f93e00b997edb1ef0c864 } from '@payloadcms/richtext-lexical/client';
import { OrderedListFeatureClient as OrderedListFeatureClient_e70f5e05f09f93e00b997edb1ef0c864 } from '@payloadcms/richtext-lexical/client';
import { UnorderedListFeatureClient as UnorderedListFeatureClient_e70f5e05f09f93e00b997edb1ef0c864 } from '@payloadcms/richtext-lexical/client';
import { IndentFeatureClient as IndentFeatureClient_e70f5e05f09f93e00b997edb1ef0c864 } from '@payloadcms/richtext-lexical/client';
import { AlignFeatureClient as AlignFeatureClient_e70f5e05f09f93e00b997edb1ef0c864 } from '@payloadcms/richtext-lexical/client';
import { HeadingFeatureClient as HeadingFeatureClient_e70f5e05f09f93e00b997edb1ef0c864 } from '@payloadcms/richtext-lexical/client';
import { ParagraphFeatureClient as ParagraphFeatureClient_e70f5e05f09f93e00b997edb1ef0c864 } from '@payloadcms/richtext-lexical/client';
import { InlineCodeFeatureClient as InlineCodeFeatureClient_e70f5e05f09f93e00b997edb1ef0c864 } from '@payloadcms/richtext-lexical/client';
import { SuperscriptFeatureClient as SuperscriptFeatureClient_e70f5e05f09f93e00b997edb1ef0c864 } from '@payloadcms/richtext-lexical/client';
import { SubscriptFeatureClient as SubscriptFeatureClient_e70f5e05f09f93e00b997edb1ef0c864 } from '@payloadcms/richtext-lexical/client';
import { StrikethroughFeatureClient as StrikethroughFeatureClient_e70f5e05f09f93e00b997edb1ef0c864 } from '@payloadcms/richtext-lexical/client';
import { UnderlineFeatureClient as UnderlineFeatureClient_e70f5e05f09f93e00b997edb1ef0c864 } from '@payloadcms/richtext-lexical/client';
import { BoldFeatureClient as BoldFeatureClient_e70f5e05f09f93e00b997edb1ef0c864 } from '@payloadcms/richtext-lexical/client';
import { ItalicFeatureClient as ItalicFeatureClient_e70f5e05f09f93e00b997edb1ef0c864 } from '@payloadcms/richtext-lexical/client';
import { default as default_9ed509b5e5f7d08a16335393f27586cc } from '../../../src/payload/components/Icon';
import { default as default_5470ea90f7a8fd882c2fe59ff2b1c5b9 } from '../../../src/payload/components/Logo';
import { ChatWindowProvider as ChatWindowProvider_d32a660df96f186e48bfc5b31626ccf5 } from '@mintel/payload-ai/components/ChatWindow';
import { CollectionCards as CollectionCards_f9c02e79a4aed9a3924487c0cd4cafb1 } from '@payloadcms/next/rsc';
export const importMap = {
"@payloadcms/richtext-lexical/rsc#RscEntryLexicalCell": RscEntryLexicalCell_44fe37237e0ebf4470c9990d8cb7b07e,
"@payloadcms/richtext-lexical/rsc#RscEntryLexicalField": RscEntryLexicalField_44fe37237e0ebf4470c9990d8cb7b07e,
"@payloadcms/richtext-lexical/rsc#LexicalDiffComponent": LexicalDiffComponent_44fe37237e0ebf4470c9990d8cb7b07e,
"@payloadcms/richtext-lexical/client#BlocksFeatureClient": BlocksFeatureClient_e70f5e05f09f93e00b997edb1ef0c864,
"@payloadcms/richtext-lexical/client#InlineToolbarFeatureClient": InlineToolbarFeatureClient_e70f5e05f09f93e00b997edb1ef0c864,
"@payloadcms/richtext-lexical/client#HorizontalRuleFeatureClient": HorizontalRuleFeatureClient_e70f5e05f09f93e00b997edb1ef0c864,
"@payloadcms/richtext-lexical/client#UploadFeatureClient": UploadFeatureClient_e70f5e05f09f93e00b997edb1ef0c864,
"@payloadcms/richtext-lexical/client#BlockquoteFeatureClient": BlockquoteFeatureClient_e70f5e05f09f93e00b997edb1ef0c864,
"@payloadcms/richtext-lexical/client#RelationshipFeatureClient": RelationshipFeatureClient_e70f5e05f09f93e00b997edb1ef0c864,
"@payloadcms/richtext-lexical/client#LinkFeatureClient": LinkFeatureClient_e70f5e05f09f93e00b997edb1ef0c864,
"@payloadcms/richtext-lexical/client#ChecklistFeatureClient": ChecklistFeatureClient_e70f5e05f09f93e00b997edb1ef0c864,
"@payloadcms/richtext-lexical/client#OrderedListFeatureClient": OrderedListFeatureClient_e70f5e05f09f93e00b997edb1ef0c864,
"@payloadcms/richtext-lexical/client#UnorderedListFeatureClient": UnorderedListFeatureClient_e70f5e05f09f93e00b997edb1ef0c864,
"@payloadcms/richtext-lexical/client#IndentFeatureClient": IndentFeatureClient_e70f5e05f09f93e00b997edb1ef0c864,
"@payloadcms/richtext-lexical/client#AlignFeatureClient": AlignFeatureClient_e70f5e05f09f93e00b997edb1ef0c864,
"@payloadcms/richtext-lexical/client#HeadingFeatureClient": HeadingFeatureClient_e70f5e05f09f93e00b997edb1ef0c864,
"@payloadcms/richtext-lexical/client#ParagraphFeatureClient": ParagraphFeatureClient_e70f5e05f09f93e00b997edb1ef0c864,
"@payloadcms/richtext-lexical/client#InlineCodeFeatureClient": InlineCodeFeatureClient_e70f5e05f09f93e00b997edb1ef0c864,
"@payloadcms/richtext-lexical/client#SuperscriptFeatureClient": SuperscriptFeatureClient_e70f5e05f09f93e00b997edb1ef0c864,
"@payloadcms/richtext-lexical/client#SubscriptFeatureClient": SubscriptFeatureClient_e70f5e05f09f93e00b997edb1ef0c864,
"@payloadcms/richtext-lexical/client#StrikethroughFeatureClient": StrikethroughFeatureClient_e70f5e05f09f93e00b997edb1ef0c864,
"@payloadcms/richtext-lexical/client#UnderlineFeatureClient": UnderlineFeatureClient_e70f5e05f09f93e00b997edb1ef0c864,
"@payloadcms/richtext-lexical/client#BoldFeatureClient": BoldFeatureClient_e70f5e05f09f93e00b997edb1ef0c864,
"@payloadcms/richtext-lexical/client#ItalicFeatureClient": ItalicFeatureClient_e70f5e05f09f93e00b997edb1ef0c864,
"/src/payload/components/Icon#default": default_9ed509b5e5f7d08a16335393f27586cc,
"/src/payload/components/Logo#default": default_5470ea90f7a8fd882c2fe59ff2b1c5b9,
"@payloadcms/next/rsc#CollectionCards": CollectionCards_f9c02e79a4aed9a3924487c0cd4cafb1
}
'@payloadcms/richtext-lexical/rsc#RscEntryLexicalCell':
RscEntryLexicalCell_44fe37237e0ebf4470c9990d8cb7b07e,
'@payloadcms/richtext-lexical/rsc#RscEntryLexicalField':
RscEntryLexicalField_44fe37237e0ebf4470c9990d8cb7b07e,
'@payloadcms/richtext-lexical/rsc#LexicalDiffComponent':
LexicalDiffComponent_44fe37237e0ebf4470c9990d8cb7b07e,
'@payloadcms/richtext-lexical/client#BlocksFeatureClient':
BlocksFeatureClient_e70f5e05f09f93e00b997edb1ef0c864,
'@payloadcms/richtext-lexical/client#InlineToolbarFeatureClient':
InlineToolbarFeatureClient_e70f5e05f09f93e00b997edb1ef0c864,
'@payloadcms/richtext-lexical/client#HorizontalRuleFeatureClient':
HorizontalRuleFeatureClient_e70f5e05f09f93e00b997edb1ef0c864,
'@payloadcms/richtext-lexical/client#UploadFeatureClient':
UploadFeatureClient_e70f5e05f09f93e00b997edb1ef0c864,
'@payloadcms/richtext-lexical/client#BlockquoteFeatureClient':
BlockquoteFeatureClient_e70f5e05f09f93e00b997edb1ef0c864,
'@payloadcms/richtext-lexical/client#RelationshipFeatureClient':
RelationshipFeatureClient_e70f5e05f09f93e00b997edb1ef0c864,
'@payloadcms/richtext-lexical/client#LinkFeatureClient':
LinkFeatureClient_e70f5e05f09f93e00b997edb1ef0c864,
'@payloadcms/richtext-lexical/client#ChecklistFeatureClient':
ChecklistFeatureClient_e70f5e05f09f93e00b997edb1ef0c864,
'@payloadcms/richtext-lexical/client#OrderedListFeatureClient':
OrderedListFeatureClient_e70f5e05f09f93e00b997edb1ef0c864,
'@payloadcms/richtext-lexical/client#UnorderedListFeatureClient':
UnorderedListFeatureClient_e70f5e05f09f93e00b997edb1ef0c864,
'@payloadcms/richtext-lexical/client#IndentFeatureClient':
IndentFeatureClient_e70f5e05f09f93e00b997edb1ef0c864,
'@payloadcms/richtext-lexical/client#AlignFeatureClient':
AlignFeatureClient_e70f5e05f09f93e00b997edb1ef0c864,
'@payloadcms/richtext-lexical/client#HeadingFeatureClient':
HeadingFeatureClient_e70f5e05f09f93e00b997edb1ef0c864,
'@payloadcms/richtext-lexical/client#ParagraphFeatureClient':
ParagraphFeatureClient_e70f5e05f09f93e00b997edb1ef0c864,
'@payloadcms/richtext-lexical/client#InlineCodeFeatureClient':
InlineCodeFeatureClient_e70f5e05f09f93e00b997edb1ef0c864,
'@payloadcms/richtext-lexical/client#SuperscriptFeatureClient':
SuperscriptFeatureClient_e70f5e05f09f93e00b997edb1ef0c864,
'@payloadcms/richtext-lexical/client#SubscriptFeatureClient':
SubscriptFeatureClient_e70f5e05f09f93e00b997edb1ef0c864,
'@payloadcms/richtext-lexical/client#StrikethroughFeatureClient':
StrikethroughFeatureClient_e70f5e05f09f93e00b997edb1ef0c864,
'@payloadcms/richtext-lexical/client#UnderlineFeatureClient':
UnderlineFeatureClient_e70f5e05f09f93e00b997edb1ef0c864,
'@payloadcms/richtext-lexical/client#BoldFeatureClient':
BoldFeatureClient_e70f5e05f09f93e00b997edb1ef0c864,
'@payloadcms/richtext-lexical/client#ItalicFeatureClient':
ItalicFeatureClient_e70f5e05f09f93e00b997edb1ef0c864,
'/src/payload/components/Icon#default': default_9ed509b5e5f7d08a16335393f27586cc,
'/src/payload/components/Logo#default': default_5470ea90f7a8fd882c2fe59ff2b1c5b9,
'@mintel/payload-ai/components/ChatWindow#ChatWindowProvider':
ChatWindowProvider_d32a660df96f186e48bfc5b31626ccf5,
'@payloadcms/next/rsc#CollectionCards': CollectionCards_f9c02e79a4aed9a3924487c0cd4cafb1,
};

View File

@@ -1,8 +1,8 @@
import { notFound, redirect, permanentRedirect } from 'next/navigation';
import { notFound, redirect } from 'next/navigation';
import { Container, Badge, Heading } from '@/components/ui';
import { getTranslations, setRequestLocale } from 'next-intl/server';
import { Metadata } from 'next';
import { getPageBySlug, getAllPages } from '@/lib/pages';
import { getPageBySlug } from '@/lib/pages';
import { mapSlugToFileSlug, mapFileSlugToTranslated } from '@/lib/slugs';
import PayloadRichText from '@/components/PayloadRichText';
import { SITE_URL } from '@/lib/schema';
@@ -62,15 +62,6 @@ export default async function StandardPage({ params }: PageProps) {
notFound();
}
// Handle explicit CMS redirects (e.g. /en/terms -> /de/terms)
if (pageData.redirectUrl) {
if (pageData.redirectPermanent) {
permanentRedirect(pageData.redirectUrl);
} else {
redirect(pageData.redirectUrl);
}
}
// Redirect if accessed via a different locale's slug
const fileSlug = await mapSlugToFileSlug(pageData.slug || slug, locale);
const correctSlug = await mapFileSlugToTranslated(fileSlug, locale);

View File

@@ -6,7 +6,6 @@ import {
getAdjacentPosts,
getReadingTime,
extractLexicalHeadings,
getPostSlugs,
} from '@/lib/blog';
import { Metadata } from 'next';
import Link from 'next/link';
@@ -34,21 +33,12 @@ export async function generateMetadata({ params }: BlogPostProps): Promise<Metad
if (!post) return {};
const slugs = await getPostSlugs(slug, locale);
const deSlug = slugs?.de || post.slug;
const enSlug = slugs?.en || post.slug;
const description = post.frontmatter.excerpt || '';
return {
title: post.frontmatter.title,
description: description,
alternates: {
canonical: `${SITE_URL}/${locale}/blog/${post.slug}`,
languages: {
de: `${SITE_URL}/de/blog/${deSlug}`,
en: `${SITE_URL}/en/blog/${enSlug}`,
'x-default': `${SITE_URL}/en/blog/${enSlug}`,
},
},
openGraph: {
title: `${post.frontmatter.title} | KLZ Cables`,
@@ -134,14 +124,11 @@ export default async function BlogPost({ params }: BlogPostProps) {
</Heading>
<div className="flex flex-wrap items-center gap-6 text-white/80 text-sm md:text-base font-medium">
<time dateTime={post.frontmatter.date} suppressHydrationWarning>
{new Date(post.frontmatter.date).toLocaleDateString(
locale === 'en' ? 'en-US' : 'de-DE',
{
year: 'numeric',
month: 'long',
day: 'numeric',
},
)}
{new Date(post.frontmatter.date).toLocaleDateString(locale || 'de', {
year: 'numeric',
month: 'long',
day: 'numeric',
})}
</time>
<span className="w-1 h-1 bg-white/30 rounded-full" />
<span>{getReadingTime(rawTextContent)} min read</span>
@@ -174,14 +161,11 @@ export default async function BlogPost({ params }: BlogPostProps) {
</Heading>
<div className="flex items-center gap-6 text-text-primary/80 font-medium">
<time dateTime={post.frontmatter.date} suppressHydrationWarning>
{new Date(post.frontmatter.date).toLocaleDateString(
locale === 'en' ? 'en-US' : 'de-DE',
{
year: 'numeric',
month: 'long',
day: 'numeric',
},
)}
{new Date(post.frontmatter.date).toLocaleDateString(locale || 'de', {
year: 'numeric',
month: 'long',
day: 'numeric',
})}
</time>
<span className="w-1 h-1 bg-neutral-400 rounded-full" />
<span>{getReadingTime(rawTextContent)} min read</span>

View File

@@ -14,7 +14,7 @@ interface BlogIndexProps {
}>;
}
export async function generateMetadata({ params }: BlogIndexProps) {
export async function generateMetadata({ params }: BlogIndexProps): Promise<Metadata> {
const { locale } = await params;
const t = await getTranslations({ locale, namespace: 'Blog.meta' });
return {
@@ -198,14 +198,11 @@ export default async function BlogIndex({ params }: BlogIndexProps) {
<div className="flex items-center gap-3 text-xs md:text-sm font-bold text-white/80 mb-3 tracking-widest uppercase">
<time dateTime={post.frontmatter.date} suppressHydrationWarning>
{new Date(post.frontmatter.date).toLocaleDateString(
locale === 'en' ? 'en-US' : 'de-DE',
{
year: 'numeric',
month: 'long',
day: 'numeric',
},
)}
{new Date(post.frontmatter.date).toLocaleDateString(locale || 'de', {
year: 'numeric',
month: 'long',
day: 'numeric',
})}
</time>
</div>

View File

@@ -5,7 +5,7 @@ import { Container, Heading, Section } from '@/components/ui';
import { Metadata } from 'next';
import { getTranslations, setRequestLocale } from 'next-intl/server';
import { SITE_URL } from '@/lib/schema';
import { getOGImageMetadata } from '@/lib/metadata';
import { Suspense } from 'react';
import ContactMap from '@/components/ContactMap';
import ObfuscatedEmail from '@/components/ObfuscatedEmail';

View File

@@ -7,10 +7,8 @@ import AnalyticsShell from '@/components/analytics/AnalyticsShell';
import { Metadata, Viewport } from 'next';
import { NextIntlClientProvider } from 'next-intl';
import { getMessages } from 'next-intl/server';
import { Suspense } from 'react';
import '../../styles/globals.css';
import { SITE_URL } from '@/lib/schema';
import { config } from '@/lib/config';
import FeedbackClientWrapper from '@/components/FeedbackClientWrapper';
import { setRequestLocale } from 'next-intl/server';
import { Inter } from 'next/font/google';
@@ -35,6 +33,13 @@ export async function generateMetadata(props: {
},
metadataBase: new URL(baseUrl),
manifest: '/manifest.webmanifest',
alternates: {
canonical: `${baseUrl}/${locale}`,
languages: {
de: `${baseUrl}/de`,
en: `${baseUrl}/en`,
},
},
icons: {
icon: [
{ url: '/favicon.ico', sizes: 'any' },
@@ -54,6 +59,7 @@ export const viewport: Viewport = {
themeColor: '#001a4d',
};
import AutoBrochureModal from '@/components/AutoBrochureModal';
export default async function Layout(props: {
children: React.ReactNode;
params: Promise<{ locale: string }>;
@@ -70,7 +76,7 @@ export default async function Layout(props: {
let messages: Record<string, any> = {};
try {
messages = await getMessages();
} catch (error) {
} catch {
messages = {};
}
@@ -84,6 +90,7 @@ export default async function Layout(props: {
'Home',
'Error',
'StandardPage',
'Brochure',
];
const clientMessages: Record<string, any> = {};
for (const key of clientKeys) {
@@ -125,7 +132,11 @@ export default async function Layout(props: {
const feedbackEnabled = process.env.NEXT_PUBLIC_FEEDBACK_ENABLED === 'true';
return (
<html lang={safeLocale} className={`overflow-x-hidden ${inter.variable}`}>
<html
lang={safeLocale}
className={`scroll-smooth overflow-x-hidden ${inter.variable}`}
data-scroll-behavior="smooth"
>
<head>
<link rel="preconnect" href="https://fonts.googleapis.com" />
<link rel="preconnect" href="https://fonts.gstatic.com" crossOrigin="anonymous" />
@@ -149,6 +160,8 @@ export default async function Layout(props: {
<AnalyticsShell />
<FeedbackClientWrapper feedbackEnabled={feedbackEnabled} />
<AutoBrochureModal />
</NextIntlClientProvider>
</body>
</html>

View File

@@ -72,7 +72,7 @@ export default async function NotFound() {
}
suggestedUrl = '/' + pathParts.join('/');
}
} catch (e) {
} catch {
// Ignore Payload errors in 404
}
}

View File

@@ -1,12 +1,11 @@
import JsonLd from '@/components/JsonLd';
import { SITE_URL } from '@/lib/schema';
import ProductSidebar from '@/components/ProductSidebar';
import ProductTabs from '@/components/ProductTabs';
import ProductTechnicalData from '@/components/ProductTechnicalData';
import ExcelDownload from '@/components/ExcelDownload';
import RelatedProducts from '@/components/RelatedProducts';
import DatasheetDownload from '@/components/DatasheetDownload';
import { Badge, Card, Container, Heading, Section } from '@/components/ui';
import { getDatasheetPath } from '@/lib/datasheets';
import { getDatasheetPath, getExcelDatasheetPath } from '@/lib/datasheets';
import { getAllProducts, getProductBySlug } from '@/lib/products';
import { mapFileSlugToTranslated, mapSlugToFileSlug } from '@/lib/slugs';
import { Metadata } from 'next';
@@ -278,6 +277,7 @@ export default async function ProductPage({ params }: ProductPageProps) {
}
const datasheetPath = getDatasheetPath(productSlug, locale);
const excelPath = getExcelDatasheetPath(productSlug, locale);
const isFallback = (product.frontmatter as any).isFallback;
const categorySlug = slug[0];
const categoryFileSlug = await mapSlugToFileSlug(categorySlug, locale);
@@ -343,6 +343,7 @@ export default async function ProductPage({ params }: ProductPageProps) {
productName={product.frontmatter.title}
productImage={product.frontmatter.images?.[0]}
datasheetPath={datasheetPath}
excelPath={excelPath}
/>
);
@@ -496,7 +497,15 @@ export default async function ProductPage({ params }: ProductPageProps) {
</h2>
<div className="h-1.5 w-24 bg-accent rounded-full" />
</div>
<DatasheetDownload datasheetPath={datasheetPath} />
<div className="flex flex-row flex-wrap items-center gap-4 max-w-2xl">
<DatasheetDownload
datasheetPath={datasheetPath}
className="mt-0 w-full sm:w-auto"
/>
{excelPath && (
<ExcelDownload excelPath={excelPath} className="mt-0 w-full sm:w-auto" />
)}
</div>
</div>
)}

View File

@@ -2,7 +2,7 @@ import { getTranslations, setRequestLocale } from 'next-intl/server';
import { Metadata } from 'next';
import JsonLd from '@/components/JsonLd';
import { getBreadcrumbSchema, SITE_URL } from '@/lib/schema';
import { Section, Container, Heading, Badge, Button } from '@/components/ui';
import { Section, Container, Heading, Badge } from '@/components/ui';
import Image from 'next/image';
import Reveal from '@/components/Reveal';
import Gallery from '@/components/team/Gallery';

View File

@@ -1,84 +1,123 @@
'use server';
import * as React from 'react';
import { renderToBuffer } from '@react-pdf/renderer';
import { getAllProducts } from '@/lib/products';
import { getExcelTechnicalDataForProduct } from '@/lib/excel-products';
import { PDFBrochure } from '@/lib/pdf-brochure';
import { ProductData as PDFProductData } from '@/lib/pdf-datasheet';
import { getServerAppServices } from '@/lib/services/create-services.server';
/**
* Server action to generate a PDF brochure.
* Fetches products from Payload CMS and technical data from Excel.
* Returns the PDF as a base64 string.
*/
export async function generateBrochureAction(params: {
locale: 'en' | 'de';
productSlugs?: string[];
title?: string;
subtitle?: string;
}) {
export async function requestBrochureAction(formData: FormData) {
const services = getServerAppServices();
const logger = services.logger.child({ action: 'requestBrochureAction' });
const { headers } = await import('next/headers');
const requestHeaders = await headers();
if ('setServerContext' in services.analytics) {
(services.analytics as any).setServerContext({
userAgent: requestHeaders.get('user-agent') || undefined,
language: requestHeaders.get('accept-language')?.split(',')[0] || undefined,
referrer: requestHeaders.get('referer') || undefined,
ip: requestHeaders.get('x-forwarded-for')?.split(',')[0] || undefined,
});
}
services.analytics.track('brochure-request-attempt');
const email = formData.get('email') as string;
const locale = (formData.get('locale') as string) || 'en';
// Anti-spam Honeypot Check
const honeypot = formData.get('company_website') as string;
if (honeypot) {
logger.warn('Spam detected via honeypot in brochure request', { email });
// Silently succeed to fool the bot without doing actual work
return { success: true };
}
if (!email) {
logger.warn('Missing email in brochure request');
return { success: false, error: 'Missing email address' };
}
// Basic email validation
if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email)) {
return { success: false, error: 'Invalid email address' };
}
// 1. Save to CMS
try {
const { locale, productSlugs, title, subtitle } = params;
const { getPayload } = await import('payload');
const configPromise = (await import('@payload-config')).default;
const payload = await getPayload({ config: configPromise });
// 1. Fetch products from Payload
let products = await getAllProducts(locale);
// 2. Filter by slugs if provided
if (productSlugs && productSlugs.length > 0) {
products = products.filter((p) => productSlugs.includes(p.slug));
}
// 3. Map Payload products to PDF internal model and attach Excel data
const pdfProducts: PDFProductData[] = products.map((p, index) => {
// Get technical data from Excel
const excelData = getExcelTechnicalDataForProduct({
slug: p.slug,
sku: p.frontmatter.sku,
name: p.frontmatter.title,
});
return {
id: index + 1,
name: p.frontmatter.title,
sku: p.frontmatter.sku,
shortDescriptionHtml: p.frontmatter.description,
descriptionHtml: p.frontmatter.description,
// Application text is usually part of description in Payload v3
applicationHtml: '',
featuredImage: p.frontmatter.images[0] || null,
images: p.frontmatter.images,
categories: p.frontmatter.categories.map((name) => ({ name })),
attributes: excelData?.attributes || [],
};
await payload.create({
collection: 'form-submissions',
data: {
name: email.split('@')[0],
email,
message: `Brochure download request (${locale})`,
type: 'brochure_download' as any,
},
overrideAccess: true,
});
if (pdfProducts.length === 0) {
throw new Error('No products found for brochure generation.');
}
logger.info('Successfully saved brochure request to Payload CMS', { email });
} catch (error) {
logger.error('Failed to store brochure request in Payload CMS', { error });
services.errors.captureException(error, { action: 'payload_store_brochure_request' });
}
// 4. Render to PDF buffer
// Note: React 19 / Next 15+ might need specific handling for renderToBuffer in actions
const buffer = await renderToBuffer(
React.createElement(PDFBrochure, {
products: pdfProducts,
locale,
title,
subtitle,
})
// 2. Notify via Gotify
try {
await services.notifications.notify({
title: '📑 Brochure Download Request',
message: `New brochure download request from ${email} (${locale})`,
priority: 3,
});
} catch (error) {
logger.error('Failed to send notification', { error });
}
// 3. Send Brochure via Email
const brochureUrl = `https://klz-cables.com/brochure/klz-product-catalog-${locale}.pdf`;
try {
const { sendEmail } = await import('@/lib/mail/mailer');
const { render } = await import('@mintel/mail');
const React = await import('react');
const { BrochureDeliveryEmail } = await import('@/components/emails/BrochureDeliveryEmail');
const html = await render(
React.createElement(BrochureDeliveryEmail, {
_email: email,
brochureUrl,
locale: locale as 'en' | 'de',
}),
);
// 5. Convert to Base64 for returning to client
return {
success: true,
data: buffer.toString('base64'),
fileName: `KLZ_Cables_Brochure_${locale.toUpperCase()}.pdf`,
};
const emailResult = await sendEmail({
to: email,
subject: locale === 'de' ? 'Ihr KLZ Kabelkatalog' : 'Your KLZ Cable Catalog',
html,
});
if (emailResult.success) {
logger.info('Brochure email sent successfully', { email });
} else {
logger.error('Failed to send brochure email', { error: emailResult.error, email });
services.errors.captureException(new Error(`Brochure email failed: ${emailResult.error}`), {
action: 'requestBrochureAction_email',
email,
});
return { success: false, error: 'Failed to send email. Please try again later.' };
}
} catch (error) {
console.error('[Action] Brochure generation failed:', error);
return {
success: false,
error: error instanceof Error ? error.message : 'Unknown error occurred',
};
logger.error('Exception while sending brochure email', { error });
return { success: false, error: 'Failed to send email. Please try again later.' };
}
// 4. Track success
services.analytics.track('brochure-request-success', {
locale,
delivery_method: 'email',
});
return { success: true };
}

View File

@@ -1,7 +1,6 @@
'use server';
import { sendEmail } from '@/lib/mail/mailer';
import { env } from '@/lib/env';
import { render, ContactFormNotification, ConfirmationMessage } from '@mintel/mail';
import React from 'react';
import { getServerAppServices } from '@/lib/services/create-services.server';
@@ -26,6 +25,14 @@ export async function sendContactFormAction(formData: FormData) {
// Track attempt
services.analytics.track('contact-form-attempt');
// Anti-spam Honeypot Check
const honeypot = formData.get('company_website') as string;
if (honeypot) {
logger.warn('Spam detected via honeypot in contact request', { email: formData.get('email') });
// Silently succeed to fool the bot without doing actual work
return { success: true };
}
const name = formData.get('name') as string;
const email = formData.get('email') as string;
const message = formData.get('message') as string;
@@ -88,7 +95,7 @@ export async function sendContactFormAction(formData: FormData) {
);
if (!isTestSubmission) {
logger.info('Sending internal notification', { recipients: env.MAIL_RECIPIENTS });
logger.info('Sending internal notification', { recipients: config.mail.recipients });
const notificationResult = await sendEmail({
replyTo: email,
subject: notificationSubject,
@@ -103,14 +110,14 @@ export async function sendContactFormAction(formData: FormData) {
logger.error('Notification email DELIVERY FAILED', {
error: notificationResult.error,
subject: notificationSubject,
recipients: env.MAIL_RECIPIENTS,
recipients: config.mail.recipients,
});
services.errors.captureException(
new Error(`Notification email failed: ${notificationResult.error}`),
{
action: 'sendContactFormAction_notification',
email,
recipients: env.MAIL_RECIPIENTS
recipients: config.mail.recipients
},
);
}

217
app/api/ai-search/route.ts Normal file
View File

@@ -0,0 +1,217 @@
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';
import { generateText } from 'ai';
import { createOpenAI } from '@ai-sdk/openai';
import { createMcpTools } from '@mintel/payload-ai/tools/mcpAdapter';
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');
if (productDescriptions) {
contextStr = `KATALOG & PRODUKTE:\n${productDescriptions}`;
}
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."
- Wenn technisches Wissen aus dem Kabelhandbuch benötigt wird, NUTZE UNBEDINGT eines der "kabelfachmann_*" Tools, anstatt zu raten oder zu behaupten du wüsstest es nicht! Das Tool weiss alles.
VORGEHEN:
1. Prüfe den KONTEXT auf passende Katalog-Kabel für das Kundenprojekt.
2. Wenn du tiefgehendes Wissen zu einem Kabeltyp brauchst (z.B. Biegeradius, Normen, Querschnitte), rufe das Kabelfachmann-Tool auf.
3. Nenne direkt 1-2 passende Produktserien aus dem Kontext oder der Tool-Abfrage, die für diesen Fall Sinn machen.
4. 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?).
5. 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 openrouterApiKey = process.env.OPENROUTER_API_KEY;
if (!openrouterApiKey) {
throw new Error('OPENROUTER_API_KEY is not set');
}
const openrouter = createOpenAI({
baseURL: 'https://openrouter.ai/api/v1',
apiKey: openrouterApiKey,
});
let mcpTools: Record<string, any> = {};
const mcpUrl = process.env.KABELFACHMANN_MCP_URL || 'http://host.docker.internal:3007/sse';
try {
const { tools } = await createMcpTools({
name: 'kabelfachmann',
url: mcpUrl,
});
mcpTools = tools;
} catch (e) {
console.warn('Failed to load MCP tools', e);
Sentry.captureException(e, { tags: { context: 'ai-search-mcp' } });
}
const { text } = await generateText({
model: openrouter('openai/gpt-4o-mini'),
system: systemPrompt,
messages: cappedMessages.map((m: any) => ({
role: m.role,
content: typeof m.content === 'string' ? m.content : JSON.stringify(m.content),
})),
tools: mcpTools,
// @ts-expect-error - maxSteps might be missing in some versions of generateText types
maxSteps: 5,
temperature: 0.3,
maxTokens: MAX_RESPONSE_TOKENS,
});
// 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

@@ -16,6 +16,14 @@ export async function GET() {
const payload = await getPayload({ config: configPromise });
checks.init = 'ok';
// Ensure migrations are applied on startup (reliable for standalone builds)
try {
await payload.db.migrate();
} catch (e: any) {
console.error('Migration failed:', e.message);
// We continue to check the collections even if migration fails
}
// Verify each collection can be queried (catches missing locale tables, broken migrations)
const collections = ['posts', 'products', 'pages', 'media'] as const;
for (const collection of collections) {
@@ -27,7 +35,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

@@ -1,81 +0,0 @@
import { NextRequest, NextResponse } from 'next/server';
import { getPayload } from 'payload';
import configPromise from '@payload-config';
import { renderToStream } from '@react-pdf/renderer';
import React from 'react';
import { PDFPage } from '@/lib/pdf-page';
import fs from 'fs';
import path from 'path';
export async function GET(req: NextRequest, { params }: { params: Promise<{ slug: string }> }) {
try {
const { slug } = await params;
// Hardcoded bypass for AGBs to use the original uploaded PDF.
if (slug === 'agbs') {
const filePath = path.join(process.cwd(), 'public', 'AVB-KLZ-4-2026.pdf');
if (fs.existsSync(filePath)) {
const fileBuffer = fs.readFileSync(filePath);
return new NextResponse(fileBuffer, {
status: 200,
headers: {
'Content-Type': 'application/pdf',
'Content-Disposition': `attachment; filename="AVB-KLZ-4-2026.pdf"`,
},
});
}
}
// 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

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

@@ -24,34 +24,14 @@ export async function POST(request: NextRequest) {
return NextResponse.json({ status: 'ignored_in_dev' }, { status: 200 });
}
let body;
try {
body = await request.json();
} catch (_parseError) {
logger.warn('Received malformed or empty JSON in analytics proxy');
return NextResponse.json({ status: 'ignored_bad_payload' }, { status: 200 });
}
if (!body || typeof body !== 'object') {
logger.warn('Received invalid body type in analytics proxy', { type: typeof body });
return NextResponse.json({ status: 'ignored_invalid_body' }, { status: 200 });
}
const body = await request.json();
const { type, payload } = body;
if (!type || !payload) {
logger.warn('Received incomplete analytics payload', {
hasType: !!type,
hasPayload: !!payload
});
return NextResponse.json({ status: 'ignored_incomplete_payload' }, { status: 200 });
}
// Inject the secret websiteId from server config
const websiteId = config.analytics.umami.websiteId;
if (!websiteId) {
logger.warn('Umami tracking received but no Website ID configured on server');
return NextResponse.json({ status: 'ignored_no_config' }, { status: 200 });
return NextResponse.json({ status: 'ignored' }, { status: 200 });
}
// Prepare the enhanced payload with the secret ID

2117
ast.json

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,28 @@
'use client';
import { useState, useEffect } from 'react';
import dynamic from 'next/dynamic';
const BrochureModal = dynamic(() => import('./BrochureModal'), { ssr: false });
export default function AutoBrochureModal() {
const [isOpen, setIsOpen] = useState(false);
useEffect(() => {
// Check if user has already seen or interacted with the modal
const hasSeenModal = localStorage.getItem('klz_brochure_modal_seen');
if (!hasSeenModal) {
// Auto-open after 5 seconds to not interrupt immediate page load
const timer = setTimeout(() => {
setIsOpen(true);
// Mark as seen so it doesn't bother them again on next page load
localStorage.setItem('klz_brochure_modal_seen', 'true');
}, 5000);
return () => clearTimeout(timer);
}
}, []);
return <BrochureModal isOpen={isOpen} onClose={() => setIsOpen(false)} />;
}

View File

@@ -0,0 +1,88 @@
'use client';
import { useState } from 'react';
import { useTranslations } from 'next-intl';
import { cn } from '@/components/ui/utils';
import dynamic from 'next/dynamic';
const BrochureModal = dynamic(() => import('./BrochureModal'), { ssr: false });
interface Props {
className?: string;
compact?: boolean;
}
/**
* BrochureCTA — Shows a button that opens a modal asking for an email address.
* The full-catalog PDF is ONLY revealed after email submission.
* No direct download link is exposed anywhere.
*/
export default function BrochureCTA({ className, compact = false }: Props) {
const t = useTranslations('Brochure');
const [open, setOpen] = useState(false);
return (
<>
<div className={cn(className)}>
<button
type="button"
onClick={() => setOpen(true)}
className={cn(
'group relative flex w-full items-center gap-4 overflow-hidden rounded-[28px] bg-[#000d26] border border-white/[0.08] text-left cursor-pointer',
'transition-all duration-300 hover:border-[#82ed20]/30 hover:shadow-[0_8px_30px_rgba(0,0,0,0.3)]',
compact ? 'p-4 md:p-5' : 'p-6 md:p-8',
)}
>
{/* Green top accent */}
<span className="pointer-events-none absolute inset-x-0 top-0 h-px bg-gradient-to-r from-transparent via-[#82ed20]/50 to-transparent" />
{/* Icon */}
<span className="flex h-11 w-11 shrink-0 items-center justify-center rounded-xl bg-[#82ed20]/10 border border-[#82ed20]/20 group-hover:bg-[#82ed20] transition-colors duration-300">
<svg
className="h-5 w-5 text-[#82ed20] group-hover:text-[#000d26] transition-colors duration-300"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={1.5}
d="M12 6.253v13m0-13C10.832 5.477 9.246 5 7.5 5S4.168 5.477 3 6.253v13C4.168 18.477 5.754 18 7.5 18s3.332.477 4.5 1.253m0-13C13.168 5.477 14.754 5 16.5 5c1.747 0 3.332.477 4.5 1.253v13C19.832 18.477 18.247 18 16.5 18c-1.746 0-3.332.477-4.5 1.253"
/>
</svg>
</span>
{/* Labels */}
<span className="flex-1 min-w-0">
<span className="block text-[9px] font-black uppercase tracking-[0.2em] text-[#82ed20] mb-0.5">
PDF Katalog
</span>
<span
className={cn(
'block font-black text-white uppercase tracking-tight group-hover:text-[#82ed20] transition-colors duration-200',
compact ? 'text-base' : 'text-lg md:text-xl',
)}
>
{t('ctaTitle')}
</span>
</span>
{/* Arrow */}
<span className="flex h-8 w-8 shrink-0 items-center justify-center rounded-full bg-white/5 text-white/20 group-hover:bg-[#82ed20] group-hover:text-[#000d26] transition-all duration-300">
<svg className="h-4 w-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2.5}
d="M9 5l7 7-7 7"
/>
</svg>
</span>
</button>
</div>
<BrochureModal isOpen={open} onClose={() => setOpen(false)} />
</>
);
}

View File

@@ -0,0 +1,256 @@
'use client';
import { useState, useRef, useEffect } from 'react';
import { createPortal } from 'react-dom';
import { useTranslations, useLocale } from 'next-intl';
import { cn } from '@/components/ui/utils';
import { requestBrochureAction } from '@/app/actions/brochure';
import { useAnalytics } from './analytics/useAnalytics';
import { AnalyticsEvents } from './analytics/analytics-events';
interface BrochureModalProps {
isOpen: boolean;
onClose: () => void;
}
export default function BrochureModal({ isOpen, onClose }: BrochureModalProps) {
const t = useTranslations('Brochure');
const locale = useLocale();
const { trackEvent } = useAnalytics();
const formRef = useRef<HTMLFormElement>(null);
const modalRef = useRef<HTMLDivElement>(null);
const [state, setState] = useState<'idle' | 'submitting' | 'success' | 'error'>('idle');
const [errorMsg, setErrorMsg] = useState('');
// Close on escape + lock scroll + focus trap
useEffect(() => {
if (!isOpen) return;
// Auto-focus input when opened
const firstInput = document.getElementById('brochure-email');
if (firstInput) {
setTimeout(() => firstInput.focus(), 50);
}
const handleKeyDown = (e: KeyboardEvent) => {
if (e.key === 'Escape') onClose();
if (e.key === 'Tab' && modalRef.current) {
const focusable = modalRef.current.querySelectorAll(
'button, [href], input, select, textarea, [tabindex]:not([tabindex="-1"])',
) as NodeListOf<HTMLElement>;
if (focusable.length > 0) {
const first = focusable[0];
const last = focusable[focusable.length - 1];
if (e.shiftKey && document.activeElement === first) {
last.focus();
e.preventDefault();
} else if (!e.shiftKey && document.activeElement === last) {
first.focus();
e.preventDefault();
}
}
}
};
document.addEventListener('keydown', handleKeyDown);
// Strict overflow lock on mobile as well
document.documentElement.style.setProperty('overflow', 'hidden', 'important');
document.body.style.setProperty('overflow', 'hidden', 'important');
return () => {
document.removeEventListener('keydown', handleKeyDown);
document.documentElement.style.overflow = '';
document.body.style.overflow = '';
};
}, [isOpen, onClose]);
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
if (!formRef.current) return;
setState('submitting');
setErrorMsg('');
try {
const formData = new FormData(formRef.current);
formData.set('locale', locale);
const result = await requestBrochureAction(formData);
if (result.success) {
setState('success');
trackEvent(AnalyticsEvents.DOWNLOAD, {
file_name: `klz-product-catalog-${locale}.pdf`,
file_type: 'brochure',
location: 'brochure_modal',
});
} else {
setState('error');
setErrorMsg(result.error || 'Something went wrong');
}
} catch {
setState('error');
setErrorMsg('Network error');
}
};
const handleClose = () => {
setState('idle');
setErrorMsg('');
onClose();
};
if (!isOpen) return null;
const modal = (
<div
className="fixed inset-0 z-[9999] flex items-center justify-center p-4"
role="dialog"
aria-modal="true"
>
{/* Backdrop */}
<div
className="absolute inset-0 bg-black/70 backdrop-blur-sm"
onClick={handleClose}
aria-hidden="true"
/>
{/* Modal Panel */}
<div
ref={modalRef}
className="relative z-10 w-full max-w-md rounded-[28px] bg-[#000d26] border border-white/10 shadow-[0_40px_80px_rgba(0,0,0,0.6)] overflow-hidden"
>
{/* Accent bar at top */}
<div className="h-1 w-full bg-gradient-to-r from-[#82ed20] via-[#5cb516] to-[#82ed20]" />
{/* Close Button */}
<button
type="button"
onClick={handleClose}
className="absolute top-4 right-4 z-10 flex h-8 w-8 items-center justify-center rounded-full bg-white/5 text-white/40 hover:bg-white/10 hover:text-white transition-colors cursor-pointer"
aria-label={t('close')}
>
<svg className="h-4 w-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M6 18L18 6M6 6l12 12"
/>
</svg>
</button>
<div className="p-8 pt-7">
{/* Icon + Header */}
<div className="mb-7">
<div className="flex h-12 w-12 items-center justify-center rounded-2xl bg-[#82ed20]/10 border border-[#82ed20]/20 mb-4">
<svg
className="h-6 w-6 text-[#82ed20]"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={1.5}
d="M12 6.253v13m0-13C10.832 5.477 9.246 5 7.5 5S4.168 5.477 3 6.253v13C4.168 18.477 5.754 18 7.5 18s3.332.477 4.5 1.253m0-13C13.168 5.477 14.754 5 16.5 5c1.747 0 3.332.477 4.5 1.253v13C19.832 18.477 18.247 18 16.5 18c-1.746 0-3.332.477-4.5 1.253"
/>
</svg>
</div>
<h2 className="text-2xl font-black text-white uppercase tracking-tight leading-none mb-2">
{t('title')}
</h2>
<p className="text-sm text-white/50 leading-relaxed">{t('subtitle')}</p>
</div>
{state === 'success' ? (
<div>
<div className="flex items-center gap-3 mb-6 p-4 rounded-2xl bg-[#82ed20]/10 border border-[#82ed20]/20">
<div className="flex h-9 w-9 shrink-0 items-center justify-center rounded-xl bg-[#82ed20]/20">
<svg
className="h-5 w-5 text-[#82ed20]"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M5 13l4 4L19 7"
/>
</svg>
</div>
<div>
<p className="text-sm font-bold text-[#82ed20]">
{locale === 'de' ? 'Erfolgreich gesendet' : 'Successfully sent'}
</p>
<p className="text-xs text-white/50 mt-0.5">
{locale === 'de'
? 'Bitte prüfen Sie Ihren Posteingang.'
: 'Please check your inbox.'}
</p>
</div>
</div>
<button
type="button"
onClick={handleClose}
className="flex items-center justify-center gap-3 w-full py-4 px-6 rounded-2xl bg-white/10 hover:bg-white/20 text-white font-black text-sm uppercase tracking-widest transition-colors"
>
{t('close')}
</button>
</div>
) : (
<form ref={formRef} onSubmit={handleSubmit}>
<div className="mb-5">
<label
htmlFor="brochure-email"
className="block text-[10px] font-black uppercase tracking-[0.2em] text-white/40 mb-2"
>
{t('emailLabel')}
</label>
<input
id="brochure-email"
name="email"
type="email"
required
autoComplete="email"
placeholder={t('emailPlaceholder')}
className="w-full rounded-xl bg-white/5 border border-white/10 px-4 py-3.5 text-white placeholder:text-white/20 text-sm font-medium focus:outline-none focus:border-[#82ed20]/40 transition-colors"
disabled={state === 'submitting'}
/>
</div>
{state === 'error' && errorMsg && (
<p className="text-red-400 text-xs mb-4 font-medium">{errorMsg}</p>
)}
<button
type="submit"
disabled={state === 'submitting'}
className={cn(
'w-full py-4 px-6 rounded-2xl font-black text-sm uppercase tracking-widest transition-colors',
state === 'submitting'
? 'bg-white/10 text-white/40 cursor-wait'
: 'bg-[#82ed20] hover:bg-[#6dd318] text-[#000d26]',
)}
>
{state === 'submitting' ? t('submitting') : t('submit')}
</button>
<p className="mt-4 text-[10px] text-white/25 text-center leading-relaxed">
{t('privacyNote')}
</p>
</form>
)}
</div>
</div>
</div>
);
return createPortal(modal, document.body);
}

View File

@@ -15,10 +15,12 @@ export default function CMSConnectivityNotice() {
const isDebug = new URLSearchParams(window.location.search).has('cms_debug');
const isLocal = config.isDevelopment;
const isTesting = config.isTesting;
const target = process.env.NEXT_PUBLIC_TARGET || '';
const isBranch = target === 'branch';
// Only proceed with check if it's developer context (Local or Testing)
// Only proceed with check if it's developer context (Local, Testing, or Branch preview)
// Staging and Production should NEVER see this unless forced with ?cms_debug
if (!isLocal && !isTesting && !isDebug) return;
if (!isLocal && !isTesting && !isBranch && !isDebug) return;
try {
const response = await fetch('/api/health/cms');
@@ -58,8 +60,8 @@ export default function CMSConnectivityNotice() {
<h4 className="font-bold text-sm mb-1">CMS Issue Detected</h4>
<p className="text-xs opacity-90 leading-relaxed mb-3">
{errorMsg === 'relation "products" does not exist'
? 'The database schema is missing. Please sync your local data to this environment.'
: errorMsg || 'The application cannot connect to the Directus CMS.'}
? 'The database schema is missing. Please run migrations for this environment.'
: 'A content service is unavailable. Check the deployment logs for details.'}
</p>
<div className="flex gap-2">
<button

View File

@@ -138,7 +138,20 @@ export default function ContactForm() {
<Heading level={3} subtitle={t('form.subtitle')} className="mb-6 md:mb-10">
{t('form.title')}
</Heading>
<form onSubmit={handleSubmit} className="grid grid-cols-1 md:grid-cols-2 gap-4 md:gap-8">
<form
id="contact-form"
onSubmit={handleSubmit}
className="grid grid-cols-1 md:grid-cols-2 gap-4 md:gap-8"
>
{/* Anti-spam Honeypot */}
<input
type="text"
name="company_website"
tabIndex={-1}
autoComplete="off"
style={{ display: 'none' }}
aria-hidden="true"
/>
<div className="space-y-1 md:space-y-2">
<Label htmlFor="contact-name">{t('form.name')}</Label>
<Input

View File

@@ -33,12 +33,12 @@ export default function DatasheetDownload({ datasheetPath, className }: Datashee
<div className="absolute inset-0 bg-gradient-to-r from-accent via-saturated to-accent opacity-20 group-hover:opacity-40 transition-opacity duration-500 animate-gradient-x" />
{/* Inner Content */}
<div className="relative flex items-center gap-6 rounded-[31px] bg-primary-dark/90 backdrop-blur-xl p-6 md:p-8 border border-white/10">
<div className="relative flex items-center gap-5 rounded-[31px] bg-primary-dark/90 backdrop-blur-xl p-6 md:px-6 md:py-6 border border-white/10">
{/* Icon Container */}
<div className="relative flex h-16 w-16 flex-shrink-0 items-center justify-center rounded-2xl bg-white/5 border border-white/10 group-hover:bg-accent group-hover:border-white/20 transition-all duration-500">
<div className="relative flex h-14 w-14 flex-shrink-0 items-center justify-center rounded-2xl bg-white/5 border border-white/10 group-hover:bg-accent group-hover:border-white/20 transition-all duration-500">
<div className="absolute inset-0 rounded-2xl bg-accent/20 blur-xl opacity-0 group-hover:opacity-100 transition-opacity duration-500" />
<svg
className="relative h-8 w-8 text-white transition-transform duration-500 group-hover:scale-110 group-hover:rotate-3"
className="relative h-7 w-7 text-white transition-transform duration-500 group-hover:scale-110 group-hover:rotate-3"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
@@ -54,13 +54,13 @@ export default function DatasheetDownload({ datasheetPath, className }: Datashee
</div>
{/* Text Content */}
<div className="flex-1">
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2 mb-1">
<span className="text-[10px] font-black uppercase tracking-[0.2em] text-accent">
PDF Datasheet
</span>
</div>
<h3 className="text-xl md:text-2xl font-black text-white uppercase tracking-tighter leading-none group-hover:text-accent transition-colors duration-300">
<h3 className="text-xl font-black text-white uppercase tracking-tighter leading-none group-hover:text-accent transition-colors duration-300">
{t('downloadDatasheet')}
</h3>
<p className="mt-2 text-sm font-medium text-white/60 leading-relaxed">
@@ -69,9 +69,9 @@ export default function DatasheetDownload({ datasheetPath, className }: Datashee
</div>
{/* Arrow Icon */}
<div className="flex h-10 w-10 items-center justify-center rounded-full bg-white/5 text-white/20 group-hover:bg-accent group-hover:text-white group-hover:translate-x-1 transition-all duration-500">
<div className="flex h-10 w-10 flex-shrink-0 items-center justify-center rounded-full bg-white/5 text-white/20 group-hover:bg-accent group-hover:text-white group-hover:translate-x-1 transition-all duration-500">
<svg
className="h-6 w-6"
className="h-5 w-5"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"

View File

@@ -0,0 +1,94 @@
'use client';
import { cn } from '@/components/ui/utils';
import { useTranslations } from 'next-intl';
import { useAnalytics } from './analytics/useAnalytics';
import { AnalyticsEvents } from './analytics/analytics-events';
interface ExcelDownloadProps {
excelPath: string;
className?: string;
}
export default function ExcelDownload({ excelPath, className }: ExcelDownloadProps) {
const t = useTranslations('Products');
const { trackEvent } = useAnalytics();
return (
<div className={cn('mt-4 animate-slight-fade-in-from-bottom', className)}>
<a
href={excelPath}
target="_blank"
rel="noopener noreferrer"
onClick={() =>
trackEvent(AnalyticsEvents.DOWNLOAD, {
file_name: excelPath.split('/').pop(),
file_path: excelPath,
file_type: 'excel',
location: 'product_page',
})
}
className="group relative block w-full overflow-hidden rounded-[32px] bg-primary-dark p-1 transition-all duration-500 hover:shadow-[0_20px_50px_rgba(0,0,0,0.2)] hover:-translate-y-1"
>
{/* Animated Background Gradient */}
<div className="absolute inset-0 bg-gradient-to-r from-emerald-500 via-teal-400 to-emerald-500 opacity-20 group-hover:opacity-40 transition-opacity duration-500 animate-gradient-x" />
{/* Inner Content */}
<div className="relative flex items-center gap-5 rounded-[31px] bg-primary-dark/90 backdrop-blur-xl p-6 md:px-6 md:py-6 border border-white/10">
{/* Icon Container */}
<div className="relative flex h-14 w-14 flex-shrink-0 items-center justify-center rounded-2xl bg-white/5 border border-white/10 group-hover:bg-emerald-600 group-hover:border-white/20 transition-all duration-500">
<div className="absolute inset-0 rounded-2xl bg-emerald-500/20 blur-xl opacity-0 group-hover:opacity-100 transition-opacity duration-500" />
{/* Spreadsheet/Table Icon */}
<svg
className="relative h-7 w-7 text-white transition-transform duration-500 group-hover:scale-110 group-hover:rotate-3"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
aria-hidden="true"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={1.5}
d="M3 10h18M3 14h18M10 3v18M3 6a3 3 0 013-3h12a3 3 0 013 3v12a3 3 0 01-3 3H6a3 3 0 01-3-3V6z"
/>
</svg>
</div>
{/* Text Content */}
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2 mb-1">
<span className="text-[10px] font-black uppercase tracking-[0.2em] text-emerald-400">
Excel Datasheet
</span>
</div>
<h3 className="text-xl font-black text-white uppercase tracking-tighter leading-none group-hover:text-emerald-400 transition-colors duration-300">
{t('downloadExcel')}
</h3>
<p className="mt-2 text-sm font-medium text-white/60 leading-relaxed">
{t('downloadExcelDesc')}
</p>
</div>
{/* Arrow Icon */}
<div className="flex h-10 w-10 flex-shrink-0 items-center justify-center rounded-full bg-white/5 text-white/20 group-hover:bg-emerald-600 group-hover:text-white group-hover:translate-x-1 transition-all duration-500">
<svg
className="h-5 w-5"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
aria-hidden="true"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2.5}
d="M9 5l7 7-7 7"
/>
</svg>
</div>
</div>
</a>
</div>
);
}

View File

@@ -3,9 +3,11 @@
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';
import FooterBrochureForm from './FooterBrochureForm';
export default function Footer() {
const t = useTranslations('Footer');
@@ -243,6 +245,10 @@ export default function Footer() {
</div>
</div>
<div className="mb-12 md:mb-16">
<FooterBrochureForm />
</div>
<div className="pt-8 md:pt-12 border-t border-white/10 flex flex-row justify-between items-center gap-4 text-white/70 text-xs md:text-sm font-medium">
<p>{t('copyright', { year: currentYear })}</p>
<div className="flex gap-8">
@@ -276,6 +282,48 @@ export default function Footer() {
</Link>
</div>
</div>
{/* Brand & Quality Sub-Footer */}
<div className="pt-8 mt-8 border-t border-white/5 flex flex-col md:flex-row justify-between items-center gap-6 text-white/40 text-[10px] sm:text-xs">
<div>
<a
href="https://mintel.me"
target="_blank"
rel="noopener noreferrer"
onClick={() =>
trackEvent(AnalyticsEvents.LINK_CLICK, {
target: 'mintel_agency',
location: 'sub_footer',
})
}
className="hover:text-white/80 transition-colors flex items-center gap-1.5"
>
Website entwickelt von Marc Mintel
</a>
</div>
<div className="flex flex-wrap justify-center md:justify-end gap-x-6 gap-y-3">
<div className="flex items-center gap-1.5" title="SSL Secured">
<ShieldCheck className="w-3.5 h-3.5" />
<span>SSL Secured</span>
</div>
<div className="flex items-center gap-1.5" title="Green Hosting">
<Leaf className="w-3.5 h-3.5" />
<span>Green Hosting</span>
</div>
<div className="flex items-center gap-1.5" title="DSGVO Compliant">
<Lock className="w-3.5 h-3.5" />
<span>DSGVO Compliant</span>
</div>
<div className="flex items-center gap-1.5" title="WCAG">
<Accessibility className="w-3.5 h-3.5" />
<span>WCAG</span>
</div>
<div className="flex items-center gap-1.5" title="PageSpeed 90+">
<Zap className="w-3.5 h-3.5" />
<span>PageSpeed 90+</span>
</div>
</div>
</div>
</Container>
</footer>
);

View File

@@ -0,0 +1,134 @@
'use client';
import { useState, useRef } from 'react';
import { useTranslations, useLocale } from 'next-intl';
import { requestBrochureAction } from '@/app/actions/brochure';
import { cn } from '@/components/ui/utils';
import { useAnalytics } from './analytics/useAnalytics';
import { AnalyticsEvents } from './analytics/analytics-events';
interface Props {
className?: string;
}
export default function FooterBrochureForm({ className }: Props) {
const t = useTranslations('Brochure');
const locale = useLocale();
const { trackEvent } = useAnalytics();
const formRef = useRef<HTMLFormElement>(null);
const [phase, setPhase] = useState<'idle' | 'loading' | 'success' | 'error'>('idle');
const [err, setErr] = useState('');
async function handleSubmit(e: React.FormEvent) {
e.preventDefault();
if (!formRef.current) return;
setPhase('loading');
const fd = new FormData(formRef.current);
fd.set('locale', locale);
try {
const res = await requestBrochureAction(fd);
if (res.success) {
setPhase('success');
trackEvent(AnalyticsEvents.DOWNLOAD, {
file_name: `klz-product-catalog-${locale}.pdf`,
file_type: 'brochure',
location: 'footer_inline',
});
} else {
setErr(res.error || 'Error');
setPhase('error');
}
} catch {
setErr('Network error');
setPhase('error');
}
}
if (phase === 'success') {
return (
<div
className={cn(
'flex flex-col sm:flex-row items-center gap-4 bg-white/5 border border-[#82ed20]/20 rounded-2xl p-6',
className,
)}
>
<div className="flex h-10 w-10 shrink-0 items-center justify-center rounded-full bg-[#82ed20]/20 text-[#82ed20]">
<svg className="h-5 w-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M5 13l4 4L19 7" />
</svg>
</div>
<div>
<h4 className="text-white font-bold mb-1">
{locale === 'de' ? 'Erfolgreich angefordert!' : 'Successfully requested!'}
</h4>
<p className="text-white/60 text-sm">
{locale === 'de'
? 'Wir haben Ihnen den Katalog soeben per E-Mail zugesendet.'
: 'We have just sent the catalog to your email.'}
</p>
</div>
</div>
);
}
return (
<div
className={cn(
'bg-white/5 border border-white/10 rounded-3xl p-6 md:p-8 flex flex-col md:flex-row items-start md:items-center justify-between gap-6 md:gap-12',
className,
)}
>
<div className="flex-1 max-w-xl">
<h4 className="text-lg font-black text-white uppercase tracking-tight mb-2">
{t('ctaTitle')}
</h4>
<p className="text-sm text-white/60 leading-relaxed mb-0">{t('subtitle')}</p>
</div>
<form
ref={formRef}
onSubmit={handleSubmit}
className="w-full md:w-auto flex flex-col sm:flex-row gap-3"
>
{/* Anti-spam Honeypot */}
<input
type="text"
name="company_website"
tabIndex={-1}
autoComplete="off"
style={{ display: 'none' }}
aria-hidden="true"
/>
<div className="relative w-full sm:w-64">
<input
name="email"
type="email"
required
placeholder={t('emailPlaceholder')}
disabled={phase === 'loading'}
className="w-full bg-primary-dark border border-white/20 rounded-xl px-4 py-3 text-sm text-white placeholder:text-white/30 focus:outline-none focus:border-[#82ed20]/50 transition-colors"
/>
</div>
<button
type="submit"
disabled={phase === 'loading'}
className={cn(
'flex items-center justify-center shrink-0 px-6 py-3 rounded-xl font-bold text-sm uppercase tracking-widest transition-colors',
phase === 'loading'
? 'bg-white/10 text-white/40 cursor-wait'
: 'bg-[#82ed20] text-[#000d26] hover:bg-[#6dd318] cursor-pointer',
)}
>
{phase === 'loading' ? t('submitting') : t('submit')}
</button>
</form>
{phase === 'error' && err && (
<div className="absolute mt-16 text-red-400 text-xs font-medium">{err}</div>
)}
</div>
);
}

View File

@@ -9,6 +9,8 @@ import { useEffect, useState, useRef } from 'react';
import { cn } from './ui';
import { useAnalytics } from './analytics/useAnalytics';
import { AnalyticsEvents } from './analytics/analytics-events';
import { Search } from 'lucide-react';
import { AISearchResults } from './search/AISearchResults';
export default function Header() {
const t = useTranslations('Navigation');
@@ -16,6 +18,7 @@ export default function Header() {
const { trackEvent } = useAnalytics();
const [isScrolled, setIsScrolled] = useState(false);
const [isMobileMenuOpen, setIsMobileMenuOpen] = useState(false);
const [isSearchOpen, setIsSearchOpen] = useState(false);
const mobileMenuRef = useRef<HTMLDivElement>(null);
// Extract locale from pathname
@@ -36,6 +39,7 @@ export default function Header() {
// Prevent scroll when mobile menu is open and handle focus trap
useEffect(() => {
if (isMobileMenuOpen) {
document.documentElement.style.overflow = 'hidden';
document.body.style.overflow = 'hidden';
// Focus trap logic
const focusableElements = mobileMenuRef.current?.querySelectorAll(
@@ -80,7 +84,8 @@ export default function Header() {
};
}
} else {
document.body.style.overflow = 'unset';
document.documentElement.style.overflow = '';
document.body.style.overflow = '';
}
}, [isMobileMenuOpen]);
@@ -273,6 +278,19 @@ export default function Header() {
<div
className="animate-in fade-in zoom-in-95 fill-mode-both"
style={{ animationDuration: '600ms', animationDelay: '700ms' }}
>
<button
onClick={() => setIsSearchOpen(true)}
className="hover:text-accent transition-colors p-2"
aria-label="Search"
>
<Search className="w-5 h-5 md:w-6 md:h-6" />
</button>
</div>
<div
className="animate-in fade-in zoom-in-95 fill-mode-both"
style={{ animationDuration: '600ms', animationDelay: '800ms' }}
>
<Button
href={`/${currentLocale}/${currentLocale === 'de' ? 'kontakt' : 'contact'}`}
@@ -467,6 +485,8 @@ export default function Header() {
</div>
</nav>
</div>
<AISearchResults isOpen={isSearchOpen} onClose={() => setIsSearchOpen(false)} />
</>
);
}

View File

@@ -7,14 +7,16 @@ interface JsonLdProps {
export default function JsonLd({ id, data }: JsonLdProps) {
// If data is provided, use it. Otherwise, use the default Organization + WebSite schema.
const rawData = data || [
const schemaData = data || [
{
'@context': 'https://schema.org',
'@type': 'Organization',
name: 'KLZ Cables',
url: 'https://klz-cables.com',
logo: 'https://klz-cables.com/logo-blue.svg',
sameAs: ['https://www.linkedin.com/company/klz-cables'],
sameAs: [
'https://www.linkedin.com/company/klz-cables',
],
description: 'Premium Cable Solutions for Renewable Energy and Infrastructure.',
address: {
'@type': 'PostalAddress',
@@ -34,32 +36,15 @@ export default function JsonLd({ id, data }: JsonLdProps) {
},
'query-input': 'required name=search_term_string',
},
},
}
];
// Harden schema items: Ensure each item is an object and has @context
const schemaData = (Array.isArray(rawData) ? rawData : [rawData])
.filter((item): item is WithContext<Thing> => {
// Basic sanity check: must be an object and not null
return typeof item === 'object' && item !== null;
})
.map((item) => {
// Ensure @context is present and correctly typed for consumer libraries
if (!item['@context']) {
return {
...(item as Record<string, any>),
'@context': 'https://schema.org',
} as WithContext<Thing>;
}
return item;
});
return (
<script
id={id}
type="application/ld+json"
dangerouslySetInnerHTML={{
__html: JSON.stringify(schemaData.length === 1 ? schemaData[0] : schemaData),
__html: JSON.stringify(schemaData),
}}
/>
);

View File

@@ -23,7 +23,7 @@ export default function Lightbox({ isOpen, images, initialIndex, onClose }: Ligh
const previousFocusRef = useRef<HTMLElement | null>(null);
useEffect(() => {
setMounted(true); // eslint-disable-line react-hooks/set-state-in-effect
setMounted(true);
return () => setMounted(false);
}, []);
@@ -61,7 +61,7 @@ export default function Lightbox({ isOpen, images, initialIndex, onClose }: Ligh
if (photoParam !== null) {
const index = parseInt(photoParam, 10);
if (!isNaN(index) && index >= 0 && index < images.length) {
setCurrentIndex(index); // eslint-disable-line react-hooks/set-state-in-effect
setCurrentIndex(index);
}
}
}, [searchParams, images.length]);
@@ -125,13 +125,17 @@ export default function Lightbox({ isOpen, images, initialIndex, onClose }: Ligh
};
// Lock scroll
const originalStyle = window.getComputedStyle(document.body).overflow;
document.body.style.overflow = 'hidden';
const originalBodyStyle = window.getComputedStyle(document.body).overflow;
const originalHtmlStyle = window.getComputedStyle(document.documentElement).overflow;
document.documentElement.style.setProperty('overflow', 'hidden', 'important');
document.body.style.setProperty('overflow', 'hidden', 'important');
window.addEventListener('keydown', handleKeyDown);
return () => {
document.body.style.overflow = originalStyle;
document.documentElement.style.overflow = originalHtmlStyle;
document.body.style.overflow = originalBodyStyle;
window.removeEventListener('keydown', handleKeyDown);
};
}, [isOpen, prevImage, nextImage, handleClose]);
@@ -139,7 +143,7 @@ export default function Lightbox({ isOpen, images, initialIndex, onClose }: Ligh
if (!mounted) return null;
return createPortal(
<LazyMotion strict features={() => import('@/lib/framer-features').then(res => res.default)}>
<LazyMotion strict features={() => import('@/lib/framer-features').then((res) => res.default)}>
<AnimatePresence>
{isOpen && (
<div

View File

@@ -17,6 +17,7 @@ export default function ObfuscatedEmail({ email, className = '', children }: Obf
const [mounted, setMounted] = useState(false);
useEffect(() => {
// eslint-disable-next-line react-hooks/set-state-in-effect
setMounted(true);
}, []);

View File

@@ -16,6 +16,7 @@ export default function ObfuscatedPhone({ phone, className = '', children }: Obf
const [mounted, setMounted] = useState(false);
useEffect(() => {
// eslint-disable-next-line react-hooks/set-state-in-effect
setMounted(true);
}, []);

View File

@@ -793,8 +793,8 @@ const jsxConverters: JSXConverters = {
</Section>
);
},
imageGallery: ({ node }: any) => <Gallery />,
'block-imageGallery': ({ node }: any) => <Gallery />,
imageGallery: () => <Gallery />,
'block-imageGallery': () => <Gallery />,
categoryGrid: ({ node }: any) => {
const cats = node.fields.categories || [];
return (

View File

@@ -4,6 +4,7 @@ import Image from 'next/image';
import { useTranslations } from 'next-intl';
import RequestQuoteForm from '@/components/RequestQuoteForm';
import DatasheetDownload from '@/components/DatasheetDownload';
import ExcelDownload from '@/components/ExcelDownload';
import Scribble from '@/components/Scribble';
import { cn } from '@/components/ui/utils';
@@ -11,6 +12,7 @@ interface ProductSidebarProps {
productName: string;
productImage?: string;
datasheetPath?: string | null;
excelPath?: string | null;
className?: string;
}
@@ -18,6 +20,7 @@ export default function ProductSidebar({
productName,
productImage,
datasheetPath,
excelPath,
className,
}: ProductSidebarProps) {
const t = useTranslations('Products');
@@ -70,6 +73,9 @@ export default function ProductSidebar({
{/* Datasheet Download */}
{datasheetPath && <DatasheetDownload datasheetPath={datasheetPath} className="mt-0" />}
{/* Excel Download right below datasheet */}
{excelPath && <ExcelDownload excelPath={excelPath} className="mt-0" />}
</aside>
);
}

View File

@@ -2,6 +2,7 @@
import React, { useState } from 'react';
import { useTranslations } from 'next-intl';
import { formatTechnicalValue } from '@/lib/utils/technical';
interface KeyValueItem {
label: string;
@@ -45,22 +46,40 @@ export default function ProductTechnicalData({ data }: ProductTechnicalDataProps
<div className="w-2 h-8 bg-accent rounded-full" />
General Data
</h3>
<dl className="grid grid-cols-2 sm:grid-cols-2 lg:grid-cols-3 gap-x-6 gap-y-6 md:gap-x-12 md:gap-y-8">
{technicalItems.map((item, idx) => (
<div key={idx} className="flex flex-col group">
<dt className="text-sm font-bold uppercase tracking-widest text-primary/40 mb-2 group-hover:text-accent transition-colors">
{item.label}
</dt>
<dd className="text-lg font-semibold text-text-primary">
{item.value}{' '}
{item.unit && (
<span className="text-sm font-normal text-text-secondary ml-1">
{item.unit}
</span>
)}
</dd>
</div>
))}
<dl className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-x-6 gap-y-6 md:gap-x-12 md:gap-y-8">
{technicalItems.map((item, idx) => {
const formatted = formatTechnicalValue(item.value);
return (
<div key={idx} className="flex flex-col group">
<dt className="text-sm font-bold uppercase tracking-widest text-primary/40 mb-2 group-hover:text-accent transition-colors">
{item.label}
</dt>
<dd className="text-lg font-semibold text-text-primary">
{formatted.isList ? (
<div className="flex flex-wrap gap-2 mt-1">
{formatted.parts.map((p, pIdx) => (
<span
key={pIdx}
className="inline-block px-3 py-1 bg-neutral-light border border-neutral-dark/10 rounded-lg text-xs font-bold text-primary shadow-sm hover:border-accent/40 transition-colors"
>
{p}
</span>
))}
</div>
) : (
<>
{item.value}{' '}
{item.unit && (
<span className="text-sm font-normal text-text-secondary ml-1">
{item.unit}
</span>
)}
</>
)}
</dd>
</div>
);
})}
</dl>
</div>
)}

View File

@@ -164,7 +164,17 @@ export default function RequestQuoteForm({ productName }: RequestQuoteFormProps)
}
return (
<form onSubmit={handleSubmit} className="space-y-3 !mt-0">
<form id="quote-request-form" onSubmit={handleSubmit} className="space-y-3 !mt-0">
{/* Anti-spam Honeypot */}
<input
type="text"
name="company_website"
tabIndex={-1}
autoComplete="off"
style={{ display: 'none' }}
aria-hidden="true"
/>
<div className="space-y-2 !mt-0">
<div className="space-y-1 !mt-0">
<label htmlFor={emailId} className="sr-only">

View File

@@ -28,13 +28,13 @@ export default function TrackedLink({
}: TrackedLinkProps) {
const { trackEvent } = useAnalytics();
const handleClick = (e: React.MouseEvent) => {
const handleClick = () => {
try {
trackEvent(eventName, {
href,
...eventProperties,
});
} catch (_e) {
} catch {
// Analytics tracking should not block navigation, so we catch and ignore errors.
}
if (onClick) onClick();

View File

@@ -1,5 +1,5 @@
import React from 'react';
import Scribble from '@/components/Scribble';
import { formatTechnicalValue } from '@/lib/utils/technical';
interface TechnicalGridItem {
label: string;
@@ -18,25 +18,44 @@ export default function TechnicalGrid({ title, items }: TechnicalGridProps) {
<h3 className="text-2xl font-bold text-text-primary mb-8 flex items-center gap-4 relative">
<span className="relative inline-block">
{title}
<Scribble
variant="underline"
className="absolute -bottom-2 left-0 w-full h-3 text-accent/40"
<Scribble
variant="underline"
className="absolute -bottom-2 left-0 w-full h-3 text-accent/40"
/>
</span>
</h3>
)}
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
{items.map((item, index) => (
<div key={index} className="bg-white p-8 rounded-2xl border border-neutral-200 shadow-sm hover:shadow-md transition-all duration-300 group relative overflow-hidden">
<div className="absolute top-0 right-0 w-16 h-16 bg-primary/5 -mr-8 -mt-8 rotate-45 transition-transform group-hover:scale-110" />
<span className="block text-xs font-bold text-primary uppercase tracking-[0.2em] mb-3 opacity-70">
{item.label}
</span>
<span className="text-lg text-text-secondary leading-relaxed group-hover:text-text-primary transition-colors">
{item.value}
</span>
</div>
))}
{items.map((item, index) => {
const formatted = formatTechnicalValue(item.value);
return (
<div
key={index}
className="bg-white p-8 rounded-2xl border border-neutral-200 shadow-sm hover:shadow-md transition-all duration-300 group relative overflow-hidden"
>
<div className="absolute top-0 right-0 w-16 h-16 bg-primary/5 -mr-8 -mt-8 rotate-45 transition-transform group-hover:scale-110" />
<span className="block text-xs font-bold text-primary uppercase tracking-[0.2em] mb-3 opacity-70">
{item.label}
</span>
<div className="text-lg text-text-secondary leading-relaxed group-hover:text-text-primary transition-colors">
{formatted.isList ? (
<div className="flex flex-wrap gap-2 mt-2">
{formatted.parts.map((p, pIdx) => (
<span
key={pIdx}
className="inline-block px-3 py-1 bg-neutral-light border border-neutral-dark/10 rounded-lg text-xs font-bold text-primary shadow-sm group-hover:border-accent/40 transition-colors"
>
{p}
</span>
))}
</div>
) : (
item.value
)}
</div>
</div>
);
})}
</div>
</div>
);

View File

@@ -0,0 +1,145 @@
import {
Body,
Container,
Head,
Heading,
Hr,
Html,
Preview,
Section,
Text,
Button,
} from '@react-email/components';
import * as React from 'react';
interface BrochureDeliveryEmailProps {
_email: string;
brochureUrl: string;
locale: 'en' | 'de';
}
export const BrochureDeliveryEmail = ({
_email,
brochureUrl,
locale = 'en',
}: BrochureDeliveryEmailProps) => {
const t =
locale === 'de'
? {
subject: 'Ihr KLZ Kabelkatalog',
greeting: 'Vielen Dank für Ihr Interesse an KLZ Cables.',
body: 'Anbei erhalten Sie den Link zu unserem aktuellen Produktkatalog. Dieser enthält alle wichtigen technischen Spezifikationen und detaillierten Produktdaten.',
button: 'Katalog herunterladen',
footer: 'Diese E-Mail wurde von klz-cables.com gesendet.',
}
: {
subject: 'Your KLZ Cable Catalog',
greeting: 'Thank you for your interest in KLZ Cables.',
body: 'Below you will find the link to our current product catalog. It contains all key technical specifications and detailed product data.',
button: 'Download Catalog',
footer: 'This email was sent from klz-cables.com.',
};
return (
<Html>
<Head />
<Preview>{t.subject}</Preview>
<Body style={main}>
<Container style={container}>
<Section style={headerSection}>
<Heading style={h1}>{t.subject}</Heading>
</Section>
<Section style={section}>
<Text style={text}>
<strong>{t.greeting}</strong>
</Text>
<Text style={text}>{t.body}</Text>
<Section style={buttonContainer}>
<Button style={button} href={brochureUrl}>
{t.button}
</Button>
</Section>
<Hr style={hr} />
</Section>
<Text style={footer}>{t.footer}</Text>
</Container>
</Body>
</Html>
);
};
export default BrochureDeliveryEmail;
const main = {
backgroundColor: '#f6f9fc',
fontFamily:
'-apple-system,BlinkMacSystemFont,"Segoe UI",Roboto,"Helvetica Neue",Ubuntu,sans-serif',
};
const container = {
backgroundColor: '#ffffff',
margin: '0 auto',
padding: '0 0 48px',
marginBottom: '64px',
borderRadius: '8px',
overflow: 'hidden',
border: '1px solid #e6ebf1',
};
const headerSection = {
backgroundColor: '#000d26',
padding: '32px 48px',
borderBottom: '4px solid #4da612',
};
const h1 = {
color: '#ffffff',
fontSize: '24px',
fontWeight: 'bold',
margin: '0',
};
const section = {
padding: '32px 48px 0',
};
const text = {
color: '#333',
fontSize: '16px',
lineHeight: '24px',
textAlign: 'left' as const,
};
const buttonContainer = {
textAlign: 'center' as const,
marginTop: '32px',
marginBottom: '32px',
};
const button = {
backgroundColor: '#4da612',
borderRadius: '4px',
color: '#ffffff',
fontSize: '16px',
fontWeight: 'bold',
textDecoration: 'none',
textAlign: 'center' as const,
display: 'inline-block',
padding: '16px 32px',
};
const hr = {
borderColor: '#e6ebf1',
margin: '20px 0',
};
const footer = {
color: '#8898aa',
fontSize: '12px',
lineHeight: '16px',
textAlign: 'center' as const,
marginTop: '20px',
};

View File

@@ -1,98 +1,221 @@
'use client';
import Scribble from '@/components/Scribble';
import { Button, Container, Heading, Section } from '@/components/ui';
import { useTranslations, useLocale } from 'next-intl';
import dynamic from 'next/dynamic';
import { useAnalytics } from '../analytics/useAnalytics';
import { AnalyticsEvents } from '../analytics/analytics-events';
import { useState, useEffect, useRef, useCallback } from 'react';
import { ChevronRight } from 'lucide-react';
import { AISearchResults } from '../search/AISearchResults';
const HeroIllustration = dynamic(() => import('./HeroIllustration'), { ssr: false });
const AIOrb = dynamic(() => import('../search/AIOrb'), { ssr: false });
export default function Hero({ data }: { data?: any }) {
const t = useTranslations('Home.hero');
const locale = useLocale();
const { trackEvent } = useAnalytics();
const [searchQuery, setSearchQuery] = useState('');
const [isSearchOpen, setIsSearchOpen] = useState(false);
const [heroPlaceholder, setHeroPlaceholder] = useState(
'Projekt beschreiben oder Kabel suchen...',
);
const typingRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const HERO_PLACEHOLDERS = [
'Querschnittsberechnung für 110kV Trasse', // Hochspannung
'Wie schwer ist NAYY 4x150?',
'Ich plane einen Solarpark, was brauche ich?', // Projekt Solar
'Unterschied zwischen N2XSY und NAY2XSY?', // Fach
'Mittelspannungskabel für Windkraftanlage', // Windpark
'Welches Aluminiumkabel für 20kV?', // Mittelspannung
];
// Typing animation for the hero search placeholder
useEffect(() => {
if (searchQuery) {
setHeroPlaceholder('Projekt beschreiben oder Kabel suchen...');
return;
}
let textIdx = 0;
let charIdx = 0;
let deleting = false;
const tick = () => {
const fullText = HERO_PLACEHOLDERS[textIdx];
if (deleting) {
charIdx--;
setHeroPlaceholder(fullText.substring(0, charIdx));
} else {
charIdx++;
setHeroPlaceholder(fullText.substring(0, charIdx));
}
let delay = deleting ? 30 : 70;
if (!deleting && charIdx === fullText.length) {
delay = 2500;
deleting = true;
} else if (deleting && charIdx === 0) {
deleting = false;
textIdx = (textIdx + 1) % HERO_PLACEHOLDERS.length;
delay = 400;
}
typingRef.current = setTimeout(tick, delay);
};
typingRef.current = setTimeout(tick, 1500);
return () => {
if (typingRef.current) clearTimeout(typingRef.current);
};
}, [searchQuery]);
const handleSearchSubmit = (e: React.FormEvent) => {
e.preventDefault();
if (searchQuery.trim()) {
setIsSearchOpen(true);
}
};
return (
<Section className="relative min-h-[85vh] md:h-[90vh] flex flex-col items-center justify-center overflow-hidden bg-primary py-12 md:py-0 lg:py-0">
<Container className="relative z-10 text-center md:text-left text-white w-full order-2 md:order-none">
<div className="max-w-5xl mx-auto md:mx-0">
<div>
<Heading
level={1}
className="text-center md:text-left mb-6 md:mb-8 md:max-w-none text-white text-3xl sm:text-4xl md:text-5xl font-extrabold"
>
{data?.title ? (
<span
dangerouslySetInnerHTML={{
__html: data.title
.replace(/<green>/g, '<span class="text-accent italic">')
.replace(/<\/green>/g, '</span>'),
}}
/>
) : (
t.rich('title', {
green: (chunks) => <span className="text-accent italic">{chunks}</span>,
})
)}
</Heading>
</div>
<div>
<p className="text-lg md:text-xl text-white leading-relaxed max-w-2xl mb-10 md:mb-12">
{data?.subtitle || t('subtitle')}
</p>
</div>
<div className="flex flex-col sm:flex-row justify-center md:justify-start gap-4 md:gap-6">
<>
<Section className="relative min-h-[85vh] md:h-[90vh] flex flex-col items-center justify-center overflow-hidden bg-primary py-12 md:py-0 lg:py-0">
<Container className="relative z-10 text-center md:text-left text-white w-full order-2 md:order-none">
<div className="max-w-5xl mx-auto md:mx-0">
<div>
<Heading
level={1}
className="text-center md:text-left mb-6 md:mb-8 md:max-w-none text-white text-4xl sm:text-5xl md:text-7xl font-extrabold [text-shadow:_-2px_-2px_0_#002b49,_2px_-2px_0_#002b49,_-2px_2px_0_#002b49,_2px_2px_0_#002b49,_-2px_0_0_#002b49,_2px_0_0_#002b49,_0_-2px_0_#002b49,_0_2px_0_#002b49]"
>
{data?.title ? (
<span
dangerouslySetInnerHTML={{
__html: data.title
.replace(
/<green>/g,
'<span class="relative inline-block"><span class="relative z-10 text-accent italic inline-block">',
)
.replace(
/<\/green>/g,
'</span><div class="w-[140%] h-[140%] -top-[20%] -left-[20%] text-accent/30 hidden md:block absolute -z-10 animate-in fade-in zoom-in-0 duration-1000 ease-out fill-mode-both" style="animation-delay: 500ms;"><Scribble variant="circle" /></div></span>',
),
}}
/>
) : (
t.rich('title', {
green: (chunks) => (
<span className="relative inline-block">
<span className="relative z-10 text-accent italic inline-block">
{chunks}
</span>
<div
className="w-[140%] h-[140%] -top-[20%] -left-[20%] text-accent/30 hidden md:block absolute -z-10 animate-in fade-in zoom-in-0 duration-1000 ease-out fill-mode-both"
style={{ animationDelay: '500ms' }}
>
<Scribble variant="circle" />
</div>
</span>
),
})
)}
</Heading>
</div>
<div>
<p className="text-lg md:text-xl text-white leading-relaxed max-w-2xl mb-10 md:mb-12">
{data?.subtitle || t('subtitle')}
</p>
</div>
<form
onSubmit={handleSearchSubmit}
className="w-full max-w-2xl bg-white/10 backdrop-blur-md border border-white/20 rounded-2xl p-2 flex items-center mt-8 mb-10 transition-all focus-within:bg-white/15 focus-within:border-accent shadow-lg relative"
>
<div className="absolute left-1 w-20 h-20 flex items-center justify-center z-10 overflow-visible">
<AIOrb isThinking={false} />
</div>
<input
type="text"
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
placeholder={heroPlaceholder}
className="flex-1 bg-transparent border-none text-white pl-20 pr-2 py-4 placeholder:text-white/50 focus:outline-none text-lg lg:text-xl"
autoFocus
/>
<Button
href="/contact"
type="submit"
variant="accent"
size="lg"
className="group w-full sm:w-auto h-14 md:h-16 px-8 md:px-10 text-base md:text-lg hover:scale-105 transition-transform"
onClick={() =>
trackEvent(AnalyticsEvents.BUTTON_CLICK, {
label: data?.ctaLabel || t('cta'),
location: 'home_hero_primary',
})
}
className="rounded-xl px-6 py-4 shrink-0 flex items-center shadow-md font-bold cursor-pointer hover:bg-accent hover:brightness-110"
>
{data?.ctaLabel || t('cta')}
<span className="transition-transform group-hover/btn:translate-x-1 ml-2">
&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')}
Fragen
<ChevronRight className="w-5 h-5 ml-2 -mr-1" />
</Button>
</form>
<div className="flex flex-col sm:flex-row justify-center md:justify-start gap-4 md:gap-6">
<div>
<Button
href="/contact"
variant="white"
size="lg"
className="group w-full sm:w-auto h-14 md:h-16 px-8 md:px-10 text-base md:text-lg hover:scale-105 transition-all outline-none"
onClick={() =>
trackEvent(AnalyticsEvents.BUTTON_CLICK, {
label: data?.ctaLabel || t('cta'),
location: 'home_hero_primary',
})
}
>
{data?.ctaLabel || t('cta')}
<span className="transition-transform group-hover/btn:translate-x-1 ml-2">
&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>
</div>
</Container>
</Container>
<div className="relative md:absolute inset-0 z-0 w-full h-[40vh] md:h-full order-1 md:order-none mb-[-80px] md:mb-0 mt-[80px] md:mt-0 overflow-visible pointer-events-none animate-in fade-in zoom-in-95 duration-1000 ease-out fill-mode-both">
<HeroIllustration />
</div>
<div
className="absolute bottom-6 md:bottom-10 left-1/2 -translate-x-1/2 hidden sm:block animate-in fade-in slide-in-from-bottom-4 duration-1000 ease-out fill-mode-both"
style={{ animationDelay: '2000ms' }}
>
<div className="w-6 h-10 border-2 border-white/30 rounded-full flex justify-center p-1">
<div className="w-1 h-2 bg-white rounded-full animate-bounce" />
<div className="relative md:absolute inset-0 z-0 w-full h-[40vh] md:h-full order-1 md:order-none mb-[-80px] md:mb-0 mt-[80px] md:mt-0 overflow-visible pointer-events-none animate-in fade-in zoom-in-95 duration-1000 ease-out fill-mode-both">
<HeroIllustration />
</div>
</div>
</Section>
<div
className="absolute bottom-6 md:bottom-10 left-1/2 -translate-x-1/2 hidden sm:block animate-in fade-in slide-in-from-bottom-4 duration-1000 ease-out fill-mode-both"
style={{ animationDelay: '2000ms' }}
>
<div className="w-6 h-10 border-2 border-white/30 rounded-full flex justify-center p-1">
<div className="w-1 h-2 bg-white rounded-full animate-bounce" />
</div>
</div>
</Section>
<AISearchResults
isOpen={isSearchOpen}
onClose={() => setIsSearchOpen(false)}
initialQuery={searchQuery}
triggerSearch={true}
/>
</>
);
}

View File

@@ -75,7 +75,7 @@ export default async function RecentPosts({ locale, data }: RecentPostsProps) {
className="px-3 py-1 text-white/80 text-[10px] md:text-xs font-bold uppercase tracking-widest border border-white/20 rounded-full bg-white/10 backdrop-blur-md"
>
{new Date(post.frontmatter.date).toLocaleDateString(
locale === 'en' ? 'en-US' : 'de-DE',
['en', 'de'].includes(locale) ? locale : 'de',
{
year: 'numeric',
month: 'short',

371
components/search/AIOrb.tsx Normal file
View File

@@ -0,0 +1,371 @@
'use client';
import React, { useRef, useEffect, useCallback } from 'react';
interface AIOrbProps {
isThinking: boolean;
hasError?: boolean;
}
function lerp(a: number, b: number, t: number) {
return a + (b - a) * t;
}
// Simple noise function for organic movement
function noise(x: number, y: number, t: number): number {
return (
Math.sin(x * 1.3 + t * 0.7) * Math.cos(y * 0.9 + t * 0.5) * 0.5 +
Math.sin(x * 2.7 + y * 1.1 + t * 1.3) * 0.25 +
Math.cos(x * 0.8 - y * 2.3 + t * 0.9) * 0.25
);
}
// ── Particle ───────────────────────────────────────────────────
interface Particle {
// Sphere position (target shape)
theta: number;
phi: number;
// Current position
x: number;
y: number;
z: number;
// Velocity
vx: number;
vy: number;
vz: number;
// Properties
size: number;
baseSize: number;
hue: number; // 0=blue, 1=green
brightness: number;
phase: number;
orbitSpeed: number;
noiseScale: number;
}
function createParticles(count: number): Particle[] {
const particles: Particle[] = [];
for (let i = 0; i < count; i++) {
// Fibonacci sphere distribution for even spacing
const golden = Math.PI * (3 - Math.sqrt(5));
const y = 1 - (i / (count - 1)) * 2;
const radiusAtY = Math.sqrt(1 - y * y);
const theta = golden * i;
const phi = Math.acos(y);
particles.push({
theta,
phi,
x: Math.cos(theta) * radiusAtY,
y,
z: Math.sin(theta) * radiusAtY,
vx: 0,
vy: 0,
vz: 0,
size: 0.4 + Math.random() * 0.8,
baseSize: 0.4 + Math.random() * 0.8,
hue: Math.random() > 0.45 ? 0 : 1,
brightness: 0.5 + Math.random() * 0.5,
phase: Math.random() * Math.PI * 2,
orbitSpeed: (0.1 + Math.random() * 0.4) * (Math.random() > 0.5 ? 1 : -1),
noiseScale: 0.5 + Math.random() * 1.5,
});
}
return particles;
}
export default function AIOrb({ isThinking = false, hasError = false }: AIOrbProps) {
const canvasRef = useRef<HTMLCanvasElement>(null);
const wrapRef = useRef<HTMLDivElement>(null);
const animRef = useRef<number>(0);
const particlesRef = useRef<Particle[]>([]);
const mouse = useRef({ x: 0.5, y: 0.5, hover: false });
const state = useRef({
pulse: 0,
hover: 0,
error: 0,
mouseX: 0.5,
mouseY: 0.5,
rotY: 0,
rotX: 0,
breathe: 0,
scatter: 0,
shake: 0,
});
const onMove = useCallback((e: React.PointerEvent) => {
const r = wrapRef.current?.getBoundingClientRect();
if (!r) return;
mouse.current.x = (e.clientX - r.left) / r.width;
mouse.current.y = (e.clientY - r.top) / r.height;
}, []);
const onEnter = useCallback(() => {
mouse.current.hover = true;
}, []);
const onLeave = useCallback(() => {
mouse.current.hover = false;
mouse.current.x = 0.5;
mouse.current.y = 0.5;
}, []);
const draw = useCallback(
function drawStep() {
const canvas = canvasRef.current;
if (!canvas) return;
const ctx = canvas.getContext('2d');
if (!ctx) return;
const dpr = window.devicePixelRatio || 1;
const rect = canvas.getBoundingClientRect();
const w = rect.width * dpr;
const h = rect.height * dpr;
if (canvas.width !== w || canvas.height !== h) {
canvas.width = w;
canvas.height = h;
}
const cx = w / 2;
const cy = h / 2;
const minDim = Math.min(w, h);
// Reduced further to give maximum breathing room for glow + movement
const sphereR = minDim * 0.16;
const time = performance.now() / 1000;
const s = state.current;
const m = mouse.current;
// ── Interpolate state ──
s.pulse = lerp(s.pulse, isThinking ? 1 : 0, 0.03);
s.hover = lerp(s.hover, m.hover ? 1 : 0, 0.12);
s.error = lerp(s.error, hasError ? 1 : 0, 0.05);
s.mouseX = lerp(s.mouseX, m.x, 0.12);
s.mouseY = lerp(s.mouseY, m.y, 0.12);
s.scatter = lerp(s.scatter, m.hover ? 0.8 : hasError ? 0.5 : 0, 0.06);
s.shake += 0.15 * s.error;
// Global rotation — ALWAYS rotating + ALWAYS facing cursor
s.rotY += lerp(0.008, 0.04, Math.max(s.pulse, s.hover));
const mouseRotY = (s.mouseX - 0.5) * 1.2; // always face cursor
const mouseRotX = (s.mouseY - 0.5) * 0.8;
s.breathe += lerp(1.2, 3.0, s.pulse) / 60;
const breathe = Math.sin(s.breathe) * 0.5 + 0.5;
// ── Clear ──
ctx.clearRect(0, 0, w, h);
// ── Subtle core glow ──
const shakeX = Math.sin(s.shake * 17) * s.error * minDim * 0.02;
const glowCX = cx + shakeX;
const glowCY = cy;
// Clamp glow radius so it never exceeds ~48% of canvas (leaves padding for movement)
const glowR = Math.min(
sphereR * lerp(2.2, 4.0, Math.max(s.pulse, s.hover * 0.8)),
minDim * 0.48,
);
const glowA = lerp(0.1, 0.4, Math.max(s.pulse, s.hover * 0.7, s.error * 0.8));
const glow = ctx.createRadialGradient(glowCX, glowCY, 0, glowCX, glowCY, glowR);
// Glow color: blue normally, red on error
const glowR1 = Math.round(lerp(20, 255, s.error));
const glowG1 = Math.round(lerp(60, 40, s.error));
const glowB1 = Math.round(lerp(255, 40, s.error));
glow.addColorStop(0, `rgba(${glowR1}, ${glowG1}, ${glowB1}, ${glowA * 2})`);
glow.addColorStop(
0.25,
`rgba(${Math.round(lerp(80, 200, s.error))}, ${Math.round(lerp(140, 50, s.error))}, ${Math.round(lerp(255, 50, s.error))}, ${glowA * 1.2})`,
);
glow.addColorStop(0.6, `rgba(${glowR1}, ${glowG1}, ${glowB1}, ${glowA * 0.4})`);
glow.addColorStop(1, `rgba(${glowR1}, ${glowG1}, ${glowB1}, 0)`);
ctx.fillStyle = glow;
ctx.beginPath();
ctx.arc(glowCX, glowCY, glowR, 0, Math.PI * 2);
ctx.fill();
// ── Create particles if empty ──
if (particlesRef.current.length === 0) {
particlesRef.current = createParticles(350);
}
// ── Update & draw particles ──
const cosRY = Math.cos(s.rotY + mouseRotY);
const sinRY = Math.sin(s.rotY + mouseRotY);
const cosRX = Math.cos(mouseRotX);
const sinRX = Math.sin(mouseRotX);
// Sort by z for correct layering
type ParticleWithScreen = { p: Particle; sx: number; sy: number; sz: number; depth: number };
const projected: ParticleWithScreen[] = [];
for (const p of particlesRef.current) {
// Target position: sphere surface + noise displacement
const n = noise(p.theta * p.noiseScale, p.phi * p.noiseScale, time * 0.5 + p.phase);
const displacement = 1 + n * lerp(0.12, 0.3, s.pulse);
// Orbit: rotate theta — always moving, faster idle
const activeTheta = p.theta + time * p.orbitSpeed * lerp(0.35, 0.8, s.pulse);
// Sphere coordinates to cartesian
const sinPhi = Math.sin(p.phi);
const tgtX = Math.cos(activeTheta) * sinPhi * displacement;
// Excitement from hover + pulse + error
const targetExcite = Math.max(s.hover * 0.9, s.pulse, s.error * 0.8);
const tgtY = Math.cos(p.phi) * displacement;
const tgtZ = Math.sin(activeTheta) * sinPhi * displacement;
// Scatter on hover: push particles outward
const scatterMul = 1 + s.scatter * (0.5 + n * 0.5);
// Spring physics toward target
const tx = tgtX * scatterMul;
const ty = tgtY * scatterMul;
const tz = tgtZ * scatterMul;
p.vx += (tx - p.x) * 0.08;
p.vy += (ty - p.y) * 0.08;
p.vz += (tz - p.z) * 0.08;
p.vx *= 0.88;
p.vy *= 0.88;
p.vz *= 0.88;
p.x += p.vx;
p.y += p.vy;
p.z += p.vz;
// 3D rotation (Y then X)
const rx = p.x * cosRY - p.z * sinRY;
const rz = p.x * sinRY + p.z * cosRY;
const ry = p.y * cosRX - rz * sinRX;
const finalZ = p.y * sinRX + rz * cosRX;
// Project to screen
const perspective = 3;
const scale = perspective / (perspective + finalZ);
const sx = cx + rx * sphereR * scale;
const sy = cy + ry * sphereR * scale;
projected.push({ p, sx, sy, sz: finalZ, depth: scale });
}
// Sort back-to-front
projected.sort((a, b) => a.sz - b.sz);
for (const { p, sx, sy, sz, depth } of projected) {
// Depth-based alpha and size
const depthAlpha = 0.25 + (sz + 1) * 0.375; // 0.25 (back) → 1.0 (front)
const twinkle = 0.75 + 0.25 * Math.sin(time * 3.5 + p.phase);
const alpha =
depthAlpha * twinkle * p.brightness * lerp(0.8, 1.3, Math.max(s.pulse, s.hover * 0.8));
const drawSize =
p.baseSize * depth * dpr * lerp(1.0, 2.0, Math.max(s.pulse, s.hover * 0.7));
// Color — shift to red on error
let r: number, g: number, b: number;
if (s.error > 0.1) {
// Error: red family
if (p.hue === 0) {
r = Math.round(lerp(40 + sz * 30, 255, s.error));
g = Math.round(lerp(80 + sz * 40, 40 + sz * 20, s.error));
b = Math.round(lerp(255, 40, s.error));
} else {
r = Math.round(lerp(100 + sz * 30, 230, s.error));
g = Math.round(lerp(220 + sz * 17, 60, s.error));
b = Math.round(lerp(20, 20, s.error));
}
} else if (p.hue === 0) {
r = 60 + Math.round(sz * 40);
g = 100 + Math.round(sz * 50);
b = 255;
} else {
r = 120 + Math.round(sz * 30);
g = 237 + Math.round(sz * 10);
b = 30;
}
// Thinking: shift toward brighter, more saturated
if (s.pulse > 0.1) {
r = Math.round(lerp(r, p.hue === 0 ? 100 : 130, s.pulse * 0.3));
g = Math.round(lerp(g, p.hue === 0 ? 140 : 237, s.pulse * 0.3));
b = Math.round(lerp(b, p.hue === 0 ? 255 : 32, s.pulse * 0.3));
}
// Micro glow — always visible, stronger on front
if (depthAlpha > 0.25) {
const gSize = drawSize * lerp(4, 7, s.hover);
const pg = ctx.createRadialGradient(sx, sy, 0, sx, sy, gSize);
pg.addColorStop(0, `rgba(${r},${g},${b},${alpha * 0.5})`);
pg.addColorStop(1, `rgba(${r},${g},${b},0)`);
ctx.fillStyle = pg;
ctx.beginPath();
ctx.arc(sx, sy, gSize, 0, Math.PI * 2);
ctx.fill();
}
// Core dot — bright
ctx.fillStyle = `rgba(${Math.min(r + 40, 255)},${Math.min(g + 30, 255)},${b},${Math.min(alpha * 1.6, 1)})`;
ctx.beginPath();
ctx.arc(sx, sy, Math.max(drawSize * 0.5, 0.3 * dpr), 0, Math.PI * 2);
ctx.fill();
}
// ── Loading rings (thinking) ──
if (s.pulse > 0.02) {
ctx.save();
ctx.translate(cx, cy);
// Spinning arc
const spinAngle = time * 2;
const arcLen = Math.PI * lerp(0.3, 1.0, (Math.sin(time * 1.5) + 1) / 2);
ctx.rotate(spinAngle);
ctx.strokeStyle = `rgba(130, 237, 32, ${s.pulse * 0.4})`;
ctx.lineWidth = 1.2 * dpr;
ctx.lineCap = 'round';
ctx.beginPath();
ctx.arc(0, 0, sphereR * 1.25, 0, arcLen);
ctx.stroke();
// Counter-spinning arc
ctx.rotate(-spinAngle * 2);
ctx.strokeStyle = `rgba(1, 29, 255, ${s.pulse * 0.3})`;
ctx.lineWidth = 0.8 * dpr;
ctx.beginPath();
ctx.arc(0, 0, sphereR * 1.35, 0, arcLen * 0.6);
ctx.stroke();
ctx.restore();
// Expanding pulse
const pulsePhase = (time * 0.8) % 1;
const pulseR = sphereR * (1 + pulsePhase * 1.5);
const pulseA = s.pulse * (1 - pulsePhase) * 0.15;
ctx.strokeStyle = `rgba(130, 237, 32, ${pulseA})`;
ctx.lineWidth = 1 * dpr;
ctx.beginPath();
ctx.arc(cx, cy, pulseR, 0, Math.PI * 2);
ctx.stroke();
}
animRef.current = requestAnimationFrame(drawStep);
},
[isThinking, hasError],
);
useEffect(() => {
animRef.current = requestAnimationFrame(draw);
return () => cancelAnimationFrame(animRef.current);
}, [draw]);
return (
<div
ref={wrapRef}
className="w-full h-full relative overflow-visible"
onPointerMove={onMove}
onPointerEnter={onEnter}
onPointerLeave={onLeave}
style={{ cursor: 'pointer' }}
>
<canvas ref={canvasRef} className="w-full h-full block" style={{ imageRendering: 'auto' }} />
</div>
);
}

View File

@@ -0,0 +1,646 @@
'use client';
import { useState, useRef, useEffect, KeyboardEvent } from 'react';
import { ArrowUp, X, Sparkles, ChevronRight, RotateCcw, Copy, Check } from 'lucide-react';
import Link from 'next/link';
import { useAnalytics } from '../analytics/useAnalytics';
import { AnalyticsEvents } from '../analytics/analytics-events';
import ReactMarkdown from 'react-markdown';
import remarkGfm from 'remark-gfm';
import dynamic from 'next/dynamic';
const AIOrb = dynamic(() => import('./AIOrb'), { ssr: false });
const LOADING_TEXTS = [
'Durchsuche das Kabelhandbuch... 📖',
'Frage den Senior-Ingenieur... 👴🔧',
'Frage ChatGPTs Cousin 2. Grades... 🤖',
];
interface ProductMatch {
id: string;
title: string;
sku: string;
slug: string;
}
interface Message {
role: 'user' | 'assistant';
content: string;
products?: ProductMatch[];
timestamp: number;
}
interface ComponentProps {
isOpen: boolean;
onClose: () => void;
initialQuery?: string;
triggerSearch?: boolean;
}
export function AISearchResults({
isOpen,
onClose,
initialQuery = '',
triggerSearch = false,
}: ComponentProps) {
const { trackEvent } = useAnalytics();
const [query, setQuery] = useState('');
const [messages, setMessages] = useState<Message[]>([]);
const [honeypot, setHoneypot] = useState('');
const [isLoading, setIsLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const [copiedIndex, setCopiedIndex] = useState<number | null>(null);
const [copiedAll, setCopiedAll] = useState(false);
const [loadingText, setLoadingText] = useState(LOADING_TEXTS[0]);
const inputRef = useRef<HTMLInputElement>(null);
const modalRef = useRef<HTMLDivElement>(null);
const messagesEndRef = useRef<HTMLDivElement>(null);
const loadingIntervalRef = useRef<ReturnType<typeof setInterval> | null>(null);
const hasTriggeredRef = useRef(false);
// Dedicated focus effect — polls until the input actually has focus
useEffect(() => {
if (!isOpen) return;
let attempts = 0;
const focusTimer = setInterval(() => {
const el = inputRef.current;
if (el && document.activeElement !== el) {
el.focus({ preventScroll: true });
}
attempts++;
if (attempts >= 15 || document.activeElement === el) {
clearInterval(focusTimer);
}
}, 100);
return () => clearInterval(focusTimer);
}, [isOpen]);
useEffect(() => {
if (isOpen) {
document.body.style.overflow = 'hidden';
// Trigger initial search only once
if (triggerSearch && initialQuery && !hasTriggeredRef.current) {
hasTriggeredRef.current = true;
handleSearch(initialQuery);
}
} else {
document.body.style.overflow = 'unset';
setQuery('');
setMessages([]);
setError(null);
setIsLoading(false);
hasTriggeredRef.current = false;
}
return () => {
document.body.style.overflow = 'unset';
};
}, [isOpen, triggerSearch]);
useEffect(() => {
messagesEndRef.current?.scrollIntoView({ behavior: 'smooth' });
}, [messages, isLoading]);
// Global ESC handler
useEffect(() => {
if (!isOpen) return;
const handleEsc = (e: globalThis.KeyboardEvent) => {
if (e.key === 'Escape') {
const activeElement = document.activeElement;
const isInputFocused = activeElement === inputRef.current;
if (query.trim()) {
// If there's text, clear it but keep chat open
setQuery('');
inputRef.current?.focus();
} else if (!isInputFocused) {
// If no text and input is not focused, focus it
inputRef.current?.focus();
} else {
// If no text and input IS focused, close the chat
onClose();
}
}
};
document.addEventListener('keydown', handleEsc);
return () => document.removeEventListener('keydown', handleEsc);
}, [isOpen, onClose, query]);
const handleSearch = async (searchQuery: string = query) => {
if (!searchQuery.trim() || isLoading) return;
const newUserMessage: Message = { role: 'user', content: searchQuery, timestamp: Date.now() };
const newMessagesContext = [...messages, newUserMessage];
setMessages(newMessagesContext);
setQuery(''); // Always clear input after send
setError(null);
// Give the user message animation 400ms to arrive before showing "thinking"
setTimeout(() => {
setIsLoading(true);
// Start rotating loading texts
let textIdx = Math.floor(Math.random() * LOADING_TEXTS.length);
setLoadingText(LOADING_TEXTS[textIdx]);
loadingIntervalRef.current = setInterval(() => {
textIdx = (textIdx + 1) % LOADING_TEXTS.length;
setLoadingText(LOADING_TEXTS[textIdx]);
}, 2500);
}, 400);
trackEvent(AnalyticsEvents.FORM_SUBMIT, {
type: 'ai_chat',
query: searchQuery,
});
try {
const controller = new AbortController();
const timeout = setTimeout(() => controller.abort(), 60000);
const res = await fetch('/api/ai-search', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
signal: controller.signal,
body: JSON.stringify({
messages: newMessagesContext,
_honeypot: honeypot,
}),
});
clearTimeout(timeout);
const data = await res.json().catch(() => null);
if (!res.ok || !data) {
throw new Error(data?.error || `Server antwortete mit Status ${res.status}`);
}
setMessages((prev) => [
...prev,
{
role: 'assistant',
content: data.answerText,
products: data.products,
timestamp: Date.now(),
},
]);
setTimeout(() => inputRef.current?.focus(), 100);
} catch (err: any) {
console.error(err);
const msg =
err.name === 'AbortError'
? 'Anfrage hat zu lange gedauert. Bitte versuche es erneut.'
: err.message || 'Ein Fehler ist aufgetreten.';
// Show error as a system message in the chat instead of a separate error banner
setMessages((prev) => [
...prev,
{
role: 'assistant',
content: `⚠️ ${msg}`,
timestamp: Date.now(),
},
]);
trackEvent(AnalyticsEvents.ERROR, {
location: 'ai_chat',
message: err.message,
query: searchQuery,
});
} finally {
setIsLoading(false);
if (loadingIntervalRef.current) {
clearInterval(loadingIntervalRef.current);
loadingIntervalRef.current = null;
}
// Always re-focus the input
setTimeout(() => inputRef.current?.focus(), 50);
}
};
const onKeyDown = (e: KeyboardEvent<HTMLInputElement>) => {
if (e.key === 'Enter') {
e.preventDefault();
handleSearch();
}
if (e.key === 'ArrowUp' && !query) {
// Find the last user message and put it into the input
const lastUserNav = [...messages].reverse().find((m) => m.role === 'user');
if (lastUserNav) {
e.preventDefault();
setQuery(lastUserNav.content);
}
}
};
const handleCopy = (content: string, index?: number) => {
navigator.clipboard.writeText(content);
if (index !== undefined) {
setCopiedIndex(index);
setTimeout(() => setCopiedIndex(null), 2000);
} else {
setCopiedAll(true);
setTimeout(() => setCopiedAll(false), 2000);
}
};
const handleCopyChat = () => {
const fullChat = messages
.map((m) => `${m.role === 'user' ? 'Du' : 'Ohm'}:\n${m.content}`)
.join('\n\n');
handleCopy(fullChat);
};
if (!isOpen) return null;
const handleBackdropClick = (e: React.MouseEvent) => {
if (e.target === e.currentTarget) {
onClose();
}
};
return (
<div
className="fixed inset-0 z-[100] flex items-start justify-center pt-6 md:pt-12 px-4"
onClick={handleBackdropClick}
role="dialog"
aria-modal="true"
style={{ animation: 'chatBackdropIn 0.4s ease-out forwards' }}
>
{/* Animated backdrop */}
<div
className="absolute inset-0 bg-[#000a18]/90 backdrop-blur-2xl"
style={{ animation: 'chatFadeIn 0.3s ease-out' }}
/>
<div
ref={modalRef}
className="relative w-full max-w-3xl flex flex-col"
style={{
height: 'min(90vh, 900px)',
animation: 'chatSlideUp 0.5s cubic-bezier(0.16, 1, 0.3, 1) forwards',
}}
>
{/* ── Glassmorphism container ── */}
<div className="flex flex-col h-full rounded-3xl overflow-hidden border border-white/[0.08] bg-gradient-to-b from-white/[0.06] to-white/[0.02] shadow-[0_32px_64px_-12px_rgba(0,0,0,0.6)]">
{/* ── Header ── */}
<div className="flex items-center justify-between px-5 py-4 border-b border-white/[0.06]">
<div className="flex items-center gap-3">
<div className="w-8 h-8 overflow-hidden rounded-full">
<AIOrb isThinking={isLoading} hasError={!!error} />
</div>
<div>
<h2 className="text-white font-bold text-sm tracking-wide">Ohm</h2>
<p className="text-[10px] text-white/30 font-medium tracking-wider uppercase">
{isLoading ? 'Denkt nach...' : error ? 'Fehler aufgetreten' : 'Online'}
</p>
</div>
</div>
<div className="flex items-center gap-2">
{messages.length > 0 && (
<button
onClick={handleCopyChat}
className="flex items-center gap-1.5 text-[10px] font-bold text-white/40 hover:text-white/80 transition-all duration-200 hover:bg-white/5 rounded-full px-3 py-1.5 cursor-pointer uppercase tracking-wider"
title="gesamten Chat kopieren"
>
{copiedAll ? (
<Check className="w-3.5 h-3.5 text-accent" />
) : (
<Copy className="w-3.5 h-3.5" />
)}
<span>{copiedAll ? 'Kopiert' : 'Chat kopieren'}</span>
</button>
)}
<button
onClick={onClose}
className="text-white/30 hover:text-white/80 transition-all duration-200 hover:bg-white/5 rounded-xl p-2 cursor-pointer"
aria-label="Close"
>
<X className="w-5 h-5" />
</button>
</div>
</div>
{/* ── Chat Area ── */}
<div className="flex-1 overflow-y-auto px-5 py-6 space-y-5 scroll-smooth chat-scrollbar">
{/* Empty state */}
{messages.length === 0 && !isLoading && !error && (
<div
className="flex flex-col items-center justify-center h-full text-center space-y-5"
style={{ animation: 'chatFadeIn 0.6s ease-out 0.3s both' }}
>
<div className="w-24 h-24 mb-2">
<AIOrb isThinking={false} hasError={false} />
</div>
<div>
<p className="text-xl md:text-2xl font-bold text-white/80">
Wie kann ich helfen?
</p>
<p className="text-sm text-white/30 mt-2 max-w-md">
Beschreibe dein Projekt, frag nach bestimmten Kabeln, oder nenne mir deine
Anforderungen.
</p>
</div>
{/* Quick prompts */}
<div className="flex flex-wrap justify-center gap-2 mt-4">
{['Windpark 33kV Verkabelung', 'NYCWY 4x185', 'Erdkabel für Solarpark'].map(
(prompt) => (
<button
key={prompt}
onClick={() => handleSearch(prompt)}
className="text-xs text-white/40 hover:text-white/80 border border-white/10 hover:border-white/20 hover:bg-white/5 rounded-full px-4 py-2 transition-all duration-200 cursor-pointer"
>
{prompt}
</button>
),
)}
</div>
</div>
)}
{/* Messages */}
{messages.map((msg, index) => (
<div
key={index}
className={`flex ${msg.role === 'user' ? 'justify-end' : 'justify-start'}`}
style={{
animation: `chatMessageIn 0.4s cubic-bezier(0.16, 1, 0.3, 1) ${index * 0.05}s both`,
}}
>
<div
className={`relative group max-w-[85%] rounded-2xl px-5 py-4 ${
msg.role === 'user'
? 'bg-accent text-primary font-semibold rounded-br-lg'
: 'bg-white/[0.05] border border-white/[0.06] text-white/90 rounded-bl-lg'
}`}
>
{/* Copy Button */}
<button
onClick={() => handleCopy(msg.content, index)}
className={`absolute opacity-0 group-hover:opacity-100 transition-opacity p-1.5 rounded-lg cursor-pointer ${
msg.role === 'user'
? 'top-2 right-2 bg-primary/10 hover:bg-primary/20 text-primary/60 hover:text-primary'
: 'top-2 right-2 bg-white/5 hover:bg-white/10 text-white/40 hover:text-white'
}`}
title="Nachricht kopieren"
>
{copiedIndex === index ? (
<Check className="w-3.5 h-3.5" />
) : (
<Copy className="w-3.5 h-3.5" />
)}
</button>
{msg.role === 'assistant' && (
<div className="flex items-center gap-2 mb-2">
<Sparkles className="w-3 h-3 text-accent/60" />
<span className="text-[10px] font-bold tracking-widest uppercase text-accent/50">
Ohm
</span>
</div>
)}
<div
className={`text-sm md:text-[15px] leading-relaxed ${
msg.role === 'assistant'
? 'prose prose-invert prose-sm prose-p:leading-relaxed prose-a:text-accent prose-strong:text-accent/90 prose-ul:list-disc prose-ol:list-decimal'
: ''
}`}
>
{msg.role === 'assistant' ? (
<ReactMarkdown remarkPlugins={[remarkGfm]}>{msg.content}</ReactMarkdown>
) : (
<p className="whitespace-pre-wrap">{msg.content}</p>
)}
</div>
{/* Timestamp */}
{!msg.products?.length && (
<p
className={`text-[9px] mt-2 font-medium tracking-wide ${msg.role === 'user' ? 'text-primary/40' : 'text-white/20'}`}
>
{new Date(msg.timestamp).toLocaleTimeString('de', {
hour: '2-digit',
minute: '2-digit',
})}
</p>
)}
{/* Product cards */}
{msg.role === 'assistant' && msg.products && msg.products.length > 0 && (
<div className="mt-4 space-y-2 border-t border-white/[0.06] pt-4">
<h4 className="text-[10px] font-bold tracking-widest uppercase text-white/30 mb-2">
Empfohlene Produkte
</h4>
<div className="grid grid-cols-1 sm:grid-cols-2 gap-2">
{msg.products.map((product, idx) => (
<Link
key={idx}
href={`/produkte/${product.slug}`}
onClick={() => {
onClose();
trackEvent(AnalyticsEvents.BUTTON_CLICK, {
target: product.slug,
location: 'ai_chat',
});
}}
className="group flex items-center justify-between bg-white/[0.04] hover:bg-white/[0.08] border border-white/[0.06] hover:border-accent/30 rounded-xl px-4 py-3 transition-all duration-300"
style={{ animation: `chatFadeIn 0.3s ease-out ${idx * 0.1}s both` }}
>
<div className="min-w-0">
<p className="text-[9px] font-bold text-white/25 tracking-wider">
{product.sku}
</p>
<h5 className="text-xs font-bold text-white/70 group-hover:text-accent truncate transition-colors">
{product.title}
</h5>
</div>
<ChevronRight className="w-3.5 h-3.5 text-white/20 group-hover:text-accent shrink-0 ml-3 group-hover:translate-x-0.5 transition-all" />
</Link>
))}
</div>
</div>
)}
</div>
</div>
))}
{/* Loading indicator */}
{isLoading && (
<div
className="flex justify-start"
style={{ animation: 'chatMessageIn 0.4s cubic-bezier(0.16, 1, 0.3, 1) forwards' }}
>
<div className="flex items-center gap-4 bg-white/[0.03] border border-white/[0.06] rounded-2xl rounded-bl-lg px-5 py-4">
<div className="w-10 h-10 shrink-0">
<AIOrb isThinking={true} hasError={false} />
</div>
<div>
<p
className="text-sm text-white/50 font-medium"
style={{ animation: 'chatTextSwap 0.4s ease-out' }}
key={loadingText}
>
{loadingText}
</p>
<div className="flex gap-1 mt-2">
{[0, 1, 2].map((i) => (
<div
key={i}
className="w-1.5 h-1.5 rounded-full bg-accent/40"
style={{
animation: 'chatDotBounce 1.2s ease-in-out infinite',
animationDelay: `${i * 0.15}s`,
}}
/>
))}
</div>
</div>
</div>
</div>
)}
{/* Error */}
{error && (
<div className="flex justify-start" style={{ animation: 'chatShake 0.5s ease-out' }}>
<div className="flex items-center gap-4 bg-red-500/[0.06] border border-red-500/20 rounded-2xl rounded-bl-lg px-5 py-4">
<div className="w-10 h-10 shrink-0">
<AIOrb isThinking={false} hasError={true} />
</div>
<div>
<h3 className="text-sm font-bold text-red-300">Da ist was schiefgelaufen 😬</h3>
<p className="text-xs text-red-300/60 mt-1">{error}</p>
<button
onClick={() => {
setError(null);
inputRef.current?.focus();
}}
className="flex items-center gap-1.5 text-[10px] font-bold text-red-300/50 hover:text-red-300 mt-2 transition-colors cursor-pointer"
>
<RotateCcw className="w-3 h-3" />
Nochmal versuchen
</button>
</div>
</div>
</div>
)}
<div ref={messagesEndRef} />
</div>
{/* ── Input Area ── */}
<div className="px-5 pb-5 pt-3 border-t border-white/[0.04]">
<div
className={`relative flex items-center rounded-2xl transition-all duration-300 ${
query.trim()
? 'bg-white/[0.08] border border-accent/30 shadow-[0_0_20px_-4px_rgba(130,237,32,0.1)]'
: 'bg-white/[0.04] border border-white/[0.06]'
}`}
>
<input
ref={inputRef}
type="text"
value={query}
onChange={(e) => setQuery(e.target.value)}
onKeyDown={onKeyDown}
placeholder="Nachricht eingeben..."
className="flex-1 bg-transparent border-none text-white text-sm md:text-base px-5 py-4 focus:outline-none placeholder:text-white/20"
disabled={isLoading}
tabIndex={1}
autoFocus
/>
<input
type="text"
className="hidden"
value={honeypot}
onChange={(e) => setHoneypot(e.target.value)}
tabIndex={-1}
autoComplete="off"
aria-hidden="true"
/>
<button
onClick={() => handleSearch()}
disabled={!query.trim() || isLoading}
className={`mr-2 w-9 h-9 rounded-xl flex items-center justify-center shrink-0 transition-all duration-300 cursor-pointer ${
query.trim()
? 'bg-accent text-primary shadow-lg shadow-accent/20 hover:shadow-accent/40 hover:scale-105 active:scale-95'
: 'bg-white/5 text-white/20'
}`}
aria-label="Nachricht senden"
>
<ArrowUp className="w-4 h-4" strokeWidth={2.5} />
</button>
</div>
<div className="flex items-center justify-center gap-3 mt-2.5">
<span className="text-[9px] uppercase tracking-[0.15em] font-medium text-white/15">
Enter zum Senden · Esc zum Schließen
</span>
<span className="text-[9px] uppercase tracking-[0.15em] font-medium text-white/15">
·
</span>
<span className="text-[9px] uppercase tracking-[0.15em] font-medium text-accent/40 flex items-center gap-1">
🛡 DSGVO-konform · EU-Server
</span>
</div>
</div>
</div>
</div>
{/* ── Keyframe animations ── */}
<style>{`
@keyframes chatBackdropIn {
from { opacity: 0; }
to { opacity: 1; }
}
@keyframes chatFadeIn {
from { opacity: 0; }
to { opacity: 1; }
}
@keyframes chatSlideUp {
from { opacity: 0; transform: translateY(40px) scale(0.96); }
to { opacity: 1; transform: translateY(0) scale(1); }
}
@keyframes chatMessageIn {
from { opacity: 0; transform: translateY(12px); }
to { opacity: 1; transform: translateY(0); }
}
@keyframes chatDotBounce {
0%, 80%, 100% { transform: scale(0.6); opacity: 0.3; }
40% { transform: scale(1); opacity: 1; }
}
@keyframes chatTextSwap {
from { opacity: 0; transform: translateY(4px); }
to { opacity: 1; transform: translateY(0); }
}
@keyframes chatShake {
0%, 100% { transform: translateX(0); }
15% { transform: translateX(-6px); }
30% { transform: translateX(5px); }
45% { transform: translateX(-4px); }
60% { transform: translateX(3px); }
75% { transform: translateX(-1px); }
}
/* Custom scrollbar */
.chat-scrollbar::-webkit-scrollbar {
width: 4px;
}
.chat-scrollbar::-webkit-scrollbar-track {
background: transparent;
}
.chat-scrollbar::-webkit-scrollbar-thumb {
background: rgba(255, 255, 255, 0.08);
border-radius: 4px;
}
.chat-scrollbar::-webkit-scrollbar-thumb:hover {
background: rgba(255, 255, 255, 0.16);
}
.chat-scrollbar {
scrollbar-width: thin;
scrollbar-color: rgba(255, 255, 255, 0.08) transparent;
}
`}</style>
</div>
);
}

View File

@@ -1,38 +0,0 @@
import puppeteer from 'puppeteer';
import fs from 'fs';
async function generatePDF() {
const pandocHtml = fs.readFileSync('AVB_2026.html', 'utf-8');
const html = `
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8">
<style>
body { font-family: 'Helvetica Neue', Helvetica, Arial, sans-serif; font-size: 10pt; line-height: 1.5; color: #000; margin: 40px; }
h2 { font-size: 16pt; margin-bottom: 20px; font-weight: bold; }
h3 { font-size: 12pt; margin-top: 25px; margin-bottom: 10px; font-weight: bold; color: #222; }
p { margin-bottom: 12px; text-align: justify; }
</style>
</head>
<body>
${pandocHtml}
</body>
</html>
`;
const browser = await puppeteer.launch();
const page = await browser.newPage();
await page.setContent(html, { waitUntil: 'networkidle0' });
await page.pdf({
path: 'KLZ_AVB_April_2026.pdf',
format: 'A4',
margin: { top: '2cm', right: '2cm', bottom: '2cm', left: '2cm' },
printBackground: true
});
await browser.close();
console.log('PDF generated: KLZ_AVB_April_2026.pdf');
}
generatePDF().catch(console.error);

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

2480
data/processed/products.json Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -8,7 +8,7 @@ services:
- infra
labels:
- "caddy=http://${TRAEFIK_HOST:-klz.localhost}"
- "caddy.reverse_proxy=host.docker.internal:3100"
- "caddy.reverse_proxy=http://klz-app:3000"
# Full Docker dev (use with `pnpm run dev:docker`)
klz-app:
@@ -26,13 +26,20 @@ services:
- ${ENV_FILE:-.env}
environment:
NODE_ENV: development
# Force Garbage Collection before Docker kills the container (OOM)
NODE_OPTIONS: "--max-old-space-size=6144"
NEXT_TELEMETRY_DISABLED: "1"
POSTGRES_URI: postgres://${PAYLOAD_DB_USER:-payload}:${PAYLOAD_DB_PASSWORD:-120in09oenaoinsd9iaidon}@klz-db:5432/${PAYLOAD_DB_NAME:-payload}
PAYLOAD_SECRET: ${PAYLOAD_SECRET:-fallback-secret-for-dev}
NODE_OPTIONS: "--max-old-space-size=8192"
UV_THREADPOOL_SIZE: "4"
UV_THREADPOOL_SIZE: "1"
RAYON_NUM_THREADS: "1"
NEXT_PRIVATE_WORKER_THREADS: "false"
NPM_TOKEN: ${NPM_TOKEN:-}
CI: "true"
QDRANT_URL: "http://klz-qdrant:6333"
REDIS_URL: "redis://klz-redis:6379"
MISTRAL_API_KEY: ${MISTRAL_API_KEY:-}
OPENROUTER_API_KEY: ${OPENROUTER_API_KEY:-}
volumes:
- .:/app
- klz_node_modules:/app/node_modules
@@ -42,19 +49,34 @@ services:
- /app/.git
- /app/reference
- /app/data
deploy:
resources:
limits:
cpus: '4'
memory: 8G
command: >
sh -c "pnpm install && pnpm next dev --webpack --hostname 0.0.0.0"
sh -c "pnpm install --no-frozen-lockfile &&
while true; do
(
echo '[warmup] Waiting for Next.js to be reachable...'
until curl -sf http://localhost:3000 > /dev/null; do sleep 2; done
echo '[warmup] Server is up! Pre-compiling routes...'
curl -sf http://localhost:3000/de > /dev/null 2>&1 && echo '[warmup] /de ready'
curl -sf http://localhost:3000/api/health/cms > /dev/null 2>&1 && echo '[warmup] /api/health/cms ready'
curl -sf -X POST -H 'Content-Type: application/json' -d '{\"messages\":[{\"role\":\"user\",\"content\":\"warmup\"}]}' http://localhost:3000/api/ai-search > /dev/null 2>&1 && echo '[warmup] /api/ai-search ready'
echo '[warmup] Syncing CMS data to Qdrant...'
SYNC_RESULT=$(curl -sf http://localhost:3000/api/sync-qdrant 2>&1)
echo \"[warmup] Qdrant sync: $SYNC_RESULT\"
echo '[warmup] All routes pre-compiled + Qdrant synced ✓'
) &
pnpm next dev --webpack --hostname 0.0.0.0;
echo '[klz-app] next dev exited, restarting in 2s...';
sleep 2;
done"
labels:
- "traefik.enable=true"
- "traefik.http.services.${PROJECT_NAME:-klz}-app-svc.loadbalancer.server.port=3000"
- "traefik.docker.network=infra"
- "caddy=http://${TRAEFIK_HOST:-klz.localhost}"
- "caddy.reverse_proxy={{upstreams 3000}}"
klz-db:
image: postgres:15-alpine
@@ -73,7 +95,37 @@ services:
networks:
- default
ports:
- "54324:5432"
- "54322:5432"
klz-redis:
image: redis:7-alpine
restart: unless-stopped
networks:
- default
ports:
- "16379:6379"
klz-qdrant:
image: qdrant/qdrant:v1.13.2
restart: unless-stopped
volumes:
- klz_qdrant_data:/qdrant/storage
networks:
- default
ports:
- "16333:6333"
klz-kabelfachmann:
image: registry.infra.mintel.me/mintel/kabelfachmann-mcp:latest
restart: unless-stopped
networks:
- default
env_file:
- ${ENV_FILE:-.env}
environment:
QDRANT_URL: http://klz-qdrant:6333
depends_on:
- klz-qdrant
networks:
default:
@@ -84,6 +136,8 @@ networks:
volumes:
klz_db_data:
external: false
klz_qdrant_data:
external: false
klz_node_modules:
klz_next_cache:
klz_turbo_cache:

View File

@@ -14,6 +14,7 @@ services:
PAYLOAD_SECRET: ${PAYLOAD_SECRET:-fallback-secret-for-production-needs-change}
volumes:
- klz_media_data:/app/public/media
- klz_datasheets:/app/public/datasheets
labels:
- "traefik.enable=true"
# HTTP ⇒ HTTPS redirect
@@ -29,8 +30,7 @@ services:
- "traefik.http.routers.${PROJECT_NAME:-klz}.middlewares=${AUTH_MIDDLEWARE:-klz-ratelimit,klz-forward,klz-compress}"
# Public Router paths that bypass Gatekeeper auth (health, SEO, static assets, OG images)
- "traefik.http.routers.${PROJECT_NAME:-klz}-public.rule=(${TRAEFIK_HOST_RULE:-Host(`${TRAEFIK_HOST:-klz-cables.com}`)}) && (PathPrefix(`/_next`) || PathPrefix(`/health`) || PathPrefix(`/api/health`) || PathPrefix(`/login`) || PathPrefix(`/gatekeeper`) || PathPrefix(`/uploads`) || PathPrefix(`/media`) || Path(`/robots.txt`) || Path(`/manifest.webmanifest`) || PathPrefix(`/sitemap`) || PathPrefix(`/api/og`) || PathPrefix(`/opengraph-image`) || PathRegexp(`^/([a-z]{2}/)?(health|login|gatekeeper|uploads|media|robots\\\\.txt|sitemap|api/og|api/health|opengraph-image)`))"
- "traefik.http.routers.${PROJECT_NAME:-klz}-public.rule=(${TRAEFIK_HOST_RULE:-Host(`${TRAEFIK_HOST:-klz-cables.com}`)}) && PathRegexp(`^/([a-z]{2}/)?(health|login|gatekeeper|uploads|media|robots\\.txt|manifest\\.webmanifest|sitemap(-[0-9]+)?\\.xml|(.*/)?api/og(/.*)?|(.*/)?opengraph-image.*)`)"
- "traefik.http.routers.${PROJECT_NAME:-klz}-public.entrypoints=${TRAEFIK_ENTRYPOINT:-web}"
- "traefik.http.routers.${PROJECT_NAME:-klz}-public.tls.certresolver=${TRAEFIK_CERT_RESOLVER:-}"
- "traefik.http.routers.${PROJECT_NAME:-klz}-public.tls=${TRAEFIK_TLS:-false}"
@@ -61,7 +61,7 @@ services:
klz-gatekeeper:
profiles: [ "gatekeeper" ]
image: git.infra.mintel.me/mmintel/gatekeeper:v1.9.18
image: registry.infra.mintel.me/mintel/gatekeeper:testing
restart: unless-stopped
networks:
infra:
@@ -101,6 +101,37 @@ 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
klz-kabelfachmann:
image: registry.infra.mintel.me/mintel/kabelfachmann-mcp:latest
restart: unless-stopped
networks:
- default
env_file:
- ${ENV_FILE:-.env}
environment:
QDRANT_URL: http://klz-qdrant:6333
depends_on:
- klz-qdrant
networks:
default:
name: ${PROJECT_NAME:-klz-cables}-internal
@@ -112,3 +143,7 @@ volumes:
external: false
klz_media_data:
external: false
klz_qdrant_data:
external: false
klz_datasheets:
external: false

View File

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

19157
kabelhandbuch.txt Normal file

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -136,60 +136,6 @@ export async function getPostBySlug(slug: string, locale: string): Promise<PostD
}
}
export async function getPostSlugs(slug: string, locale: string): Promise<Record<string, string>> {
try {
const payload = await getPayload({ config: configPromise });
// First, find the post in the current locale to get its ID
let { docs } = await payload.find({
collection: 'posts',
where: {
slug: { equals: slug },
...(!config.showDrafts ? { _status: { equals: 'published' } } : {}),
},
locale: locale as any,
draft: config.showDrafts,
limit: 1,
});
if (!docs || docs.length === 0) {
// Fallback: search across all locales
const { docs: crossLocaleDocs } = await payload.find({
collection: 'posts',
where: {
slug: { equals: slug },
...(!config.showDrafts ? { _status: { equals: 'published' } } : {}),
},
locale: 'all',
draft: config.showDrafts,
limit: 1,
});
docs = crossLocaleDocs;
}
if (!docs || docs.length === 0) return {};
const postId = docs[0].id;
// Fetch the post with locale 'all' to get all localized fields
const { docs: allLocalesDocs } = await payload.find({
collection: 'posts',
where: {
id: { equals: postId },
},
locale: 'all',
draft: config.showDrafts,
limit: 1,
});
if (!allLocalesDocs || allLocalesDocs.length === 0) return {};
return (allLocalesDocs[0].slug as unknown as Record<string, string>) || {};
} catch (error) {
console.error(`[Payload] getPostSlugs failed for ${slug}:`, error);
return {};
}
}
export async function getAllPosts(locale: string): Promise<PostData[]> {
try {
const payload = await getPayload({ config: configPromise });

View File

@@ -66,8 +66,8 @@ function createConfig() {
port: env.MAIL_PORT,
user: env.MAIL_USERNAME,
pass: env.MAIL_PASSWORD,
from: env.MAIL_FROM || 'KLZ Cables <postmaster@mg.mintel.me>',
recipients: env.MAIL_RECIPIENTS || 'info@klz-cables.com',
from: env.MAIL_FROM,
recipients: env.MAIL_RECIPIENTS,
},
infraCMS: {
url: env.INFRA_DIRECTUS_URL,

View File

@@ -7,7 +7,7 @@ import path from 'path';
*/
export function getDatasheetPath(slug: string, locale: string): string | null {
const datasheetsDir = path.join(process.cwd(), 'public', 'datasheets');
if (!fs.existsSync(datasheetsDir)) {
return null;
}
@@ -16,16 +16,21 @@ export function getDatasheetPath(slug: string, locale: string): string | null {
const normalizedSlug = slug.replace(/-hv$|-mv$/, '');
// Subdirectories to search in
const subdirs = ['', 'low-voltage', 'medium-voltage', 'high-voltage', 'solar'];
const subdirs = ['', 'products', 'low-voltage', 'medium-voltage', 'high-voltage', 'solar'];
// List of patterns to try for the current locale
// Also try with -mv and -hv suffixes since some product slugs omit the voltage class
const patterns = [
`${slug}-${locale}.pdf`,
`${slug}-2-${locale}.pdf`,
`${slug}-3-${locale}.pdf`,
`${slug}-mv-${locale}.pdf`,
`${slug}-hv-${locale}.pdf`,
`${normalizedSlug}-${locale}.pdf`,
`${normalizedSlug}-2-${locale}.pdf`,
`${normalizedSlug}-3-${locale}.pdf`,
`${normalizedSlug}-mv-${locale}.pdf`,
`${normalizedSlug}-hv-${locale}.pdf`,
];
for (const subdir of subdirs) {
@@ -44,9 +49,70 @@ export function getDatasheetPath(slug: string, locale: string): string | null {
`${slug}-en.pdf`,
`${slug}-2-en.pdf`,
`${slug}-3-en.pdf`,
`${slug}-mv-en.pdf`,
`${slug}-hv-en.pdf`,
`${normalizedSlug}-en.pdf`,
`${normalizedSlug}-2-en.pdf`,
`${normalizedSlug}-3-en.pdf`,
`${normalizedSlug}-mv-en.pdf`,
`${normalizedSlug}-hv-en.pdf`,
];
for (const subdir of subdirs) {
for (const pattern of enPatterns) {
const relativePath = path.join(subdir, pattern);
const filePath = path.join(datasheetsDir, relativePath);
if (fs.existsSync(filePath)) {
return `/datasheets/${relativePath}`;
}
}
}
}
return null;
}
/**
* Finds the datasheet Excel path for a given product slug and locale.
* Checks public/datasheets for matching .xlsx files.
*/
export function getExcelDatasheetPath(slug: string, locale: string): string | null {
const datasheetsDir = path.join(process.cwd(), 'public', 'datasheets');
if (!fs.existsSync(datasheetsDir)) {
return null;
}
const normalizedSlug = slug.replace(/-hv$|-mv$/, '');
const subdirs = ['', 'products', 'low-voltage', 'medium-voltage', 'high-voltage', 'solar'];
const patterns = [
`${slug}-${locale}.xlsx`,
`${slug}-2-${locale}.xlsx`,
`${slug}-3-${locale}.xlsx`,
`${normalizedSlug}-${locale}.xlsx`,
`${normalizedSlug}-2-${locale}.xlsx`,
`${normalizedSlug}-3-${locale}.xlsx`,
];
for (const subdir of subdirs) {
for (const pattern of patterns) {
const relativePath = path.join(subdir, pattern);
const filePath = path.join(datasheetsDir, relativePath);
if (fs.existsSync(filePath)) {
return `/datasheets/${relativePath}`;
}
}
}
// Fallback to English if locale is not 'en'
if (locale !== 'en') {
const enPatterns = [
`${slug}-en.xlsx`,
`${slug}-2-en.xlsx`,
`${slug}-3-en.xlsx`,
`${normalizedSlug}-en.xlsx`,
`${normalizedSlug}-2-en.xlsx`,
`${normalizedSlug}-3-en.xlsx`,
];
for (const subdir of subdirs) {
for (const pattern of enPatterns) {

View File

@@ -11,21 +11,10 @@ export async function getOgFonts() {
try {
console.log(`[OG] Loading fonts: bold=${boldFontPath}, regular=${regularFontPath}`);
const boldFontBuffer = readFileSync(boldFontPath);
const regularFontBuffer = readFileSync(regularFontPath);
// Satori (Vercel OG) strictly requires an ArrayBuffer, not a Node Buffer view.
const boldFont = boldFontBuffer.buffer.slice(
boldFontBuffer.byteOffset,
boldFontBuffer.byteOffset + boldFontBuffer.byteLength,
);
const regularFont = regularFontBuffer.buffer.slice(
regularFontBuffer.byteOffset,
regularFontBuffer.byteOffset + regularFontBuffer.byteLength,
);
const boldFont = readFileSync(boldFontPath);
const regularFont = readFileSync(regularFontPath);
console.log(
`[OG] Fonts loaded successfully (${boldFont.byteLength} and ${regularFont.byteLength} bytes)`,
`[OG] Fonts loaded successfully (${boldFont.length} and ${regularFont.length} bytes)`,
);
return [

View File

@@ -15,8 +15,6 @@ export interface PageFrontmatter {
export interface PageData {
slug: string;
redirectUrl?: string;
redirectPermanent?: boolean;
frontmatter: PageFrontmatter;
content: any; // Lexical AST Document
}
@@ -98,8 +96,6 @@ export async function getPageBySlug(slug: string, locale: string): Promise<PageD
return {
slug: doc.slug,
redirectUrl: doc.redirectUrl,
redirectPermanent: doc.redirectPermanent ?? true,
frontmatter: {
title: doc.title,
excerpt: doc.excerpt || '',

File diff suppressed because it is too large Load Diff

View File

@@ -1,390 +1,220 @@
import * as React from 'react';
import { Document, Page, View, Text, Image, StyleSheet } from '@react-pdf/renderer';
import { Document, Page, View, Text, Image, StyleSheet, Font } from '@react-pdf/renderer';
import type { DatasheetVoltageTable, KeyValueItem } from '../scripts/pdf/model/types';
// 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.
// Standard built-in fonts are used.
Font.registerHyphenationCallback((word) => [word]);
const C = {
navy: '#001a4d',
navyDeep: '#000d26',
green: '#4da612',
greenLight: '#e8f5d8',
white: '#FFFFFF',
offWhite: '#f8f9fa',
gray100: '#f3f4f6',
gray200: '#e5e7eb',
gray300: '#d1d5db',
gray400: '#9ca3af',
gray600: '#4b5563',
gray900: '#111827',
};
const MARGIN = 56;
// Industrial/technical/restrained design - STYLEGUIDE.md compliant
const styles = StyleSheet.create({
page: {
color: '#111827', // Text Primary
lineHeight: 1.5,
backgroundColor: '#FFFFFF',
paddingTop: 0,
paddingBottom: 100,
paddingHorizontal: MARGIN,
paddingBottom: 80,
paddingTop: 40,
fontFamily: 'Helvetica',
backgroundColor: C.white,
color: C.gray900,
},
// Hero-style header
hero: {
backgroundColor: '#FFFFFF',
paddingTop: 24,
paddingBottom: 0,
paddingHorizontal: 72,
marginBottom: 20,
position: 'relative',
borderBottomWidth: 0,
borderBottomColor: '#e5e7eb',
},
hero: { paddingBottom: 20, marginBottom: 10 },
header: {
flexDirection: 'row',
justifyContent: 'space-between',
alignItems: 'center',
marginBottom: 16,
},
logoText: {
fontSize: 24,
fontSize: 22,
fontWeight: 700,
color: '#000d26',
letterSpacing: 1,
textTransform: 'uppercase',
},
docTitle: {
fontSize: 10,
fontWeight: 700,
color: '#001a4d',
color: C.navyDeep,
letterSpacing: 2,
textTransform: 'uppercase',
},
productRow: {
flexDirection: 'row',
alignItems: 'center',
gap: 20,
},
productInfoCol: {
flex: 1,
justifyContent: 'center',
docTitle: {
fontSize: 8,
fontWeight: 700,
color: C.green,
letterSpacing: 2,
textTransform: 'uppercase',
},
productRow: { flexDirection: 'row', alignItems: 'center', gap: 20 },
productInfoCol: { flex: 1, justifyContent: 'center' },
productImageCol: {
flex: 1,
height: 120,
justifyContent: 'center',
alignItems: 'center',
borderRadius: 8,
borderRadius: 4,
borderWidth: 1,
borderColor: '#e5e7eb',
backgroundColor: '#FFFFFF',
borderColor: C.gray200,
backgroundColor: C.white,
overflow: 'hidden',
},
// Product Hero Info
productHero: {
marginTop: 0,
},
productName: {
fontSize: 24,
fontWeight: 700,
color: '#000d26',
marginBottom: 0,
color: C.navyDeep,
textTransform: 'uppercase',
letterSpacing: -0.5,
},
productMeta: {
fontSize: 10,
color: '#4b5563',
color: C.gray600,
fontWeight: 700,
textTransform: 'uppercase',
letterSpacing: 1,
marginBottom: 2,
},
heroImage: { width: '100%', height: '100%', objectFit: 'contain' },
noImage: { fontSize: 8, color: C.gray400, textAlign: 'center' },
heroImage: {
width: '100%',
height: '100%',
objectFit: 'contain',
},
noImage: {
fontSize: 8,
color: '#9ca3af',
textAlign: 'center',
},
// Content Area
content: {
paddingHorizontal: 72,
},
// Content sections
section: {
marginBottom: 20,
},
content: {},
section: { marginBottom: 20 },
sectionTitle: {
fontSize: 14,
fontSize: 8,
fontWeight: 700,
color: '#000d26', // Primary Dark
marginBottom: 8,
color: C.green,
marginBottom: 6,
textTransform: 'uppercase',
letterSpacing: -0.2,
letterSpacing: 1.5,
},
sectionAccent: {
width: 30,
height: 3,
backgroundColor: '#82ed20', // Accent Green
height: 2,
backgroundColor: C.green,
marginBottom: 8,
borderRadius: 1.5,
},
description: {
fontSize: 11,
lineHeight: 1.7,
color: '#4b5563', // Text Secondary
},
// Technical data table
specsTable: {
marginTop: 8,
border: '1px solid #e5e7eb',
borderRadius: 8,
overflow: 'hidden',
},
specsTableRow: {
flexDirection: 'row',
borderBottomWidth: 1,
borderBottomColor: '#e5e7eb',
},
specsTableRowLast: {
borderBottomWidth: 0,
},
specsTableLabelCell: {
flex: 1,
paddingVertical: 4,
paddingHorizontal: 16,
backgroundColor: '#f8f9fa',
borderRightWidth: 1,
borderRightColor: '#e5e7eb',
},
specsTableValueCell: {
flex: 1,
paddingVertical: 4,
paddingHorizontal: 16,
},
specsTableLabelText: {
fontSize: 9,
fontWeight: 700,
color: '#000d26',
textTransform: 'uppercase',
letterSpacing: 0.5,
},
specsTableValueText: {
fontSize: 10,
color: '#111827',
fontWeight: 500,
},
// Categories
categories: {
flexDirection: 'row',
flexWrap: 'wrap',
gap: 8,
borderRadius: 1,
},
description: { fontSize: 10, lineHeight: 1.7, color: C.gray600 },
categories: { flexDirection: 'row', flexWrap: 'wrap', gap: 6 },
categoryTag: {
backgroundColor: '#f8f9fa',
paddingHorizontal: 12,
paddingVertical: 6,
border: '1px solid #e5e7eb',
borderRadius: 100,
backgroundColor: C.offWhite,
paddingHorizontal: 10,
paddingVertical: 4,
borderWidth: 0.5,
borderColor: C.gray200,
borderRadius: 3,
},
categoryText: {
fontSize: 8,
color: '#4b5563',
fontSize: 7,
color: C.gray600,
fontWeight: 700,
textTransform: 'uppercase',
letterSpacing: 0.5,
},
// Footer
footer: {
position: 'absolute',
bottom: 40,
left: 72,
right: 72,
bottom: 28,
left: MARGIN,
right: MARGIN,
flexDirection: 'row',
justifyContent: 'space-between',
alignItems: 'center',
paddingTop: 24,
borderTop: '1px solid #e5e7eb',
paddingTop: 12,
borderTopWidth: 2,
borderTopColor: C.green,
},
footerText: {
fontSize: 8,
color: '#9ca3af',
fontWeight: 500,
fontSize: 7,
color: C.gray400,
fontWeight: 400,
textTransform: 'uppercase',
letterSpacing: 1,
letterSpacing: 0.8,
},
footerBrand: {
fontSize: 9,
fontWeight: 700,
color: C.navyDeep,
textTransform: 'uppercase',
letterSpacing: 1.5,
},
footerBrand: {
fontSize: 10,
fontWeight: 700,
color: '#000d26',
textTransform: 'uppercase',
letterSpacing: 1,
kvGrid: { width: '100%', borderWidth: 1, borderColor: C.gray200 },
kvRow: { flexDirection: 'row', borderBottomWidth: 1, borderBottomColor: C.gray200 },
kvRowAlt: { backgroundColor: C.offWhite },
kvRowLast: { borderBottomWidth: 0 },
kvCell: { paddingVertical: 6, paddingHorizontal: 8 },
kvMidDivider: { borderRightWidth: 1, borderRightColor: C.gray200 },
kvLabelText: { fontSize: 8.5, fontWeight: 700, color: C.gray600 },
kvValueText: { fontSize: 9.5, color: C.gray900 },
tableWrap: { width: '100%', borderWidth: 1, borderColor: C.gray200, marginBottom: 14 },
tableHeader: {
width: '100%',
flexDirection: 'row',
backgroundColor: C.white,
borderBottomWidth: 1,
borderBottomColor: C.gray200,
},
tableHeaderCell: {
paddingVertical: 5,
paddingHorizontal: 4,
fontSize: 6.6,
fontWeight: 700,
color: C.navyDeep,
},
tableHeaderCellCfg: { paddingHorizontal: 6 },
tableHeaderCellDivider: { borderRightWidth: 1, borderRightColor: C.gray200 },
tableRow: {
width: '100%',
flexDirection: 'row',
borderBottomWidth: 1,
borderBottomColor: C.gray200,
},
tableRowAlt: { backgroundColor: C.offWhite },
tableCell: { paddingVertical: 4, paddingHorizontal: 4, fontSize: 6.6, color: C.gray900 },
tableCellCfg: { paddingHorizontal: 6 },
tableCellDivider: { borderRightWidth: 1, borderRightColor: C.gray200 },
});
export interface ProductData {
interface ProductData {
id: number;
name: string;
shortDescriptionHtml: string;
descriptionHtml: string;
applicationHtml?: string;
images: string[];
featuredImage: string | null;
sku: string;
categories: Array<{ name: string }>;
attributes: Array<{
name: string;
options: string[];
}>;
categoriesLine?: string;
descriptionText?: string;
heroSrc?: string | null;
productUrl?: string;
shortDescriptionHtml?: string;
descriptionHtml?: string;
applicationHtml?: string;
images?: string[];
featuredImage?: string | null;
logoDataUrl?: string | null;
categories?: Array<{ name: string }>;
attributes?: Array<{ name: string; options: string[] }>;
}
interface ProductDatasheetPageProps {
export interface PDFDatasheetProps {
product: ProductData;
locale: 'en' | 'de';
logoDataUrl?: string | null;
technicalItems?: KeyValueItem[];
voltageTables?: DatasheetVoltageTable[];
legendItems?: KeyValueItem[];
}
export const ProductDatasheetPage: React.FC<ProductDatasheetPageProps> = ({
product,
locale,
}) => {
const labels = getLabels(locale);
const stripHtml = (html: string): string => html.replace(/<[^>]*>/g, '');
return (
<Page size="A4" style={styles.page}>
{/* Hero Header */}
<View style={styles.hero}>
<View style={styles.header}>
<View>
<Text style={styles.logoText}>KLZ</Text>
</View>
<Text style={styles.docTitle}>{labels.productDatasheet}</Text>
</View>
<View style={styles.productRow}>
<View style={styles.productInfoCol}>
<View style={styles.productHero}>
<View style={styles.categories}>
{product.categories.map((cat, index) => (
<Text key={index} style={styles.productMeta}>
{cat.name}
{index < product.categories.length - 1 ? ' • ' : ''}
</Text>
))}
</View>
<Text style={styles.productName}>{product.name}</Text>
</View>
</View>
<View style={styles.productImageCol}>
{product.featuredImage ? (
<Image src={product.featuredImage} style={styles.heroImage} />
) : (
<Text style={styles.noImage}>{labels.noImage}</Text>
)}
</View>
</View>
</View>
<View style={styles.content}>
{/* Description section */}
{(product.applicationHtml || product.shortDescriptionHtml || product.descriptionHtml) && (
<View style={styles.section}>
<Text style={styles.sectionTitle}>{labels.description}</Text>
<View style={styles.sectionAccent} />
<Text style={styles.description}>
{stripHtml(
product.applicationHtml || product.shortDescriptionHtml || product.descriptionHtml,
)}
</Text>
</View>
)}
{/* Technical specifications */}
{product.attributes && product.attributes.length > 0 && (
<View style={styles.section}>
<Text style={styles.sectionTitle}>{labels.specifications}</Text>
<View style={styles.sectionAccent} />
<View style={styles.specsTable}>
{product.attributes.map((attr, index) => (
<View
key={index}
style={[
styles.specsTableRow,
index === product.attributes.length - 1 && styles.specsTableRowLast,
]}
>
<View style={styles.specsTableLabelCell}>
<Text style={styles.specsTableLabelText}>{attr.name}</Text>
</View>
<View style={styles.specsTableValueCell}>
<Text style={styles.specsTableValueText}>{attr.options.join(', ')}</Text>
</View>
</View>
))}
</View>
</View>
)}
{/* Categories as clean tags */}
{product.categories && product.categories.length > 0 && (
<View style={styles.section}>
<Text style={styles.sectionTitle}>{labels.categories}</Text>
<View style={styles.sectionAccent} />
<View style={styles.categories}>
{product.categories.map((cat, index) => (
<View key={index} style={styles.categoryTag}>
<Text style={styles.categoryText}>{cat.name}</Text>
</View>
))}
</View>
</View>
)}
</View>
{/* Minimal footer */}
<View style={styles.footer} fixed>
<Text style={styles.footerBrand}>KLZ CABLES</Text>
<Text style={styles.footerText}>
{new Date().toLocaleDateString(locale === 'en' ? 'en-US' : 'de-DE', {
year: 'numeric',
month: 'long',
day: 'numeric',
})}
</Text>
</View>
</Page>
);
};
interface PDFDatasheetProps {
product: ProductData;
locale: 'en' | 'de';
logoUrl?: string;
}
// Helper to strip HTML tags
const stripHtml = (html: string): string => {
return html.replace(/<[^>]*>/g, '');
};
// Helper to get translated labels
const getLabels = (locale: 'en' | 'de') => {
const labels = {
const getLabels = (locale: 'en' | 'de') =>
({
en: {
productDatasheet: 'Technical Datasheet',
description: 'APPLICATION',
@@ -392,6 +222,9 @@ const getLabels = (locale: 'en' | 'de') => {
categories: 'CATEGORIES',
sku: 'SKU',
noImage: 'No image available',
crossSection: 'Configurations',
slug_cs: 'Cores & CS',
abbreviations: 'ABBREVIATIONS',
},
de: {
productDatasheet: 'Technisches Datenblatt',
@@ -400,16 +233,386 @@ const getLabels = (locale: 'en' | 'de') => {
categories: 'KATEGORIEN',
sku: 'ARTIKELNUMMER',
noImage: 'Kein Bild verfügbar',
crossSection: 'Konfigurationen',
slug_cs: 'Adern & QS',
abbreviations: 'ABKÜRZUNGEN',
},
};
return labels[locale];
};
})[locale];
function clamp(n: number, min: number, max: number) {
return Math.max(min, Math.min(max, n));
}
function normTextForMeasure(v: unknown) {
return String(v ?? '')
.replace(/\s+/g, ' ')
.trim();
}
function textLen(v: unknown) {
return normTextForMeasure(v).length;
}
function distributeWithMinMax(
weights: number[],
total: number,
minEach: number,
maxEach: number,
): number[] {
const n = weights.length;
if (!n) return [];
const mins = Array.from({ length: n }, () => minEach);
const maxs = Array.from({ length: n }, () => maxEach);
const minSum = mins.reduce((a, b) => a + b, 0);
if (minSum > total) return mins.map((m) => m * (total / minSum));
const result = mins.slice();
let remaining = total - minSum;
let remainingIdx = Array.from({ length: n }, (_, i) => i);
while (remaining > 1e-9 && remainingIdx.length) {
const wSum = remainingIdx.reduce((acc, i) => acc + Math.max(0, weights[i] || 0), 0);
if (wSum <= 1e-9) {
const even = remaining / remainingIdx.length;
for (const i of remainingIdx) result[i] += even;
remaining = 0;
break;
}
const nextIdx: number[] = [];
for (const i of remainingIdx) {
const w = Math.max(0, weights[i] || 0);
const add = (w / wSum) * remaining;
const capped = Math.min(result[i] + add, maxs[i]);
const used = capped - result[i];
result[i] = capped;
remaining -= used;
if (result[i] + 1e-9 < maxs[i]) nextIdx.push(i);
}
remainingIdx = nextIdx;
}
const sum = result.reduce((a, b) => a + b, 0);
const drift = total - sum;
if (Math.abs(drift) > 1e-9) result[result.length - 1] += drift;
return result;
}
function KeyValueGrid({ items }: { items: KeyValueItem[] }) {
const filtered = (items || []).filter((i) => i.label && i.value);
if (!filtered.length) return null;
const rows: Array<[KeyValueItem, KeyValueItem | null]> = [];
for (let i = 0; i < filtered.length; i += 2) rows.push([filtered[i], filtered[i + 1] || null]);
return (
<View style={styles.kvGrid}>
{rows.map(([left, right], rowIndex) => {
const isLast = rowIndex === rows.length - 1;
const leftValue = left.unit ? `${left.value} ${left.unit}` : left.value;
const rightValue = right ? (right.unit ? `${right.value} ${right.unit}` : right.value) : '';
return (
<View
key={`${left.label}-${rowIndex}`}
style={[
styles.kvRow,
rowIndex % 2 === 0 ? styles.kvRowAlt : null,
isLast ? styles.kvRowLast : null,
]}
wrap={false}
>
<View style={[styles.kvCell, { width: '23%' }]}>
<Text style={styles.kvLabelText}>{left.label}</Text>
</View>
<View style={[styles.kvCell, styles.kvMidDivider, { width: '27%' }]}>
<Text style={styles.kvValueText}>{leftValue}</Text>
</View>
<View style={[styles.kvCell, { width: '23%' }]}>
<Text style={styles.kvLabelText}>{right?.label || ''}</Text>
</View>
<View style={[styles.kvCell, { width: '27%' }]}>
<Text style={styles.kvValueText}>{rightValue}</Text>
</View>
</View>
);
})}
</View>
);
}
function DenseTable({
table,
firstColLabel,
}: {
table: Pick<DatasheetVoltageTable, 'columns' | 'rows'>;
firstColLabel: string;
}) {
const cols = table.columns;
const rows = table.rows;
const headerText = (label: string) =>
String(label || '')
.replace(/\s+/g, '\u00A0')
.trim();
const cfgMin = 0.14,
cfgMax = 0.23;
const cfgContentLen = Math.max(
textLen(firstColLabel),
...rows.map((r) => textLen(r.configuration)),
8,
);
const dataContentLens = cols.map((c, ci) => {
const headerL = textLen(c.label);
let cellMax = 0;
for (const r of rows) cellMax = Math.max(cellMax, textLen(r.cells[ci]));
return Math.max(headerL * 1.15, cellMax, 3);
});
const cfgWeight = cfgContentLen * 1.05;
const dataWeights = dataContentLens.map((l) => l);
const dataWeightSum = dataWeights.reduce((a, b) => a + b, 0);
const rawCfgPct = dataWeightSum > 0 ? cfgWeight / (cfgWeight + dataWeightSum) : 0.28;
let cfgPct = clamp(rawCfgPct, cfgMin, cfgMax);
const minDataPct =
cols.length >= 14 ? 0.045 : cols.length >= 12 ? 0.05 : cols.length >= 10 ? 0.055 : 0.06;
const cfgPctMaxForMinData = 1 - cols.length * minDataPct;
if (Number.isFinite(cfgPctMaxForMinData)) cfgPct = Math.min(cfgPct, cfgPctMaxForMinData);
cfgPct = clamp(cfgPct, cfgMin, cfgMax);
const dataTotal = Math.max(0, 1 - cfgPct);
const maxDataPct = Math.min(0.24, Math.max(minDataPct * 2.8, dataTotal * 0.55));
const dataPcts = distributeWithMinMax(dataWeights, dataTotal, minDataPct, maxDataPct);
const cfgW = `${(cfgPct * 100).toFixed(4)}%`;
const dataWs = dataPcts.map((p, idx) => {
if (idx === dataPcts.length - 1) {
const used = dataPcts.slice(0, -1).reduce((a, b) => a + b, 0);
const remainder = Math.max(0, dataTotal - used);
return `${(remainder * 100).toFixed(4)}%`;
}
return `${(p * 100).toFixed(4)}%`;
});
const headerFontSize =
cols.length >= 14 ? 5.7 : cols.length >= 12 ? 5.9 : cols.length >= 10 ? 6.2 : 6.6;
return (
<View style={styles.tableWrap} break={false}>
<View style={styles.tableHeader} wrap={false}>
<View style={{ width: cfgW }}>
<Text
style={[
styles.tableHeaderCell,
styles.tableHeaderCellCfg,
{ fontSize: headerFontSize, paddingHorizontal: 3 },
cols.length ? styles.tableHeaderCellDivider : null,
]}
wrap={false}
>
{headerText(firstColLabel)}
</Text>
</View>
{cols.map((c, idx) => {
const isLast = idx === cols.length - 1;
return (
<View key={c.key} style={{ width: dataWs[idx] }}>
<Text
style={[
styles.tableHeaderCell,
{ fontSize: headerFontSize, paddingHorizontal: 3 },
!isLast ? styles.tableHeaderCellDivider : null,
]}
wrap={false}
>
{headerText(c.label)}
</Text>
</View>
);
})}
</View>
{rows.map((r, ri) => (
<View
key={`${r.configuration}-${ri}`}
style={[styles.tableRow, ri % 2 === 0 ? styles.tableRowAlt : null]}
wrap={false}
minPresenceAhead={16}
>
<View style={{ width: cfgW }} wrap={false}>
<Text
style={[
styles.tableCell,
styles.tableCellCfg,
{ fontSize: 6.2, paddingHorizontal: 3 },
cols.length ? styles.tableCellDivider : null,
]}
wrap={false}
>
{r.configuration}
</Text>
</View>
{r.cells.map((cell, ci) => (
<View key={`${cols[ci]?.key || ci}`} style={{ width: dataWs[ci] }} wrap={false}>
<Text
style={[
styles.tableCell,
ci !== r.cells.length - 1 ? styles.tableCellDivider : null,
]}
wrap={false}
>
{cell}
</Text>
</View>
))}
</View>
))}
</View>
);
}
export const PDFDatasheet: React.FC<PDFDatasheetProps> = ({
product,
locale,
technicalItems = [],
voltageTables = [],
legendItems = [],
}) => {
const labels = getLabels(locale);
const description = stripHtml(
product.applicationHtml ||
product.shortDescriptionHtml ||
product.descriptionHtml ||
product.descriptionText ||
'',
);
export const PDFDatasheet: React.FC<PDFDatasheetProps> = ({ product, locale }) => {
return (
<Document>
<ProductDatasheetPage product={product} locale={locale} />
<Page size="A4" style={styles.page}>
<View style={styles.hero}>
<View style={styles.header}>
<View style={{ width: 80 }}>
{product.logoDataUrl || (product as any).logoDataUrl ? (
<Image
src={product.logoDataUrl || (product as any).logoDataUrl}
style={{ width: '100%', objectFit: 'contain' }}
/>
) : (
<Text style={styles.logoText}>KLZ</Text>
)}
</View>
<Text style={styles.docTitle}>{labels.productDatasheet}</Text>
</View>
<View style={styles.productRow}>
<View style={styles.productInfoCol}>
<Text style={styles.productMeta}>
{product.categoriesLine ||
(product.categories || []).map((c) => c.name).join(' • ')}
</Text>
<Text style={styles.productName}>{product.name}</Text>
</View>
<View style={styles.productImageCol}>
{product.featuredImage ? (
<Image src={product.featuredImage} style={styles.heroImage} />
) : (
<Text style={styles.noImage}>{labels.noImage}</Text>
)}
</View>
</View>
</View>
<View style={styles.content}>
{description && (
<View style={styles.section}>
<Text style={styles.sectionTitle}>{labels.description}</Text>
<View style={styles.sectionAccent} />
<Text style={styles.description}>{description}</Text>
</View>
)}
{technicalItems.length > 0 && (
<View style={styles.section}>
<Text style={styles.sectionTitle}>{labels.specifications}</Text>
<View style={styles.sectionAccent} />
<KeyValueGrid items={technicalItems} />
</View>
)}
{voltageTables.map((table, idx) => (
<View key={idx} style={styles.section} break={false}>
<Text
style={styles.sectionTitle}
>{`${labels.crossSection}${table.voltageLabel}`}</Text>
<View style={styles.sectionAccent} />
<DenseTable table={table} firstColLabel={labels.slug_cs} />
</View>
))}
{legendItems.length > 0 && (
<View style={styles.section} break={false}>
<Text style={styles.sectionTitle}>{labels.abbreviations}</Text>
<View style={styles.sectionAccent} />
<KeyValueGrid items={legendItems} />
</View>
)}
{!technicalItems.length &&
!voltageTables.length &&
product.attributes &&
product.attributes.length > 0 && (
<View style={styles.section}>
<Text style={styles.sectionTitle}>{labels.specifications}</Text>
<View style={styles.sectionAccent} />
<View style={{ borderWidth: 1, borderColor: C.gray200 }}>
{product.attributes.map((attr, index) => (
<View
key={index}
style={{
flexDirection: 'row',
borderBottomWidth: index === product.attributes!.length - 1 ? 0 : 1,
borderBottomColor: C.gray200,
backgroundColor: index % 2 === 0 ? C.offWhite : C.white,
}}
>
<View
style={{
flex: 1,
padding: 6,
borderRightWidth: 1,
borderRightColor: C.gray200,
}}
>
<Text style={{ fontSize: 8.5, fontWeight: 700, color: C.gray600 }}>
{attr.name}
</Text>
</View>
<View style={{ flex: 1, padding: 6 }}>
<Text style={{ fontSize: 9.5, color: C.gray900 }}>
{attr.options.join(', ')}
</Text>
</View>
</View>
))}
</View>
</View>
)}
</View>
<View style={styles.footer} fixed>
<View style={{ width: 60 }}>
{product.logoDataUrl || (product as any).logoDataUrl ? (
<Image
src={product.logoDataUrl || (product as any).logoDataUrl}
style={{ width: '100%', objectFit: 'contain' }}
/>
) : (
<Text style={styles.footerBrand}>KLZ CABLES</Text>
)}
</View>
<Text style={styles.footerText}>
{new Date().toLocaleDateString(locale === 'en' ? 'en-US' : 'de-DE', {
year: 'numeric',
month: 'long',
day: 'numeric',
})}
</Text>
</View>
</Page>
</Document>
);
};

View File

@@ -1,15 +1,21 @@
import React from 'react';
import * as React from 'react';
import { Document, Page, View, Text, StyleSheet, Font, Link } from '@react-pdf/renderer';
import { Document, Page, View, Text, StyleSheet, Link, Image } from '@react-pdf/renderer';
// Standard fonts like Helvetica are built-in to PDF and don't require registration
// unless we want to use specific TTF files. Using built-in Helvetica for maximum stability.
// 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 },
],
});
// ─── Brand Tokens (matching datasheet) ──────────────────────────────────
const C = {
navy: '#001a4d',
navyDeep: '#000d26',
accent: '#82ed20',
green: '#4da612',
greenLight: '#e8f5d8',
white: '#FFFFFF',
offWhite: '#f8f9fa',
gray100: '#f3f4f6',
@@ -20,72 +26,50 @@ const C = {
gray900: '#111827',
};
const MARGIN = 72;
const MARGIN = 56;
const styles = StyleSheet.create({
page: {
color: C.gray900,
lineHeight: 1.6,
lineHeight: 1.5,
backgroundColor: C.white,
paddingTop: 50,
paddingTop: 0,
paddingBottom: 80,
fontFamily: 'Helvetica',
},
// Premium Header Layout
// Hero-style header
hero: {
backgroundColor: C.offWhite,
backgroundColor: C.white,
paddingTop: 24,
paddingBottom: 24,
paddingBottom: 0,
paddingHorizontal: MARGIN,
marginBottom: 40,
marginTop: -50, // Counters the page padding to achieve full-bleed top
borderBottomWidth: 1,
borderBottomColor: C.gray200,
marginBottom: 20,
position: 'relative',
borderBottomWidth: 0,
},
headerTop: {
header: {
flexDirection: 'row',
justifyContent: 'space-between',
alignItems: 'center',
marginBottom: 32,
marginBottom: 16,
},
logo: {
width: 140, // Increased to fit full logo
height: 45,
objectFit: 'contain',
},
docType: {
fontSize: 9,
fontWeight: 700,
color: C.navy,
letterSpacing: 2,
textTransform: 'uppercase',
opacity: 0.6,
},
headerTitleArea: {
marginTop: 0,
},
pageTitle: {
fontSize: 16,
logoText: {
fontSize: 22,
fontWeight: 700,
color: C.navyDeep,
marginBottom: 4,
letterSpacing: 2,
textTransform: 'uppercase',
letterSpacing: 0,
},
accentBar: {
width: 40,
height: 4,
backgroundColor: C.accent,
marginTop: 12,
borderRadius: 2,
docTitle: {
fontSize: 8,
fontWeight: 700,
color: C.green,
letterSpacing: 2,
textTransform: 'uppercase',
},
// Content Area
@@ -93,129 +77,115 @@ const styles = StyleSheet.create({
paddingHorizontal: MARGIN,
},
// Lexical Elements with high-fidelity formatting
pageTitle: {
fontSize: 24,
fontWeight: 700,
color: C.navyDeep,
marginBottom: 8,
marginTop: 10,
textTransform: 'uppercase',
letterSpacing: -0.5,
},
accentBar: {
width: 30,
height: 2,
backgroundColor: C.green,
marginBottom: 20,
borderRadius: 1,
},
// Lexical Elements
paragraph: {
fontSize: 10,
color: C.gray600,
lineHeight: 1.7,
marginBottom: 14,
textAlign: 'justify',
},
heading1: {
fontSize: 14,
fontWeight: 700,
color: C.navyDeep,
textTransform: 'uppercase',
letterSpacing: 0.8,
marginTop: 0,
marginBottom: 0,
},
heading1Wrapper: {
marginTop: 24,
marginBottom: 12,
borderLeftWidth: 3,
borderLeftColor: C.accent,
paddingLeft: 12,
},
heading2: {
fontSize: 11,
heading1: {
fontSize: 16,
fontWeight: 700,
color: C.navyDeep,
marginTop: 18,
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: 14,
marginBottom: 8,
},
list: {
marginBottom: 16,
marginLeft: 4,
},
listItem: {
flexDirection: 'row',
marginTop: 12,
marginBottom: 6,
},
list: {
marginBottom: 12,
marginLeft: 8,
},
listItem: {
flexDirection: 'row',
marginBottom: 4,
},
listItemBullet: {
width: 20,
width: 12,
fontSize: 10,
color: C.gray400,
color: C.green,
fontWeight: 700,
},
listItemContent: {
flex: 1,
fontSize: 10,
color: C.gray600,
lineHeight: 1.7,
textAlign: 'justify',
},
link: {
color: C.navy,
textDecoration: 'underline',
color: C.green,
textDecoration: 'none',
},
textBold: {
fontWeight: 700,
fontFamily: 'Helvetica-Bold',
color: C.navyDeep,
},
textItalic: {
fontStyle: 'italic',
},
// Industrial Footer
// Footer — matches brochure style
footer: {
position: 'absolute',
bottom: 30,
bottom: 28,
left: MARGIN,
right: MARGIN,
flexDirection: 'row',
justifyContent: 'space-between',
alignItems: 'flex-end',
paddingTop: 16,
borderTopWidth: 0.5,
borderTopColor: C.gray200,
},
footerInfo: {
flexDirection: 'column',
},
footerBrand: {
fontSize: 10,
fontWeight: 700,
color: C.navyDeep,
textTransform: 'uppercase',
letterSpacing: 1,
marginBottom: 4,
alignItems: 'center',
paddingTop: 12,
borderTopWidth: 2,
borderTopColor: C.green,
},
footerText: {
fontSize: 7,
color: C.gray400,
fontWeight: 500,
fontWeight: 400,
textTransform: 'uppercase',
letterSpacing: 0.5,
letterSpacing: 0.8,
},
pageNum: {
fontSize: 8,
color: C.gray400,
footerBrand: {
fontSize: 9,
fontWeight: 700,
color: C.navyDeep,
textTransform: 'uppercase',
letterSpacing: 1.5,
},
});
@@ -242,49 +212,32 @@ const renderLexicalNode = (node: any, idx: number): React.ReactNode => {
}
case 'paragraph': {
if (!node.children || node.children.length === 0) return null;
return (
<Text key={idx} style={styles.paragraph} minPresenceAhead={15}>
<Text key={idx} style={styles.paragraph}>
{node.children?.map((child: any, i: number) => renderLexicalNode(child, i))}
</Text>
);
}
case 'heading': {
if (!node.children || node.children.length === 0) return null;
let hStyle = styles.heading3;
let isH1 = false;
if (node.tag === 'h1') {
hStyle = styles.heading1;
isH1 = true;
}
if (node.tag === 'h1') hStyle = styles.heading1;
if (node.tag === 'h2') hStyle = styles.heading2;
const content = (
<Text key={idx} style={hStyle} wrap={false} minPresenceAhead={100}>
return (
<Text key={idx} style={hStyle}>
{node.children?.map((child: any, i: number) => renderLexicalNode(child, i))}
</Text>
);
if (isH1) {
return (
<View key={idx} style={styles.heading1Wrapper} wrap={false} minPresenceAhead={100}>
{content}
</View>
);
}
return content;
}
case 'list': {
if (!node.children || node.children.length === 0) return null;
return (
<View key={idx} style={styles.list}>
{node.children?.map((child: any, i: number) => {
if (child.type === 'listitem') {
return (
<View key={i} style={styles.listItem} wrap={false}>
<View key={i} style={styles.listItem}>
<Text style={styles.listItemBullet}>
{node.listType === 'number' ? `${i + 1}.` : '•'}
</Text>
@@ -341,24 +294,23 @@ export const PDFPage: React.FC<PDFPageProps> = ({ page, locale = 'de' }) => {
day: 'numeric',
});
const logoPath = `${process.cwd()}/public/logo-full.png`;
return (
<Document>
<Page size="A4" style={styles.page}>
{/* Improved Hero Header - No longer fixed so it doesn't repeat on all pages */}
<View style={styles.hero}>
<View style={styles.headerTop}>
<Image src={logoPath} style={styles.logo} />
</View>
<View style={styles.headerTitleArea}>
<Text style={styles.pageTitle}>{page.title}</Text>
<View style={styles.accentBar} />
{/* 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>
<View style={styles.content}>
<Text style={styles.pageTitle}>{page.title}</Text>
<View style={styles.accentBar} />
<View>
{page.content?.root?.children?.map((node: any, i: number) =>
renderLexicalNode(node, i),
@@ -366,18 +318,10 @@ export const PDFPage: React.FC<PDFPageProps> = ({ page, locale = 'de' }) => {
</View>
</View>
{/* Industrial footer with page numbers */}
<View style={{ ...styles.footer, position: 'absolute', bottom: 40, height: 40 }} fixed>
<View style={styles.footerInfo}>
<Text style={styles.footerBrand}>KLZ VERTRIEBS GMBH</Text>
<Text style={styles.footerText}>
RAIFFEISENSTRASSE 22 73630 REMSHALDEN {dateStr}
</Text>
</View>
<Text
style={styles.pageNum}
render={({ pageNumber, totalPages }) => `${pageNumber} / ${totalPages}`}
/>
{/* Minimal footer */}
<View style={styles.footer} fixed>
<Text style={styles.footerBrand}>KLZ CABLES</Text>
<Text style={styles.footerText}>{dateStr}</Text>
</View>
</Page>
</Document>

104
lib/utils/technical.ts Normal file
View File

@@ -0,0 +1,104 @@
/**
* Utility for formatting technical data values.
* Handles long lists of standards and simplifies repetitive strings.
*/
export interface FormattedTechnicalValue {
original: string;
isList: boolean;
parts: string[];
displayValue: string;
}
/**
* Formats a technical value string.
* Detects if it's a list (separated by / or ,) and tries to clean it up.
*/
export function formatTechnicalValue(value: string | null | undefined): FormattedTechnicalValue {
if (!value) {
return { original: '', isList: false, parts: [], displayValue: '' };
}
const str = String(value).trim();
// Detect list separators
let parts: string[] = [];
if (str.includes(' / ')) {
parts = str.split(' / ').map((p) => p.trim());
} else if (str.includes(' /')) {
parts = str.split(' /').map((p) => p.trim());
} else if (str.includes('/ ')) {
parts = str.split('/ ').map((p) => p.trim());
} else if (str.split('/').length > 2) {
// Check if it's actually many standards separated by / without spaces
// e.g. EN123/EN456/EN789
const split = str.split('/');
if (split.length > 3) {
parts = split.map((p) => p.trim());
}
}
// If no parts found yet, try comma
if (parts.length === 0 && str.includes(', ')) {
parts = str.split(', ').map((p) => p.trim());
}
// Filter out empty parts
parts = parts.filter(Boolean);
// If we have parts, let's see if we can simplify them
if (parts.length > 2) {
// Find common prefix to condense repetitive standards
let commonPrefix = '';
const first = parts[0];
const last = parts[parts.length - 1];
let i = 0;
while (i < first.length && first.charAt(i) === last.charAt(i)) {
i++;
}
commonPrefix = first.substring(0, i);
// If a meaningful prefix exists (e.g., "EN 60 332-1-")
if (commonPrefix.length > 4) {
const suffixParts: string[] = [];
for (let idx = 0; idx < parts.length; idx++) {
if (idx === 0) {
suffixParts.push(parts[idx]);
} else {
const suffix = parts[idx].substring(commonPrefix.length).trim();
if (suffix) {
suffixParts.push(suffix);
}
}
}
// Condense into a single string like "EN 60 332-1-2 / -3 / -4"
// Wait, returning a single string might still wrap badly.
// Instead, we return them as chunks or just a condensed string.
const condensedString = suffixParts[0] + ' / -' + suffixParts.slice(1).join(' / -');
return {
original: str,
isList: false, // Turn off badge rendering to use text block instead
parts: [condensedString],
displayValue: condensedString,
};
}
// If no common prefix, return as list so UI can render badges
return {
original: str,
isList: true,
parts,
displayValue: parts.join(', '),
};
}
return {
original: str,
isList: false,
parts: [str],
displayValue: str,
};
}

BIN
lychee Executable file

Binary file not shown.

View File

@@ -3,7 +3,7 @@
"pages": {
"impressum": "impressum",
"datenschutz": "datenschutz",
"agbs": "terms",
"agbs": "agbs",
"kontakt": "contact",
"team": "team",
"blog": "blog",
@@ -73,8 +73,8 @@
"legalNoticeSlug": "impressum",
"privacyPolicy": "Datenschutz",
"privacyPolicySlug": "datenschutz",
"terms": "AVB",
"termsSlug": "terms",
"terms": "AGB",
"termsSlug": "agbs",
"products": "Produkte",
"lowVoltage": "Niederspannungskabel",
"mediumVoltage": "Mittelspannungskabel",
@@ -226,6 +226,10 @@
"requestQuoteDesc": "Erhalten Sie technische Spezifikationen und Preise für Ihr Projekt.",
"downloadDatasheet": "Datenblatt herunterladen",
"downloadDatasheetDesc": "Erhalten Sie die vollständigen technischen Spezifikationen als PDF.",
"downloadExcel": "Excel herunterladen",
"downloadExcelDesc": "Erhalten Sie die technischen Daten als editierbare Tabelle.",
"downloadBrochure": "Produktbroschüre",
"downloadBrochureDesc": "Laden Sie unseren kompletten Produktkatalog mit allen technischen Spezifikationen herunter.",
"form": {
"contactInfo": "Kontaktinformationen",
"projectDetails": "Projektdetails",
@@ -395,5 +399,21 @@
"description": "Es scheint, als wäre das Kabel zu dieser Seite unterbrochen worden. Wir konnten die gesuchte Ressource nicht finden.",
"cta": "Zurück zur Sicherheit"
}
},
"Brochure": {
"title": "Produktkatalog",
"subtitle": "Erhalten Sie unsere komplette Produktbroschüre mit allen technischen Spezifikationen und Kabellösungen.",
"emailPlaceholder": "ihre@email.de",
"emailLabel": "E-Mail-Adresse",
"submit": "Broschüre erhalten",
"submitting": "Wird gesendet...",
"successTitle": "Ihre Broschüre ist bereit!",
"successDesc": "Vielen Dank für Ihr Interesse. Klicken Sie unten, um den kompletten KLZ-Produktkatalog herunterzuladen.",
"download": "Broschüre herunterladen",
"privacyNote": "Mit dem Absenden erklären Sie sich mit unserer Datenschutzerklärung einverstanden.",
"close": "Schließen",
"ctaTitle": "Kompletter Produktkatalog",
"ctaDesc": "Alle Datenblätter in einem Premium-PDF — technische Spezifikationen, Kabellösungen & mehr.",
"ctaButton": "Kostenlose Broschüre erhalten"
}
}

View File

@@ -3,7 +3,7 @@
"pages": {
"legal-notice": "impressum",
"privacy-policy": "datenschutz",
"terms": "terms",
"terms": "agbs",
"contact": "contact",
"team": "team",
"blog": "blog",
@@ -226,6 +226,10 @@
"requestQuoteDesc": "Get technical specifications and pricing for your project.",
"downloadDatasheet": "Download Datasheet",
"downloadDatasheetDesc": "Get the full technical specifications in PDF format.",
"downloadExcel": "Download Excel",
"downloadExcelDesc": "Get the technical data as editable spreadsheet.",
"downloadBrochure": "Product Brochure",
"downloadBrochureDesc": "Download our complete product catalog with all technical specifications.",
"form": {
"contactInfo": "Contact Information",
"projectDetails": "Project Details",
@@ -395,5 +399,21 @@
"description": "It seems the cable to this page has been disconnected. We couldn't find the resource you were looking for.",
"cta": "Back to Safety"
}
},
"Brochure": {
"title": "Product Catalog",
"subtitle": "Get our complete product brochure with all technical specifications and cable solutions.",
"emailPlaceholder": "your@email.com",
"emailLabel": "Email Address",
"submit": "Get Brochure",
"submitting": "Sending...",
"successTitle": "Your brochure is ready!",
"successDesc": "Thank you for your interest. Click below to download the complete KLZ product catalog.",
"download": "Download Brochure",
"privacyNote": "By submitting you agree to our privacy policy.",
"close": "Close",
"ctaTitle": "Complete Product Catalog",
"ctaDesc": "All datasheets in one premium PDF — technical specifications, cable solutions & more.",
"ctaButton": "Get Free Brochure"
}
}

View File

@@ -14,12 +14,8 @@ export default async function middleware(request: NextRequest) {
const { method, url, headers } = request;
const { pathname } = request.nextUrl;
// Explicit bypass for infrastructure, Payload CMS, and Server Actions
// Next-Action header is present for all Next.js Server Actions
const isServerAction = headers.has('next-action');
// Explicit bypass for infrastructure and Payload CMS routes to avoid locale redirects/interception
if (
isServerAction ||
pathname.startsWith('/admin') ||
pathname.startsWith('/api') ||
pathname.startsWith('/stats') ||
@@ -55,23 +51,31 @@ export default async function middleware(request: NextRequest) {
const hostHeader = headers.get('x-forwarded-host') || headers.get('host') || fallbackHost;
urlObj.protocol = proto;
// Only create a new request for GET/HEAD to avoid consuming the body stream on POST/PUT
// This resolves the "failed to pipe response" error (160k occurrences in GlitchTip)
if (['GET', 'HEAD'].includes(request.method)) {
// Clone headers to ensure all Next.js internal headers (like router state) are preserved
const clonedHeaders = new Headers(request.headers);
// Do NOT reconstruct NextRequest for GET/HEAD requests to avoid stream locking/piping errors.
// next-intl middleware primarily uses the URL and headers, so a simplified request-like object
// or a redirected URL is often enough. For robustness, we only clone if we really have to.
if (method === 'GET' || method === 'HEAD') {
// In Next.js 15+, we can clone headers but avoid re-passing the body stream.
effectiveRequest = new NextRequest(urlObj, {
headers: clonedHeaders,
headers: new Headers(request.headers),
method: request.method,
// No body for GET/HEAD
});
} else {
// For POST/PUT etc, we must be careful. Re-passing the body can only be done once.
// If we don't need to fix the URL for the body-reading part, we can skip it.
// But usually intl-middleware doesn't care about the body.
effectiveRequest = new NextRequest(urlObj, {
headers: new Headers(request.headers),
method: request.method,
body: request.body,
});
}
if (process.env.NODE_ENV !== 'production' || !process.env.CI) {
console.log(
`🛡️ Proxy: Fixed internal URL leak: ${url} -> ${urlObj.toString()} | Proto: ${proto} | Host: ${hostHeader}`,
);
}
if (process.env.NODE_ENV !== 'production' || !process.env.CI) {
console.log(
`🛡️ Proxy: Fixed internal URL leak: ${url} -> ${urlObj.toString()} | Proto: ${proto} | Host: ${hostHeader}`,
);
}
}
@@ -112,7 +116,7 @@ export default async function middleware(request: NextRequest) {
export const config = {
matcher: [
'/((?!api|_next/static|_next/image|favicon.ico|admin|manifest.webmanifest|.*\\.(?:svg|png|jpg|jpeg|gif|webp|pdf|txt|vcf|xml|webm|mp4|map)$).*)',
'/((?!api|_next/static|_next/image|favicon.ico|admin|manifest.webmanifest|.*\\.(?:svg|png|jpg|jpeg|gif|webp|pdf|xlsx|txt|vcf|xml|webm|mp4|map)$).*)',
'/(de|en)/:path*',
],
};

View File

@@ -1 +0,0 @@
Logging into staging...

2
next-env.d.ts vendored
View File

@@ -1,6 +1,6 @@
/// <reference types="next" />
/// <reference types="next/image-types/global" />
import "./.next/dev/types/routes.d.ts";
import "./.next/types/routes.d.ts";
// NOTE: This file should not be edited
// see https://nextjs.org/docs/app/api-reference/config/typescript for more information.

View File

@@ -7,21 +7,29 @@ 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', '@mintel/payload-ai'],
onDemandEntries: {
// Make sure entries are not disposed too quickly
maxInactiveAge: 60 * 1000,
// Keep compiled pages/routes in memory for 5 minutes (reduced from 25m to prevent OOM)
maxInactiveAge: 5 * 60 * 1000,
// Keep up to 2 pages in the dev buffer (reduced from 10 to prevent OOM)
pagesBufferLength: 2,
},
experimental: {
staleTimes: {
dynamic: 0,
static: 30,
},
optimizePackageImports: ['lucide-react', 'framer-motion', '@/components/ui'],
cpus: 3,
optimizePackageImports: [
'lucide-react',
'framer-motion',
'@/components/ui',
'@sentry/nextjs',
'@payloadcms/richtext-lexical',
'react-hook-form',
'zod',
'date-fns',
],
workerThreads: false,
serverActions: {
allowedOrigins: ["*.klz-cables.com", "*.branch.klz-cables.com", "localhost:3000", "klz.localhost"],
},
memoryBasedWorkersCount: true,
},
serverActions: {
allowedOrigins: ["*.klz-cables.com", "*.branch.klz-cables.com", "localhost:3000", "klz.localhost"],
},
reactStrictMode: false,
productionBrowserSourceMaps: false,
@@ -31,6 +39,22 @@ 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;
},
// Rewrites moved to bottom merged function
async headers() {
const isProd = process.env.NODE_ENV === 'production';
const umamiDomain = new URL(process.env.UMAMI_API_ENDPOINT || 'https://analytics.infra.mintel.me').origin;
@@ -53,7 +77,7 @@ const nextConfig = {
style-src 'self' 'unsafe-inline' https://fonts.googleapis.com;
font-src 'self' https://fonts.gstatic.com;
img-src 'self' data: blob: ${extraImgDomains};
connect-src 'self' ${umamiDomain} ${glitchtipDomain};
connect-src 'self' ${umamiDomain} ${glitchtipDomain} https://raw.githack.com https://raw.githubusercontent.com;
frame-src 'self';
object-src 'none';
base-uri 'self';
@@ -85,7 +109,7 @@ const nextConfig = {
},
{
key: 'Strict-Transport-Security',
value: 'max-age=63072000; includeSubDomains; preload',
value: isProd ? 'max-age=63072000; includeSubDomains; preload' : 'max-age=0',
},
];
@@ -399,6 +423,7 @@ const nextConfig = {
];
},
images: {
qualities: [25, 50, 75, 100],
formats: ['image/webp'],
deviceSizes: [640, 750, 828, 1080, 1200, 1920, 2048, 3840],
remotePatterns: [
@@ -431,6 +456,22 @@ const nextConfig = {
async rewrites() {
return {
beforeFiles: [
{
source: '/:locale/datasheets/:path*',
destination: '/api/datasheets/:path*',
},
{
source: '/:locale/brochures/:path*',
destination: '/api/brochures/:path*',
},
{
source: '/datasheets/:path*',
destination: '/api/datasheets/:path*',
},
{
source: '/brochures/:path*',
destination: '/api/brochures/:path*',
},
{
source: '/de/produkte',
destination: '/de/products',
@@ -464,6 +505,10 @@ const nextConfig = {
source: '/en/datenschutz',
destination: '/en/privacy-policy',
},
{
source: '/en/agbs',
destination: '/en/terms',
},
],
afterFiles: [],
fallback: [],

File diff suppressed because one or more lines are too long

View File

@@ -4,19 +4,26 @@
"private": true,
"packageManager": "pnpm@10.18.3",
"dependencies": {
"@mintel/mail": "1.9.18",
"@mintel/next-config": "1.9.18",
"@mintel/next-feedback": "1.9.18",
"@mintel/next-utils": "1.9.18",
"@ai-sdk/google": "^3.0.31",
"@ai-sdk/openai": "^3.0.36",
"@mintel/mail": "1.9.11",
"@mintel/next-config": "1.9.11",
"@mintel/next-feedback": "1.9.11",
"@mintel/next-utils": "1.9.11",
"@mintel/payload-ai": "1.9.18",
"@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.8",
"@react-pdf/renderer": "^4.3.2",
"@react-three/drei": "^10.7.7",
"@react-three/fiber": "^9.5.0",
"@sentry/nextjs": "^10.39.0",
"@types/recharts": "^2.0.1",
"ai": "^6.0.101",
"axios": "^1.13.5",
"clsx": "^2.1.1",
"framer-motion": "^12.34.0",
@@ -24,6 +31,7 @@
"gray-matter": "^4.0.3",
"i18next": "^25.7.3",
"import-in-the-middle": "^1.11.0",
"ioredis": "^5.9.3",
"jsdom": "^27.4.0",
"leaflet": "^1.9.4",
"next": "16.1.6",
@@ -38,13 +46,17 @@
"react-dom": "^19.2.4",
"react-email": "^5.2.5",
"react-leaflet": "^4.2.1",
"react-markdown": "^10.1.0",
"recharts": "^3.7.0",
"rehype-raw": "^7.0.0",
"remark-gfm": "^4.0.1",
"require-in-the-middle": "^8.0.1",
"resend": "^3.5.0",
"schema-dts": "^1.1.5",
"sharp": "^0.34.5",
"svg-to-pdfkit": "^0.1.8",
"tailwind-merge": "^3.4.0",
"three": "^0.183.1",
"xlsx": "npm:@e965/xlsx@^0.20.3",
"zod": "3.25.76"
},
@@ -53,8 +65,8 @@
"@commitlint/config-conventional": "^20.4.0",
"@cspell/dict-de-de": "^4.1.2",
"@lhci/cli": "^0.15.1",
"@mintel/eslint-config": "1.9.18",
"@mintel/tsconfig": "1.9.18",
"@mintel/eslint-config": "^1.9.11",
"@mintel/tsconfig": "^1.9.11",
"@next/bundle-analyzer": "^16.1.6",
"@tailwindcss/cli": "^4.1.18",
"@tailwindcss/postcss": "^4.1.18",
@@ -80,6 +92,7 @@
"lint-staged": "^16.2.7",
"lucide-react": "^0.563.0",
"pa11y-ci": "^4.0.1",
"pdf-parse": "^2.4.5",
"postcss": "^8.5.6",
"prettier": "^3.8.1",
"puppeteer": "^24.37.3",
@@ -89,11 +102,12 @@
"tsx": "^4.21.0",
"turbo": "^2.8.10",
"typescript": "^5.7.2",
"vitest": "^4.0.16"
"vitest": "^4.0.16",
"xlsx-cli": "^1.1.3"
},
"scripts": {
"dev": "bash -c 'trap \"COMPOSE_PROJECT_NAME=klz-2026 docker-compose -f docker-compose.dev.yml down\" EXIT INT TERM; docker network create infra 2>/dev/null || true && COMPOSE_PROJECT_NAME=klz-2026 docker-compose -f docker-compose.dev.yml down && COMPOSE_PROJECT_NAME=klz-2026 docker-compose -f docker-compose.dev.yml up klz-app klz-db --remove-orphans'",
"dev:local": "bash -c 'trap \"COMPOSE_PROJECT_NAME=klz-2026 docker-compose -f docker-compose.dev.yml down\" EXIT INT TERM; COMPOSE_PROJECT_NAME=klz-2026 docker-compose -f docker-compose.dev.yml up -d klz-db klz-proxy && POSTGRES_URI=NODE_ENV=development next dev --webpack --port 3100 --hostname 0.0.0.0'",
"dev": "bash -c '[ -f .env ] || (cp .env.example .env && sed -i.bak \"s/TRAEFIK_HOST=klz-cables.com/TRAEFIK_HOST=klz.localhost/\" .env && rm -f .env.bak && echo \"✅ Created .env from .env.example\"); trap \"COMPOSE_PROJECT_NAME=klz-2026 docker-compose -f docker-compose.dev.yml down\" EXIT INT TERM; docker network create infra 2>/dev/null || true && COMPOSE_PROJECT_NAME=klz-2026 docker-compose -f docker-compose.dev.yml down && COMPOSE_PROJECT_NAME=klz-2026 docker-compose -f docker-compose.dev.yml up klz-app klz-db klz-proxy klz-qdrant klz-redis --remove-orphans'",
"dev:local": "bash -c 'trap \"COMPOSE_PROJECT_NAME=klz-2026 docker-compose -f docker-compose.dev.yml down\" EXIT INT TERM; COMPOSE_PROJECT_NAME=klz-2026 docker-compose -f docker-compose.dev.yml up -d klz-db klz-proxy klz-qdrant klz-redis && POSTGRES_URI=NODE_ENV=development next dev --webpack --port 3100 --hostname 0.0.0.0'",
"dev:infra": "COMPOSE_PROJECT_NAME=klz-2026 docker-compose -f docker-compose.dev.yml up -d klz-db klz-proxy",
"build": "next build",
"start": "next start",
@@ -101,6 +115,7 @@
"typecheck": "tsc --noEmit",
"test": "vitest run --passWithNoTests",
"test:og": "vitest run tests/og-image.test.ts",
"test:e2e": "vitest run tests/*.e2e.test.ts",
"check:og": "tsx scripts/check-og-images.ts",
"check:a11y": "pa11y-ci",
"check:wcag": "tsx ./scripts/wcag-sitemap.ts",
@@ -113,9 +128,11 @@
"check:assets": "tsx ./scripts/check-broken-assets.ts",
"check:forms": "tsx ./scripts/check-forms.ts",
"check:apis": "tsx ./scripts/check-apis.ts",
"pdf:datasheets": "tsx ./scripts/generate-pdf-datasheets.ts",
"pdf:datasheets": "tsx ./scripts/generate-pdf-datasheets.tsx",
"pdf:datasheets:legacy": "tsx ./scripts/generate-pdf-datasheets-pdf-lib.ts",
"cms:migrate": "payload migrate",
"excel:datasheets": "tsx ./scripts/generate-excel-datasheets.ts",
"brochure:generate": "tsx ./scripts/generate-brochure.ts",
"cms:migrate": "tsx ./node_modules/payload/bin.js migrate",
"cms:seed": "tsx ./scripts/seed-payload.ts",
"assets:push:testing": "bash ./scripts/assets-sync.sh local testing",
"assets:push:staging": "bash ./scripts/assets-sync.sh local staging",
@@ -125,6 +142,10 @@
"assets:pull:prod": "bash ./scripts/assets-sync.sh prod local",
"assets:sync:testing-to-staging": "bash ./scripts/assets-sync.sh testing staging",
"assets:sync:staging-to-prod": "bash ./scripts/assets-sync.sh staging prod",
"qdrant:push:testing": "bash ./scripts/qdrant-sync.sh testing",
"qdrant:push:staging": "bash ./scripts/qdrant-sync.sh staging",
"qdrant:push:prod": "bash ./scripts/qdrant-sync.sh prod",
"qdrant:push:branch": "bash ./scripts/qdrant-sync.sh",
"pagespeed:test": "tsx ./scripts/pagespeed-sitemap.ts",
"pagespeed:audit": "./scripts/audit-local.sh",
"pagespeed:urls": "tsx -e \"import sitemap from './app/sitemap'; sitemap().then(urls => console.log(urls.map(u => u.url).join('\\n')))\"",
@@ -139,7 +160,7 @@
"prepare": "husky",
"preinstall": "npx only-allow pnpm"
},
"version": "2.3.21",
"version": "2.3.12",
"pnpm": {
"onlyBuiltDependencies": [
"@parcel/watcher",
@@ -150,7 +171,12 @@
],
"overrides": {
"next": "16.1.6",
"minimatch": ">=10.2.2"
"minimatch": ">=10.2.2",
"@mintel/content-engine": "1.9.11",
"@mintel/thumbnail-generator": "1.9.11"
},
"patchedDependencies": {
"@mintel/payload-ai@1.9.18": "patches/@mintel__payload-ai@1.9.18.patch"
}
},
"browserslist": [

View File

@@ -0,0 +1,28 @@
diff --git a/dist/endpoints/chatEndpoint.js b/dist/endpoints/chatEndpoint.js
index 1f1b2327d8d5d3c57b29fdfaf70bec3a537782c7..be8df4bae39e470f557985bb154be15492f22263 100644
--- a/dist/endpoints/chatEndpoint.js
+++ b/dist/endpoints/chatEndpoint.js
@@ -97,7 +97,7 @@ export const handleMcpChat = async (req) => {
: "";
try {
const result = streamText({
- model: openrouter("google/gemini-3.0-flash"),
+ model: openrouter("google/gemini-3.1-flash-lite-preview"),
messages,
tools: activeTools,
// @ts-expect-error - AI SDK type mismatch with maxSteps
diff --git a/package.json b/package.json
index df232b30d7edbca2ebad6d6c08592c7be5385b15..b2944cc4c6058ea52b0f613acf3d6e7ac12ff684 100644
--- a/package.json
+++ b/package.json
@@ -11,8 +11,8 @@
"types": "./dist/index.d.ts",
"exports": {
".": "./dist/index.js",
- "./components/FieldGenerators/*": "./dist/components/FieldGenerators/*.js",
- "./components/*": "./dist/components/*.js",
+ "./components/FieldGenerators/*": "./dist/components/FieldGenerators/*",
+ "./components/*": "./dist/components/*",
"./actions/*": "./dist/actions/*.js",
"./globals/*": "./dist/globals/*.js",
"./endpoints/*": "./dist/endpoints/*.js",

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>;
};
@@ -252,7 +254,7 @@ export interface FormSubmission {
id: number;
name: string;
email: string;
type: 'contact' | 'product_quote';
type: 'contact' | 'product_quote' | 'brochure_download';
/**
* The specific KLZ product the user requested a quote for.
*/
@@ -331,14 +333,6 @@ export interface Page {
layout?: ('default' | 'fullBleed') | null;
excerpt?: string | null;
featuredImage?: (number | null) | Media;
/**
* If set, visiting this page will immediately redirect the user to this URL (e.g. /de/terms).
*/
redirectUrl?: string | null;
/**
* Check for a permanent (301) redirect. Uncheck for a temporary (302) redirect.
*/
redirectPermanent?: boolean | null;
content: {
root: {
type: string;
@@ -585,8 +579,6 @@ export interface PagesSelect<T extends boolean = true> {
layout?: T;
excerpt?: T;
featuredImage?: T;
redirectUrl?: T;
redirectPermanent?: T;
content?: T;
updatedAt?: T;
createdAt?: T;
@@ -980,7 +972,6 @@ export interface Auth {
[k: string]: unknown;
}
declare module 'payload' {
export interface GeneratedTypes extends Config {}
}
}

View File

@@ -23,6 +23,13 @@ import { Products } from './src/payload/collections/Products';
import { Pages } from './src/payload/collections/Pages';
import { seedDatabase } from './src/payload/seed';
const isMigrate = process.argv.includes('migrate');
let chatPlugin: any = null;
if (!isMigrate) {
const mod = await import('@mintel/payload-ai');
chatPlugin = mod.payloadChatPlugin;
}
const filename = fileURLToPath(import.meta.url);
const dirname = path.dirname(filename);
@@ -71,8 +78,6 @@ export default buildConfig({
},
db: postgresAdapter({
prodMigrations: migrations,
migrationDir:
process.env.NODE_ENV === 'production' ? undefined : path.resolve(dirname, 'src/migrations'),
pool: {
connectionString:
process.env.DATABASE_URI ||
@@ -100,5 +105,14 @@ export default buildConfig({
})
: undefined,
sharp,
plugins: [],
plugins: [
...(chatPlugin
? [
chatPlugin({
enabled: true,
mcpServers: [{ name: 'klz-qdrant-mcp' }],
}),
]
: []),
],
});

View File

@@ -1 +0,0 @@
PDF rendered successfully offline!

5422
pnpm-lock.yaml generated

File diff suppressed because it is too large Load Diff

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

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