Compare commits

...

109 Commits

Author SHA1 Message Date
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
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
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
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
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
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
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
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
6a748a3ac8 fix(ci): improve lhci auth with puppeteer script and relax perf assertion
All checks were successful
Build & Deploy / 🔍 Prepare (push) Successful in 9s
Build & Deploy / 🧪 QA (push) Successful in 2m47s
Build & Deploy / 🏗️ Build (push) Successful in 3m50s
Build & Deploy / 🚀 Deploy (push) Successful in 24s
Build & Deploy / 🧪 Post-Deploy Verification (push) Successful in 5m27s
Build & Deploy / 🔔 Notify (push) Successful in 1s
Nightly QA / 🎭 Lighthouse (push) Successful in 3m34s
Nightly QA / ♿ Accessibility (push) Successful in 5m18s
Nightly QA / 🔗 Links & Deps (push) Successful in 2m42s
Nightly QA / 🔍 Static Analysis (push) Successful in 7m8s
Nightly QA / 🔔 Notify (push) Successful in 1s
2026-03-02 14:16:06 +01:00
d69e0eebe6 fix(ci): broaden lychee exclusions for external and internal restricted urls
Some checks failed
Build & Deploy / 🔍 Prepare (push) Successful in 27s
Nightly QA / 🔗 Links & Deps (push) Successful in 3m29s
Nightly QA / ♿ Accessibility (push) Successful in 5m49s
Build & Deploy / 🧪 QA (push) Successful in 2m29s
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
Nightly QA / 🔍 Static Analysis (push) Successful in 7m43s
Nightly QA / 🎭 Lighthouse (push) Successful in 8m10s
Nightly QA / 🔔 Notify (push) Successful in 3s
2026-03-02 14:03:59 +01:00
1577bfd2ec fix(ci): optimize pipeline speed and fix link check stability
Some checks failed
Build & Deploy / 🔍 Prepare (push) Successful in 1m7s
Nightly QA / 🔗 Links & Deps (push) Failing after 2m43s
Build & Deploy / 🧪 QA (push) Has been cancelled
Build & Deploy / 🏗️ Build (push) Has been cancelled
Build & Deploy / 🚀 Deploy (push) Has been cancelled
Build & Deploy / 🧪 Post-Deploy Verification (push) Has been cancelled
Build & Deploy / 🔔 Notify (push) Has been cancelled
Nightly QA / 🔍 Static Analysis (push) Has been cancelled
Nightly QA / ♿ Accessibility (push) Has started running
Nightly QA / 🎭 Lighthouse (push) Has started running
Nightly QA / 🔔 Notify (push) Has been cancelled
2026-03-02 13:57:53 +01:00
6440d893f0 fix(ci): add hardcoded fallback for puppeteer chrome in lighthouse
Some checks failed
Build & Deploy / 🔍 Prepare (push) Successful in 8s
Nightly QA / 🔗 Links & Deps (push) Failing after 2m48s
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
Nightly QA / ♿ Accessibility (push) Successful in 5m21s
Nightly QA / 🔔 Notify (push) Has been cancelled
Nightly QA / 🔍 Static Analysis (push) Has been cancelled
Nightly QA / 🎭 Lighthouse (push) Has been cancelled
2026-03-02 13:33:02 +01:00
d8e3c7d9a3 fix(ci): improve chrome detection and debug logging for lighthouse
Some checks failed
Build & Deploy / 🔍 Prepare (push) Successful in 16s
Nightly QA / 🔗 Links & Deps (push) Failing after 2m46s
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
Nightly QA / 🔔 Notify (push) Has been cancelled
Nightly QA / ♿ Accessibility (push) Has been cancelled
Nightly QA / 🎭 Lighthouse (push) Has been cancelled
Nightly QA / 🔍 Static Analysis (push) Has been cancelled
2026-03-02 13:28:31 +01:00
aa14f39dba fix(ci): detect puppeteer chrome path for lighthouse
Some checks failed
Build & Deploy / 🔍 Prepare (push) Successful in 18s
Nightly QA / 🎭 Lighthouse (push) Successful in 3m38s
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
Nightly QA / 🔔 Notify (push) Has been cancelled
Nightly QA / 🔗 Links & Deps (push) Has been cancelled
Nightly QA / 🔍 Static Analysis (push) Has been cancelled
Nightly QA / ♿ Accessibility (push) Has been cancelled
2026-03-02 13:24:06 +01:00
1cfc0523f3 fix(ci): update chrome deps for ubuntu 24.04 and robust url parsing
Some checks failed
Build & Deploy / 🔍 Prepare (push) Successful in 24s
Nightly QA / 🔗 Links & Deps (push) Failing after 2m45s
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
Nightly QA / 🔔 Notify (push) Has been cancelled
Nightly QA / ♿ Accessibility (push) Has been cancelled
Nightly QA / 🎭 Lighthouse (push) Has been cancelled
Nightly QA / 🔍 Static Analysis (push) Has been cancelled
2026-03-02 13:19:40 +01:00
3ff20fd2c9 fix(ci): add chrome system libraries and fix pagespeed url parsing
Some checks failed
Build & Deploy / 🔍 Prepare (push) Successful in 17s
Nightly QA / 🔍 Static Analysis (push) Failing after 2m44s
Nightly QA / ♿ Accessibility (push) Failing after 2m46s
Nightly QA / 🎭 Lighthouse (push) Failing after 2m48s
Build & Deploy / 🏗️ Build (push) Has been cancelled
Build & Deploy / 🚀 Deploy (push) Has been cancelled
Build & Deploy / 🧪 Post-Deploy Verification (push) Has been cancelled
Build & Deploy / 🔔 Notify (push) Has been cancelled
Nightly QA / 🔔 Notify (push) Has been cancelled
Nightly QA / 🔗 Links & Deps (push) Has been cancelled
Build & Deploy / 🧪 QA (push) Has been cancelled
2026-03-02 13:15:27 +01:00
549ee34490 chore(ci): add push trigger to qa.yml for automatic verification
Some checks failed
Build & Deploy / 🔍 Prepare (push) Successful in 7s
Nightly QA / 🔍 Static Analysis (push) Failing after 2m57s
Nightly QA / 🎭 Lighthouse (push) Failing after 2m39s
Nightly QA / ♿ Accessibility (push) Successful in 4m6s
Nightly QA / 🔗 Links & Deps (push) Failing after 2m58s
Nightly QA / 🔔 Notify (push) Successful in 3s
Build & Deploy / 🧪 QA (push) Successful in 2m32s
Build & Deploy / 🏗️ Build (push) Successful in 4m29s
Build & Deploy / 🚀 Deploy (push) Successful in 16s
Build & Deploy / 🔔 Notify (push) Has been cancelled
Build & Deploy / 🧪 Post-Deploy Verification (push) Has been cancelled
2026-03-02 12:56:19 +01:00
8a8e30400c chore(ci): fix artifact upload and add chrome dependency for diagnostic scripts
Some checks failed
Build & Deploy / 🔍 Prepare (push) Successful in 7s
Build & Deploy / 🧪 QA (push) Has been cancelled
Build & Deploy / 🏗️ Build (push) Has been cancelled
Build & Deploy / 🚀 Deploy (push) Has been cancelled
Build & Deploy / 🧪 Post-Deploy Verification (push) Has been cancelled
Build & Deploy / 🔔 Notify (push) Has been cancelled
2026-03-02 12:52:28 +01:00
4faed38f47 chore: remove explicit email and phone inline blocks in favor of automatic obfuscation
Some checks failed
Build & Deploy / 🔍 Prepare (push) Successful in 14s
Build & Deploy / 🧪 QA (push) Successful in 3m46s
Build & Deploy / 🏗️ Build (push) Successful in 3m58s
Build & Deploy / 🚀 Deploy (push) Successful in 15s
Build & Deploy / 🔔 Notify (push) Has been cancelled
Build & Deploy / 🧪 Post-Deploy Verification (push) Has been cancelled
2026-03-02 12:39:07 +01:00
1e0886144f ci(qa): rewrite pipeline with 6 transparent inline jobs [skip ci] 2026-03-02 12:21:48 +01:00
c933d9b886 ci(qa): revert qa push triggers [skip ci] 2026-03-02 12:08:05 +01:00
5c56d8babf ci(qa): force template refresh by using SHA
Some checks failed
Nightly QA / call-qa-workflow (push) Failing after 6s
Build & Deploy / 🔍 Prepare (push) Successful in 9s
Build & Deploy / 🧪 QA (push) Successful in 2m20s
Build & Deploy / 🏗️ Build (push) Successful in 3m13s
Build & Deploy / 🚀 Deploy (push) Failing after 7s
Build & Deploy / 🧪 Post-Deploy Verification (push) Has been skipped
Build & Deploy / 🔔 Notify (push) Successful in 1s
2026-03-02 12:01:40 +01:00
c4c6fb3b07 ci(qa): re-triggering with latest template
Some checks failed
Build & Deploy / 🔍 Prepare (push) Successful in 5s
Nightly QA / call-qa-workflow (push) Failing after 48s
Build & Deploy / 🧪 QA (push) Successful in 2m8s
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-02 11:57:19 +01:00
ff685b9933 ci(qa): temporary push trigger for verification
Some checks failed
Build & Deploy / 🔍 Prepare (push) Successful in 14s
Nightly QA / call-qa-workflow (push) Failing after 1m57s
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-02 11:53:05 +01:00
980258af5c ci(qa): update QA workflow to pass NPM_TOKEN to reusable template
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
2026-03-02 11:51:13 +01:00
57b6963efe chore: release v2.2.12
All checks were successful
Build & Deploy / 🔍 Prepare (push) Successful in 9s
Build & Deploy / 🧪 QA (push) Successful in 2m44s
Build & Deploy / 🏗️ Build (push) Successful in 4m54s
Build & Deploy / 🚀 Deploy (push) Successful in 15s
Build & Deploy / 🧪 Post-Deploy Verification (push) Successful in 5m41s
Build & Deploy / 🔔 Notify (push) Successful in 2s
2026-03-02 10:23:08 +01:00
1a136540d0 feat: implement email and phone obfuscation with Payload inline blocks
Some checks failed
Build & Deploy / 🔍 Prepare (push) Successful in 7s
Build & Deploy / 🧪 QA (push) Has started running
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-02 10:22:52 +01:00
92bc88dfbd style: design refinements — reduce title/heading sizes, remove Scribble decorations, add image quality
Some checks failed
Build & Deploy / 🔍 Prepare (push) Successful in 12s
Build & Deploy / 🧪 QA (push) Successful in 3m19s
Build & Deploy / 🏗️ Build (push) Successful in 5m26s
Build & Deploy / 🧪 Post-Deploy Verification (push) Successful in 5m4s
Build & Deploy / 🚀 Deploy (push) Successful in 25s
Build & Deploy / 🔔 Notify (push) Successful in 2s
Nightly QA / call-qa-workflow (push) Failing after 43s
- 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:13:28 +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
fb3ec6e10a fix(blog): preserve newlines in Lexical text nodes as <br> for proper list rendering
Some checks failed
Build & Deploy / 🔍 Prepare (push) Successful in 7s
Build & Deploy / 🧪 QA (push) Successful in 2m28s
Build & Deploy / 🏗️ Build (push) Successful in 6m15s
Build & Deploy / 🚀 Deploy (push) Failing after 7s
Build & Deploy / 🧪 Post-Deploy Verification (push) Has been skipped
Build & Deploy / 🔔 Notify (push) Successful in 1s
test(og): use real blog slug from sitemap instead of hardcoded hello-world

chore(release): bump version to 2.2.10
2026-03-01 23:21:35 +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
acf642d7e6 fix(blog): prioritize original img url over small card size for sharp headers
All checks were successful
Build & Deploy / 🔍 Prepare (push) Successful in 6s
Build & Deploy / 🧪 QA (push) Successful in 2m32s
Build & Deploy / 🏗️ Build (push) Successful in 5m3s
Build & Deploy / 🚀 Deploy (push) Successful in 16s
Build & Deploy / 🧪 Post-Deploy Verification (push) Successful in 5m10s
Build & Deploy / 🔔 Notify (push) Successful in 1s
chore(release): bump version to 2.2.9
2026-03-01 22:39:51 +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
d5da2a91c8 test: improve E2E form error logging
All checks were successful
Build & Deploy / 🔍 Prepare (push) Successful in 5s
Build & Deploy / 🧪 QA (push) Successful in 2m9s
Build & Deploy / 🏗️ Build (push) Successful in 3m18s
Build & Deploy / 🚀 Deploy (push) Successful in 15s
Build & Deploy / 🧪 Post-Deploy Verification (push) Successful in 5m18s
Build & Deploy / 🔔 Notify (push) Successful in 1s
2026-03-01 17:45:41 +01:00
ebe664f984 fix(qa): resolve testing gatekeeper auth & htmlWYSIWYG errors
Some checks failed
Build & Deploy / 🔍 Prepare (push) Successful in 7s
Build & Deploy / 🧪 QA (push) Successful in 2m18s
Build & Deploy / 🏗️ Build (push) Successful in 3m53s
Build & Deploy / 🚀 Deploy (push) Successful in 24s
Build & Deploy / 🧪 Post-Deploy Verification (push) Failing after 5m1s
Build & Deploy / 🔔 Notify (push) Successful in 2s
2026-03-01 16:32:58 +01:00
9c7324ee92 fix(blog): restore image optimization but force quality 100 for fidelity
All checks were successful
Build & Deploy / 🔍 Prepare (push) Successful in 7s
Build & Deploy / 🧪 QA (push) Successful in 3m0s
Build & Deploy / 🏗️ Build (push) Successful in 5m53s
Build & Deploy / 🚀 Deploy (push) Successful in 19s
Build & Deploy / 🧪 Post-Deploy Verification (push) Successful in 6m47s
Build & Deploy / 🔔 Notify (push) Successful in 2s
chore(release): bump version to 2.2.8
2026-03-01 16:13:05 +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
272 changed files with 32980 additions and 2822 deletions

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 }} next_public_url: ${{ steps.determine.outputs.next_public_url }}
project_name: ${{ steps.determine.outputs.project_name }} project_name: ${{ steps.determine.outputs.project_name }}
short_sha: ${{ steps.determine.outputs.short_sha }} short_sha: ${{ steps.determine.outputs.short_sha }}
slug: ${{ steps.determine.outputs.slug }}
gatekeeper_host: ${{ steps.determine.outputs.gatekeeper_host }}
container: container:
image: catthehacker/ubuntu:act-latest image: catthehacker/ubuntu:act-latest
steps: steps:
@@ -83,7 +85,7 @@ jobs:
SLUG=$(echo "$REF" | tr '[:upper:]' '[:lower:]' | sed 's/[^a-z0-9]/-/g' | sed 's/--*/-/g' | sed 's/^-//;s/-$//') SLUG=$(echo "$REF" | tr '[:upper:]' '[:lower:]' | sed 's/[^a-z0-9]/-/g' | sed 's/--*/-/g' | sed 's/^-//;s/-$//')
IMAGE_TAG="branch-${SLUG}-${SHORT_SHA}" IMAGE_TAG="branch-${SLUG}-${SHORT_SHA}"
ENV_FILE=".env.branch-${SLUG}" ENV_FILE=".env.branch-${SLUG}"
TRAEFIK_HOST="${SLUG}.branch.mintel.me" TRAEFIK_HOST="${SLUG}.branch.klz-cables.com"
fi fi
# Standardize Traefik Rule (escaped backticks for Traefik v3) # Standardize Traefik Rule (escaped backticks for Traefik v3)
@@ -113,6 +115,7 @@ jobs:
echo "project_name=$PRJ-$TARGET" echo "project_name=$PRJ-$TARGET"
fi fi
echo "short_sha=$SHORT_SHA" echo "short_sha=$SHORT_SHA"
echo "slug=$SLUG"
} >> "$GITHUB_OUTPUT" } >> "$GITHUB_OUTPUT"
# ⏳ Wait for Upstream Packages/Images if Tagged # ⏳ Wait for Upstream Packages/Images if Tagged
@@ -156,6 +159,8 @@ jobs:
needs: prepare needs: prepare
if: needs.prepare.outputs.target != 'skip' if: needs.prepare.outputs.target != 'skip'
runs-on: docker runs-on: docker
env:
PUPPETEER_EXECUTABLE_PATH: /usr/bin/chromium
container: container:
image: catthehacker/ubuntu:act-latest image: catthehacker/ubuntu:act-latest
steps: steps:
@@ -181,12 +186,12 @@ jobs:
- name: 🔒 Security Audit - name: 🔒 Security Audit
run: pnpm audit --audit-level high || echo "⚠️ Audit found vulnerabilities (non-blocking)" run: pnpm audit --audit-level high || echo "⚠️ Audit found vulnerabilities (non-blocking)"
- name: 🧪 QA Checks - name: 🧪 QA Checks
if: github.event.inputs.skip_checks != 'true' if: github.event.inputs.skip_checks != 'true'
env: env:
TURBO_TELEMETRY_DISABLED: "1" TURBO_TELEMETRY_DISABLED: "1"
run: npx turbo run lint typecheck test --cache-dir=".turbo" run: npx turbo run lint typecheck test --cache-dir=".turbo"
# ────────────────────────────────────────────────────────────────────────────── # ──────────────────────────────────────────────────────────────────────────────
# JOB 3: Build & Push # JOB 3: Build & Push
# ────────────────────────────────────────────────────────────────────────────── # ──────────────────────────────────────────────────────────────────────────────
@@ -203,7 +208,8 @@ jobs:
- name: 🐳 Set up Docker Buildx - name: 🐳 Set up Docker Buildx
uses: docker/setup-buildx-action@v3 uses: docker/setup-buildx-action@v3
- name: 🔐 Registry Login - 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 - name: 🏗️ Build and Push
uses: docker/build-push-action@v5 uses: docker/build-push-action@v5
with: with:
@@ -219,7 +225,7 @@ jobs:
NPM_TOKEN=${{ secrets.NPM_TOKEN }} NPM_TOKEN=${{ secrets.NPM_TOKEN }}
tags: registry.infra.mintel.me/mintel/klz-2026:${{ needs.prepare.outputs.image_tag }} tags: registry.infra.mintel.me/mintel/klz-2026:${{ needs.prepare.outputs.image_tag }}
secrets: | secrets: |
"NPM_TOKEN=${{ secrets.NPM_TOKEN }}" NPM_TOKEN=${{ secrets.NPM_TOKEN }}
# ────────────────────────────────────────────────────────────────────────────── # ──────────────────────────────────────────────────────────────────────────────
# JOB 4: Deploy # JOB 4: Deploy
@@ -237,6 +243,7 @@ jobs:
NEXT_PUBLIC_BASE_URL: ${{ needs.prepare.outputs.next_public_url }} NEXT_PUBLIC_BASE_URL: ${{ needs.prepare.outputs.next_public_url }}
TRAEFIK_HOST: ${{ needs.prepare.outputs.traefik_host }} TRAEFIK_HOST: ${{ needs.prepare.outputs.traefik_host }}
GATEKEEPER_HOST: ${{ needs.prepare.outputs.gatekeeper_host }} GATEKEEPER_HOST: ${{ needs.prepare.outputs.gatekeeper_host }}
SLUG: ${{ needs.prepare.outputs.slug }}
# Secrets mapping (Payload CMS) # Secrets mapping (Payload CMS)
PAYLOAD_SECRET: ${{ secrets.PAYLOAD_SECRET || vars.PAYLOAD_SECRET || 'you-need-to-set-a-payload-secret' }} PAYLOAD_SECRET: ${{ secrets.PAYLOAD_SECRET || vars.PAYLOAD_SECRET || 'you-need-to-set-a-payload-secret' }}
@@ -249,8 +256,8 @@ jobs:
MAIL_PORT: ${{ secrets.SMTP_PORT || vars.SMTP_PORT || '587' }} MAIL_PORT: ${{ secrets.SMTP_PORT || vars.SMTP_PORT || '587' }}
MAIL_USERNAME: ${{ secrets.SMTP_USER || vars.SMTP_USER }} MAIL_USERNAME: ${{ secrets.SMTP_USER || vars.SMTP_USER }}
MAIL_PASSWORD: ${{ secrets.SMTP_PASS || vars.SMTP_PASS }} MAIL_PASSWORD: ${{ secrets.SMTP_PASS || vars.SMTP_PASS }}
MAIL_FROM: ${{ secrets.SMTP_FROM || vars.SMTP_FROM }} MAIL_FROM: ${{ secrets.SMTP_FROM || vars.SMTP_FROM || 'noreply@klz-cables.com' }}
MAIL_RECIPIENTS: ${{ secrets.CONTACT_RECIPIENT || vars.CONTACT_RECIPIENT }} MAIL_RECIPIENTS: ${{ secrets.CONTACT_RECIPIENT || vars.CONTACT_RECIPIENT || 'info@klz-cables.com' }}
# Monitoring # Monitoring
SENTRY_DSN: ${{ secrets.SENTRY_DSN || vars.SENTRY_DSN }} SENTRY_DSN: ${{ secrets.SENTRY_DSN || vars.SENTRY_DSN }}
@@ -261,6 +268,15 @@ jobs:
# Analytics # Analytics
UMAMI_WEBSITE_ID: ${{ secrets.UMAMI_WEBSITE_ID || vars.UMAMI_WEBSITE_ID }} 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' }} UMAMI_API_ENDPOINT: ${{ secrets.UMAMI_API_ENDPOINT || vars.UMAMI_API_ENDPOINT || 'https://analytics.infra.mintel.me' }}
# Search & AI
OPENROUTER_API_KEY: ${{ secrets.OPENROUTER_API_KEY || vars.OPENROUTER_API_KEY }}
QDRANT_URL: ${{ secrets.QDRANT_URL || vars.QDRANT_URL || 'http://klz-qdrant:6333' }}
QDRANT_API_KEY: ${{ secrets.QDRANT_API_KEY || vars.QDRANT_API_KEY }}
REDIS_URL: ${{ secrets.REDIS_URL || vars.REDIS_URL || 'redis://klz-redis:6379' }}
# Container Registry (standalone)
REGISTRY_USER: ${{ secrets.REGISTRY_USER }}
REGISTRY_PASS: ${{ secrets.REGISTRY_PASS }}
steps: steps:
- name: Checkout repository - name: Checkout repository
uses: actions/checkout@v4 uses: actions/checkout@v4
@@ -319,6 +335,12 @@ jobs:
echo "UMAMI_WEBSITE_ID=$UMAMI_WEBSITE_ID" echo "UMAMI_WEBSITE_ID=$UMAMI_WEBSITE_ID"
echo "UMAMI_API_ENDPOINT=$UMAMI_API_ENDPOINT" echo "UMAMI_API_ENDPOINT=$UMAMI_API_ENDPOINT"
echo "" echo ""
echo "# Search & AI"
echo "OPENROUTER_API_KEY=$OPENROUTER_API_KEY"
echo "QDRANT_URL=$QDRANT_URL"
echo "QDRANT_API_KEY=$QDRANT_API_KEY"
echo "REDIS_URL=$REDIS_URL"
echo ""
echo "TARGET=$TARGET" echo "TARGET=$TARGET"
echo "SENTRY_ENVIRONMENT=$TARGET" echo "SENTRY_ENVIRONMENT=$TARGET"
echo "PROJECT_NAME=$PROJECT_NAME" echo "PROJECT_NAME=$PROJECT_NAME"
@@ -338,9 +360,39 @@ jobs:
cat .env.deploy cat .env.deploy
echo "----------------------------" 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 - name: 🚀 SSH Deploy
shell: bash shell: bash
env: 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 }} ENV_FILE: ${{ needs.prepare.outputs.env_file }}
run: | run: |
mkdir -p ~/.ssh mkdir -p ~/.ssh
@@ -348,6 +400,9 @@ jobs:
chmod 600 ~/.ssh/id_ed25519 chmod 600 ~/.ssh/id_ed25519
ssh-keyscan -H alpha.mintel.me >> ~/.ssh/known_hosts 2>/dev/null ssh-keyscan -H alpha.mintel.me >> ~/.ssh/known_hosts 2>/dev/null
# Determine deployment paths
echo "Preparing deployment for $TARGET..."
# Transfer and Restart # Transfer and Restart
if [[ "$TARGET" == "production" ]]; then if [[ "$TARGET" == "production" ]]; then
SITE_DIR="/home/deploy/sites/klz-cables.com" SITE_DIR="/home/deploy/sites/klz-cables.com"
@@ -356,63 +411,55 @@ jobs:
elif [[ "$TARGET" == "staging" ]]; then elif [[ "$TARGET" == "staging" ]]; then
SITE_DIR="/home/deploy/sites/staging.klz-cables.com" SITE_DIR="/home/deploy/sites/staging.klz-cables.com"
else else
SITE_DIR="/home/deploy/sites/branch.klz-cables.com/${SLUG:-unknown}" SITE_DIR="/home/deploy/sites/branch.klz-cables.com/$SLUG"
fi fi
# Transfer files
ssh root@alpha.mintel.me "mkdir -p $SITE_DIR" ssh root@alpha.mintel.me "mkdir -p $SITE_DIR"
scp .env.deploy root@alpha.mintel.me:$SITE_DIR/$ENV_FILE scp .env.deploy root@alpha.mintel.me:$SITE_DIR/$ENV_FILE
scp docker-compose.yml root@alpha.mintel.me:$SITE_DIR/docker-compose.yml 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" APP_CONTAINER="${{ needs.prepare.outputs.project_name }}-klz-app-1"
ssh root@alpha.mintel.me "cd $SITE_DIR && docker compose -p '${{ needs.prepare.outputs.project_name }}' --env-file '$ENV_FILE' pull"
ssh root@alpha.mintel.me "cd $SITE_DIR && docker compose -p '${{ needs.prepare.outputs.project_name }}' --env-file '$ENV_FILE' up -d --remove-orphans"
# Sanitize Payload Migrations: Replace 'dev' push entries with proper migration names.
# Without this, Payload prompts interactively for confirmation and blocks forever in Docker.
DB_CONTAINER="${{ needs.prepare.outputs.project_name }}-klz-db-1" 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)..." # Branch Seeding Logic (Production -> Branch)
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") if [[ "$TARGET" == "branch" ]]; then
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") echo "🌱 Seeding Branch Environment from Production Database..."
REMOTE_DB_USER="${REMOTE_DB_USER:-payload}" 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"
REMOTE_DB_NAME="${REMOTE_DB_NAME:-payload}"
# Wait for DB to be healthy with a 60s timeout
# Auto-detect migrations from src/migrations/*.ts echo "⏳ Waiting for branch database to be ready..."
BATCH=1 ssh root@alpha.mintel.me "
VALUES="" for i in {1..30}; do
for f in $(ls src/migrations/*.ts 2>/dev/null | sort); do if docker exec $DB_CONTAINER pg_isready -U payload >/dev/null 2>&1; then
NAME=$(basename "$f" .ts) exit 0
[ -n "$VALUES" ] && VALUES="$VALUES," fi
VALUES="$VALUES ('$NAME', $BATCH)" sleep 2
((BATCH++)) done
done echo '❌ Database failed to become ready after 60 seconds'
exit 1
if [ -n "$VALUES" ]; then " || exit 1
ssh root@alpha.mintel.me "docker exec $DB_CONTAINER psql -U $REMOTE_DB_USER -d $REMOTE_DB_NAME -c \"
DO \\\$\\\$ BEGIN # Copy Production Payload DB to Branch Payload DB & ensure media is copied
DELETE FROM payload_migrations WHERE batch = -1; echo "📦 Syncing Production DB into Branch DB..."
INSERT INTO payload_migrations (name, batch) ssh root@alpha.mintel.me "
SELECT name, batch FROM (VALUES $VALUES) AS v(name, batch) set -e -o pipefail
WHERE NOT EXISTS (SELECT 1 FROM payload_migrations pm WHERE pm.name = v.name); 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
EXCEPTION WHEN undefined_table THEN 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/
RAISE NOTICE 'payload_migrations table does not exist yet — skipping sanitization'; " || exit 1
END \\\$\\\$;
\"" || echo "⚠️ Migration sanitization skipped (table may not exist yet)" echo "✅ Branch database and media synced successfully."
fi 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 # 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" 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'" ssh root@alpha.mintel.me "docker system prune -f --filter 'until=24h'"
- name: 🧹 Post-Deploy Cleanup (Runner) - name: 🧹 Post-Deploy Cleanup (Runner)
@@ -425,7 +472,7 @@ jobs:
post_deploy_checks: post_deploy_checks:
name: 🧪 Post-Deploy Verification name: 🧪 Post-Deploy Verification
needs: [prepare, deploy] needs: [prepare, deploy]
if: needs.deploy.result == 'success' && needs.prepare.outputs.target != 'branch' if: needs.deploy.result == 'success' && true
runs-on: docker runs-on: docker
container: container:
image: catthehacker/ubuntu:act-latest image: catthehacker/ubuntu:act-latest
@@ -518,6 +565,15 @@ jobs:
GATEKEEPER_PASSWORD: ${{ secrets.GATEKEEPER_PASSWORD || 'klz2026' }} GATEKEEPER_PASSWORD: ${{ secrets.GATEKEEPER_PASSWORD || 'klz2026' }}
UMAMI_API_ENDPOINT: ${{ secrets.UMAMI_API_ENDPOINT || vars.UMAMI_API_ENDPOINT || 'https://analytics.infra.mintel.me' }} UMAMI_API_ENDPOINT: ${{ secrets.UMAMI_API_ENDPOINT || vars.UMAMI_API_ENDPOINT || 'https://analytics.infra.mintel.me' }}
SENTRY_DSN: ${{ secrets.SENTRY_DSN || vars.SENTRY_DSN }} 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 - name: 📝 E2E Form Submission Test
if: always() && steps.deps.outcome == 'success' if: always() && steps.deps.outcome == 'success'
@@ -571,11 +627,16 @@ jobs:
STATUS_LINE="All checks passed" STATUS_LINE="All checks passed"
fi fi
TITLE="$EMOJI klz-cables.com $VERSION $TARGET" TITLE="$EMOJI klz-cables.com $VERSION -> $TARGET"
MESSAGE="$STATUS_LINE MESSAGE="$STATUS_LINE
Deploy: $DEPLOY | Smoke: $SMOKE | Perf: $PERF Deploy: $DEPLOY | Smoke: $SMOKE | Perf: $PERF
$URL" $URL"
if [[ -z "${{ secrets.GOTIFY_URL }}" || -z "${{ secrets.GOTIFY_TOKEN }}" ]]; then
echo "⚠️ Gotify credentials missing, skipping notification."
exit 0
fi
curl -s -k -X POST "${{ secrets.GOTIFY_URL }}/message?token=${{ secrets.GOTIFY_TOKEN }}" \ curl -s -k -X POST "${{ secrets.GOTIFY_URL }}/message?token=${{ secrets.GOTIFY_TOKEN }}" \
-F "title=$TITLE" \ -F "title=$TITLE" \
-F "message=$MESSAGE" \ -F "message=$MESSAGE" \

View File

@@ -5,13 +5,232 @@ on:
- cron: '0 3 * * *' - cron: '0 3 * * *'
workflow_dispatch: workflow_dispatch:
env:
TARGET_URL: 'https://testing.klz-cables.com'
PROJECT_NAME: 'klz-2026'
jobs: jobs:
call-qa-workflow: # ────────────────────────────────────────────────────
uses: mmintel/at-mintel/.gitea/workflows/quality-assurance-template.yml@main # 1. Static Checks (HTML, Assets, HTTP)
with: # ────────────────────────────────────────────────────
TARGET_URL: 'https://testing.klz-cables.com' static:
PROJECT_NAME: 'klz-2026' name: 🔍 Static Analysis
secrets: runs-on: docker
GOTIFY_URL: ${{ secrets.GOTIFY_URL }} container:
GOTIFY_TOKEN: ${{ secrets.GOTIFY_TOKEN }} image: catthehacker/ubuntu:act-latest
GATEKEEPER_PASSWORD: ${{ secrets.GATEKEEPER_PASSWORD || 'klz2026' }} steps:
- uses: actions/checkout@v4
- uses: pnpm/action-setup@v3
with:
version: 10
- uses: actions/setup-node@v4
with:
node-version: 20
- name: 🔐 Registry Auth
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: 📦 Cache node_modules
uses: actions/cache@v4
id: cache-deps
with:
path: node_modules
key: pnpm-${{ hashFiles('pnpm-lock.yaml') }}
- name: Install
if: steps.cache-deps.outputs.cache-hit != 'true'
run: |
pnpm store prune
pnpm install --no-frozen-lockfile
- name: 🌐 Install Chrome & Dependencies
run: |
apt-get update && apt-get install -y libnss3 libnspr4 libatk1.0-0 libatk-bridge2.0-0 libcups2 libdrm2 libxkbcommon0 libxcomposite1 libxdamage1 libxext6 libxfixes3 libxrandr2 libgbm1 libasound2t64 libpango-1.0-0 libcairo2
npx puppeteer browsers install chrome
- name: 🌐 HTML Validation
env:
NEXT_PUBLIC_BASE_URL: ${{ env.TARGET_URL }}
GATEKEEPER_PASSWORD: ${{ secrets.GATEKEEPER_PASSWORD }}
run: pnpm run check:html
- name: 🖼️ Broken Assets
env:
NEXT_PUBLIC_BASE_URL: ${{ env.TARGET_URL }}
GATEKEEPER_PASSWORD: ${{ secrets.GATEKEEPER_PASSWORD }}
ASSET_CHECK_LIMIT: 10
run: pnpm run check:assets
- name: 🔒 HTTP Headers
env:
NEXT_PUBLIC_BASE_URL: ${{ env.TARGET_URL }}
GATEKEEPER_PASSWORD: ${{ secrets.GATEKEEPER_PASSWORD }}
run: pnpm run check:http
# ────────────────────────────────────────────────────
# 2. Accessibility (WCAG)
# ────────────────────────────────────────────────────
a11y:
name: ♿ Accessibility
runs-on: docker
container:
image: catthehacker/ubuntu:act-latest
steps:
- uses: actions/checkout@v4
- uses: pnpm/action-setup@v3
with:
version: 10
- uses: actions/setup-node@v4
with:
node-version: 20
- name: 🔐 Registry Auth
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: 📦 Cache node_modules
uses: actions/cache@v4
id: cache-deps
with:
path: node_modules
key: pnpm-${{ hashFiles('pnpm-lock.yaml') }}
- name: Install
if: steps.cache-deps.outputs.cache-hit != 'true'
run: |
pnpm store prune
pnpm install --no-frozen-lockfile
- name: 🌐 Install Chrome & Dependencies
run: |
apt-get update && apt-get install -y libnss3 libnspr4 libatk1.0-0 libatk-bridge2.0-0 libcups2 libdrm2 libxkbcommon0 libxcomposite1 libxdamage1 libxext6 libxfixes3 libxrandr2 libgbm1 libasound2t64 libpango-1.0-0 libcairo2
npx puppeteer browsers install chrome
- name: ♿ WCAG Scan
continue-on-error: true
env:
NEXT_PUBLIC_BASE_URL: ${{ env.TARGET_URL }}
GATEKEEPER_PASSWORD: ${{ secrets.GATEKEEPER_PASSWORD }}
run: pnpm run check:wcag
# ────────────────────────────────────────────────────
# 3. Performance (Lighthouse)
# ────────────────────────────────────────────────────
lighthouse:
name: 🎭 Lighthouse
runs-on: docker
container:
image: catthehacker/ubuntu:act-latest
steps:
- uses: actions/checkout@v4
- uses: pnpm/action-setup@v3
with:
version: 10
- uses: actions/setup-node@v4
with:
node-version: 20
- name: 🔐 Registry Auth
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: 📦 Cache node_modules
uses: actions/cache@v4
id: cache-deps
with:
path: node_modules
key: pnpm-${{ hashFiles('pnpm-lock.yaml') }}
- name: Install
if: steps.cache-deps.outputs.cache-hit != 'true'
run: |
pnpm store prune
pnpm install --no-frozen-lockfile
- name: 🌐 Install Chrome & Dependencies
run: |
apt-get update && apt-get install -y libnss3 libnspr4 libatk1.0-0 libatk-bridge2.0-0 libcups2 libdrm2 libxkbcommon0 libxcomposite1 libxdamage1 libxext6 libxfixes3 libxrandr2 libgbm1 libasound2t64 libpango-1.0-0 libcairo2
npx puppeteer browsers install chrome
- name: 🎭 Desktop
env:
NEXT_PUBLIC_BASE_URL: ${{ env.TARGET_URL }}
GATEKEEPER_PASSWORD: ${{ secrets.GATEKEEPER_PASSWORD }}
PAGESPEED_LIMIT: 5
run: pnpm run pagespeed:test -- --collect.settings.preset=desktop
- name: 📱 Mobile
env:
NEXT_PUBLIC_BASE_URL: ${{ env.TARGET_URL }}
GATEKEEPER_PASSWORD: ${{ secrets.GATEKEEPER_PASSWORD }}
PAGESPEED_LIMIT: 5
run: pnpm run pagespeed:test -- --collect.settings.preset=mobile
# ────────────────────────────────────────────────────
# 4. Link Check & Dependency Audit
# ────────────────────────────────────────────────────
links:
name: 🔗 Links & Deps
runs-on: docker
container:
image: catthehacker/ubuntu:act-latest
steps:
- uses: actions/checkout@v4
- uses: pnpm/action-setup@v3
with:
version: 10
- uses: actions/setup-node@v4
with:
node-version: 20
- name: 🔐 Registry Auth
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: 📦 Cache node_modules
uses: actions/cache@v4
id: cache-deps
with:
path: node_modules
key: pnpm-${{ hashFiles('pnpm-lock.yaml') }}
- name: Install
if: steps.cache-deps.outputs.cache-hit != 'true'
run: |
pnpm store prune
pnpm install --no-frozen-lockfile
- name: 📦 Depcheck
continue-on-error: true
run: pnpm dlx depcheck --ignores="*eslint*,*typescript*,*tailwindcss*,*postcss*,*prettier*,*@types/*,*husky*,*lint-staged*,*@next/*,*@lhci/*,*commitlint*,*cspell*,*rimraf*,*@payloadcms/*,*start-server-and-test*,*html-validate*,*critters*,*dotenv*,*turbo*" || true
- name: 🔗 Lychee Link Check
uses: lycheeverse/lychee-action@v2
with:
args: --accept 200,204,429 --timeout 15 --insecure --exclude "file://*" --exclude "https://logs.infra.***.me/*" --exclude "https://git.infra.***.me/*" --exclude "https://umami.is/docs/best-practices" --exclude "https://***/*" .
fail: true
# ────────────────────────────────────────────────────
# 5. Notification
# ────────────────────────────────────────────────────
notify:
name: 🔔 Notify
needs: [static, a11y, lighthouse, links]
if: failure()
runs-on: docker
container:
image: catthehacker/ubuntu:act-latest
steps:
- name: 🔔 Gotify
shell: bash
run: |
STATIC="${{ needs.static.result }}"
A11Y="${{ needs.a11y.result }}"
LIGHTHOUSE="${{ needs.lighthouse.result }}"
LINKS="${{ needs.links.result }}"
if [[ "$STATIC" != "success" || "$LIGHTHOUSE" != "success" ]]; then
PRIORITY=8
EMOJI="🚨"
STATUS="Failed"
else
PRIORITY=2
EMOJI="✅"
STATUS="Passed"
fi
TITLE="$EMOJI ${{ env.PROJECT_NAME }} QA $STATUS"
MESSAGE="Static: $STATIC | A11y: $A11Y | Lighthouse: $LIGHTHOUSE | Links: $LINKS
${{ env.TARGET_URL }}"
if [[ -z "${{ secrets.GOTIFY_URL }}" || -z "${{ secrets.GOTIFY_TOKEN }}" ]]; then
echo "⚠️ Gotify credentials missing, skipping notification."
exit 0
fi
curl -s -k -X POST "${{ secrets.GOTIFY_URL }}/message?token=${{ secrets.GOTIFY_TOKEN }}" \
-F "title=$TITLE" \
-F "message=$MESSAGE" \
-F "priority=$PRIORITY" || true

View File

@@ -17,6 +17,10 @@
"valid-id": "off", "valid-id": "off",
"element-required-attributes": "off", "element-required-attributes": "off",
"attribute-empty-style": "off", "attribute-empty-style": "off",
"element-permitted-content": "off" "element-permitted-content": "off",
"element-required-content": "off",
"element-permitted-parent": "off",
"no-implicit-close": "off",
"close-order": "off"
} }
} }

View File

@@ -1,18 +1,16 @@
# Stage 1: Builder # Stage 1: Builder
FROM registry.infra.mintel.me/mintel/nextjs:v1.8.20 AS base FROM git.infra.mintel.me/mmintel/nextjs:latest AS base
WORKDIR /app WORKDIR /app
# Arguments for build-time configuration # Arguments for build-time configuration
ARG NEXT_PUBLIC_BASE_URL ARG NEXT_PUBLIC_BASE_URL
ARG NEXT_PUBLIC_TARGET ARG NEXT_PUBLIC_TARGET
ARG DIRECTUS_URL
ARG UMAMI_WEBSITE_ID ARG UMAMI_WEBSITE_ID
ARG UMAMI_API_ENDPOINT ARG UMAMI_API_ENDPOINT
# Environment variables for Next.js build # Environment variables for Next.js build
ENV NEXT_PUBLIC_BASE_URL=$NEXT_PUBLIC_BASE_URL ENV NEXT_PUBLIC_BASE_URL=$NEXT_PUBLIC_BASE_URL
ENV NEXT_PUBLIC_TARGET=$NEXT_PUBLIC_TARGET ENV NEXT_PUBLIC_TARGET=$NEXT_PUBLIC_TARGET
ENV DIRECTUS_URL=$DIRECTUS_URL
ENV UMAMI_WEBSITE_ID=$UMAMI_WEBSITE_ID ENV UMAMI_WEBSITE_ID=$UMAMI_WEBSITE_ID
ENV UMAMI_API_ENDPOINT=$UMAMI_API_ENDPOINT ENV UMAMI_API_ENDPOINT=$UMAMI_API_ENDPOINT
ENV SKIP_RUNTIME_ENV_VALIDATION=true ENV SKIP_RUNTIME_ENV_VALIDATION=true
@@ -50,16 +48,12 @@ ENV RAYON_NUM_THREADS=3
ENV UV_THREADPOOL_SIZE=3 ENV UV_THREADPOOL_SIZE=3
RUN pnpm build RUN pnpm build
# Excel generation moved to post-deploy
# Stage 2: Runner # Stage 2: Runner
FROM registry.infra.mintel.me/mintel/runtime:v1.8.20 AS runner FROM git.infra.mintel.me/mmintel/runtime:latest AS runner
WORKDIR /app WORKDIR /app
# Create nextjs user and group (standardized in runtime image but ensuring local ownership)
USER root
RUN chown -R nextjs:nodejs /app
USER nextjs
ENV HOSTNAME="0.0.0.0" ENV HOSTNAME="0.0.0.0"
ENV PORT=3000 ENV PORT=3000
ENV NODE_ENV=production ENV NODE_ENV=production
@@ -71,3 +65,4 @@ COPY --from=builder --chown=nextjs:nodejs /app/.next/static ./.next/static
COPY --from=builder --chown=nextjs:nodejs /app/.next/cache ./.next/cache COPY --from=builder --chown=nextjs:nodejs /app/.next/cache ./.next/cache
CMD ["node", "server.js"] CMD ["node", "server.js"]

View File

@@ -2,7 +2,7 @@ import { notFound, redirect } from 'next/navigation';
import { Container, Badge, Heading } from '@/components/ui'; import { Container, Badge, Heading } from '@/components/ui';
import { getTranslations, setRequestLocale } from 'next-intl/server'; import { getTranslations, setRequestLocale } from 'next-intl/server';
import { Metadata } from 'next'; import { Metadata } from 'next';
import { getPageBySlug, getAllPages } from '@/lib/pages'; import { getPageBySlug } from '@/lib/pages';
import { mapSlugToFileSlug, mapFileSlugToTranslated } from '@/lib/slugs'; import { mapSlugToFileSlug, mapFileSlugToTranslated } from '@/lib/slugs';
import PayloadRichText from '@/components/PayloadRichText'; import PayloadRichText from '@/components/PayloadRichText';
import { SITE_URL } from '@/lib/schema'; import { SITE_URL } from '@/lib/schema';

View File

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

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 { locale } = await params;
const t = await getTranslations({ locale, namespace: 'Blog.meta' }); const t = await getTranslations({ locale, namespace: 'Blog.meta' });
return { return {

View File

@@ -5,9 +5,10 @@ import { Container, Heading, Section } from '@/components/ui';
import { Metadata } from 'next'; import { Metadata } from 'next';
import { getTranslations, setRequestLocale } from 'next-intl/server'; import { getTranslations, setRequestLocale } from 'next-intl/server';
import { SITE_URL } from '@/lib/schema'; import { SITE_URL } from '@/lib/schema';
import { getOGImageMetadata } from '@/lib/metadata';
import { Suspense } from 'react'; import { Suspense } from 'react';
import ContactMap from '@/components/ContactMap'; import ContactMap from '@/components/ContactMap';
import ObfuscatedEmail from '@/components/ObfuscatedEmail';
interface ContactPageProps { interface ContactPageProps {
params: Promise<{ params: Promise<{
@@ -204,12 +205,10 @@ export default async function ContactPage({ params }: ContactPageProps) {
<h4 className="text-base md:text-xl font-bold text-primary mb-1 md:mb-2"> <h4 className="text-base md:text-xl font-bold text-primary mb-1 md:mb-2">
{t('info.email')} {t('info.email')}
</h4> </h4>
<a <ObfuscatedEmail
href="mailto:info@klz-cables.com" email="info@klz-cables.com"
className="text-sm md:text-lg text-text-secondary hover:text-primary transition-colors font-medium touch-target" className="text-sm md:text-lg text-text-secondary hover:text-primary transition-colors font-medium touch-target"
> />
info@klz-cables.com
</a>
</div> </div>
</div> </div>
</address> </address>

View File

@@ -7,10 +7,8 @@ import AnalyticsShell from '@/components/analytics/AnalyticsShell';
import { Metadata, Viewport } from 'next'; import { Metadata, Viewport } from 'next';
import { NextIntlClientProvider } from 'next-intl'; import { NextIntlClientProvider } from 'next-intl';
import { getMessages } from 'next-intl/server'; import { getMessages } from 'next-intl/server';
import { Suspense } from 'react';
import '../../styles/globals.css'; import '../../styles/globals.css';
import { SITE_URL } from '@/lib/schema'; import { SITE_URL } from '@/lib/schema';
import { config } from '@/lib/config';
import FeedbackClientWrapper from '@/components/FeedbackClientWrapper'; import FeedbackClientWrapper from '@/components/FeedbackClientWrapper';
import { setRequestLocale } from 'next-intl/server'; import { setRequestLocale } from 'next-intl/server';
import { Inter } from 'next/font/google'; import { Inter } from 'next/font/google';
@@ -61,6 +59,7 @@ export const viewport: Viewport = {
themeColor: '#001a4d', themeColor: '#001a4d',
}; };
import AutoBrochureModal from '@/components/AutoBrochureModal';
export default async function Layout(props: { export default async function Layout(props: {
children: React.ReactNode; children: React.ReactNode;
params: Promise<{ locale: string }>; params: Promise<{ locale: string }>;
@@ -77,7 +76,7 @@ export default async function Layout(props: {
let messages: Record<string, any> = {}; let messages: Record<string, any> = {};
try { try {
messages = await getMessages(); messages = await getMessages();
} catch (error) { } catch {
messages = {}; messages = {};
} }
@@ -91,6 +90,7 @@ export default async function Layout(props: {
'Home', 'Home',
'Error', 'Error',
'StandardPage', 'StandardPage',
'Brochure',
]; ];
const clientMessages: Record<string, any> = {}; const clientMessages: Record<string, any> = {};
for (const key of clientKeys) { for (const key of clientKeys) {
@@ -160,6 +160,8 @@ export default async function Layout(props: {
<AnalyticsShell /> <AnalyticsShell />
<FeedbackClientWrapper feedbackEnabled={feedbackEnabled} /> <FeedbackClientWrapper feedbackEnabled={feedbackEnabled} />
<AutoBrochureModal />
</NextIntlClientProvider> </NextIntlClientProvider>
</body> </body>
</html> </html>

View File

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

View File

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

View File

@@ -1,5 +1,4 @@
import Reveal from '@/components/Reveal'; import Reveal from '@/components/Reveal';
import Scribble from '@/components/Scribble';
import { Badge, Button, Card, Container, Heading, Section } from '@/components/ui'; import { Badge, Button, Card, Container, Heading, Section } from '@/components/ui';
import { getTranslations, setRequestLocale } from 'next-intl/server'; import { getTranslations, setRequestLocale } from 'next-intl/server';
import { Metadata } from 'next'; import { Metadata } from 'next';
@@ -95,7 +94,7 @@ export default async function ProductsPage({ params }: ProductsPageProps) {
return ( return (
<div className="flex flex-col min-h-screen bg-neutral-light"> <div className="flex flex-col min-h-screen bg-neutral-light">
{/* Hero Section */} {/* Hero Section */}
<section className="relative min-h-[50vh] md:min-h-[70vh] flex items-center pt-32 pb-20 md:pt-40 md:pb-32 overflow-hidden bg-primary-dark"> <section className="relative flex items-center pt-32 pb-16 md:pt-40 md:pb-24 overflow-hidden bg-primary-dark">
<Container className="relative z-10"> <Container className="relative z-10">
<div className="max-w-4xl animate-slide-up"> <div className="max-w-4xl animate-slide-up">
<Badge <Badge
@@ -106,15 +105,7 @@ export default async function ProductsPage({ params }: ProductsPageProps) {
</Badge> </Badge>
<Heading level={1} className="text-white mb-4 md:mb-8"> <Heading level={1} className="text-white mb-4 md:mb-8">
{t.rich('title', { {t.rich('title', {
green: (chunks) => ( green: (chunks) => <span className="text-accent italic">{chunks}</span>,
<span className="relative inline-block">
<span className="relative z-10 text-accent italic">{chunks}</span>
<Scribble
variant="circle"
className="w-[140%] h-[140%] -top-[20%] -left-[20%] text-accent/30 hidden md:block"
/>
</span>
),
})} })}
</Heading> </Heading>
<p className="text-lg md:text-xl text-white/70 leading-relaxed max-w-2xl mb-8 md:mb-12 line-clamp-2 md:line-clamp-none"> <p className="text-lg md:text-xl text-white/70 leading-relaxed max-w-2xl mb-8 md:mb-12 line-clamp-2 md:line-clamp-none">
@@ -223,7 +214,7 @@ export default async function ProductsPage({ params }: ProductsPageProps) {
<div className="absolute top-0 right-0 w-1/2 h-full bg-accent/5 -skew-x-12 translate-x-1/4" /> <div className="absolute top-0 right-0 w-1/2 h-full bg-accent/5 -skew-x-12 translate-x-1/4" />
<div className="relative z-10 flex flex-col lg:flex-row items-center justify-between gap-6 md:gap-12"> <div className="relative z-10 flex flex-col lg:flex-row items-center justify-between gap-6 md:gap-12">
<div className="max-w-2xl text-center lg:text-left"> <div className="max-w-2xl text-center lg:text-left">
<h2 className="text-2xl md:text-5xl lg:text-6xl font-bold text-white mb-4 md:mb-8 tracking-tight"> <h2 className="text-2xl md:text-4xl font-bold text-white mb-4 md:mb-8 tracking-tight">
{t('cta.title')} {t('cta.title')}
</h2> </h2>
<p className="text-base md:text-xl text-white/70 leading-relaxed"> <p className="text-base md:text-xl text-white/70 leading-relaxed">

View File

@@ -2,7 +2,7 @@ import { getTranslations, setRequestLocale } from 'next-intl/server';
import { Metadata } from 'next'; import { Metadata } from 'next';
import JsonLd from '@/components/JsonLd'; import JsonLd from '@/components/JsonLd';
import { getBreadcrumbSchema, SITE_URL } from '@/lib/schema'; 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 Image from 'next/image';
import Reveal from '@/components/Reveal'; import Reveal from '@/components/Reveal';
import Gallery from '@/components/team/Gallery'; import Gallery from '@/components/team/Gallery';
@@ -122,12 +122,12 @@ export default async function TeamPage({ params }: TeamPageProps) {
<Badge variant="accent" className="mb-4 md:mb-8"> <Badge variant="accent" className="mb-4 md:mb-8">
{t('michael.role')} {t('michael.role')}
</Badge> </Badge>
<Heading level={2} className="text-white mb-6 md:mb-10 text-3xl md:text-5xl"> <Heading level={2} className="text-white mb-6 md:mb-10 text-2xl md:text-4xl">
<span className="text-white">{t('michael.name')}</span> <span className="text-white">{t('michael.name')}</span>
</Heading> </Heading>
<div className="relative mb-6 md:mb-12"> <div className="relative mb-6 md:mb-12">
<div className="absolute -left-4 md:-left-8 top-0 bottom-0 w-1 md:w-1.5 bg-accent rounded-full" /> <div className="absolute -left-4 md:-left-8 top-0 bottom-0 w-1 md:w-1.5 bg-accent rounded-full" />
<p className="text-lg md:text-2xl font-bold italic leading-relaxed pl-5 md:pl-8 text-white/90"> <p className="text-base md:text-xl font-bold italic leading-relaxed pl-5 md:pl-8 text-white/90">
{t('michael.quote')} {t('michael.quote')}
</p> </p>
</div> </div>
@@ -156,6 +156,7 @@ export default async function TeamPage({ params }: TeamPageProps) {
alt={t('michael.name')} alt={t('michael.name')}
fill fill
className="object-cover scale-105 hover:scale-100 transition-transform duration-1000" className="object-cover scale-105 hover:scale-100 transition-transform duration-1000"
quality={100}
sizes="(max-width: 1024px) 100vw, 50vw" sizes="(max-width: 1024px) 100vw, 50vw"
/> />
<div className="absolute inset-0 bg-gradient-to-t from-primary-dark/60 lg:bg-gradient-to-r lg:from-primary-dark/20 to-transparent" /> <div className="absolute inset-0 bg-gradient-to-t from-primary-dark/60 lg:bg-gradient-to-r lg:from-primary-dark/20 to-transparent" />
@@ -225,6 +226,7 @@ export default async function TeamPage({ params }: TeamPageProps) {
alt={t('klaus.name')} alt={t('klaus.name')}
fill fill
className="object-cover scale-105 hover:scale-100 transition-transform duration-1000" className="object-cover scale-105 hover:scale-100 transition-transform duration-1000"
quality={100}
sizes="(max-width: 1024px) 100vw, 50vw" sizes="(max-width: 1024px) 100vw, 50vw"
/> />
<div className="absolute inset-0 bg-gradient-to-t from-white/60 lg:bg-gradient-to-l lg:from-primary-dark/20 to-transparent" /> <div className="absolute inset-0 bg-gradient-to-t from-white/60 lg:bg-gradient-to-l lg:from-primary-dark/20 to-transparent" />
@@ -235,12 +237,12 @@ export default async function TeamPage({ params }: TeamPageProps) {
<Badge variant="saturated" className="mb-4 md:mb-8"> <Badge variant="saturated" className="mb-4 md:mb-8">
{t('klaus.role')} {t('klaus.role')}
</Badge> </Badge>
<Heading level={2} className="text-saturated mb-6 md:mb-10 text-3xl md:text-6xl"> <Heading level={2} className="text-saturated mb-6 md:mb-10 text-2xl md:text-4xl">
{t('klaus.name')} {t('klaus.name')}
</Heading> </Heading>
<div className="relative mb-6 md:mb-12"> <div className="relative mb-6 md:mb-12">
<div className="absolute -left-4 md:-left-8 top-0 bottom-0 w-1 md:w-1.5 bg-saturated rounded-full" /> <div className="absolute -left-4 md:-left-8 top-0 bottom-0 w-1 md:w-1.5 bg-saturated rounded-full" />
<p className="text-lg md:text-3xl font-bold italic leading-relaxed pl-5 md:pl-8 text-text-secondary"> <p className="text-base md:text-xl font-bold italic leading-relaxed pl-5 md:pl-8 text-text-secondary">
{t('klaus.quote')} {t('klaus.quote')}
</p> </p>
</div> </div>

123
app/actions/brochure.ts Normal file
View File

@@ -0,0 +1,123 @@
'use server';
import { getServerAppServices } from '@/lib/services/create-services.server';
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 { getPayload } = await import('payload');
const configPromise = (await import('@payload-config')).default;
const payload = await getPayload({ config: configPromise });
await payload.create({
collection: 'form-submissions',
data: {
name: email.split('@')[0],
email,
message: `Brochure download request (${locale})`,
type: 'brochure_download' as any,
},
overrideAccess: true,
});
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' });
}
// 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',
}),
);
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) {
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

@@ -25,6 +25,14 @@ export async function sendContactFormAction(formData: FormData) {
// Track attempt // Track attempt
services.analytics.track('contact-form-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 name = formData.get('name') as string;
const email = formData.get('email') as string; const email = formData.get('email') as string;
const message = formData.get('message') as string; const message = formData.get('message') as string;
@@ -54,6 +62,7 @@ export async function sendContactFormAction(formData: FormData) {
type: productName ? 'product_quote' : 'contact', type: productName ? 'product_quote' : 'contact',
productName: productName || undefined, productName: productName || undefined,
}, },
overrideAccess: true,
}); });
logger.info('Successfully saved form submission to Payload CMS', { logger.info('Successfully saved form submission to Payload CMS', {

View File

@@ -16,6 +16,14 @@ export async function GET() {
const payload = await getPayload({ config: configPromise }); const payload = await getPayload({ config: configPromise });
checks.init = 'ok'; 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) // Verify each collection can be queried (catches missing locale tables, broken migrations)
const collections = ['posts', 'products', 'pages', 'media'] as const; const collections = ['posts', 'products', 'pages', 'media'] as const;
for (const collection of collections) { 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( return NextResponse.json(
{ status: hasErrors ? 'degraded' : 'ok', checks }, { status: hasErrors ? 'degraded' : 'ok', checks },
{ status: hasErrors ? 503 : 200 }, { status: hasErrors ? 503 : 200 },

View File

@@ -0,0 +1,64 @@
import { NextRequest, NextResponse } from 'next/server';
import { getPayload } from 'payload';
import configPromise from '@payload-config';
import { renderToStream } from '@react-pdf/renderer';
import React from 'react';
import { PDFPage } from '@/lib/pdf-page';
export async function GET(req: NextRequest, { params }: { params: Promise<{ slug: string }> }) {
try {
const { slug } = await params;
// Get Payload App
const payload = await getPayload({ config: configPromise });
// Fetch the page
const pages = await payload.find({
collection: 'pages',
where: {
slug: { equals: slug },
_status: { equals: 'published' },
},
limit: 1,
});
if (pages.totalDocs === 0) {
return new NextResponse('Page not found', { status: 404 });
}
const page = pages.docs[0];
// Determine locale from searchParams or default to 'de'
const searchParams = req.nextUrl.searchParams;
const locale = (searchParams.get('locale') as 'en' | 'de') || 'de';
// Render the React-PDF document into a stream
const stream = await renderToStream(<PDFPage page={page} locale={locale} />);
// Pipe the Node.js Readable stream into a valid fetch/Web Response stream
const body = new ReadableStream({
start(controller) {
stream.on('data', (chunk) => controller.enqueue(chunk));
stream.on('end', () => controller.close());
stream.on('error', (err) => controller.error(err));
},
cancel() {
(stream as any).destroy?.();
},
});
const filename = `${slug}.pdf`;
return new NextResponse(body, {
status: 200,
headers: {
'Content-Type': 'application/pdf',
'Content-Disposition': `attachment; filename="${filename}"`,
// Cache control if needed, skip for now.
},
});
} catch (error) {
console.error('Error generating PDF:', error);
return new NextResponse('Internal Server Error', { status: 500 });
}
}

View File

@@ -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 isDebug = new URLSearchParams(window.location.search).has('cms_debug');
const isLocal = config.isDevelopment; const isLocal = config.isDevelopment;
const isTesting = config.isTesting; 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 // Staging and Production should NEVER see this unless forced with ?cms_debug
if (!isLocal && !isTesting && !isDebug) return; if (!isLocal && !isTesting && !isBranch && !isDebug) return;
try { try {
const response = await fetch('/api/health/cms'); 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> <h4 className="font-bold text-sm mb-1">CMS Issue Detected</h4>
<p className="text-xs opacity-90 leading-relaxed mb-3"> <p className="text-xs opacity-90 leading-relaxed mb-3">
{errorMsg === 'relation "products" does not exist' {errorMsg === 'relation "products" does not exist'
? 'The database schema is missing. Please sync your local data to this environment.' ? 'The database schema is missing. Please run migrations for this environment.'
: errorMsg || 'The application cannot connect to the Directus CMS.'} : 'A content service is unavailable. Check the deployment logs for details.'}
</p> </p>
<div className="flex gap-2"> <div className="flex gap-2">
<button <button

View File

@@ -138,7 +138,20 @@ export default function ContactForm() {
<Heading level={3} subtitle={t('form.subtitle')} className="mb-6 md:mb-10"> <Heading level={3} subtitle={t('form.subtitle')} className="mb-6 md:mb-10">
{t('form.title')} {t('form.title')}
</Heading> </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"> <div className="space-y-1 md:space-y-2">
<Label htmlFor="contact-name">{t('form.name')}</Label> <Label htmlFor="contact-name">{t('form.name')}</Label>
<Input <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" /> <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 */} {/* 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 */} {/* 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" /> <div className="absolute inset-0 rounded-2xl bg-accent/20 blur-xl opacity-0 group-hover:opacity-100 transition-opacity duration-500" />
<svg <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" fill="none"
stroke="currentColor" stroke="currentColor"
viewBox="0 0 24 24" viewBox="0 0 24 24"
@@ -54,13 +54,13 @@ export default function DatasheetDownload({ datasheetPath, className }: Datashee
</div> </div>
{/* Text Content */} {/* Text Content */}
<div className="flex-1"> <div className="flex-1 min-w-0">
<div className="flex items-center gap-2 mb-1"> <div className="flex items-center gap-2 mb-1">
<span className="text-[10px] font-black uppercase tracking-[0.2em] text-accent"> <span className="text-[10px] font-black uppercase tracking-[0.2em] text-accent">
PDF Datasheet PDF Datasheet
</span> </span>
</div> </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')} {t('downloadDatasheet')}
</h3> </h3>
<p className="mt-2 text-sm font-medium text-white/60 leading-relaxed"> <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> </div>
{/* Arrow Icon */} {/* 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 <svg
className="h-6 w-6" className="h-5 w-5"
fill="none" fill="none"
stroke="currentColor" stroke="currentColor"
viewBox="0 0 24 24" 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

@@ -6,6 +6,7 @@ import { useTranslations, useLocale } from 'next-intl';
import { Container } from './ui'; import { Container } from './ui';
import { useAnalytics } from './analytics/useAnalytics'; import { useAnalytics } from './analytics/useAnalytics';
import { AnalyticsEvents } from './analytics/analytics-events'; import { AnalyticsEvents } from './analytics/analytics-events';
import FooterBrochureForm from './FooterBrochureForm';
export default function Footer() { export default function Footer() {
const t = useTranslations('Footer'); const t = useTranslations('Footer');
@@ -243,6 +244,10 @@ export default function Footer() {
</div> </div>
</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"> <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> <p>{t('copyright', { year: currentYear })}</p>
<div className="flex gap-8"> <div className="flex gap-8">

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

@@ -36,6 +36,7 @@ export default function Header() {
// Prevent scroll when mobile menu is open and handle focus trap // Prevent scroll when mobile menu is open and handle focus trap
useEffect(() => { useEffect(() => {
if (isMobileMenuOpen) { if (isMobileMenuOpen) {
document.documentElement.style.overflow = 'hidden';
document.body.style.overflow = 'hidden'; document.body.style.overflow = 'hidden';
// Focus trap logic // Focus trap logic
const focusableElements = mobileMenuRef.current?.querySelectorAll( const focusableElements = mobileMenuRef.current?.querySelectorAll(
@@ -80,7 +81,8 @@ export default function Header() {
}; };
} }
} else { } else {
document.body.style.overflow = 'unset'; document.documentElement.style.overflow = '';
document.body.style.overflow = '';
} }
}, [isMobileMenuOpen]); }, [isMobileMenuOpen]);

View File

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

View File

@@ -0,0 +1,39 @@
'use client';
import React, { useState, useEffect } from 'react';
interface ObfuscatedEmailProps {
email: string;
className?: string;
children?: React.ReactNode;
}
/**
* A component that helps protect email addresses from simple spambots.
* It uses client-side mounting to render the actual email address,
* making it harder for static crawlers to harvest.
*/
export default function ObfuscatedEmail({ email, className = '', children }: ObfuscatedEmailProps) {
const [mounted, setMounted] = useState(false);
useEffect(() => {
// eslint-disable-next-line react-hooks/set-state-in-effect
setMounted(true);
}, []);
if (!mounted) {
// Show a placeholder or obscured version during SSR
return (
<span className={className} aria-hidden="true">
{children || email.replace('@', ' [at] ').replace(/\./g, ' [dot] ')}
</span>
);
}
// Once mounted on the client, render the real mailto link
return (
<a href={`mailto:${email}`} className={className}>
{children || email}
</a>
);
}

View File

@@ -0,0 +1,42 @@
'use client';
import React, { useState, useEffect } from 'react';
interface ObfuscatedPhoneProps {
phone: string;
className?: string;
children?: React.ReactNode;
}
/**
* A component that helps protect phone numbers from simple spambots.
* It stays obscured during SSR and hydrates into a functional tel: link on the client.
*/
export default function ObfuscatedPhone({ phone, className = '', children }: ObfuscatedPhoneProps) {
const [mounted, setMounted] = useState(false);
useEffect(() => {
// eslint-disable-next-line react-hooks/set-state-in-effect
setMounted(true);
}, []);
// Format phone number for tel: link (remove spaces, etc.)
const telLink = `tel:${phone.replace(/\s+/g, '')}`;
if (!mounted) {
// Show a placeholder or obscured version during SSR
// e.g. +49 881 925 [at] 37298
const obscured = phone.replace(/(\d{3})(\d{3})$/, ' $1...$2');
return (
<span className={className} aria-hidden="true">
{children || obscured}
</span>
);
}
return (
<a href={telLink} className={className}>
{children || phone}
</a>
);
}

View File

@@ -0,0 +1,34 @@
'use client';
import React from 'react';
import { usePathname } from 'next/navigation';
export const PDFDownloadBlock: React.FC<{ label: string; style: string }> = ({ label, style }) => {
const pathname = usePathname();
// Extract slug from pathname
const segments = pathname.split('/').filter(Boolean);
// Pathname is usually /[locale]/[slug] or /[locale]/products/[slug]
// We want the page slug.
const slug = segments[segments.length - 1] || 'home';
const href = `/api/pages/${slug}/pdf`;
return (
<div className="my-8">
<a
href={href}
className={`inline-flex items-center px-8 py-3.5 font-bold rounded-full transition-all duration-300 shadow-lg hover:shadow-xl group ${
style === 'primary'
? 'bg-primary text-white hover:bg-primary-dark'
: style === 'secondary'
? 'bg-accent text-primary-dark hover:bg-neutral-light'
: 'border-2 border-primary text-primary hover:bg-primary hover:text-white'
}`}
>
<span className="mr-3 transition-transform group-hover:scale-12 bit-bounce">📄</span>
{label}
</a>
</div>
);
};

View File

@@ -1,7 +1,7 @@
import { defaultJSXConverters, RichText } from '@payloadcms/richtext-lexical/react'; import { defaultJSXConverters, RichText } from '@payloadcms/richtext-lexical/react';
import type { JSXConverters } from '@payloadcms/richtext-lexical/react'; import type { JSXConverters } from '@payloadcms/richtext-lexical/react';
import Image from 'next/image'; import Image from 'next/image';
import { Suspense } from 'react'; import { Suspense, Fragment } from 'react';
// Import all custom React components that were previously mapped via Markdown // Import all custom React components that were previously mapped via Markdown
import StickyNarrative from '@/components/blog/StickyNarrative'; import StickyNarrative from '@/components/blog/StickyNarrative';
@@ -24,6 +24,8 @@ import Reveal from '@/components/Reveal';
import { Badge, Container, Heading, Section, Card } from '@/components/ui'; import { Badge, Container, Heading, Section, Card } from '@/components/ui';
import TrackedLink from '@/components/analytics/TrackedLink'; import TrackedLink from '@/components/analytics/TrackedLink';
import { useLocale } from 'next-intl'; import { useLocale } from 'next-intl';
import ObfuscatedEmail from '@/components/ObfuscatedEmail';
import ObfuscatedPhone from '@/components/ObfuscatedPhone';
import HomeHero from '@/components/home/Hero'; import HomeHero from '@/components/home/Hero';
import ProductCategories from '@/components/home/ProductCategories'; import ProductCategories from '@/components/home/ProductCategories';
@@ -35,10 +37,97 @@ import MeetTheTeam from '@/components/home/MeetTheTeam';
import GallerySection from '@/components/home/GallerySection'; import GallerySection from '@/components/home/GallerySection';
import VideoSection from '@/components/home/VideoSection'; import VideoSection from '@/components/home/VideoSection';
import CTA from '@/components/home/CTA'; import CTA from '@/components/home/CTA';
import { PDFDownloadBlock } from '@/components/PDFDownloadBlock';
/**
* Splits a text string on \n and intersperses <br /> elements.
* This is needed because Lexical stores newlines as literal \n characters inside
* text nodes (e.g. dash-lists typed in the editor), but HTML collapses whitespace.
*/
function textWithLineBreaks(text: string, key: string) {
const parts = text.split('\n');
if (parts.length === 1) return text;
return parts.map((part, i) => (
<Fragment key={`${key}-${i}`}>
{part}
{i < parts.length - 1 && <br />}
</Fragment>
));
}
const jsxConverters: JSXConverters = { const jsxConverters: JSXConverters = {
...defaultJSXConverters, ...defaultJSXConverters,
// Let the default converters handle text nodes to preserve valid formatting // Handle Lexical linebreak nodes (explicit shift+enter)
linebreak: () => <br />,
// Custom text converter: preserve \n inside text nodes as <br /> and obfuscate emails
text: ({ node }: any) => {
let content: React.ReactNode = node.text || '';
// Split newlines first
if (typeof content === 'string' && content.includes('\n')) {
content = textWithLineBreaks(content, `t-${(node.text || '').slice(0, 8)}`);
}
// Obfuscate emails in text content
if (typeof content === 'string' && content.includes('@')) {
const emailRegex = /([a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,})/g;
const parts = content.split(emailRegex);
content = parts.map((part, i) => {
if (part.match(emailRegex)) {
return <ObfuscatedEmail key={`e-${i}`} email={part} />;
}
return part;
});
}
// Obfuscate phone numbers in text content (simple pattern for +XX XXX ...)
if (typeof content === 'string' && content.match(/\+\d+/)) {
const phoneRegex = /(\+\d{1,4}[\d\s-]{5,15})/g;
const parts = content.split(phoneRegex);
content = parts.map((part, i) => {
if (part.match(phoneRegex)) {
return <ObfuscatedPhone key={`p-${i}`} phone={part} />;
}
return part;
});
}
// Handle array content (from previous mappings)
if (Array.isArray(content)) {
content = content.map((item, idx) => {
if (typeof item === 'string') {
// Re-apply phone regex to strings in array
const phoneRegex = /(\+\d{1,4}[\d\s-]{5,15})/g;
if (item.match(phoneRegex)) {
const parts = item.split(phoneRegex);
return parts.map((part, i) => {
if (part.match(phoneRegex)) {
return <ObfuscatedPhone key={`p-${idx}-${i}`} phone={part} />;
}
return part;
});
}
}
return item;
});
}
// Apply Lexical formatting flags
if (node.format) {
if (node.format & 1) content = <strong>{content}</strong>;
if (node.format & 2) content = <em>{content}</em>;
if (node.format & 8) content = <u>{content}</u>;
if (node.format & 4) content = <s>{content}</s>;
if (node.format & 16)
content = (
<code className="px-1.5 py-0.5 bg-neutral-100 rounded text-sm font-mono text-primary">
{content}
</code>
);
if (node.format & 32) content = <sub>{content}</sub>;
if (node.format & 64) content = <sup>{content}</sup>;
}
return <>{content}</>;
},
// Use div instead of p for paragraphs to allow nested block elements (like the lists above) // Use div instead of p for paragraphs to allow nested block elements (like the lists above)
paragraph: ({ node, nodesToJSX }: any) => { paragraph: ({ node, nodesToJSX }: any) => {
return ( return (
@@ -73,7 +162,7 @@ const jsxConverters: JSXConverters = {
return ( return (
<h2 <h2
id={id} id={id}
className="text-3xl md:text-4xl font-bold mt-12 mb-6 text-text-primary scroll-mt-24" className="text-2xl md:text-3xl font-bold mt-10 mb-5 text-text-primary scroll-mt-24"
> >
{children} {children}
</h2> </h2>
@@ -82,7 +171,7 @@ const jsxConverters: JSXConverters = {
return ( return (
<h3 <h3
id={id} id={id}
className="text-2xl md:text-3xl font-bold mt-10 mb-5 text-text-primary scroll-mt-24" className="text-xl md:text-2xl font-bold mt-8 mb-4 text-text-primary scroll-mt-24"
> >
{children} {children}
</h3> </h3>
@@ -91,7 +180,7 @@ const jsxConverters: JSXConverters = {
return ( return (
<h4 <h4
id={id} id={id}
className="text-xl md:text-2xl font-bold mt-8 mb-4 text-text-primary scroll-mt-24" className="text-lg md:text-xl font-bold mt-6 mb-3 text-text-primary scroll-mt-24"
> >
{children} {children}
</h4> </h4>
@@ -168,6 +257,17 @@ const jsxConverters: JSXConverters = {
// Handling Payload CMS link nodes // Handling Payload CMS link nodes
const href = node?.fields?.url || node?.url || '#'; const href = node?.fields?.url || node?.url || '#';
const newTab = node?.fields?.newTab || node?.newTab; const newTab = node?.fields?.newTab || node?.newTab;
if (href.startsWith('mailto:')) {
const email = href.replace('mailto:', '');
return (
<ObfuscatedEmail
email={email}
className="text-primary no-underline hover:underline font-medium transition-colors"
/>
);
}
return ( return (
<a <a
href={href} href={href}
@@ -330,6 +430,12 @@ const jsxConverters: JSXConverters = {
{node.fields.content && <RichText data={node.fields.content} converters={jsxConverters} />} {node.fields.content && <RichText data={node.fields.content} converters={jsxConverters} />}
</ProductTabs> </ProductTabs>
), ),
pdfDownload: ({ node }: any) => (
<PDFDownloadBlock label={node.fields.label} style={node.fields.style} />
),
'block-pdfDownload': ({ node }: any) => (
<PDFDownloadBlock label={node.fields.label} style={node.fields.style} />
),
// ─── New Page Blocks ─────────────────────────────────────────── // ─── New Page Blocks ───────────────────────────────────────────
heroSection: ({ node }: any) => { heroSection: ({ node }: any) => {
const f = node.fields; const f = node.fields;
@@ -687,8 +793,8 @@ const jsxConverters: JSXConverters = {
</Section> </Section>
); );
}, },
imageGallery: ({ node }: any) => <Gallery />, imageGallery: () => <Gallery />,
'block-imageGallery': ({ node }: any) => <Gallery />, 'block-imageGallery': () => <Gallery />,
categoryGrid: ({ node }: any) => { categoryGrid: ({ node }: any) => {
const cats = node.fields.categories || []; const cats = node.fields.categories || [];
return ( return (

View File

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

View File

@@ -2,6 +2,7 @@
import React, { useState } from 'react'; import React, { useState } from 'react';
import { useTranslations } from 'next-intl'; import { useTranslations } from 'next-intl';
import { formatTechnicalValue } from '@/lib/utils/technical';
interface KeyValueItem { interface KeyValueItem {
label: string; label: string;
@@ -45,22 +46,40 @@ export default function ProductTechnicalData({ data }: ProductTechnicalDataProps
<div className="w-2 h-8 bg-accent rounded-full" /> <div className="w-2 h-8 bg-accent rounded-full" />
General Data General Data
</h3> </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"> <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) => ( {technicalItems.map((item, idx) => {
<div key={idx} className="flex flex-col group"> const formatted = formatTechnicalValue(item.value);
<dt className="text-sm font-bold uppercase tracking-widest text-primary/40 mb-2 group-hover:text-accent transition-colors"> return (
{item.label} <div key={idx} className="flex flex-col group">
</dt> <dt className="text-sm font-bold uppercase tracking-widest text-primary/40 mb-2 group-hover:text-accent transition-colors">
<dd className="text-lg font-semibold text-text-primary"> {item.label}
{item.value}{' '} </dt>
{item.unit && ( <dd className="text-lg font-semibold text-text-primary">
<span className="text-sm font-normal text-text-secondary ml-1"> {formatted.isList ? (
{item.unit} <div className="flex flex-wrap gap-2 mt-1">
</span> {formatted.parts.map((p, pIdx) => (
)} <span
</dd> key={pIdx}
</div> 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> </dl>
</div> </div>
)} )}
@@ -77,7 +96,7 @@ export default function ProductTechnicalData({ data }: ProductTechnicalDataProps
<h3 className="text-2xl font-bold text-primary mb-8 flex items-center gap-3"> <h3 className="text-2xl font-bold text-primary mb-8 flex items-center gap-3">
<div className="w-2 h-8 bg-accent rounded-full" /> <div className="w-2 h-8 bg-accent rounded-full" />
{table.voltageLabel !== 'Voltage unknown' && {table.voltageLabel !== 'Voltage unknown' &&
table.voltageLabel !== 'Spannung unbekannt' table.voltageLabel !== 'Spannung unbekannt'
? table.voltageLabel ? table.voltageLabel
: 'Technical Specifications'} : 'Technical Specifications'}
</h3> </h3>
@@ -102,9 +121,8 @@ export default function ProductTechnicalData({ data }: ProductTechnicalDataProps
<div className="pointer-events-none absolute right-0 top-0 h-full w-8 bg-gradient-to-l from-white to-transparent z-20 md:hidden" /> <div className="pointer-events-none absolute right-0 top-0 h-full w-8 bg-gradient-to-l from-white to-transparent z-20 md:hidden" />
<div <div
id={`voltage-table-${idx}`} id={`voltage-table-${idx}`}
className={`overflow-x-auto -mx-5 md:-mx-12 px-5 md:px-12 transition-all duration-500 ease-in-out ${ className={`overflow-x-auto -mx-5 md:-mx-12 px-5 md:px-12 transition-all duration-500 ease-in-out ${!isExpanded && hasManyRows ? 'max-h-[400px] overflow-y-hidden' : 'max-h-[none]'
!isExpanded && hasManyRows ? 'max-h-[400px] overflow-y-hidden' : 'max-h-[none]' }`}
}`}
> >
<table className="min-w-full border-separate border-spacing-0"> <table className="min-w-full border-separate border-spacing-0">
<thead> <thead>

View File

@@ -164,7 +164,17 @@ export default function RequestQuoteForm({ productName }: RequestQuoteFormProps)
} }
return ( 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-2 !mt-0">
<div className="space-y-1 !mt-0"> <div className="space-y-1 !mt-0">
<label htmlFor={emailId} className="sr-only"> <label htmlFor={emailId} className="sr-only">

View File

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

View File

@@ -1,5 +1,5 @@
import React from 'react';
import Scribble from '@/components/Scribble'; import Scribble from '@/components/Scribble';
import { formatTechnicalValue } from '@/lib/utils/technical';
interface TechnicalGridItem { interface TechnicalGridItem {
label: string; label: string;
@@ -18,25 +18,41 @@ export default function TechnicalGrid({ title, items }: TechnicalGridProps) {
<h3 className="text-2xl font-bold text-text-primary mb-8 flex items-center gap-4 relative"> <h3 className="text-2xl font-bold text-text-primary mb-8 flex items-center gap-4 relative">
<span className="relative inline-block"> <span className="relative inline-block">
{title} {title}
<Scribble <Scribble
variant="underline" variant="underline"
className="absolute -bottom-2 left-0 w-full h-3 text-accent/40" className="absolute -bottom-2 left-0 w-full h-3 text-accent/40"
/> />
</span> </span>
</h3> </h3>
)} )}
<div className="grid grid-cols-1 md:grid-cols-2 gap-6"> <div className="grid grid-cols-1 md:grid-cols-2 gap-6">
{items.map((item, index) => ( {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"> const formatted = formatTechnicalValue(item.value);
<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" /> return (
<span className="block text-xs font-bold text-primary uppercase tracking-[0.2em] mb-3 opacity-70"> <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">
{item.label} <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> <span className="block text-xs font-bold text-primary uppercase tracking-[0.2em] mb-3 opacity-70">
<span className="text-lg text-text-secondary leading-relaxed group-hover:text-text-primary transition-colors"> {item.label}
{item.value} </span>
</span> <div className="text-lg text-text-secondary leading-relaxed group-hover:text-text-primary transition-colors">
</div> {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>
</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

@@ -15,6 +15,7 @@ export default function Experience({ data }: { data?: any }) {
fill fill
className="object-cover object-center scale-105 animate-slow-zoom" className="object-cover object-center scale-105 animate-slow-zoom"
sizes="100vw" sizes="100vw"
quality={100}
/> />
<div className="absolute inset-0 bg-primary/80 mix-blend-multiply" /> <div className="absolute inset-0 bg-primary/80 mix-blend-multiply" />
<div className="absolute inset-0 bg-gradient-to-r from-primary via-primary/40 to-transparent" /> <div className="absolute inset-0 bg-gradient-to-r from-primary via-primary/40 to-transparent" />

View File

@@ -1,6 +1,5 @@
'use client'; 'use client';
import Scribble from '@/components/Scribble';
import { Button, Container, Heading, Section } from '@/components/ui'; import { Button, Container, Heading, Section } from '@/components/ui';
import { useTranslations, useLocale } from 'next-intl'; import { useTranslations, useLocale } from 'next-intl';
import dynamic from 'next/dynamic'; import dynamic from 'next/dynamic';
@@ -20,45 +19,19 @@ export default function Hero({ data }: { data?: any }) {
<div> <div>
<Heading <Heading
level={1} 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]" 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 ? ( {data?.title ? (
<> <span
{data.title.split(/(<green>.*?<\/green>)/g).map((part: string, i: number) => { dangerouslySetInnerHTML={{
if (part.startsWith('<green>') && part.endsWith('</green>')) { __html: data.title
const content = part.replace(/<\/?green>/g, ''); .replace(/<green>/g, '<span class="text-accent italic">')
return ( .replace(/<\/green>/g, '</span>'),
<span key={i} className="relative inline-block"> }}
<span className="relative z-10 text-accent italic inline-block"> />
{content}
</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>
);
}
return <span key={i}>{part}</span>;
})}
</>
) : ( ) : (
t.rich('title', { t.rich('title', {
green: (chunks) => ( green: (chunks) => <span className="text-accent italic">{chunks}</span>,
<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> </Heading>

View File

@@ -17,6 +17,7 @@ export default function MeetTheTeam({ data }: { data?: any }) {
fill fill
className="object-cover scale-105 animate-slow-zoom" className="object-cover scale-105 animate-slow-zoom"
sizes="100vw" sizes="100vw"
quality={100}
/> />
<div className="absolute inset-0 bg-primary/70 mix-blend-multiply" /> <div className="absolute inset-0 bg-primary/70 mix-blend-multiply" />
<div className="absolute inset-0 bg-gradient-to-t from-primary via-primary/20 to-transparent" /> <div className="absolute inset-0 bg-gradient-to-t from-primary via-primary/20 to-transparent" />

View File

@@ -74,11 +74,14 @@ export default async function RecentPosts({ locale, data }: RecentPostsProps) {
suppressHydrationWarning suppressHydrationWarning
className="px-3 py-1 text-white/80 text-[10px] md:text-xs font-bold uppercase tracking-widest border border-white/20 rounded-full bg-white/10 backdrop-blur-md" 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 || 'de', { {new Date(post.frontmatter.date).toLocaleDateString(
year: 'numeric', ['en', 'de'].includes(locale) ? locale : 'de',
month: 'short', {
day: 'numeric', year: 'numeric',
})} month: 'short',
day: 'numeric',
},
)}
</time> </time>
{(new Date(post.frontmatter.date) > new Date() || {(new Date(post.frontmatter.date) > new Date() ||
post.frontmatter.public === false) && ( post.frontmatter.public === false) && (

View File

@@ -1,7 +1,6 @@
'use client'; 'use client';
import React, { useState, useEffect, useRef } from 'react'; import React, { useState, useEffect, useRef } from 'react';
import Scribble from '@/components/Scribble';
import { useTranslations } from 'next-intl'; import { useTranslations } from 'next-intl';
export default function VideoSection({ data }: { data?: any }) { export default function VideoSection({ data }: { data?: any }) {
@@ -41,18 +40,16 @@ export default function VideoSection({ data }: { data?: any }) {
<div className="max-w-5xl px-6 text-center animate-slide-up pointer-events-auto"> <div className="max-w-5xl px-6 text-center animate-slide-up pointer-events-auto">
<h2 className="text-3xl md:text-4xl lg:text-5xl font-extrabold text-white leading-[1.1]"> <h2 className="text-3xl md:text-4xl lg:text-5xl font-extrabold text-white leading-[1.1]">
{data?.title ? ( {data?.title ? (
<span dangerouslySetInnerHTML={{ __html: data.title.replace(/<future>/g, '<span class="relative inline-block mx-2"><span class="relative z-10 italic text-accent">').replace(/<\/future>/g, '</span><Scribble variant="underline" class="w-full h-4 -bottom-2 left-0 text-accent/40" /></span>') }} /> <span
dangerouslySetInnerHTML={{
__html: data.title
.replace(/<future>/g, '<span class="italic text-accent">')
.replace(/<\/future>/g, '</span>'),
}}
/>
) : ( ) : (
t.rich('title', { t.rich('title', {
future: (chunks) => ( future: (chunks) => <span className="italic text-accent">{chunks}</span>,
<span className="relative inline-block mx-2">
<span className="relative z-10 italic text-accent">{chunks}</span>
<Scribble
variant="underline"
className="w-full h-4 -bottom-2 left-0 text-accent/40"
/>
</span>
),
}) })
)} )}
</h2> </h2>

View File

@@ -1,15 +1,10 @@
{ {
"ci": { "ci": {
"collect": { "collect": {
"numberOfRuns": 3, "numberOfRuns": 1,
"settings": { "settings": {
"preset": "desktop", "preset": "desktop",
"onlyCategories": [ "onlyCategories": ["performance", "accessibility", "best-practices", "seo"],
"performance",
"accessibility",
"best-practices",
"seo"
],
"chromeFlags": "--no-sandbox --disable-setuid-sandbox" "chromeFlags": "--no-sandbox --disable-setuid-sandbox"
} }
}, },
@@ -18,7 +13,7 @@
"categories:performance": [ "categories:performance": [
"error", "error",
{ {
"minScore": 0.9 "minScore": 0.7
} }
], ],
"categories:accessibility": [ "categories:accessibility": [
@@ -54,4 +49,4 @@
} }
} }
} }
} }

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

@@ -14,6 +14,7 @@ services:
PAYLOAD_SECRET: ${PAYLOAD_SECRET:-fallback-secret-for-production-needs-change} PAYLOAD_SECRET: ${PAYLOAD_SECRET:-fallback-secret-for-production-needs-change}
volumes: volumes:
- klz_media_data:/app/public/media - klz_media_data:/app/public/media
- klz_datasheets:/app/public/datasheets
labels: labels:
- "traefik.enable=true" - "traefik.enable=true"
# HTTP ⇒ HTTPS redirect # HTTP ⇒ HTTPS redirect
@@ -111,3 +112,5 @@ volumes:
external: false external: false
klz_media_data: klz_media_data:
external: false external: false
klz_datasheets:
external: false

19157
kabelhandbuch.txt Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -116,7 +116,7 @@ export async function getPostBySlug(slug: string, locale: string): Promise<PostD
category: doc.category || '', category: doc.category || '',
featuredImage: featuredImage:
typeof doc.featuredImage === 'object' && doc.featuredImage !== null typeof doc.featuredImage === 'object' && doc.featuredImage !== null
? doc.featuredImage.sizes?.card?.url || doc.featuredImage.url ? doc.featuredImage.url || doc.featuredImage.sizes?.card?.url
: null, : null,
focalX: focalX:
typeof doc.featuredImage === 'object' && doc.featuredImage !== null typeof doc.featuredImage === 'object' && doc.featuredImage !== null
@@ -162,7 +162,7 @@ export async function getAllPosts(locale: string): Promise<PostData[]> {
category: doc.category || '', category: doc.category || '',
featuredImage: featuredImage:
typeof doc.featuredImage === 'object' && doc.featuredImage !== null typeof doc.featuredImage === 'object' && doc.featuredImage !== null
? doc.featuredImage.sizes?.card?.url || doc.featuredImage.url ? doc.featuredImage.url || doc.featuredImage.sizes?.card?.url
: null, : null,
focalX: focalX:
typeof doc.featuredImage === 'object' && doc.featuredImage !== null typeof doc.featuredImage === 'object' && doc.featuredImage !== null

View File

@@ -7,7 +7,7 @@ import path from 'path';
*/ */
export function getDatasheetPath(slug: string, locale: string): string | null { export function getDatasheetPath(slug: string, locale: string): string | null {
const datasheetsDir = path.join(process.cwd(), 'public', 'datasheets'); const datasheetsDir = path.join(process.cwd(), 'public', 'datasheets');
if (!fs.existsSync(datasheetsDir)) { if (!fs.existsSync(datasheetsDir)) {
return null; return null;
} }
@@ -16,16 +16,21 @@ export function getDatasheetPath(slug: string, locale: string): string | null {
const normalizedSlug = slug.replace(/-hv$|-mv$/, ''); const normalizedSlug = slug.replace(/-hv$|-mv$/, '');
// Subdirectories to search in // 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 // 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 = [ const patterns = [
`${slug}-${locale}.pdf`, `${slug}-${locale}.pdf`,
`${slug}-2-${locale}.pdf`, `${slug}-2-${locale}.pdf`,
`${slug}-3-${locale}.pdf`, `${slug}-3-${locale}.pdf`,
`${slug}-mv-${locale}.pdf`,
`${slug}-hv-${locale}.pdf`,
`${normalizedSlug}-${locale}.pdf`, `${normalizedSlug}-${locale}.pdf`,
`${normalizedSlug}-2-${locale}.pdf`, `${normalizedSlug}-2-${locale}.pdf`,
`${normalizedSlug}-3-${locale}.pdf`, `${normalizedSlug}-3-${locale}.pdf`,
`${normalizedSlug}-mv-${locale}.pdf`,
`${normalizedSlug}-hv-${locale}.pdf`,
]; ];
for (const subdir of subdirs) { for (const subdir of subdirs) {
@@ -44,9 +49,70 @@ export function getDatasheetPath(slug: string, locale: string): string | null {
`${slug}-en.pdf`, `${slug}-en.pdf`,
`${slug}-2-en.pdf`, `${slug}-2-en.pdf`,
`${slug}-3-en.pdf`, `${slug}-3-en.pdf`,
`${slug}-mv-en.pdf`,
`${slug}-hv-en.pdf`,
`${normalizedSlug}-en.pdf`, `${normalizedSlug}-en.pdf`,
`${normalizedSlug}-2-en.pdf`, `${normalizedSlug}-2-en.pdf`,
`${normalizedSlug}-3-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 subdir of subdirs) {
for (const pattern of enPatterns) { for (const pattern of enPatterns) {

1436
lib/pdf-brochure.tsx Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -1,287 +1,220 @@
import * as React from 'react'; import * as React from 'react';
import { import { Document, Page, View, Text, Image, StyleSheet, Font } from '@react-pdf/renderer';
Document, import type { DatasheetVoltageTable, KeyValueItem } from '../scripts/pdf/model/types';
Page,
View,
Text,
Image,
StyleSheet,
Font,
} from '@react-pdf/renderer';
// Register fonts (using system fonts for now, can be customized) // Standard built-in fonts are used.
Font.register({ Font.registerHyphenationCallback((word) => [word]);
family: 'Helvetica',
fonts: [ const C = {
{ src: '/fonts/Helvetica.ttf', fontWeight: 400 }, navy: '#001a4d',
{ src: '/fonts/Helvetica-Bold.ttf', fontWeight: 700 }, 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({ const styles = StyleSheet.create({
page: { page: {
color: '#111827', // Text Primary paddingHorizontal: MARGIN,
lineHeight: 1.5, paddingBottom: 80,
backgroundColor: '#FFFFFF', paddingTop: 40,
paddingTop: 0,
paddingBottom: 100,
fontFamily: 'Helvetica', fontFamily: 'Helvetica',
backgroundColor: C.white,
color: C.gray900,
}, },
hero: { paddingBottom: 20, marginBottom: 10 },
// Hero-style header
hero: {
backgroundColor: '#FFFFFF',
paddingTop: 24,
paddingBottom: 0,
paddingHorizontal: 72,
marginBottom: 20,
position: 'relative',
borderBottomWidth: 0,
borderBottomColor: '#e5e7eb',
},
header: { header: {
flexDirection: 'row', flexDirection: 'row',
justifyContent: 'space-between', justifyContent: 'space-between',
alignItems: 'center', alignItems: 'center',
marginBottom: 16, marginBottom: 16,
}, },
logoText: { logoText: {
fontSize: 24, fontSize: 22,
fontWeight: 700, fontWeight: 700,
color: '#000d26', color: C.navyDeep,
letterSpacing: 1,
textTransform: 'uppercase',
},
docTitle: {
fontSize: 10,
fontWeight: 700,
color: '#001a4d',
letterSpacing: 2, letterSpacing: 2,
textTransform: 'uppercase', textTransform: 'uppercase',
}, },
docTitle: {
productRow: { fontSize: 8,
flexDirection: 'row', fontWeight: 700,
alignItems: 'center', color: C.green,
gap: 20, letterSpacing: 2,
}, textTransform: 'uppercase',
productInfoCol: {
flex: 1,
justifyContent: 'center',
}, },
productRow: { flexDirection: 'row', alignItems: 'center', gap: 20 },
productInfoCol: { flex: 1, justifyContent: 'center' },
productImageCol: { productImageCol: {
flex: 1, flex: 1,
height: 120, height: 120,
justifyContent: 'center', justifyContent: 'center',
alignItems: 'center', alignItems: 'center',
borderRadius: 8, borderRadius: 4,
borderWidth: 1, borderWidth: 1,
borderColor: '#e5e7eb', borderColor: C.gray200,
backgroundColor: '#FFFFFF', backgroundColor: C.white,
overflow: 'hidden', overflow: 'hidden',
}, },
// Product Hero Info
productHero: {
marginTop: 0,
},
productName: { productName: {
fontSize: 24, fontSize: 24,
fontWeight: 700, fontWeight: 700,
color: '#000d26', color: C.navyDeep,
marginBottom: 0,
textTransform: 'uppercase', textTransform: 'uppercase',
letterSpacing: -0.5, letterSpacing: -0.5,
}, },
productMeta: { productMeta: {
fontSize: 10, fontSize: 10,
color: '#4b5563', color: C.gray600,
fontWeight: 700, fontWeight: 700,
textTransform: 'uppercase', textTransform: 'uppercase',
letterSpacing: 1, letterSpacing: 1,
marginBottom: 2,
}, },
heroImage: { width: '100%', height: '100%', objectFit: 'contain' },
noImage: { fontSize: 8, color: C.gray400, textAlign: 'center' },
heroImage: { content: {},
width: '100%', section: { marginBottom: 20 },
height: '100%',
objectFit: 'contain',
},
noImage: {
fontSize: 8,
color: '#9ca3af',
textAlign: 'center',
},
// Content Area
content: {
paddingHorizontal: 72,
},
// Content sections
section: {
marginBottom: 20,
},
sectionTitle: { sectionTitle: {
fontSize: 14, fontSize: 8,
fontWeight: 700, fontWeight: 700,
color: '#000d26', // Primary Dark color: C.green,
marginBottom: 8, marginBottom: 6,
textTransform: 'uppercase', textTransform: 'uppercase',
letterSpacing: -0.2, letterSpacing: 1.5,
}, },
sectionAccent: { sectionAccent: {
width: 30, width: 30,
height: 3, height: 2,
backgroundColor: '#82ed20', // Accent Green backgroundColor: C.green,
marginBottom: 8, marginBottom: 8,
borderRadius: 1.5, borderRadius: 1,
},
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,
}, },
description: { fontSize: 10, lineHeight: 1.7, color: C.gray600 },
categories: { flexDirection: 'row', flexWrap: 'wrap', gap: 6 },
categoryTag: { categoryTag: {
backgroundColor: '#f8f9fa', backgroundColor: C.offWhite,
paddingHorizontal: 12, paddingHorizontal: 10,
paddingVertical: 6, paddingVertical: 4,
border: '1px solid #e5e7eb', borderWidth: 0.5,
borderRadius: 100, borderColor: C.gray200,
borderRadius: 3,
}, },
categoryText: { categoryText: {
fontSize: 8, fontSize: 7,
color: '#4b5563', color: C.gray600,
fontWeight: 700, fontWeight: 700,
textTransform: 'uppercase', textTransform: 'uppercase',
letterSpacing: 0.5, letterSpacing: 0.5,
}, },
// Footer
footer: { footer: {
position: 'absolute', position: 'absolute',
bottom: 40, bottom: 28,
left: 72, left: MARGIN,
right: 72, right: MARGIN,
flexDirection: 'row', flexDirection: 'row',
justifyContent: 'space-between', justifyContent: 'space-between',
alignItems: 'center', alignItems: 'center',
paddingTop: 24, paddingTop: 12,
borderTop: '1px solid #e5e7eb', borderTopWidth: 2,
borderTopColor: C.green,
}, },
footerText: { footerText: {
fontSize: 8, fontSize: 7,
color: '#9ca3af', color: C.gray400,
fontWeight: 500, fontWeight: 400,
textTransform: 'uppercase', textTransform: 'uppercase',
letterSpacing: 1, letterSpacing: 0.8,
},
footerBrand: {
fontSize: 9,
fontWeight: 700,
color: C.navyDeep,
textTransform: 'uppercase',
letterSpacing: 1.5,
}, },
footerBrand: { kvGrid: { width: '100%', borderWidth: 1, borderColor: C.gray200 },
fontSize: 10, kvRow: { flexDirection: 'row', borderBottomWidth: 1, borderBottomColor: C.gray200 },
fontWeight: 700, kvRowAlt: { backgroundColor: C.offWhite },
color: '#000d26', kvRowLast: { borderBottomWidth: 0 },
textTransform: 'uppercase', kvCell: { paddingVertical: 6, paddingHorizontal: 8 },
letterSpacing: 1, 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 },
}); });
interface ProductData { interface ProductData {
id: number; id: number;
name: string; name: string;
shortDescriptionHtml: string;
descriptionHtml: string;
applicationHtml?: string;
images: string[];
featuredImage: string | null;
sku: string; sku: string;
categories: Array<{ name: string }>; categoriesLine?: string;
attributes: Array<{ descriptionText?: string;
name: string; heroSrc?: string | null;
options: string[]; 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 PDFDatasheetProps { export interface PDFDatasheetProps {
product: ProductData; product: ProductData;
locale: 'en' | 'de'; locale: 'en' | 'de';
logoUrl?: string; logoDataUrl?: string | null;
technicalItems?: KeyValueItem[];
voltageTables?: DatasheetVoltageTable[];
legendItems?: KeyValueItem[];
} }
// Helper to strip HTML tags const stripHtml = (html: string): string => html.replace(/<[^>]*>/g, '');
const stripHtml = (html: string): string => {
return html.replace(/<[^>]*>/g, '');
};
// Helper to get translated labels const getLabels = (locale: 'en' | 'de') =>
const getLabels = (locale: 'en' | 'de') => { ({
const labels = {
en: { en: {
productDatasheet: 'Technical Datasheet', productDatasheet: 'Technical Datasheet',
description: 'APPLICATION', description: 'APPLICATION',
@@ -289,6 +222,9 @@ const getLabels = (locale: 'en' | 'de') => {
categories: 'CATEGORIES', categories: 'CATEGORIES',
sku: 'SKU', sku: 'SKU',
noImage: 'No image available', noImage: 'No image available',
crossSection: 'Configurations',
slug_cs: 'Cores & CS',
abbreviations: 'ABBREVIATIONS',
}, },
de: { de: {
productDatasheet: 'Technisches Datenblatt', productDatasheet: 'Technisches Datenblatt',
@@ -297,52 +233,283 @@ const getLabels = (locale: 'en' | 'de') => {
categories: 'KATEGORIEN', categories: 'KATEGORIEN',
sku: 'ARTIKELNUMMER', sku: 'ARTIKELNUMMER',
noImage: 'Kein Bild verfügbar', noImage: 'Kein Bild verfügbar',
crossSection: 'Konfigurationen',
slug_cs: 'Adern & QS',
abbreviations: 'ABKÜRZUNGEN',
}, },
}; })[locale];
return labels[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> = ({ export const PDFDatasheet: React.FC<PDFDatasheetProps> = ({
product, product,
locale, locale,
technicalItems = [],
voltageTables = [],
legendItems = [],
}) => { }) => {
const labels = getLabels(locale); const labels = getLabels(locale);
const description = stripHtml(
product.applicationHtml ||
product.shortDescriptionHtml ||
product.descriptionHtml ||
product.descriptionText ||
'',
);
return ( return (
<Document> <Document>
<Page size="A4" style={styles.page}> <Page size="A4" style={styles.page}>
{/* Hero Header */}
<View style={styles.hero}> <View style={styles.hero}>
<View style={styles.header}> <View style={styles.header}>
<View> <View style={{ width: 80 }}>
<Text style={styles.logoText}>KLZ</Text> {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> </View>
<Text style={styles.docTitle}> <Text style={styles.docTitle}>{labels.productDatasheet}</Text>
{labels.productDatasheet}
</Text>
</View> </View>
<View style={styles.productRow}> <View style={styles.productRow}>
<View style={styles.productInfoCol}> <View style={styles.productInfoCol}>
<View style={styles.productHero}> <Text style={styles.productMeta}>
<View style={styles.categories}> {product.categoriesLine ||
{product.categories.map((cat, index) => ( (product.categories || []).map((c) => c.name).join(' • ')}
<Text key={index} style={styles.productMeta}> </Text>
{cat.name}{index < product.categories.length - 1 ? ' • ' : ''} <Text style={styles.productName}>{product.name}</Text>
</Text>
))}
</View>
<Text style={styles.productName}>{product.name}</Text>
</View>
</View> </View>
<View style={styles.productImageCol}> <View style={styles.productImageCol}>
{product.featuredImage ? ( {product.featuredImage ? (
<Image <Image src={product.featuredImage} style={styles.heroImage} />
src={product.featuredImage}
style={styles.heroImage}
/>
) : ( ) : (
<Text style={styles.noImage}>{labels.noImage}</Text> <Text style={styles.noImage}>{labels.noImage}</Text>
)} )}
</View> </View>
@@ -350,65 +517,93 @@ export const PDFDatasheet: React.FC<PDFDatasheetProps> = ({
</View> </View>
<View style={styles.content}> <View style={styles.content}>
{/* Description section */} {description && (
{(product.applicationHtml || product.shortDescriptionHtml || product.descriptionHtml) && (
<View style={styles.section}> <View style={styles.section}>
<Text style={styles.sectionTitle}>{labels.description}</Text> <Text style={styles.sectionTitle}>{labels.description}</Text>
<View style={styles.sectionAccent} /> <View style={styles.sectionAccent} />
<Text style={styles.description}> <Text style={styles.description}>{description}</Text>
{stripHtml(product.applicationHtml || product.shortDescriptionHtml || product.descriptionHtml)}
</Text>
</View> </View>
)} )}
{/* Technical specifications */} {technicalItems.length > 0 && (
{product.attributes && product.attributes.length > 0 && (
<View style={styles.section}> <View style={styles.section}>
<Text style={styles.sectionTitle}>{labels.specifications}</Text> <Text style={styles.sectionTitle}>{labels.specifications}</Text>
<View style={styles.sectionAccent} /> <View style={styles.sectionAccent} />
<View style={styles.specsTable}> <KeyValueGrid items={technicalItems} />
{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> </View>
)} )}
{/* Categories as clean tags */} {voltageTables.map((table, idx) => (
{product.categories && product.categories.length > 0 && ( <View key={idx} style={styles.section} break={false}>
<View style={styles.section}> <Text
<Text style={styles.sectionTitle}>{labels.categories}</Text> style={styles.sectionTitle}
>{`${labels.crossSection}${table.voltageLabel}`}</Text>
<View style={styles.sectionAccent} /> <View style={styles.sectionAccent} />
<View style={styles.categories}> <DenseTable table={table} firstColLabel={labels.slug_cs} />
{product.categories.map((cat, index) => ( </View>
<View key={index} style={styles.categoryTag}> ))}
<Text style={styles.categoryText}>{cat.name}</Text>
</View> {legendItems.length > 0 && (
))} <View style={styles.section} break={false}>
</View> <Text style={styles.sectionTitle}>{labels.abbreviations}</Text>
<View style={styles.sectionAccent} />
<KeyValueGrid items={legendItems} />
</View> </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>
{/* Minimal footer */}
<View style={styles.footer} fixed> <View style={styles.footer} fixed>
<Text style={styles.footerBrand}>KLZ CABLES</Text> <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}> <Text style={styles.footerText}>
{new Date().toLocaleDateString(locale === 'en' ? 'en-US' : 'de-DE', { {new Date().toLocaleDateString(locale === 'en' ? 'en-US' : 'de-DE', {
year: 'numeric', year: 'numeric',

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

@@ -0,0 +1,329 @@
import * as React from 'react';
import { Document, Page, View, Text, StyleSheet, Font, Link } from '@react-pdf/renderer';
// Register fonts (using system fonts for now, can be customized)
Font.register({
family: 'Helvetica',
fonts: [
{ src: '/fonts/Helvetica.ttf', fontWeight: 400 },
{ src: '/fonts/Helvetica-Bold.ttf', fontWeight: 700 },
],
});
// ─── Brand Tokens (matching datasheet) ──────────────────────────────────
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;
const styles = StyleSheet.create({
page: {
color: C.gray900,
lineHeight: 1.5,
backgroundColor: C.white,
paddingTop: 0,
paddingBottom: 80,
fontFamily: 'Helvetica',
},
// Hero-style header
hero: {
backgroundColor: C.white,
paddingTop: 24,
paddingBottom: 0,
paddingHorizontal: MARGIN,
marginBottom: 20,
position: 'relative',
borderBottomWidth: 0,
},
header: {
flexDirection: 'row',
justifyContent: 'space-between',
alignItems: 'center',
marginBottom: 16,
},
logoText: {
fontSize: 22,
fontWeight: 700,
color: C.navyDeep,
letterSpacing: 2,
textTransform: 'uppercase',
},
docTitle: {
fontSize: 8,
fontWeight: 700,
color: C.green,
letterSpacing: 2,
textTransform: 'uppercase',
},
// Content Area
content: {
paddingHorizontal: MARGIN,
},
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: 12,
},
heading1: {
fontSize: 16,
fontWeight: 700,
color: C.navyDeep,
marginTop: 20,
marginBottom: 10,
textTransform: 'uppercase',
letterSpacing: 0.5,
},
heading2: {
fontSize: 12,
fontWeight: 700,
color: C.navyDeep,
marginTop: 16,
marginBottom: 8,
},
heading3: {
fontSize: 10,
fontWeight: 700,
color: C.navyDeep,
marginTop: 12,
marginBottom: 6,
},
list: {
marginBottom: 12,
marginLeft: 8,
},
listItem: {
flexDirection: 'row',
marginBottom: 4,
},
listItemBullet: {
width: 12,
fontSize: 10,
color: C.green,
fontWeight: 700,
},
listItemContent: {
flex: 1,
fontSize: 10,
color: C.gray600,
lineHeight: 1.7,
},
link: {
color: C.green,
textDecoration: 'none',
},
textBold: {
fontWeight: 700,
fontFamily: 'Helvetica-Bold',
color: C.navyDeep,
},
textItalic: {
fontStyle: 'italic',
},
// Footer — matches brochure style
footer: {
position: 'absolute',
bottom: 28,
left: MARGIN,
right: MARGIN,
flexDirection: 'row',
justifyContent: 'space-between',
alignItems: 'center',
paddingTop: 12,
borderTopWidth: 2,
borderTopColor: C.green,
},
footerText: {
fontSize: 7,
color: C.gray400,
fontWeight: 400,
textTransform: 'uppercase',
letterSpacing: 0.8,
},
footerBrand: {
fontSize: 9,
fontWeight: 700,
color: C.navyDeep,
textTransform: 'uppercase',
letterSpacing: 1.5,
},
});
// ─── Lexical to React-PDF Renderer ────────────────────────────────
const renderLexicalNode = (node: any, idx: number): React.ReactNode => {
if (!node) return null;
switch (node.type) {
case 'text': {
const format = node.format || 0;
const isBold = (format & 1) !== 0;
const isItalic = (format & 2) !== 0;
let elementStyle: any = {};
if (isBold) elementStyle = { ...elementStyle, ...styles.textBold };
if (isItalic) elementStyle = { ...elementStyle, ...styles.textItalic };
return (
<Text key={idx} style={elementStyle}>
{node.text}
</Text>
);
}
case 'paragraph': {
return (
<Text key={idx} style={styles.paragraph}>
{node.children?.map((child: any, i: number) => renderLexicalNode(child, i))}
</Text>
);
}
case 'heading': {
let hStyle = styles.heading3;
if (node.tag === 'h1') hStyle = styles.heading1;
if (node.tag === 'h2') hStyle = styles.heading2;
return (
<Text key={idx} style={hStyle}>
{node.children?.map((child: any, i: number) => renderLexicalNode(child, i))}
</Text>
);
}
case 'list': {
return (
<View key={idx} style={styles.list}>
{node.children?.map((child: any, i: number) => {
if (child.type === 'listitem') {
return (
<View key={i} style={styles.listItem}>
<Text style={styles.listItemBullet}>
{node.listType === 'number' ? `${i + 1}.` : '•'}
</Text>
<Text style={styles.listItemContent}>
{child.children?.map((c: any, ci: number) => renderLexicalNode(c, ci))}
</Text>
</View>
);
}
return renderLexicalNode(child, i);
})}
</View>
);
}
case 'link': {
const href = node.fields?.url || node.url || '#';
return (
<Link key={idx} src={href} style={styles.link}>
{node.children?.map((child: any, i: number) => renderLexicalNode(child, i))}
</Link>
);
}
case 'linebreak': {
return <Text key={idx}>{'\n'}</Text>;
}
// Ignore payload blocks recursively to avoid crashing
case 'block':
return null;
default:
if (node.children) {
return (
<Text key={idx}>
{node.children.map((child: any, i: number) => renderLexicalNode(child, i))}
</Text>
);
}
return null;
}
};
interface PDFPageProps {
page: any;
locale?: string;
}
export const PDFPage: React.FC<PDFPageProps> = ({ page, locale = 'de' }) => {
const dateStr = new Date().toLocaleDateString(locale === 'en' ? 'en-US' : 'de-DE', {
year: 'numeric',
month: 'long',
day: 'numeric',
});
return (
<Document>
<Page size="A4" style={styles.page}>
{/* Hero Header */}
<View style={styles.hero} fixed>
<View style={styles.header}>
<View>
<Text style={styles.logoText}>KLZ</Text>
</View>
<Text style={styles.docTitle}>{locale === 'en' ? 'Document' : 'Dokument'}</Text>
</View>
</View>
<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),
)}
</View>
</View>
{/* Minimal footer */}
<View style={styles.footer} fixed>
<Text style={styles.footerBrand}>KLZ CABLES</Text>
<Text style={styles.footerText}>{dateStr}</Text>
</View>
</Page>
</Document>
);
};

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

@@ -226,6 +226,10 @@
"requestQuoteDesc": "Erhalten Sie technische Spezifikationen und Preise für Ihr Projekt.", "requestQuoteDesc": "Erhalten Sie technische Spezifikationen und Preise für Ihr Projekt.",
"downloadDatasheet": "Datenblatt herunterladen", "downloadDatasheet": "Datenblatt herunterladen",
"downloadDatasheetDesc": "Erhalten Sie die vollständigen technischen Spezifikationen als PDF.", "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": { "form": {
"contactInfo": "Kontaktinformationen", "contactInfo": "Kontaktinformationen",
"projectDetails": "Projektdetails", "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.", "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" "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

@@ -226,6 +226,10 @@
"requestQuoteDesc": "Get technical specifications and pricing for your project.", "requestQuoteDesc": "Get technical specifications and pricing for your project.",
"downloadDatasheet": "Download Datasheet", "downloadDatasheet": "Download Datasheet",
"downloadDatasheetDesc": "Get the full technical specifications in PDF format.", "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": { "form": {
"contactInfo": "Contact Information", "contactInfo": "Contact Information",
"projectDetails": "Project Details", "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.", "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" "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

@@ -102,7 +102,7 @@ export default async function middleware(request: NextRequest) {
export const config = { export const config = {
matcher: [ 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*', '/(de|en)/:path*',
], ],
}; };

2
next-env.d.ts vendored
View File

@@ -1,6 +1,6 @@
/// <reference types="next" /> /// <reference types="next" />
/// <reference types="next/image-types/global" /> /// <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 // NOTE: This file should not be edited
// see https://nextjs.org/docs/app/api-reference/config/typescript for more information. // see https://nextjs.org/docs/app/api-reference/config/typescript for more information.

View File

@@ -16,6 +16,9 @@ const nextConfig = {
cpus: 3, cpus: 3,
workerThreads: false, workerThreads: false,
}, },
serverActions: {
allowedOrigins: ["*.klz-cables.com", "*.branch.klz-cables.com", "localhost:3000", "klz.localhost"],
},
reactStrictMode: false, reactStrictMode: false,
swcMinify: true, swcMinify: true,
productionBrowserSourceMaps: false, productionBrowserSourceMaps: false,
@@ -25,6 +28,7 @@ const nextConfig = {
}, },
}, },
...(isProd ? { output: 'standalone' } : {}), ...(isProd ? { output: 'standalone' } : {}),
// Rewrites moved to bottom merged function
async headers() { async headers() {
const isProd = process.env.NODE_ENV === 'production'; const isProd = process.env.NODE_ENV === 'production';
const umamiDomain = new URL(process.env.UMAMI_API_ENDPOINT || 'https://analytics.infra.mintel.me').origin; const umamiDomain = new URL(process.env.UMAMI_API_ENDPOINT || 'https://analytics.infra.mintel.me').origin;
@@ -79,7 +83,7 @@ const nextConfig = {
}, },
{ {
key: 'Strict-Transport-Security', key: 'Strict-Transport-Security',
value: 'max-age=63072000; includeSubDomains; preload', value: isProd ? 'max-age=63072000; includeSubDomains; preload' : 'max-age=0',
}, },
]; ];
@@ -393,6 +397,7 @@ const nextConfig = {
]; ];
}, },
images: { images: {
qualities: [75, 100],
formats: ['image/webp'], formats: ['image/webp'],
deviceSizes: [640, 750, 828, 1080, 1200, 1920, 2048, 3840], deviceSizes: [640, 750, 828, 1080, 1200, 1920, 2048, 3840],
remotePatterns: [ remotePatterns: [
@@ -425,6 +430,22 @@ const nextConfig = {
async rewrites() { async rewrites() {
return { return {
beforeFiles: [ 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', source: '/de/produkte',
destination: '/de/products', destination: '/de/products',

View File

@@ -13,7 +13,7 @@
"@payloadcms/next": "^3.77.0", "@payloadcms/next": "^3.77.0",
"@payloadcms/richtext-lexical": "^3.77.0", "@payloadcms/richtext-lexical": "^3.77.0",
"@payloadcms/ui": "^3.77.0", "@payloadcms/ui": "^3.77.0",
"@react-email/components": "^1.0.7", "@react-email/components": "1.0.8",
"@react-pdf/renderer": "^4.3.2", "@react-pdf/renderer": "^4.3.2",
"@sentry/nextjs": "^10.39.0", "@sentry/nextjs": "^10.39.0",
"@types/recharts": "^2.0.1", "@types/recharts": "^2.0.1",
@@ -89,7 +89,8 @@
"tsx": "^4.21.0", "tsx": "^4.21.0",
"turbo": "^2.8.10", "turbo": "^2.8.10",
"typescript": "^5.7.2", "typescript": "^5.7.2",
"vitest": "^4.0.16" "vitest": "^4.0.16",
"xlsx-cli": "^1.1.3"
}, },
"scripts": { "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": "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'",
@@ -101,6 +102,7 @@
"typecheck": "tsc --noEmit", "typecheck": "tsc --noEmit",
"test": "vitest run --passWithNoTests", "test": "vitest run --passWithNoTests",
"test:og": "vitest run tests/og-image.test.ts", "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:og": "tsx scripts/check-og-images.ts",
"check:a11y": "pa11y-ci", "check:a11y": "pa11y-ci",
"check:wcag": "tsx ./scripts/wcag-sitemap.ts", "check:wcag": "tsx ./scripts/wcag-sitemap.ts",
@@ -113,9 +115,11 @@
"check:assets": "tsx ./scripts/check-broken-assets.ts", "check:assets": "tsx ./scripts/check-broken-assets.ts",
"check:forms": "tsx ./scripts/check-forms.ts", "check:forms": "tsx ./scripts/check-forms.ts",
"check:apis": "tsx ./scripts/check-apis.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", "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", "cms:seed": "tsx ./scripts/seed-payload.ts",
"assets:push:testing": "bash ./scripts/assets-sync.sh local testing", "assets:push:testing": "bash ./scripts/assets-sync.sh local testing",
"assets:push:staging": "bash ./scripts/assets-sync.sh local staging", "assets:push:staging": "bash ./scripts/assets-sync.sh local staging",
@@ -139,7 +143,7 @@
"prepare": "husky", "prepare": "husky",
"preinstall": "npx only-allow pnpm" "preinstall": "npx only-allow pnpm"
}, },
"version": "2.2.7", "version": "2.2.12",
"pnpm": { "pnpm": {
"onlyBuiltDependencies": [ "onlyBuiltDependencies": [
"@parcel/watcher", "@parcel/watcher",
@@ -161,4 +165,4 @@
"peerDependencies": { "peerDependencies": {
"lucide-react": "^0.563.0" "lucide-react": "^0.563.0"
} }
} }

View File

@@ -87,7 +87,9 @@ export interface Config {
products: ProductsSelect<false> | ProductsSelect<true>; products: ProductsSelect<false> | ProductsSelect<true>;
pages: PagesSelect<false> | PagesSelect<true>; pages: PagesSelect<false> | PagesSelect<true>;
'payload-kv': PayloadKvSelect<false> | PayloadKvSelect<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-preferences': PayloadPreferencesSelect<false> | PayloadPreferencesSelect<true>;
'payload-migrations': PayloadMigrationsSelect<false> | PayloadMigrationsSelect<true>; 'payload-migrations': PayloadMigrationsSelect<false> | PayloadMigrationsSelect<true>;
}; };
@@ -98,6 +100,9 @@ export interface Config {
globals: {}; globals: {};
globalsSelect: {}; globalsSelect: {};
locale: 'de' | 'en'; locale: 'de' | 'en';
widgets: {
collections: CollectionsWidget;
};
user: User; user: User;
jobs: { jobs: {
tasks: unknown; tasks: unknown;
@@ -249,7 +254,7 @@ export interface FormSubmission {
id: number; id: number;
name: string; name: string;
email: string; email: string;
type: 'contact' | 'product_quote'; type: 'contact' | 'product_quote' | 'brochure_download';
/** /**
* The specific KLZ product the user requested a quote for. * The specific KLZ product the user requested a quote for.
*/ */
@@ -619,6 +624,16 @@ export interface PayloadMigrationsSelect<T extends boolean = true> {
updatedAt?: T; updatedAt?: T;
createdAt?: T; createdAt?: T;
} }
/**
* This interface was referenced by `Config`'s JSON-Schema
* via the `definition` "collections_widget".
*/
export interface CollectionsWidget {
data?: {
[k: string]: unknown;
};
width: 'full';
}
/** /**
* This interface was referenced by `Config`'s JSON-Schema * This interface was referenced by `Config`'s JSON-Schema
* via the `definition` "StatsBlock". * via the `definition` "StatsBlock".
@@ -957,7 +972,6 @@ export interface Auth {
[k: string]: unknown; [k: string]: unknown;
} }
declare module 'payload' { declare module 'payload' {
export interface GeneratedTypes extends Config {} export interface GeneratedTypes extends Config {}
} }

3184
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.

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.

Binary file not shown.

Binary file not shown.

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