Compare commits

...

35 Commits

Author SHA1 Message Date
fda2944e5a content: restore correct image for Kabelleitungsnetzbau
All checks were successful
Build & Deploy / 🔍 Prepare (push) Successful in 30s
Build & Deploy / 🧪 QA (push) Successful in 1m26s
Build & Deploy / 🏗️ Build (push) Successful in 3m10s
Build & Deploy / 🚀 Deploy (push) Successful in 34s
Build & Deploy / 🧪 Post-Deploy Verification (push) Successful in 58s
Build & Deploy / 🔔 Notify (push) Successful in 3s
Restored DSC01123.JPG (Kabelpflug) for the 'Kabelleitungsnetzbau' tiles on the Home and Kompetenzen pages. Also fixed the English alt text to 'Cable Network Construction'.
2026-06-19 20:13:01 +02:00
8ae5ab73e3 ci: fix pipeline concurrency and target evaluation bugs
All checks were successful
Build & Deploy / 🔍 Prepare (push) Successful in 42s
Build & Deploy / 🧪 QA (push) Successful in 1m33s
Build & Deploy / 🏗️ Build (push) Successful in 3m10s
Build & Deploy / 🚀 Deploy (push) Successful in 35s
Build & Deploy / 🧪 Post-Deploy Verification (push) Has been skipped
Build & Deploy / 🔔 Notify (push) Successful in 2s
1. Added native Gitea/GitHub Actions concurrency group to automatically cancel outdated/stale deploy runs for the same branch (fixes 'alte deploys durchjagen'). 2. Replaced the buggy 'github.ref_type' condition with explicit 'github.ref' matching. Gitea often failed to populate ref_type, causing staging tags to be downgraded to 'branch' target, making them equal priority to feature branches where the newer run would win (fixes 'feature branches wichtiger als staging').
2026-06-19 19:11:43 +02:00
d8d35da574 chore: remove dead code and unused PDF generation scripts
All checks were successful
Build & Deploy / 🔍 Prepare (push) Successful in 31s
Build & Deploy / 🧪 QA (push) Successful in 1m25s
Build & Deploy / 🏗️ Build (push) Successful in 3m11s
Build & Deploy / 🚀 Deploy (push) Successful in 34s
Build & Deploy / 🧪 Post-Deploy Verification (push) Successful in 59s
Build & Deploy / 🔔 Notify (push) Successful in 3s
Removed leftover brochure and PDF datasheet generation scripts from the old template that are not needed in this project.
2026-06-19 19:03:37 +02:00
792d91f9d5 content: rename Kabelleitungstiefbau to Kabelleitungsnetzbau
Some checks failed
Build & Deploy / 🔍 Prepare (push) Successful in 43s
Build & Deploy / 🧪 QA (push) Successful in 1m31s
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
Applied globally across MDX content, translations, and UI components including English analog terms (Cable Trenching -> Cable Network Construction).
2026-06-19 19:00:33 +02:00
d10d038617 perf: optimize interactive map rendering
Some checks failed
Build & Deploy / 🔍 Prepare (push) Successful in 33s
Build & Deploy / 🧪 QA (push) Successful in 1m34s
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
Memoized map nodes, map background, and hover handlers to prevent full component re-renders and SVG drop-shadow recalculations.
2026-06-19 18:58:08 +02:00
490ce4abb6 fix: load annotator assets on mount instead of requiring Shift+A
Some checks failed
Build & Deploy / 🔍 Prepare (push) Successful in 29s
Build & Deploy / 🧪 QA (push) Successful in 1m27s
Build & Deploy / 🏗️ Build (push) Successful in 3m3s
Build & Deploy / 🚀 Deploy (push) Successful in 34s
Build & Deploy / 🧪 Post-Deploy Verification (push) Has been cancelled
Build & Deploy / 🔔 Notify (push) Has been cancelled
2026-06-19 18:56:14 +02:00
bed9be1f81 fix: resolve react-hooks/rules-of-hooks in Annotator
All checks were successful
Build & Deploy / 🔍 Prepare (push) Successful in 31s
Build & Deploy / 🧪 QA (push) Successful in 1m33s
Build & Deploy / 🏗️ Build (push) Successful in 3m6s
Build & Deploy / 🚀 Deploy (push) Successful in 34s
Build & Deploy / 🧪 Post-Deploy Verification (push) Successful in 58s
Build & Deploy / 🔔 Notify (push) Successful in 2s
2026-06-19 18:40:44 +02:00
f52a3e3c46 2.2.13-rc.46
Some checks failed
Build & Deploy / 🔍 Prepare (push) Successful in 30s
Build & Deploy / 🧪 QA (push) Failing after 1m25s
Build & Deploy / 🚀 Deploy (push) Has been skipped
Build & Deploy / 🏗️ Build (push) Has been skipped
Build & Deploy / 🧪 Post-Deploy Verification (push) Has been skipped
Build & Deploy / 🔔 Notify (push) Successful in 1s
2026-06-19 18:26:58 +02:00
71825724db fix(ui): align bento grid images to start page & optimize performance
Some checks failed
Build & Deploy / 🔍 Prepare (push) Successful in 1m16s
Build & Deploy / 🧪 QA (push) Failing after 1m30s
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
- fixed Sentry NDJSON parsing error in relay endpoint
- fully isolated Annotator bundle via next/dynamic (60kb chunk, completely removed from main thread)
- aligned images for Kabelleitungsbau & Bohrtechnik across Start and Kompetenzen pages
- extended transition fallback timeout for slow dev server cold starts
2026-06-19 18:14:55 +02:00
1bb26e8db4 chore: sync bento grid images across home and kompetenzen
All checks were successful
Build & Deploy / 🔍 Prepare (push) Successful in 28s
Build & Deploy / 🧪 QA (push) Successful in 1m39s
Build & Deploy / 🏗️ Build (push) Successful in 3m24s
Build & Deploy / 🚀 Deploy (push) Successful in 33s
Build & Deploy / 🧪 Post-Deploy Verification (push) Has been skipped
Build & Deploy / 🔔 Notify (push) Successful in 2s
2026-06-19 15:49:09 +02:00
604b96afe0 2.2.13-rc.45
All checks were successful
Build & Deploy / 🔍 Prepare (push) Successful in 28s
Build & Deploy / 🧪 QA (push) Successful in 1m25s
Build & Deploy / 🏗️ Build (push) Successful in 2m50s
Build & Deploy / 🚀 Deploy (push) Successful in 35s
Build & Deploy / 🧪 Post-Deploy Verification (push) Successful in 59s
Build & Deploy / 🔔 Notify (push) Successful in 3s
2026-06-19 15:44:52 +02:00
59df260164 feat: close annotator modals via ESC (v1.0.9) 2026-06-19 15:44:51 +02:00
3449d2eaa4 2.2.13-rc.44
All checks were successful
Build & Deploy / 🔍 Prepare (push) Successful in 1m0s
Build & Deploy / 🧪 QA (push) Successful in 1m29s
Build & Deploy / 🏗️ Build (push) Successful in 3m3s
Build & Deploy / 🚀 Deploy (push) Successful in 32s
Build & Deploy / 🧪 Post-Deploy Verification (push) Successful in 53s
Build & Deploy / 🔔 Notify (push) Successful in 2s
2026-06-19 15:39:38 +02:00
1723be4008 feat: make annotator mobile friendly (v1.0.8) 2026-06-19 15:39:37 +02:00
830d425859 2.2.13-rc.43
All checks were successful
Build & Deploy / 🔍 Prepare (push) Successful in 27s
Build & Deploy / 🧪 QA (push) Successful in 1m20s
Build & Deploy / 🏗️ Build (push) Successful in 2m53s
Build & Deploy / 🚀 Deploy (push) Successful in 31s
Build & Deploy / 🧪 Post-Deploy Verification (push) Successful in 56s
Build & Deploy / 🔔 Notify (push) Successful in 5s
2026-06-19 15:30:27 +02:00
d6b9aca50f chore: remove tracked turbo logs 2026-06-19 15:30:26 +02:00
caefd5d525 fix: include tgz files in docker build context for local package installation 2026-06-19 15:30:03 +02:00
8d8e1dd460 2.2.13-rc.42
Some checks failed
Build & Deploy / 🔍 Prepare (push) Successful in 26s
Build & Deploy / 🧪 QA (push) Successful in 1m22s
Build & Deploy / 🏗️ Build (push) Failing after 37s
Build & Deploy / 🚀 Deploy (push) Has been skipped
Build & Deploy / 🧪 Post-Deploy Verification (push) Has been skipped
Build & Deploy / 🔔 Notify (push) Successful in 2s
2026-06-19 15:21:21 +02:00
c2a82ae9ae fix: submitAnnotations server action missing directive and invalid mail options 2026-06-19 15:21:20 +02:00
2ecbe9c63c 2.2.13-rc.41
Some checks failed
Build & Deploy / 🔍 Prepare (push) Successful in 26s
Build & Deploy / 🧪 QA (push) Failing after 32s
Build & Deploy / 🚀 Deploy (push) Has been skipped
Build & Deploy / 🏗️ Build (push) Has been skipped
Build & Deploy / 🧪 Post-Deploy Verification (push) Has been skipped
Build & Deploy / 🔔 Notify (push) Successful in 2s
2026-06-19 15:16:46 +02:00
255403c2e4 2.2.13-rc.3 2026-06-19 15:16:28 +02:00
38a870bea8 feat: integrate mintel annotator with email submission and environment restrictions
Some checks failed
Build & Deploy / 🔍 Prepare (push) Successful in 38s
Build & Deploy / 🧪 QA (push) Failing after 36s
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-06-19 15:13:07 +02:00
2b8dc19032 chore(git): ignore turborepo cache directories
All checks were successful
Build & Deploy / 🔍 Prepare (push) Successful in 1m24s
Build & Deploy / 🧪 QA (push) Successful in 1m28s
Build & Deploy / 🏗️ Build (push) Successful in 2m51s
Build & Deploy / 🚀 Deploy (push) Successful in 31s
Build & Deploy / 🧪 Post-Deploy Verification (push) Successful in 55s
Build & Deploy / 🔔 Notify (push) Successful in 2s
2026-06-19 10:09:22 +02:00
5101b8bd1e style: harmonize mobile and desktop vertical spacing across all components
All checks were successful
Build & Deploy / 🔍 Prepare (push) Successful in 31s
Build & Deploy / 🧪 QA (push) Successful in 1m23s
Build & Deploy / 🏗️ Build (push) Successful in 2m34s
Build & Deploy / 🚀 Deploy (push) Successful in 29s
Build & Deploy / 🧪 Post-Deploy Verification (push) Successful in 53s
Build & Deploy / 🔔 Notify (push) Successful in 3s
2026-06-19 01:14:33 +02:00
afe3565155 fix(map): optimize hit areas for desktop mouse clusters and add touch events for mobile responsiveness
All checks were successful
Build & Deploy / 🔍 Prepare (push) Successful in 27s
Build & Deploy / 🧪 QA (push) Successful in 1m21s
Build & Deploy / 🏗️ Build (push) Successful in 2m31s
Build & Deploy / 🚀 Deploy (push) Successful in 29s
Build & Deploy / 🧪 Post-Deploy Verification (push) Successful in 53s
Build & Deploy / 🔔 Notify (push) Successful in 4s
2026-06-19 00:02:13 +02:00
e677089ff9 fix(map): use useRef for hover timeout to prevent flyout flicker and immediate disappearance
All checks were successful
Build & Deploy / 🔍 Prepare (push) Successful in 32s
Build & Deploy / 🧪 QA (push) Successful in 1m31s
Build & Deploy / 🏗️ Build (push) Successful in 2m50s
Build & Deploy / 🚀 Deploy (push) Successful in 35s
Build & Deploy / 🧪 Post-Deploy Verification (push) Successful in 59s
Build & Deploy / 🔔 Notify (push) Successful in 3s
2026-06-18 23:41:09 +02:00
b7d9a4b0d0 fix(tests): update task-12-links test to verify minorLocations since defaultLocations was emptied
All checks were successful
Build & Deploy / 🔍 Prepare (push) Successful in 31s
Build & Deploy / 🧪 QA (push) Successful in 1m29s
Build & Deploy / 🏗️ Build (push) Successful in 2m46s
Build & Deploy / 🚀 Deploy (push) Successful in 33s
Build & Deploy / 🧪 Post-Deploy Verification (push) Successful in 1m4s
Build & Deploy / 🔔 Notify (push) Successful in 2s
2026-06-18 23:26:07 +02:00
6c8614f147 feat(map): completely rebuild map data from raw 2016-2026 txt files, geocode missing locations, format to spec, and downgrade all projects to minor nodes
Some checks failed
Build & Deploy / 🔍 Prepare (push) Successful in 30s
Build & Deploy / 🧪 QA (push) Failing after 1m20s
Build & Deploy / 🚀 Deploy (push) Has been skipped
Build & Deploy / 🏗️ Build (push) Has been skipped
Build & Deploy / 🧪 Post-Deploy Verification (push) Has been skipped
Build & Deploy / 🔔 Notify (push) Successful in 2s
2026-06-18 23:18:46 +02:00
aa4e121fea revert(map): restore stable map version from June 17 due to layout, lag, and scroll regressions
All checks were successful
Build & Deploy / 🔍 Prepare (push) Successful in 30s
Build & Deploy / 🧪 QA (push) Successful in 1m35s
Build & Deploy / 🏗️ Build (push) Successful in 2m48s
Build & Deploy / 🚀 Deploy (push) Successful in 31s
Build & Deploy / 🧪 Post-Deploy Verification (push) Successful in 1m0s
Build & Deploy / 🔔 Notify (push) Successful in 2s
2026-06-18 22:50:35 +02:00
6697703c91 style(map): format location names strictly to Danny's blueprint (e.g. MS-Kabeltrasse, PLZ Ort)
All checks were successful
Build & Deploy / 🔍 Prepare (push) Successful in 31s
Build & Deploy / 🧪 QA (push) Successful in 1m42s
Build & Deploy / 🏗️ Build (push) Successful in 3m8s
Build & Deploy / 🚀 Deploy (push) Successful in 35s
Build & Deploy / 🧪 Post-Deploy Verification (push) Successful in 1m1s
Build & Deploy / 🔔 Notify (push) Successful in 3s
2026-06-18 22:45:03 +02:00
e779c6ac22 fix(map): restore minor_node type for map pins to render tooltips correctly
All checks were successful
Build & Deploy / 🔍 Prepare (push) Successful in 31s
Build & Deploy / 🧪 QA (push) Successful in 1m34s
Build & Deploy / 🏗️ Build (push) Successful in 3m10s
Build & Deploy / 🚀 Deploy (push) Successful in 34s
Build & Deploy / 🧪 Post-Deploy Verification (push) Successful in 59s
Build & Deploy / 🔔 Notify (push) Successful in 2s
2026-06-18 22:42:08 +02:00
02c4f06108 hotfix: increase footer padding, restore interactive map state, disable hero map lag animation
All checks were successful
Build & Deploy / 🔍 Prepare (push) Successful in 55s
Build & Deploy / 🧪 QA (push) Successful in 1m36s
Build & Deploy / 🏗️ Build (push) Successful in 3m1s
Build & Deploy / 🚀 Deploy (push) Successful in 34s
Build & Deploy / 🧪 Post-Deploy Verification (push) Successful in 1m4s
Build & Deploy / 🔔 Notify (push) Successful in 3s
2026-06-18 22:23:09 +02:00
6d77fb92c3 chore: remove unused imports causing CI failure
All checks were successful
Build & Deploy / 🔍 Prepare (push) Successful in 29s
Build & Deploy / 🧪 QA (push) Successful in 1m25s
Build & Deploy / 🏗️ Build (push) Successful in 2m34s
Build & Deploy / 🚀 Deploy (push) Successful in 28s
Build & Deploy / 🧪 Post-Deploy Verification (push) Successful in 58s
Build & Deploy / 🔔 Notify (push) Successful in 2s
2026-06-18 21:48:59 +02:00
cb165b06e5 hotfix: fix map scroll jump, horizontal double scrollbars, and severe lag
All checks were successful
Build & Deploy / 🔍 Prepare (push) Successful in 26s
Build & Deploy / 🧪 QA (push) Successful in 1m21s
Build & Deploy / 🏗️ Build (push) Successful in 2m38s
Build & Deploy / 🚀 Deploy (push) Successful in 40s
Build & Deploy / 🧪 Post-Deploy Verification (push) Successful in 54s
Build & Deploy / 🔔 Notify (push) Successful in 3s
2026-06-18 21:46:22 +02:00
ca1b19cc0f hotfix: adjust EU badge distance and ScaleOfImpact unit spacing 2026-06-18 21:43:58 +02:00
100 changed files with 1971 additions and 8501 deletions

View File

@@ -13,6 +13,10 @@ on:
required: false
default: 'false'
concurrency:
group: ${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: true
env:
PUPPETEER_SKIP_DOWNLOAD: "true"
COREPACK_NPM_REGISTRY: "https://registry.npmmirror.com"
@@ -89,12 +93,12 @@ jobs:
DOMAIN="e-tib.com"
PRJ="etib"
if [[ "${{ github.ref_type }}" == "branch" && "$REF" == "main" ]]; then
if [[ "${{ github.ref }}" == "refs/heads/main" ]]; then
TARGET="testing"
IMAGE_TAG="main-${SHORT_SHA}"
ENV_FILE=".env.testing"
TRAEFIK_HOST="test.${DOMAIN}"
elif [[ "${{ github.ref_type }}" == "tag" ]]; then
elif [[ "${{ github.ref }}" == refs/tags/* ]]; then
if [[ "$REF" =~ ^v[0-9]+\.[0-9]+\.[0-9]+$ ]]; then
TARGET="production"
IMAGE_TAG="$REF"

4
.gitignore vendored
View File

@@ -36,3 +36,7 @@ pnpm-debug.log*
# typescript
*.tsbuildinfo
next-env.d.ts
# turborepo
.turbo
.turbo-test

View File

@@ -1 +0,0 @@
{"files":{".turbo/turbo-lint.log":{"size":11972,"mtime_nanos":1779970374061729177,"mode":420,"is_dir":false}},"order":[".turbo/turbo-lint.log"]}

View File

@@ -1 +0,0 @@
{"hash":"0864e4a10f76dcaf","duration":6769,"sha":"68c4a056b70ff215c9ef3252506875dfd3ba4eee","dirty_hash":"29197232f806b237725188c5bfe8d43cb2f12f8bb9e0df915eecf0896e2c04a3"}

Binary file not shown.

View File

@@ -1 +0,0 @@
{"files":{".turbo/turbo-test.log":{"size":2776,"mtime_nanos":1781469605328811961,"mode":420,"is_dir":false}},"order":[".turbo/turbo-test.log"]}

View File

@@ -1 +0,0 @@
{"hash":"41698960411f6bf6","duration":4168,"sha":"a3c732dd133b1b8261b19cf64b3bf684e269e839","dirty_hash":"1ef3b75af417f41468b9558671572e75e0fc055f9dbe6e805cfa6f59b1cddf70"}

Binary file not shown.

View File

@@ -1 +0,0 @@
{"files":{".turbo/turbo-typecheck.log":{"size":85,"mtime_nanos":1779970373499305257,"mode":420,"is_dir":false}},"order":[".turbo/turbo-typecheck.log"]}

View File

@@ -1 +0,0 @@
{"hash":"7668a73054ba889d","duration":6206,"sha":"68c4a056b70ff215c9ef3252506875dfd3ba4eee","dirty_hash":"29197232f806b237725188c5bfe8d43cb2f12f8bb9e0df915eecf0896e2c04a3"}

Binary file not shown.

View File

@@ -1 +0,0 @@
{"files":{".turbo/turbo-typecheck.log":{"size":90,"mtime_nanos":1781782738306875884,"mode":420,"is_dir":false}},"order":[".turbo/turbo-typecheck.log"]}

View File

@@ -1 +0,0 @@
{"hash":"7d27d7d8e3c30797","duration":14130,"sha":"d6eb1c0e7fe99bc7006a396c8eea0228c4723b31","dirty_hash":"b247ccedfcf352a6efb73993a3482b827d5b44922d26bf137f251d0d69a200b6"}

Binary file not shown.

View File

@@ -1 +0,0 @@
{"files":{".turbo/turbo-test.log":{"size":2150,"mtime_nanos":1779970370282819916,"mode":420,"is_dir":false}},"order":[".turbo/turbo-test.log"]}

View File

@@ -1 +0,0 @@
{"hash":"9c3fa4d9e1993aff","duration":2989,"sha":"68c4a056b70ff215c9ef3252506875dfd3ba4eee","dirty_hash":"29197232f806b237725188c5bfe8d43cb2f12f8bb9e0df915eecf0896e2c04a3"}

Binary file not shown.

View File

@@ -1 +0,0 @@
{"files":{".turbo/turbo-lint.log":{"size":12987,"mtime_nanos":1781469609469080321,"mode":420,"is_dir":false}},"order":[".turbo/turbo-lint.log"]}

View File

@@ -1 +0,0 @@
{"hash":"bb46018d4a7bc516","duration":8307,"sha":"a3c732dd133b1b8261b19cf64b3bf684e269e839","dirty_hash":"1ef3b75af417f41468b9558671572e75e0fc055f9dbe6e805cfa6f59b1cddf70"}

Binary file not shown.

View File

@@ -1 +0,0 @@
{"files":{".turbo/turbo-lint.log":{"size":15001,"mtime_nanos":1781782739446599688,"mode":420,"is_dir":false}},"order":[".turbo/turbo-lint.log"]}

View File

@@ -1 +0,0 @@
{"hash":"c3b4dbd60e2d475a","duration":15270,"sha":"d6eb1c0e7fe99bc7006a396c8eea0228c4723b31","dirty_hash":"b247ccedfcf352a6efb73993a3482b827d5b44922d26bf137f251d0d69a200b6"}

Binary file not shown.

View File

@@ -1 +0,0 @@
{"files":{".turbo/turbo-typecheck.log":{"size":92,"mtime_nanos":1779097674383861303,"mode":420,"is_dir":false}},"order":[".turbo/turbo-typecheck.log"]}

View File

@@ -1 +0,0 @@
{"hash":"32905076da5f4894","duration":4492,"sha":"35d9650626b56a727e9c1981308145cb7b2f28aa","dirty_hash":"58f8ebc92a0452cbb0a09c5ebe800385fcc42ff82fee098bb7d6c8828bc526f0"}

Binary file not shown.

View File

@@ -1 +0,0 @@
{"files":{".turbo/turbo-test.log":{"size":818,"mtime_nanos":1779097671747078359,"mode":420,"is_dir":false}},"order":[".turbo/turbo-test.log"]}

View File

@@ -1 +0,0 @@
{"hash":"7e2629942c881f80","duration":1855,"sha":"35d9650626b56a727e9c1981308145cb7b2f28aa","dirty_hash":"58f8ebc92a0452cbb0a09c5ebe800385fcc42ff82fee098bb7d6c8828bc526f0"}

Binary file not shown.

View File

@@ -1 +0,0 @@
{"files":{".turbo/turbo-lint.log":{"size":10746,"mtime_nanos":1779097675143794164,"mode":420,"is_dir":false}},"order":[".turbo/turbo-lint.log"]}

View File

@@ -1 +0,0 @@
{"hash":"85340b254e0f10f1","duration":5252,"sha":"35d9650626b56a727e9c1981308145cb7b2f28aa","dirty_hash":"58f8ebc92a0452cbb0a09c5ebe800385fcc42ff82fee098bb7d6c8828bc526f0"}

Binary file not shown.

View File

@@ -1 +0,0 @@
{"files":{".turbo/turbo-test.log":{"size":3396,"mtime_nanos":1781782731048784172,"mode":420,"is_dir":false}},"order":[".turbo/turbo-test.log"]}

View File

@@ -1 +0,0 @@
{"hash":"ea9126c55a797120","duration":6873,"sha":"d6eb1c0e7fe99bc7006a396c8eea0228c4723b31","dirty_hash":"b247ccedfcf352a6efb73993a3482b827d5b44922d26bf137f251d0d69a200b6"}

Binary file not shown.

View File

@@ -1 +0,0 @@
{"files":{".turbo/turbo-typecheck.log":{"size":85,"mtime_nanos":1781469608726900998,"mode":420,"is_dir":false}},"order":[".turbo/turbo-typecheck.log"]}

View File

@@ -1 +0,0 @@
{"hash":"eb4eeeb717c1af0c","duration":7565,"sha":"a3c732dd133b1b8261b19cf64b3bf684e269e839","dirty_hash":"1ef3b75af417f41468b9558671572e75e0fc055f9dbe6e805cfa6f59b1cddf70"}

Binary file not shown.

View File

@@ -1,5 +0,0 @@
> e-tib-nextjs@2.2.12 lint /Volumes/Alpha SSD/Coding/e-tib.com
> eslint .
ELIFECYCLE Command failed.

View File

@@ -1,55 +0,0 @@
> e-tib-nextjs@2.2.12 test /Volumes/Alpha SSD/Coding/e-tib.com
> vitest run --passWithNoTests
 RUN  v4.1.2 /Volumes/Alpha SSD/Coding/e-tib.com
(node:64961) [DEP0205] DeprecationWarning: `module.register()` is deprecated. Use `module.registerHooks()` instead.
(Use `node --trace-deprecation ...` to show where the warning was created)
✓ tests/mdx-links.test.ts (1 test) 29ms
✓ tests/homepage-and-competence-content.test.ts (4 tests) 14ms
 tests/legal-pages.test.ts (2 tests | 1 failed) 9ms
✓ should contain highly professional B2B sections in German AGB 5ms
 × should contain professional corresponding B2B sections in English Terms 2ms
✓ tests/task-12-links.test.ts (2 tests) 8ms
✓ tests/messen-content.test.ts (2 tests) 8ms
✓ lib/mail/mailer.test.ts (1 test) 7ms
stderr | components/layout/LanguageSwitcher.test.tsx > LanguageSwitcher TDD > translates /en/trade-fairs to /de/messen when switching to German
React does not recognize the `transitionMessage` prop on a DOM element. If you intentionally want it to appear in the DOM as a custom attribute, spell it as lowercase `transitionmessage` instead. If you accidentally passed it from a parent component, remove it from the DOM element.
✓ components/layout/LanguageSwitcher.test.tsx (5 tests) 25ms
✓ components/search/AISearchResults.test.tsx (1 test) 52ms
stderr | components/blocks/ReferencesSlider.test.tsx > ReferencesSlider TDD > renders reference tiles correctly
Received `true` for a non-boolean attribute `fill`.
If you want to write it to the DOM, pass a string instead: fill="true" or fill={value.toString()}.
✓ components/blocks/ReferencesSlider.test.tsx (2 tests) 43ms
✓ lib/env.test.ts (6 tests) 4ms
stdout | app/[locale]/referenzen/[slug]/page.test.tsx
[Config] Initializing Toggles: { feedbackEnabled: false }
✓ app/[locale]/referenzen/[slug]/page.test.tsx (1 test) 26ms
⎯⎯⎯⎯⎯⎯⎯ Failed Tests 1 ⎯⎯⎯⎯⎯⎯⎯
 FAIL  tests/legal-pages.test.ts > Legal Pages Content Validation > should contain professional corresponding B2B sections in English Terms
Error: ENOENT: no such file or directory, open '/Volumes/Alpha SSD/Coding/e-tib.com/content/en/terms.mdx'
  tests/legal-pages.test.ts:25:24
 23|
 24| it('should contain professional corresponding B2B sections in Englis…
 25| const content = fs.readFileSync(enTermsPath, 'utf8');
 | ^
 26|
 27| expect(content).toContain('title: "Terms and Conditions"');
⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯[1/1]⎯
 Test Files  1 failed | 10 passed (11)
 Tests  1 failed | 26 passed (27)
 Start at  14:54:33
 Duration  5.94s (transform 5.27s, setup 0ms, import 9.43s, tests 222ms, environment 13.80s)
ELIFECYCLE Test failed. See above for more details.

View File

@@ -1,5 +0,0 @@
> e-tib-nextjs@2.2.12 typecheck /Volumes/Alpha SSD/Coding/e-tib.com
> tsc --noEmit
ELIFECYCLE Command failed.

View File

@@ -17,7 +17,7 @@ ENV SKIP_RUNTIME_ENV_VALIDATION=true
ENV CI=true
# Copy lockfile and manifest for dependency installation caching
COPY pnpm-lock.yaml package.json .npmrc* ./
COPY pnpm-lock.yaml package.json .npmrc* *.tgz ./
COPY patches* ./patches/
ARG NPM_DOMAIN=git.infra.mintel.me

View File

@@ -0,0 +1,13 @@
import { NextResponse } from 'next/server';
export async function POST(req: Request) {
try {
const rawText = await req.text();
// Sentry sends NDJSON (Newline Delimited JSON). Split by newline to parse safely.
const items = rawText.split('\n').filter(Boolean).map(line => JSON.parse(line));
console.log("CLIENT ERROR INTERCEPTED:", JSON.stringify(items[0], null, 2));
} catch (e) {
console.log("Failed to parse relay body (NDJSON)", e);
}
return NextResponse.json({ success: true });
}

View File

@@ -10,6 +10,7 @@ import { getMessages } from 'next-intl/server';
import '../../styles/globals.css';
import { SITE_URL } from '@/lib/schema';
import FeedbackClientWrapper from '@/components/FeedbackClientWrapper';
import AnnotatorClientWrapper from '@/components/AnnotatorClientWrapper';
import { setRequestLocale } from 'next-intl/server';
import { Inter } from 'next/font/google';
import { mapFileSlugToTranslated } from '@/lib/slugs';
@@ -37,7 +38,7 @@ export async function generateMetadata(props: {
template: '%s | E-TIB',
default: 'E-TIB | Die Experten für Kabelnetzbau',
},
description: 'Ihr Partner für Kabelleitungstiefbau, Horizontalspülbohrungen, Planung und Vermessung in Guben und überregional.',
description: 'Ihr Partner für Kabelleitungsnetzbau, Horizontalspülbohrungen, Planung und Vermessung in Guben und überregional.',
metadataBase: new URL(baseUrl),
manifest: '/manifest.webmanifest',
alternates: {
@@ -214,6 +215,7 @@ export default async function Layout(props: {
<Footer companyInfo={companyInfo} />
<JsonLd />
<AnalyticsShell />
{process.env.TARGET !== 'production' && <AnnotatorClientWrapper />}
{feedbackEnabled && <FeedbackClientWrapper feedbackEnabled={feedbackEnabled} />}
</TransitionProvider>
</NextIntlClientProvider>

View File

@@ -1,38 +0,0 @@
import { describe, it, expect, vi } from 'vitest';
import ReferenceDetail from './page';
// Mock next/navigation
vi.mock('next/navigation', () => ({
notFound: vi.fn(),
}));
// Mock next-intl/server
vi.mock('next-intl/server', () => ({
setRequestLocale: vi.fn(),
getTranslations: vi.fn().mockResolvedValue(() => 'translated text'),
}));
// Mock @/lib/references
vi.mock('@/lib/references', () => ({
getReferenceBySlug: vi.fn().mockResolvedValue({
slug: 'test-slug',
frontmatter: {
title: 'Test Title',
location: 'Test Location',
category: 'Test Category',
},
content: 'Test content',
}),
}));
describe('ReferenceDetail Page TDD', () => {
it('renders reference detail correctly', async () => {
const props = {
params: Promise.resolve({ locale: 'de', slug: 'test-slug' })
};
// We expect the execution to fail if getTranslations is not defined/imported inside page.tsx
const component = await ReferenceDetail(props);
expect(component).toBeTruthy();
});
});

View File

@@ -1,212 +0,0 @@
import { notFound } from 'next/navigation';
import { Container, Badge, Heading } from '@/components/ui';
import { setRequestLocale, getTranslations } from 'next-intl/server';
import { Metadata } from 'next';
import { getReferenceBySlug, getAllReferences } from '@/lib/references';
import { MDXRemote } from 'next-mdx-remote/rsc';
import { SITE_URL } from '@/lib/schema';
import TrackedLink from '@/components/analytics/TrackedLink';
import { getButtonClasses, ButtonOverlay } from '@/components/ui/Button';
import { MapPin, Calendar, Briefcase, ChevronLeft } from 'lucide-react';
import Image from 'next/image';
interface PageProps {
params: Promise<{
locale: string;
slug: string;
}>;
}
export async function generateMetadata({ params }: PageProps): Promise<Metadata> {
const { locale, slug } = await params;
if (locale !== 'de' && locale !== 'en') return {};
const reference = await getReferenceBySlug(slug, locale);
if (!reference) return {};
return {
title: `${reference.frontmatter.title} | Referenzen | E-TIB Gruppe`,
description: `Details zum Projekt ${reference.frontmatter.title} in ${reference.frontmatter.location}.`,
alternates: {
canonical: `${SITE_URL}/${locale}/referenzen/${slug}`,
},
openGraph: {
title: `${reference.frontmatter.title} | E-TIB`,
description: `Details zum Projekt ${reference.frontmatter.title} in ${reference.frontmatter.location}.`,
url: `${SITE_URL}/${locale}/referenzen/${slug}`,
},
};
}
export async function generateStaticParams(props: any) {
const params = await props.params;
const locale = params?.locale || 'de'; // fallback to 'de' if undefined
const references = await getAllReferences(locale);
return references.map((ref) => ({
slug: ref.slug,
locale
}));
}
export default async function ReferenceDetail(props: { params: Promise<{ locale: string; slug: string }> }) {
const { locale, slug } = await props.params;
if (locale !== 'de' && locale !== 'en') {
notFound();
}
setRequestLocale(locale);
const reference = await getReferenceBySlug(slug, locale);
const t = await getTranslations('ReferenceDetail');
if (!reference) {
notFound();
}
// MDX components mapping for industrial typography
const mdxComponents = {
Heading,
h1: (props: any) => <Heading level={1} size="2" className="hidden" {...props} />, // Hidden because Hero handles H1
h2: (props: any) => <Heading level={2} size="3" className="mt-16 mb-6 border-b border-neutral-100 pb-4 text-neutral-dark" {...props} />,
h3: (props: any) => <Heading level={3} size="4" className="mt-12 mb-4 text-primary" {...props} />,
h4: (props: any) => <Heading level={4} size="5" className="mt-8 mb-4 uppercase tracking-widest text-neutral-500" {...props} />,
p: (props: any) => <p className="text-base md:text-lg text-text-secondary leading-[1.8] mb-6 font-medium max-w-3xl" {...props} />,
ul: (props: any) => <ul className="grid grid-cols-1 md:grid-cols-2 gap-4 md:gap-6 my-12" {...props} />,
ol: (props: any) => <ol className="list-decimal pl-6 mb-8 space-y-3 text-base md:text-lg text-text-secondary font-medium max-w-3xl" {...props} />,
li: (props: any) => {
// Smart Metric Card Logic
let text = '';
if (typeof props.children === 'string') {
text = props.children;
} else if (Array.isArray(props.children)) {
text = props.children.map((c: any) => typeof c === 'string' ? c : '').join('');
}
const match = text.match(/^([\d.,]+)\s*(m|Satz|Stück|km|kV|Fasern)\b(.*)/);
if (match && typeof props.children === 'string') {
const [, value, unit, rest] = match;
return (
<li className="bg-white p-6 md:p-8 rounded-3xl border border-neutral-100 flex flex-col justify-center hover:shadow-[0_20px_40px_-15px_rgba(0,0,0,0.05)] hover:border-primary/30 transition-all duration-500 group hover:-translate-y-1 relative overflow-hidden">
<div className="absolute top-0 right-0 w-32 h-32 bg-gradient-to-bl from-neutral-50 to-transparent rounded-bl-full -z-10 group-hover:from-primary/5 transition-colors duration-500" />
<div className="flex items-baseline gap-2 mb-3">
<span className="text-4xl md:text-5xl font-bold font-heading text-primary tracking-tight">{value}</span>
<span className="text-lg md:text-xl font-bold text-neutral-400">{unit}</span>
</div>
<span className="text-neutral-600 font-semibold text-sm md:text-base leading-snug">{rest.replace(/^[\s-]*\s/, '')}</span>
</li>
);
}
return (
<li className="bg-neutral-50 p-6 rounded-3xl border border-neutral-100 flex items-start gap-4 hover:shadow-md transition-all duration-300 group hover:-translate-y-1">
<div className="w-8 h-8 rounded-full bg-primary/10 flex items-center justify-center shrink-0 mt-0.5 group-hover:bg-primary/20 transition-colors">
<svg className="w-4 h-4 text-primary" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={3}>
<path strokeLinecap="round" strokeLinejoin="round" d="M5 13l4 4L19 7" />
</svg>
</div>
<span className="text-neutral-700 font-semibold leading-relaxed">{props.children}</span>
</li>
);
},
a: (props: any) => <a className="text-primary hover:text-primary-dark underline decoration-primary/30 underline-offset-4 hover:decoration-primary transition-all font-bold" {...props} />,
strong: (props: any) => <strong className="font-bold text-neutral-900" {...props} />,
blockquote: (props: any) => (
<blockquote className="border-l-4 border-primary pl-6 py-3 my-10 italic bg-neutral-50 rounded-r-2xl text-neutral-700 font-medium max-w-3xl shadow-sm" {...props} />
),
hr: (props: any) => <hr className="my-16 border-t-2 border-neutral-100 max-w-3xl" {...props} />,
img: (props: any) => <img className="rounded-2xl shadow-2xl my-12 max-w-full h-auto border border-neutral-100" {...props} />,
};
return (
<div className="flex flex-col min-h-screen bg-white">
{/* Hero Section */}
<section className="bg-[#050B14] text-white pt-28 pb-10 md:pt-52 md:pb-24 min-h-[30vh] md:min-h-[45vh] flex flex-col justify-end relative overflow-hidden">
{reference.frontmatter.featuredImage && (
<div className="absolute inset-0 z-0">
<Image
src={reference.frontmatter.featuredImage}
alt={reference.frontmatter.title}
fill
className="object-cover opacity-30 mix-blend-luminosity scale-105"
priority
/>
<div className="absolute inset-0 bg-gradient-to-t from-[#050B14] via-[#050B14]/70 to-transparent" />
<div className="absolute inset-0 bg-gradient-to-r from-[#050B14] via-[#050B14]/40 to-transparent" />
</div>
)}
<div className="absolute inset-0 opacity-20 z-0 pointer-events-none">
<div className="absolute top-0 left-0 w-full h-full bg-[radial-gradient(circle_at_center,_var(--tw-gradient-stops))] from-primary/30 via-transparent to-transparent" />
</div>
<Container className="relative z-10">
<div className="mb-12">
<TrackedLink
href={`/${locale}/referenzen`}
className="inline-flex items-center gap-2 text-white/60 hover:text-white transition-colors uppercase tracking-widest text-xs font-bold"
eventProperties={{ location: 'reference_back_btn' }}
>
<ChevronLeft className="w-4 h-4" />
{t('backToOverview')}
</TrackedLink>
</div>
<div className="max-w-5xl">
<Badge variant="accent" className="mb-6">
{t('projectReference')}
</Badge>
<Heading level={1} variant="white" className="mb-10 leading-[1.1] md:text-6xl lg:text-7xl drop-shadow-lg">
{reference.frontmatter.title}
</Heading>
<div className="grid grid-cols-1 md:grid-cols-3 gap-4 md:gap-6 mt-12 md:mt-16">
<div className="flex items-start gap-4 p-6 md:p-8 bg-white/5 rounded-3xl border border-white/10 backdrop-blur-md transition-all hover:bg-white/10">
<MapPin className="w-8 h-8 text-primary shrink-0 opacity-80" />
<div>
<p className="text-xs text-white/50 mb-1.5 uppercase tracking-widest font-bold">{t('location')}</p>
<p className="font-semibold text-lg md:text-xl text-white/90">{reference.frontmatter.location}</p>
</div>
</div>
<div className="flex items-start gap-4 p-6 md:p-8 bg-white/5 rounded-3xl border border-white/10 backdrop-blur-md transition-all hover:bg-white/10">
<Briefcase className="w-8 h-8 text-primary shrink-0 opacity-80" />
<div>
<p className="text-xs text-white/50 mb-1.5 uppercase tracking-widest font-bold">{t('client')}</p>
<p className="font-semibold text-lg md:text-xl text-white/90 line-clamp-2">{reference.frontmatter.client}</p>
</div>
</div>
<div className="flex items-start gap-4 p-6 md:p-8 bg-white/5 rounded-3xl border border-white/10 backdrop-blur-md transition-all hover:bg-white/10">
<Calendar className="w-8 h-8 text-primary shrink-0 opacity-80" />
<div>
<p className="text-xs text-white/50 mb-1.5 uppercase tracking-widest font-bold">{t('period')}</p>
<p className="font-semibold text-lg md:text-xl text-white/90">{reference.frontmatter.dateString || new Date(reference.frontmatter.date).getFullYear()}</p>
</div>
</div>
</div>
</div>
</Container>
</section>
{/* Main Content Area */}
<Container className="py-16 md:py-24">
<div className="max-w-4xl mx-auto">
<h2 className="text-3xl font-bold text-neutral-dark mb-8">{t('scopeTitle')}</h2>
<div className="w-full">
<MDXRemote source={reference.content} components={mdxComponents} />
</div>
<div className="mt-20 flex justify-center">
<TrackedLink
href={`/${locale}/referenzen`}
className={getButtonClasses('primary', 'lg')}
eventProperties={{ location: 'reference_bottom_back_btn' }}
>
<span className="relative z-10 flex items-center justify-center gap-2">
{t('viewAll')}
</span>
<ButtonOverlay variant="primary" />
</TrackedLink>
</div>
</div>
</Container>
</div>
);
}

View File

@@ -90,11 +90,9 @@ export default async function ReferenzenOverview(props: { params: Promise<{ loca
<Container className="relative z-20 mt-16 md:mt-24">
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6 md:gap-8">
{references.map((ref) => (
<TrackedLink
<div
key={ref.slug}
href={`/${locale}/referenzen/${ref.slug}`}
eventProperties={{ location: 'reference_list_card', slug: ref.slug }}
className="group flex flex-col bg-white border border-neutral-100 rounded-3xl overflow-hidden shadow-sm hover:shadow-2xl transition-all duration-300 transform hover:-translate-y-1 cursor-pointer block"
className="flex flex-col bg-white border border-neutral-100 rounded-3xl overflow-hidden shadow-sm block"
>
<div className="flex flex-col h-full relative">
{/* Image Section */}
@@ -111,7 +109,7 @@ export default async function ReferenzenOverview(props: { params: Promise<{ loca
<Image src="/assets/logo.png" alt="E-TIB Logo" width={80} height={80} className="opacity-20 grayscale" />
</div>
)}
<div className="absolute inset-0 bg-black/5 transition-colors duration-500" />
<div className="absolute inset-0 bg-black/5" />
{/* Location Badge */}
<div className="absolute top-4 left-4 z-10">
@@ -124,7 +122,7 @@ export default async function ReferenzenOverview(props: { params: Promise<{ loca
{/* Content Section */}
<div className="flex flex-col flex-grow p-6 md:p-8 bg-white">
<h3 className="text-xl md:text-2xl font-bold font-heading text-neutral-dark mb-6 transition-colors line-clamp-3 leading-[1.2] group-hover:text-primary">
<h3 className="text-xl md:text-2xl font-bold font-heading text-neutral-dark mb-6 leading-[1.2]">
{ref.frontmatter.title}
</h3>
@@ -147,7 +145,7 @@ export default async function ReferenzenOverview(props: { params: Promise<{ loca
</div>
</div>
</div>
</TrackedLink>
</div>
))}
</div>

View File

@@ -1,102 +0,0 @@
'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 - Removed along with Payload CMS
// 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://e-tib.com/brochure/etib-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 E-TIB Kabelkatalog' : 'Your E-TIB 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

@@ -0,0 +1,36 @@
'use server';
import fs from 'fs';
import path from 'path';
export async function getAnnotatorAssets() {
// Try /app/public first (docker), then process.cwd()/public
let publicDir = '/app/public';
if (!fs.existsSync(publicDir)) {
publicDir = path.join(process.cwd(), 'public');
}
const walk = async (dir: string): Promise<string[]> => {
let results: string[] = [];
if (!fs.existsSync(dir)) return results;
const list = await fs.promises.readdir(dir);
for (const file of list) {
const filePath = path.join(dir, file);
const stat = await fs.promises.stat(filePath);
if (stat && stat.isDirectory()) {
const nested = await walk(filePath);
results = results.concat(nested);
} else {
if (filePath.match(/\.(png|jpg|jpeg|webp|mp4|svg)$/i)) {
results.push(filePath.replace(publicDir, ''));
}
}
}
return results;
};
const results = await walk(publicDir);
// Console logs removed for absolute silence & speed
return results;
}

View File

@@ -0,0 +1,52 @@
'use server';
import { sendEmail } from '@/lib/mail/mailer';
import { getServerAppServices } from '@/lib/services/create-services.server';
export async function submitAnnotations(annotations: any[]) {
const services = getServerAppServices();
const logger = services.logger.child({ action: 'submitAnnotations' });
if (!annotations || annotations.length === 0) {
return { success: false, error: 'Keine Korrekturen vorhanden.' };
}
try {
const jsonContent = JSON.stringify(annotations, null, 2);
const htmlContent = `
<h2>Neue Website-Korrekturen (${annotations.length})</h2>
<p>Es wurden neue Korrekturen über den Annotator eingereicht.</p>
<ul>
${annotations.map(ann => `
<li>
<b>${new URL(ann.url).pathname}</b> [${ann.type}]<br />
Element: <code>${ann.selector}</code><br />
<i>${ann.type === 'image' ? 'Neues Bild: ' : 'Text: '}${ann.newContent}</i>
</li>
`).join('')}
</ul>
<p><i>Die genauen Daten (inkl. Scroll-Positionen, Viewport und Zeitstempel) befinden sich hier:</i></p>
<pre style="background: #f4f4f4; padding: 10px; border-radius: 5px; font-size: 12px; overflow-x: auto;">
${jsonContent}
</pre>
`;
const notificationResult = await sendEmail({
replyTo: 'info@e-tib.com', // Falls Rückfragen nötig
subject: `[Korrekturen] ${annotations.length} neue Anmerkungen für E-TIB`,
html: htmlContent
});
if (!notificationResult.success) {
logger.error('Annotation email FAILED', { error: notificationResult.error });
return { success: false, error: notificationResult.error };
}
logger.info('Annotation email sent successfully', { count: annotations.length });
return { success: true };
} catch (error) {
const errorMsg = error instanceof Error ? error.message : String(error);
logger.error('Failed to send annotations', { error: errorMsg });
return { success: false, error: errorMsg };
}
}

View File

@@ -1,7 +0,0 @@
export default function TestPage() {
return (
<div>
<h1>TEST PAGE WORKS</h1>
</div>
);
}

1
badge.svg Normal file
View File

@@ -0,0 +1 @@
Not found.

View File

@@ -0,0 +1,31 @@
'use client';
import dynamic from 'next/dynamic';
import { useEffect, useState } from 'react';
import { getAnnotatorAssets } from '@/app/actions/getAnnotatorAssets';
import { submitAnnotations } from '@/app/actions/submitAnnotations';
const Annotator = dynamic(() => import('@/components/annotator-local/Annotator').then(mod => mod.Annotator), { ssr: false });
export default function AnnotatorClientWrapper() {
const [assets, setAssets] = useState<string[]>([]);
useEffect(() => {
getAnnotatorAssets()
.then(fetchedAssets => {
if (Array.isArray(fetchedAssets)) {
setAssets(fetchedAssets);
}
})
.catch(err => console.error('[AnnotatorClientWrapper] Fetch failed:', err));
}, []);
const handleSubmit = async (annotations: any[]) => {
const result = await submitAnnotations(annotations);
if (!result.success) {
throw new Error(result.error);
}
};
return <Annotator assets={assets} onSubmit={handleSubmit} />;
}

View File

@@ -0,0 +1,626 @@
import { useState, useEffect, useMemo } from "react";
import { motion, AnimatePresence } from "framer-motion";
import { MessageSquare, X, Check, Image as ImageIcon, Type, StickyNote, Download, Trash2, List } from "lucide-react";
import { clsx, type ClassValue } from "clsx";
import { twMerge } from "tailwind-merge";
import { finder } from "@medv/finder";
function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs));
}
export interface Annotation {
id: string;
x: number;
y: number;
selector: string;
type: "text" | "image" | "note";
originalContent: string;
newContent: string;
url: string;
elementRect: Record<string, number> | null;
scrollX: number;
scrollY: number;
viewportWidth: number;
viewportHeight: number;
userAgent: string;
timestamp: string;
}
export interface AnnotatorProps {
assets?: string[]; // Array of image URLs for the media browser
onSubmit?: (annotations: Annotation[]) => Promise<void>;
}
export function Annotator({ assets = [], onSubmit }: AnnotatorProps) {
const [isActive, setIsActive] = useState(false);
const [hoveredElement, setHoveredElement] = useState<HTMLElement | null>(null);
const [selectedElement, setSelectedElement] = useState<HTMLElement | null>(null);
const [annotations, setAnnotations] = useState<Annotation[]>(() => {
if (typeof window !== "undefined") {
const saved = localStorage.getItem("mintel-annotations");
if (saved) {
try {
return JSON.parse(saved);
} catch (e) {
console.error("Failed to parse saved annotations", e);
}
}
}
return [];
});
useEffect(() => {
if (typeof window !== "undefined") {
localStorage.setItem("mintel-annotations", JSON.stringify(annotations));
}
}, [annotations]);
// Modal state
const [currentType, setCurrentType] = useState<"text" | "image" | "note">("text");
const [currentText, setCurrentText] = useState("");
const [selectedAsset, setSelectedAsset] = useState<string | null>(null);
const [showList, setShowList] = useState(false);
const [searchTerm, setSearchTerm] = useState("");
const [isSubmitting, setIsSubmitting] = useState(false);
// Disable overlays if needed (e.g. inside iframes)
const isExcluded = useMemo(() => {
if (typeof window === "undefined") return false;
return window.self !== window.top;
}, []);
const getSelector = (el: HTMLElement): string => {
try {
return finder(el, {
root: document.body,
className: (name) =>
!name.startsWith("annotator-") &&
!name.includes("[") &&
!name.includes("/") &&
!name.match(/^[a-z]-[0-9]/),
idName: (name) => !name.startsWith("__next") && !name.includes(":"),
});
} catch {
return "unknown";
}
};
useEffect(() => {
if (!isActive) {
setHoveredElement(null);
return;
}
const handleMouseMove = (e: MouseEvent) => {
if (selectedElement) return;
const target = e.target as HTMLElement;
if (target.closest(".annotator-ui-ignore")) {
setHoveredElement(null);
return;
}
setHoveredElement(target);
};
const handleClick = (e: MouseEvent) => {
if (selectedElement) return;
const target = e.target as HTMLElement;
if (target.closest(".annotator-ui-ignore")) return;
e.preventDefault();
e.stopPropagation();
setSelectedElement(target);
setHoveredElement(null);
// Auto-detect type
if (target.tagName.toLowerCase() === "img") {
setCurrentType("image");
} else {
setCurrentType("text");
setCurrentText(target.innerText || "");
}
};
window.addEventListener("mousemove", handleMouseMove);
window.addEventListener("click", handleClick, true);
return () => {
window.removeEventListener("mousemove", handleMouseMove);
window.removeEventListener("click", handleClick, true);
};
}, [isActive, selectedElement]);
useEffect(() => {
const handleKeyDown = (e: KeyboardEvent) => {
if (e.key === "Escape") {
if (selectedElement) {
setSelectedElement(null);
} else if (isActive) {
setIsActive(false);
}
}
};
window.addEventListener("keydown", handleKeyDown);
return () => window.removeEventListener("keydown", handleKeyDown);
}, [isActive, selectedElement]);
const saveAnnotation = () => {
if (!selectedElement) return;
const rect = selectedElement.getBoundingClientRect();
const newContent = currentType === "image" ? selectedAsset || "" : currentText;
if (!newContent) return;
const annotation: Annotation = {
id: Math.random().toString(36).substring(2, 9),
x: rect.left + rect.width / 2 + window.scrollX,
y: rect.top + rect.height / 2 + window.scrollY,
selector: getSelector(selectedElement),
type: currentType,
originalContent: currentType === "image" ? (selectedElement as HTMLImageElement).src || "" : selectedElement.innerText,
newContent,
url: window.location.href,
elementRect: rect ? {
x: rect.x, y: rect.y, width: rect.width, height: rect.height,
top: rect.top, right: rect.right, bottom: rect.bottom, left: rect.left
} : null,
scrollX: window.scrollX,
scrollY: window.scrollY,
viewportWidth: window.innerWidth,
viewportHeight: window.innerHeight,
userAgent: navigator.userAgent,
timestamp: new Date().toISOString(),
};
setAnnotations([...annotations, annotation]);
setSelectedElement(null);
setCurrentText("");
setSelectedAsset(null);
};
const exportJSON = () => {
const dataStr = "data:text/json;charset=utf-8," + encodeURIComponent(JSON.stringify(annotations, null, 2));
const downloadAnchorNode = document.createElement("a");
downloadAnchorNode.setAttribute("href", dataStr);
downloadAnchorNode.setAttribute("download", "corrections.json");
document.body.appendChild(downloadAnchorNode);
downloadAnchorNode.click();
downloadAnchorNode.remove();
};
const handleSubmit = async () => {
if (!onSubmit || annotations.length === 0) return;
setIsSubmitting(true);
try {
await onSubmit(annotations);
setAnnotations([]);
setShowList(false);
} catch (error) {
console.error("Submission failed", error);
alert("Fehler beim Senden der Korrekturen. Bitte JSON Export nutzen.");
} finally {
setIsSubmitting(false);
}
};
const deleteAnnotation = (id: string) => {
setAnnotations(annotations.filter((a) => a.id !== id));
};
const [updateTick, setUpdateTick] = useState(0);
useEffect(() => {
if (!isActive && !selectedElement) return;
let rafId: number;
const handleScroll = () => {
cancelAnimationFrame(rafId);
rafId = requestAnimationFrame(() => setUpdateTick(t => t + 1));
};
// Use capture phase to catch scrolls on any nested container
window.addEventListener("scroll", handleScroll, true);
window.addEventListener("resize", handleScroll);
return () => {
window.removeEventListener("scroll", handleScroll, true);
window.removeEventListener("resize", handleScroll);
cancelAnimationFrame(rafId);
};
}, [isActive, selectedElement]);
const hoveredRect = useMemo(() => hoveredElement?.getBoundingClientRect(), [hoveredElement, updateTick]);
const selectedRect = useMemo(() => selectedElement?.getBoundingClientRect(), [selectedElement, updateTick]);
if (isExcluded) return null;
return (
<div className="annotator-ui-ignore">
{/* 1. Global Toolbar */}
<div className="fixed bottom-6 left-1/2 -translate-x-1/2 z-[9999] w-max max-w-[95vw]">
<div className="bg-black/80 backdrop-blur-xl border border-white/10 p-2 rounded-2xl shadow-2xl flex items-center gap-1 sm:gap-2 overflow-x-auto hide-scrollbar" style={{ scrollbarWidth: 'none', msOverflowStyle: 'none' }}>
<button
onClick={() => setIsActive(!isActive)}
className={cn(
"flex items-center gap-2 px-4 py-2 rounded-xl transition-all font-medium",
isActive
? "bg-blue-500 text-white shadow-lg shadow-blue-500/20"
: "text-white/70 hover:text-white hover:bg-white/10"
)}
>
{isActive ? <X size={18} /> : <MessageSquare size={18} />}
{isActive ? "Modus beenden" : "Korrekturen erfassen"}
</button>
<div className="w-px h-6 bg-white/10 mx-1" />
<button
onClick={() => setShowList(!showList)}
className="p-2 text-white/70 hover:text-white hover:bg-white/10 rounded-xl relative"
>
<List size={20} />
{annotations.length > 0 && (
<span className="absolute -top-1 -right-1 w-5 h-5 bg-blue-500 text-[10px] flex items-center justify-center rounded-full text-white font-bold border-2 border-[#1a1a1a]">
{annotations.length}
</span>
)}
</button>
{annotations.length > 0 && (
<>
<div className="w-px h-6 bg-white/10 mx-1" />
<button
onClick={exportJSON}
className="flex items-center gap-2 px-4 py-2 rounded-xl text-white/70 hover:text-white hover:bg-white/10 transition-all font-medium"
>
<Download size={18} />
JSON Export
</button>
</>
)}
</div>
</div>
{/* 2. Highlights */}
<AnimatePresence>
{isActive && (
<div className="fixed inset-0 pointer-events-none z-[9998]">
{hoveredRect && (
<motion.div
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
className="absolute border-2 border-blue-400 bg-blue-400/10 rounded-sm transition-all duration-200"
style={{
top: hoveredRect.top,
left: hoveredRect.left,
width: hoveredRect.width,
height: hoveredRect.height,
}}
/>
)}
{selectedRect && (
<motion.div
initial={{ scale: 0.9, opacity: 0 }}
animate={{ scale: 1, opacity: 1 }}
className="absolute border-2 border-yellow-400 bg-yellow-400/20 rounded-sm"
style={{
top: selectedRect.top,
left: selectedRect.left,
width: selectedRect.width,
height: selectedRect.height,
}}
/>
)}
</div>
)}
</AnimatePresence>
{/* 3. Action Modal */}
<AnimatePresence>
{selectedElement && (
<div className="fixed inset-0 flex items-center justify-center z-[10000] bg-black/40 backdrop-blur-sm">
<motion.div
initial={{ opacity: 0, y: 20, scale: 0.95 }}
animate={{ opacity: 1, y: 0, scale: 1 }}
exit={{ opacity: 0, y: 20, scale: 0.95 }}
className="bg-[#1c1c1e] border border-white/10 rounded-3xl p-4 sm:p-6 w-[95vw] md:w-[800px] shadow-2xl flex flex-col max-h-[85vh]"
>
<div className="flex items-center justify-between mb-6">
<h3 className="text-white font-bold text-lg">Änderung erfassen</h3>
<button
onClick={() => setSelectedElement(null)}
className="text-white/40 hover:text-white"
>
<X size={20} />
</button>
</div>
<div className="flex gap-2 mb-6 shrink-0">
<button
onClick={() => setCurrentType("text")}
className={cn(
"flex-1 py-3 px-4 rounded-xl text-sm font-medium transition-all flex items-center justify-center gap-2",
currentType === "text"
? "bg-white text-black shadow-lg"
: "bg-white/5 text-white/40 hover:bg-white/10"
)}
>
<Type size={16} /> Text
</button>
<button
onClick={() => setCurrentType("image")}
className={cn(
"flex-1 py-3 px-4 rounded-xl text-sm font-medium transition-all flex items-center justify-center gap-2",
currentType === "image"
? "bg-white text-black shadow-lg"
: "bg-white/5 text-white/40 hover:bg-white/10"
)}
>
<ImageIcon size={16} /> Bild
</button>
<button
onClick={() => setCurrentType("note")}
className={cn(
"flex-1 py-3 px-4 rounded-xl text-sm font-medium transition-all flex items-center justify-center gap-2",
currentType === "note"
? "bg-white text-black shadow-lg"
: "bg-white/5 text-white/40 hover:bg-white/10"
)}
>
<StickyNote size={16} /> Notiz
</button>
</div>
<div className="flex-1 overflow-y-auto min-h-[200px] mb-6 hide-scrollbar" style={{ scrollbarWidth: 'none', msOverflowStyle: 'none' }}>
{currentType === "text" && (
<div className="space-y-2">
<label className="text-xs font-bold uppercase tracking-wider text-white/40">Neuer Text</label>
<textarea
autoFocus
value={currentText}
onChange={(e) => setCurrentText(e.target.value)}
className="w-full h-32 bg-black/40 border border-white/10 rounded-2xl p-4 text-white placeholder:text-white/20 focus:outline-none focus:border-blue-500/50 transition-colors resize-none"
/>
</div>
)}
{currentType === "note" && (
<div className="space-y-2">
<label className="text-xs font-bold uppercase tracking-wider text-white/40">Anmerkung</label>
<textarea
autoFocus
value={currentText}
onChange={(e) => setCurrentText(e.target.value)}
placeholder="Was soll hier gemacht werden?"
className="w-full h-32 bg-black/40 border border-white/10 rounded-2xl p-4 text-white placeholder:text-white/20 focus:outline-none focus:border-blue-500/50 transition-colors resize-none"
/>
</div>
)}
{currentType === "image" && (
<div className="space-y-2 h-full">
<label className="text-xs font-bold uppercase tracking-wider text-white/40">Neues Bild wählen</label>
{assets.length === 0 ? (
<div className="text-sm text-white/40 p-4 bg-white/5 rounded-xl border border-white/5 text-center">
Keine Assets übergeben. Bitte das prop <code>assets</code> befüllen.
</div>
) : (
<>
<style>{`.hide-scrollbar::-webkit-scrollbar { display: none; }`}</style>
<div className="mb-3">
<input
type="text"
placeholder="Suchen nach Dateinamen..."
value={searchTerm}
onChange={(e) => setSearchTerm(e.target.value)}
className="w-full bg-black/40 border border-white/10 rounded-xl px-4 py-3 text-sm text-white placeholder:text-white/40 focus:outline-none focus:border-blue-500/50 transition-colors"
/>
</div>
<div
className="grid grid-cols-2 sm:grid-cols-3 md:grid-cols-4 gap-3 h-[400px] overflow-y-auto hide-scrollbar"
style={{ scrollbarWidth: 'none', msOverflowStyle: 'none' }}
>
{assets.filter(asset => asset.toLowerCase().includes(searchTerm.toLowerCase())).map((asset) => {
const filename = asset.split('/').pop() || asset;
return (
<button
key={asset}
title={asset}
onClick={() => setSelectedAsset(asset)}
className={cn(
"relative flex flex-col items-center bg-black/20 rounded-xl overflow-hidden border-2 transition-all p-2 gap-2 w-full",
selectedAsset === asset
? "border-blue-500 shadow-[0_0_0_4px_rgba(59,130,246,0.3)] bg-blue-500/10"
: "border-transparent hover:border-white/20 hover:bg-white/5"
)}
style={{ height: '130px' }}
>
<div className="w-full flex-1 rounded-lg overflow-hidden bg-black/40 flex items-center justify-center relative">
{asset.match(/\.(mp4|webm)$/i) ? (
<video src={asset} className="w-full h-full object-cover opacity-80" />
) : (
<img src={asset} loading="lazy" alt={filename} className="w-full h-full object-contain" />
)}
{selectedAsset === asset && (
<div className="absolute inset-0 flex items-center justify-center bg-blue-500/20 backdrop-blur-[2px]">
<div className="bg-blue-500 text-white rounded-full p-1.5 shadow-lg">
<Check size={18} strokeWidth={3} />
</div>
</div>
)}
</div>
<span className="text-[11px] font-medium text-white/70 truncate w-full text-center shrink-0" title={filename}>
{filename}
</span>
</button>
);
})}
</div>
</>
)}
</div>
)}
</div>
<button
disabled={(!currentText && currentType !== "image") || (currentType === "image" && !selectedAsset)}
onClick={saveAnnotation}
className="w-full bg-blue-500 hover:bg-blue-400 disabled:opacity-50 disabled:cursor-not-allowed text-white font-bold py-4 rounded-2xl flex items-center justify-center gap-2 transition-all shadow-lg shadow-blue-500/20 shrink-0"
>
<Check size={20} />
Speichern
</button>
</motion.div>
</div>
)}
</AnimatePresence>
{/* 4. Annotations List Sidebar */}
<AnimatePresence>
{showList && (
<>
<motion.div
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
onClick={() => setShowList(false)}
className="fixed inset-0 bg-black/60 backdrop-blur-sm z-[10001]"
/>
<motion.div
initial={{ x: "100%" }}
animate={{ x: 0 }}
exit={{ x: "100%" }}
transition={{ type: "spring", damping: 25, stiffness: 200 }}
className="fixed top-0 right-0 h-full w-full sm:w-[400px] bg-[#1c1c1e] border-l border-white/10 z-[10002] shadow-2xl flex flex-col"
>
<div className="p-8 border-b border-white/10 flex items-center justify-between">
<div>
<h2 className="text-2xl font-bold text-white mb-1">Korrekturen</h2>
<p className="text-white/40 text-sm">{annotations.length} erfasst</p>
</div>
<button
onClick={() => setShowList(false)}
className="p-2 text-white/40 hover:text-white bg-white/5 rounded-xl transition-colors"
>
<X size={20} />
</button>
</div>
<div className="flex-1 overflow-y-auto p-6 space-y-4">
{annotations.length === 0 ? (
<div className="h-full flex flex-col items-center justify-center text-center px-8 opacity-40">
<List size={48} className="mb-4" />
<p>Noch keine Korrekturen vorhanden.</p>
</div>
) : (
annotations.map((ann) => (
<div
key={ann.id}
className="bg-white/5 border border-white/5 rounded-2xl p-4 flex flex-col gap-3 group relative"
>
<button
onClick={() => deleteAnnotation(ann.id)}
className="absolute top-4 right-4 text-white/20 hover:text-red-400 opacity-0 group-hover:opacity-100 transition-opacity"
>
<Trash2 size={16} />
</button>
<div className="flex items-center gap-2">
<span className={cn(
"px-2 py-1 rounded-md text-[10px] font-bold uppercase tracking-wider flex items-center gap-1",
ann.type === "text" ? "bg-blue-500/20 text-blue-400" :
ann.type === "image" ? "bg-purple-500/20 text-purple-400" :
"bg-orange-500/20 text-orange-400"
)}>
{ann.type === "text" && <Type size={12} />}
{ann.type === "image" && <ImageIcon size={12} />}
{ann.type === "note" && <StickyNote size={12} />}
{ann.type}
</span>
</div>
{ann.type === "image" ? (
<div className="flex items-center gap-2">
<img src={ann.newContent} alt="New" className="w-16 h-16 object-cover rounded-lg bg-white/10" />
<div className="text-xs text-white/60 flex-1 truncate">{ann.newContent}</div>
</div>
) : (
<p className="text-white text-sm whitespace-pre-wrap">{ann.newContent}</p>
)}
<div className="text-[10px] text-white/30 truncate mt-2 font-mono flex items-center gap-2">
<span>{ann.selector}</span>
{ann.url && <span className="truncate max-w-[100px] text-blue-400" title={ann.url}>{new URL(ann.url).pathname}</span>}
</div>
<div className="flex flex-wrap items-center gap-2 mt-2 pt-2 border-t border-white/5">
{ann.timestamp && (
<span className="text-[9px] px-1.5 py-0.5 rounded bg-white/5 text-white/40">
{new Date(ann.timestamp).toLocaleString()}
</span>
)}
{ann.viewportWidth && (
<span className="text-[9px] px-1.5 py-0.5 rounded bg-white/5 text-white/40">
{ann.viewportWidth}x{ann.viewportHeight}
</span>
)}
{ann.scrollX !== undefined && (
<span className="text-[9px] px-1.5 py-0.5 rounded bg-white/5 text-white/40">
Scroll: {Math.round(ann.scrollX)},{Math.round(ann.scrollY)}
</span>
)}
</div>
</div>
))
)}
</div>
{annotations.length > 0 && (
<div className="p-6 border-t border-white/10 flex flex-col gap-3">
{onSubmit && (
<button
onClick={handleSubmit}
disabled={isSubmitting}
className="w-full bg-blue-500 hover:bg-blue-400 text-white font-bold py-4 rounded-2xl flex items-center justify-center gap-2 transition-all shadow-lg shadow-blue-500/20 disabled:opacity-50 disabled:cursor-not-allowed"
>
{isSubmitting ? (
<div className="w-5 h-5 border-2 border-white/30 border-t-white rounded-full animate-spin" />
) : (
<Check size={20} />
)}
{isSubmitting ? "Wird gesendet..." : "Korrekturen an Entwickler senden"}
</button>
)}
<button
onClick={exportJSON}
className={cn(
"w-full font-bold py-3 rounded-2xl flex items-center justify-center gap-2 transition-all",
onSubmit ? "bg-white/5 text-white/70 hover:bg-white/10 hover:text-white" : "bg-blue-500 hover:bg-blue-400 text-white shadow-lg shadow-blue-500/20 py-4"
)}
>
<Download size={onSubmit ? 18 : 20} />
JSON {onSubmit && "lokal "}Exportieren
</button>
<button
onClick={() => {
if (confirm("Möchtest du wirklich alle erfassten Korrekturen löschen?")) {
setAnnotations([]);
}
}}
className="w-full bg-red-500/10 text-red-500 hover:bg-red-500/20 font-bold py-3 rounded-xl flex items-center justify-center gap-2 transition-all mt-2"
>
<Trash2 size={18} />
Alle löschen
</button>
</div>
)}
</motion.div>
</>
)}
</AnimatePresence>
</div>
);
}

View File

@@ -0,0 +1 @@
export { Annotator } from "./Annotator";

View File

@@ -75,7 +75,7 @@ const Icons = {
export function BenefitGrid({ badge, title, description, benefits }: BenefitGridProps) {
return (
<section className="bg-white py-12 md:py-24 relative overflow-hidden border-t border-neutral-100">
<section className="bg-white py-16 md:py-24 lg:py-32 relative overflow-hidden border-t border-neutral-100">
<div className="container px-4 max-w-7xl mx-auto relative z-10">
<div className="max-w-3xl mx-auto text-center mb-16">

View File

@@ -99,7 +99,7 @@ export function CertificatesBlock({ badge, title, description, certificates = de
if (!isMounted) {
return (
<section className="py-12 md:py-24 bg-neutral-50 relative overflow-hidden">
<section className="py-16 md:py-24 lg:py-32 bg-neutral-50 relative overflow-hidden">
<Container>
{!hideHeader && (
<div className="text-center max-w-3xl mx-auto mb-16">

View File

@@ -80,7 +80,7 @@ export function CompanyTimeline({
const lineHeight = useTransform(scrollYProgress, [0, 1], ["0%", "100%"]);
return (
<section className="py-12 md:py-32 bg-[#FAFAFA] relative overflow-hidden">
<section className="py-16 md:py-24 lg:py-32 bg-[#FAFAFA] relative overflow-hidden">
{/* Background Decor */}
<div className="absolute top-0 right-0 w-[600px] h-[600px] bg-primary/5 rounded-full blur-[100px] pointer-events-none -translate-y-1/2 translate-x-1/3" />
<div className="absolute bottom-0 left-0 w-[800px] h-[800px] bg-neutral-200/40 rounded-full blur-[120px] pointer-events-none translate-y-1/3 -translate-x-1/3" />

View File

@@ -62,7 +62,7 @@ export function CompetenceBentoGrid(props: CompetenceBentoGridProps) {
// Static fallback for SSR
if (!isMounted) {
return (
<section className="py-12 md:py-24 bg-neutral text-neutral-dark overflow-hidden relative">
<section className="py-16 md:py-24 lg:py-32 bg-neutral text-neutral-dark overflow-hidden relative">
<Container>
<div className="flex flex-col md:flex-row justify-between items-end mb-12">
<div className="max-w-2xl">

View File

@@ -41,7 +41,7 @@ export const ContactSection: React.FC<ContactSectionProps> = (props) => {
const { showForm, showMap } = props;
return (
<Section className="bg-neutral-light py-12 md:py-28 relative overflow-hidden">
<Section className="bg-neutral-light relative overflow-hidden">
<Container>
<div className="grid grid-cols-1 lg:grid-cols-12 gap-8 md:gap-16">
<motion.div

View File

@@ -21,7 +21,7 @@ export const FaqBlock: React.FC<FaqBlockProps> = ({
if (!questions || questions.length === 0) return null;
return (
<section className="py-12 md:py-16">
<section className="py-16 md:py-24 lg:py-32">
<div className="max-w-3xl mx-auto">
<div className="mb-10 text-center">
{subtitle && (

View File

@@ -1,13 +1,13 @@
'use client';
import React, { useState } from 'react';
import React, { useState, useRef, useCallback, useMemo } from 'react';
import { motion, AnimatePresence } from 'framer-motion';
import { MapPin, Factory, Zap, CheckCircle2, ArrowUpRight } from 'lucide-react';
import { MapPin, CheckCircle2, ArrowUpRight } from 'lucide-react';
import Image from 'next/image';
import { useRouter, usePathname } from 'next/navigation';
import { usePathname } from 'next/navigation';
import Link from 'next/link';
import { AnimatedGlossyBorder } from '@/components/ui/AnimatedGlossyBorder';
import { Location, defaultLocations, minorLocations, projectLocations } from '@/lib/map-data';
import { Location, projectLocations } from '@/lib/map-data';
import { standorteLocations } from '@/lib/standorte-data';
import { useLocale, useTranslations } from 'next-intl';
@@ -28,6 +28,59 @@ interface InteractiveGermanyMapProps {
hideStandorte?: boolean;
}
const MinorNode = React.memo(({ loc, isActive, onEnter, onLeave }: { loc: Location, isActive: boolean, onEnter: (loc: Location) => void, onLeave: () => void }) => (
<div
className={`absolute group/minor cursor-pointer w-8 h-8 md:w-5 md:h-5 flex items-center justify-center ${isActive ? 'z-[60]' : 'z-10 hover:z-30'}`}
style={{
left: `${loc.x}%`,
top: `${loc.y}%`,
transform: 'translate(-50%, -50%)',
}}
onMouseEnter={() => onEnter(loc)}
onMouseLeave={onLeave}
onTouchStart={() => onEnter(loc)}
onClick={() => onEnter(loc)}
>
<div className={`w-1.5 h-1.5 rounded-full transition-all duration-300 ${isActive ? 'bg-white scale-150 shadow-[0_0_10px_rgba(130,237,32,1)]' : 'bg-primary/80 group-hover/minor:bg-white group-hover/minor:scale-150'}`} />
<div className="absolute inset-0 m-auto w-2 h-2 bg-primary rounded-full opacity-0 group-hover/minor:animate-ping" />
</div>
));
MinorNode.displayName = 'MinorNode';
const MajorNode = React.memo(({ loc, isActive, idx, onEnter, onLeave }: { loc: Location, isActive: boolean, idx: number, onEnter: (loc: Location) => void, onLeave: () => void }) => {
const isHQ = loc.type === 'hq';
const isBranch = loc.type === 'branch';
return (
<div
className={`absolute transform -translate-x-1/2 -translate-y-1/2 group/pin cursor-pointer w-16 h-16 flex items-center justify-center ${isActive ? 'z-[60]' : 'z-20 hover:z-40'}`}
style={{ left: `${loc.x}%`, top: `${loc.y}%` }}
onMouseEnter={() => onEnter(loc)}
onMouseLeave={onLeave}
onTouchStart={() => onEnter(loc)}
onClick={() => onEnter(loc)}
>
{(isHQ || isBranch) && (
<div className="absolute inset-0 m-auto w-6 h-6 rounded-full animate-ping bg-primary/50 scale-150" />
)}
<motion.div
initial={{ scale: 0 }}
animate={{ scale: 1 }}
transition={{ type: 'spring', delay: idx * 0.1 }}
className={`relative flex items-center justify-center rounded-full shadow-[0_0_20px_rgba(130,237,32,0.6)] transition-transform duration-300 ${
isActive ? 'scale-125 z-30 ring-4 ring-primary/40' : 'scale-100 group-hover/pin:scale-110'
} ${
isHQ || isBranch
? 'w-7 h-7 bg-primary text-[#050B14]'
: 'w-4 h-4 bg-white ring-[3px] ring-primary'
}`}
>
{(isHQ || isBranch) && <MapPin className="w-4 h-4" />}
</motion.div>
</div>
);
});
MajorNode.displayName = 'MajorNode';
export function InteractiveGermanyMap({
badge,
title,
@@ -38,21 +91,23 @@ export function InteractiveGermanyMap({
hideStandorte = false
}: InteractiveGermanyMapProps) {
const [activeLocation, setActiveLocation] = useState<Location | null>(null);
const [hoverTimeout, setHoverTimeout] = useState<NodeJS.Timeout | null>(null);
const hoverTimeoutRef = useRef<NodeJS.Timeout | null>(null);
const handleMouseEnter = (loc: Location) => {
if (hoverTimeout) clearTimeout(hoverTimeout);
const handleMouseEnter = useCallback((loc: Location) => {
if (hoverTimeoutRef.current) {
clearTimeout(hoverTimeoutRef.current);
hoverTimeoutRef.current = null;
}
setActiveLocation(loc);
};
}, []);
const handleMouseLeave = () => {
const handleMouseLeave = useCallback(() => {
const timeout = setTimeout(() => {
setActiveLocation(null);
}, 200);
setHoverTimeout(timeout);
};
hoverTimeoutRef.current = timeout;
}, []);
const router = useRouter();
const pathname = usePathname();
const locale = useLocale();
const t = useTranslations('InteractiveGermanyMap');
@@ -64,14 +119,11 @@ export function InteractiveGermanyMap({
];
const finalBadge = badge || tStandard('badge');
// the map is mostly used with specific props, so we leave title & description to fallbacks if not provided
const finalTitle = title || (locale === 'en' ? <>Nationwide<br/>in operation for you.</> : <>Deutschlandweit<br/>für Sie im Einsatz.</>);
const finalDescription = description || (locale === 'en' ? 'From our strategic locations in Guben, Kirchheilingen and Bülstedt, we control and implement complex infrastructure projects nationwide.' : 'Von unseren strategischen Standorten in Guben, Kirchheilingen und Bülstedt steuern und realisieren wir komplexe Infrastrukturprojekte im gesamten Bundesgebiet.');
const finalLocations = React.useMemo(() => {
const finalLocations = useMemo(() => {
if (hideStandorte) return locations;
// Ensure the 3 standorte are always included, without duplicating if already present by ID
const base = [...locations];
standorteLocations.forEach(sl => {
if (!base.some(l => l.id === sl.id)) {
@@ -81,19 +133,28 @@ export function InteractiveGermanyMap({
return base;
}, [locations, hideStandorte]);
const mapBackground = useMemo(() => (
<div className="absolute inset-0 opacity-[0.25] mix-blend-screen drop-shadow-2xl brightness-200 pointer-events-none transform-gpu">
<Image
src="/germany-map.svg"
alt={locale === 'en' ? "Map of Germany" : "Deutschlandkarte"}
fill
className="object-cover"
priority
/>
</div>
), [locale]);
return (
<div key={isHero ? `hero-map-${pathname}` : undefined} className={isHero ? "relative w-full" : "relative w-full max-w-7xl mx-auto py-12 md:py-24 px-4 sm:px-6 mt-16 md:mt-20"}>
<div className={`bg-[#050B14] ${isHero ? 'pt-32 pb-16 md:pt-40 md:pb-24' : 'rounded-[2.5rem] md:rounded-[3.5rem] shadow-2xl border border-white/5'} overflow-visible relative group/map`}>
{/* Animated Border Glow */}
<AnimatedGlossyBorder opacity={0.7} className="z-30" />
{!isHero && <AnimatedGlossyBorder opacity={0.7} className="z-30" />}
{/* Background Effects */}
<div className={`absolute inset-0 bg-gradient-to-br from-[#050B14] via-[#0A1322] to-[#050B14] ${!isHero && 'rounded-[2.5rem] md:rounded-[3.5rem]'} overflow-hidden`} />
<div className="absolute top-0 right-0 w-[800px] h-[800px] bg-primary/5 rounded-full blur-[120px] pointer-events-none translate-x-1/3 -translate-y-1/3 overflow-hidden" />
<div className={`relative z-10 grid grid-cols-1 lg:grid-cols-2 xl:grid-cols-12 gap-12 items-center min-h-[600px] ${isHero ? 'max-w-7xl mx-auto px-4 sm:px-6' : 'p-8 md:p-12 lg:p-16'}`}>
{/* Content Left */}
<div className="xl:col-span-5 text-white z-20">
<div className="inline-flex items-center gap-2 px-3 py-1.5 rounded-full bg-primary/10 border border-primary/20 text-primary text-xs font-bold uppercase tracking-wider mb-8">
<MapPin className="w-4 h-4" />
@@ -114,7 +175,6 @@ export function InteractiveGermanyMap({
{finalDescription}
</p>
{/* Industrial Stats Grid */}
<div className="grid grid-cols-2 gap-4">
{finalStats.map((stat, i) => {
const StatCard = () => (
@@ -145,81 +205,32 @@ export function InteractiveGermanyMap({
</div>
</div>
{/* Map Right */}
<div className="xl:col-span-7 relative flex items-center justify-center min-h-[500px] lg:min-h-[700px] z-10 pointer-events-none">
{/* Map Container - Enforce strict aspect ratio matching the SVG (1024x1024) */}
<div className="relative w-full max-w-[600px] aspect-square group pointer-events-auto">
{/* Map SVG */}
<div className="absolute inset-0 opacity-[0.25] mix-blend-screen drop-shadow-2xl brightness-200 pointer-events-none">
<Image
src="/germany-map.svg"
alt={locale === 'en' ? "Map of Germany" : "Deutschlandkarte"}
fill
className="object-cover"
priority
/>
</div>
{mapBackground}
{/* Minor Location Markers (The 100+ generic projects) */}
{finalLocations.filter(l => l.type === 'minor_node').map((loc, idx) => (
<div
key={`minor-${idx}`}
className="absolute z-10 group/minor cursor-pointer w-10 h-10 flex items-center justify-center"
style={{
left: `${loc.x}%`,
top: `${loc.y}%`,
transform: 'translate(-50%, -50%)',
}}
onMouseEnter={() => handleMouseEnter(loc)}
onMouseLeave={handleMouseLeave}
onClick={() => handleMouseEnter(loc)}
>
<div className="w-1.5 h-1.5 bg-primary/80 rounded-full group-hover/minor:bg-white group-hover/minor:scale-150 transition-all duration-300" />
<div className="absolute inset-0 m-auto w-2 h-2 bg-primary rounded-full opacity-0 group-hover/minor:animate-ping" />
</div>
<MinorNode
key={`minor-${loc.id || idx}`}
loc={loc}
isActive={activeLocation === loc}
onEnter={handleMouseEnter}
onLeave={handleMouseLeave}
/>
))}
{/* Major Location Markers (HQ, Branch, Project) */}
{finalLocations.filter(l => l.type !== 'minor_node').map((loc, idx) => {
const isHQ = loc.type === 'hq';
const isBranch = loc.type === 'branch';
const isActive = activeLocation?.id === loc.id;
{finalLocations.filter(l => l.type !== 'minor_node').map((loc, idx) => (
<MajorNode
key={loc.id}
loc={loc}
idx={idx}
isActive={activeLocation?.id === loc.id}
onEnter={handleMouseEnter}
onLeave={handleMouseLeave}
/>
))}
return (
<div
key={loc.id}
className={`absolute transform -translate-x-1/2 -translate-y-1/2 group/pin cursor-pointer w-16 h-16 flex items-center justify-center ${isActive ? 'z-[60]' : 'z-20'}`}
style={{ left: `${loc.x}%`, top: `${loc.y}%` }}
onMouseEnter={() => handleMouseEnter(loc)}
onMouseLeave={handleMouseLeave}
onClick={() => handleMouseEnter(loc)}
>
{/* Ping Animation for HQ / Branch */}
{(isHQ || isBranch) && (
<div className="absolute inset-0 m-auto w-6 h-6 rounded-full animate-ping bg-primary/50 scale-150" />
)}
{/* Elegant Marker Dot */}
<motion.div
initial={{ scale: 0 }}
animate={{ scale: 1 }}
transition={{ type: 'spring', delay: idx * 0.1 }}
className={`relative flex items-center justify-center rounded-full shadow-[0_0_20px_rgba(130,237,32,0.6)] transition-transform duration-300 ${
isActive ? 'scale-125 z-30 ring-4 ring-primary/40' : 'scale-100 group-hover/pin:scale-110'
} ${
isHQ || isBranch
? 'w-7 h-7 bg-primary text-[#050B14]'
: 'w-4 h-4 bg-white ring-[3px] ring-primary'
}`}
>
{(isHQ || isBranch) && <MapPin className="w-4 h-4" />}
</motion.div>
</div>
);
})}
{/* Global Tooltip */}
<AnimatePresence>
{activeLocation && (
<motion.div
@@ -238,7 +249,6 @@ export function InteractiveGermanyMap({
onMouseLeave={handleMouseLeave}
>
<div className="bg-[#050B14]/95 backdrop-blur-xl border border-white/10 rounded-2xl shadow-2xl min-w-[200px] w-max max-w-[300px] text-white overflow-hidden">
{activeLocation.featuredImage && (
<div className="relative w-full h-32 bg-neutral-900">
<Image

View File

@@ -94,7 +94,7 @@ export function ReferencesSlider(props: ReferencesSliderProps) {
return (
<section id="referenzen" className="py-12 md:py-24 bg-neutral-dark text-white relative overflow-hidden">
<section id="referenzen" className="py-16 md:py-24 lg:py-32 bg-neutral-dark text-white relative overflow-hidden">
<div className="absolute inset-0 bg-gradient-to-b from-neutral-dark via-neutral-900 to-neutral-dark z-0" />
<div className="container relative z-10 mb-12 flex flex-col md:flex-row justify-between items-end gap-6">
@@ -131,16 +131,8 @@ export function ReferencesSlider(props: ReferencesSliderProps) {
transition={{ delay: i * 0.1, duration: 0.6, ease: "easeOut" }}
className="flex-shrink-0 w-[320px] md:w-[480px] snap-center group pointer-events-auto"
>
<Link
href={`/${locale}/referenzen/${ref.slug}`}
<div
data-testid="reference-tile"
draggable={false}
onClick={(e) => {
if (dragDistanceRef.current > 15) {
e.preventDefault();
}
dragDistanceRef.current = 0;
}}
className="block relative select-none aspect-[16/10] bg-neutral-800 rounded-2xl overflow-hidden mb-5 border border-white/5 shadow-2xl"
>
<Image
@@ -161,7 +153,7 @@ export function ReferencesSlider(props: ReferencesSliderProps) {
</span>
<h4 className="font-heading text-xl md:text-2xl font-bold leading-tight break-words">{ref.title}</h4>
</div>
</Link>
</div>
</motion.div>
);
})}

View File

@@ -84,19 +84,19 @@ export function ScaleOfImpact() {
{/* Static Data Grid (No jumping text) */}
<div className="grid grid-cols-2 md:grid-cols-4 gap-y-4 gap-x-2 md:gap-12 w-full max-w-5xl mt-4 md:mt-12">
<div className="flex flex-col items-center">
<div className="font-mono text-3xl md:text-5xl font-black text-white tracking-tighter mb-2">200 <span className="text-primary">+</span></div>
<div className="font-mono text-3xl md:text-5xl font-black text-white tracking-tighter mb-2">200<span className="text-primary">+</span></div>
<div className="text-xs font-bold uppercase tracking-widest text-white/50">{locale === 'en' ? 'Projects' : 'Projekte'}</div>
</div>
<div className="flex flex-col items-center">
<div className="font-mono text-3xl md:text-5xl font-black text-white tracking-tighter mb-2">974 <span className="text-primary text-xl md:text-2xl">km</span></div>
<div className="font-mono text-3xl md:text-5xl font-black text-white tracking-tighter mb-2">974<span className="text-primary text-xl md:text-2xl ml-1">km</span></div>
<div className="text-xs font-bold uppercase tracking-widest text-white/50">{locale === 'en' ? 'Cable Laying' : 'Kabelverlegung'}</div>
</div>
<div className="flex flex-col items-center">
<div className="font-mono text-3xl md:text-5xl font-black text-white tracking-tighter mb-2">300 <span className="text-primary text-xl md:text-2xl">km</span></div>
<div className="font-mono text-3xl md:text-5xl font-black text-white tracking-tighter mb-2">300<span className="text-primary text-xl md:text-2xl ml-1">km</span></div>
<div className="text-xs font-bold uppercase tracking-widest text-white/50">{locale === 'en' ? 'Open Trenching' : 'Offener Tiefbau'}</div>
</div>
<div className="flex flex-col items-center">
<div className="font-mono text-3xl md:text-5xl font-black text-white tracking-tighter mb-2">160 <span className="text-primary text-xl md:text-2xl">km</span></div>
<div className="font-mono text-3xl md:text-5xl font-black text-white tracking-tighter mb-2">160<span className="text-primary text-xl md:text-2xl ml-1">km</span></div>
<div className="text-xs font-bold uppercase tracking-widest text-white/50">{locale === 'en' ? 'HDD Drilling' : 'Spülbohrung'}</div>
</div>
</div>

View File

@@ -105,7 +105,7 @@ export function ServiceDetailGrid({
panels
}: ServiceDetailGridProps) {
return (
<section className="bg-neutral-50 py-12 md:py-24 border-b border-neutral-100 relative overflow-hidden">
<section className="bg-neutral-50 py-16 md:py-24 lg:py-32 border-b border-neutral-100 relative overflow-hidden">
<div className="container relative z-10 px-4 max-w-7xl mx-auto">
<div className="max-w-3xl mb-12 md:mb-20 text-left">

View File

@@ -68,7 +68,7 @@ export function SubCompanyTiles(props: SubCompanyTilesProps) {
})) || defaultCompanies;
return (
<section id="unternehmen" className="py-12 md:py-24 bg-neutral-dark border-b border-neutral-800 relative overflow-hidden">
<section id="unternehmen" className="py-16 md:py-24 lg:py-32 bg-neutral-dark border-b border-neutral-800 relative overflow-hidden">
<div className="container relative z-10 px-4">
{(badge || title) && (

View File

@@ -28,7 +28,7 @@ export function TeamGrid({ members }: TeamGridProps) {
if (!members || members.length === 0) return null;
return (
<section className="py-12 md:py-24 bg-white relative overflow-hidden">
<section className="py-16 md:py-24 lg:py-32 bg-white relative overflow-hidden">
<div className="container relative z-10">
<div className="mb-12">
<h2 className="text-primary font-bold tracking-wider uppercase text-sm mb-3">{t('badge')}</h2>

View File

@@ -28,7 +28,7 @@ export function EUFundingBadge() {
</div>
{/* Text overlaid on the flag */}
<div className="absolute top-0 right-0 p-5 md:p-8 flex flex-col items-end text-right z-10 drop-shadow-[0_2px_8px_rgba(0,0,0,0.9)]">
<div className="absolute top-0 right-0 pt-8 pr-8 md:pt-12 md:pr-12 flex flex-col items-end text-right z-10 drop-shadow-[0_2px_8px_rgba(0,0,0,0.9)]">
<span className="block text-[9px] md:text-[11px] font-bold text-white/90 tracking-wide leading-snug drop-shadow-md">
Kofinanziert von der
</span>

View File

@@ -1,145 +0,0 @@
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 E-TIB Kabelkatalog',
greeting: 'Vielen Dank für Ihr Interesse an E-TIB.',
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 e-tib.com gesendet.',
}
: {
subject: 'Your E-TIB Cable Catalog',
greeting: 'Thank you for your interest in E-TIB.',
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 e-tib.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: '#111111',
padding: '32px 48px',
borderBottom: '4px solid #117c61',
};
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: '#117c61',
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

@@ -7,7 +7,7 @@ export default function CTA({ data }: { data?: any }) {
const locale = useLocale();
return (
<Section className="bg-primary text-white py-16 md:py-32 relative overflow-hidden">
<Section className="bg-primary text-white relative overflow-hidden">
<div className="absolute top-0 right-0 w-1/3 h-full bg-accent/5 -skew-x-12 translate-x-1/2" />
<div className="absolute bottom-0 left-0 w-1/4 h-1/2 bg-primary/10 rounded-full blur-3xl -translate-x-1/2 translate-y-1/2" />

View File

@@ -7,7 +7,7 @@ export default function Experience({ data }: { data?: any }) {
const t = useTranslations('Home.experience');
return (
<Section className="relative py-16 md:py-48 overflow-hidden text-white">
<Section className="relative overflow-hidden text-white">
<div className="absolute inset-0 z-0">
<Image
src="/uploads/2024/12/1694273920124-copy-2.webp"

View File

@@ -24,7 +24,7 @@ export default function GallerySection({ data }: { data?: any }) {
const lightboxIndex = photoParam ? parseInt(photoParam, 10) : 0;
return (
<Section className="bg-white text-white py-16 md:py-32">
<Section className="bg-white text-white">
<Container>
<Heading level={2} subtitle={data?.subtitle || t('subtitle')} align="center">
{data?.title || t('title')}

View File

@@ -9,7 +9,7 @@ export default function MeetTheTeam({ data }: { data?: any }) {
const locale = useLocale();
return (
<Section className="relative py-16 md:py-48 overflow-hidden">
<Section className="relative overflow-hidden">
<div className="absolute inset-0 z-0">
<Image
src="/uploads/2024/12/DSC08036-Large.webp"

View File

@@ -19,7 +19,7 @@ export function Footer({ companyInfo }: FooterProps) {
const locale = useLocale();
return (
<footer className="bg-[#050B14] text-neutral-light pt-40 pb-10 md:pt-16 md:pb-16 relative overflow-hidden border-t border-white/5">
<footer className="bg-[#050B14] text-neutral-light pt-40 pb-10 md:pt-40 md:pb-16 relative overflow-hidden border-t border-white/5">
{/* Subtle background tech grid */}
<div className="absolute inset-0 bg-[linear-gradient(to_right,#8080800a_1px,transparent_1px),linear-gradient(to_bottom,#8080800a_1px,transparent_1px)] bg-[size:32px_32px] [mask-image:radial-gradient(ellipse_80%_50%_at_50%_0%,#000_70%,transparent_100%)] pointer-events-none" />
@@ -46,7 +46,7 @@ export function Footer({ companyInfo }: FooterProps) {
<p className="text-neutral-400 text-base leading-relaxed max-w-sm">
{locale === 'de'
? 'Die Experten für Kabelnetzbau, Horizontalspülbohrungen und umfassende Infrastrukturprojekte. Qualität und Zuverlässigkeit aus Guben.'
: 'Experts in cable construction, horizontal directional drilling, and comprehensive infrastructure projects. Quality and reliability from Guben.'}
: 'Experts in cable network construction, horizontal directional drilling, and comprehensive infrastructure projects. Quality and reliability from Guben.'}
</p>
</div>

View File

@@ -60,7 +60,7 @@ export function TransitionProvider({ children }: { children: ReactNode }) {
setTimeout(() => {
router.push(href);
// Fallback: If for some reason the route doesn't change after 5 seconds, open the shutter anyway
// Fallback: If for some reason the route doesn't change after 60 seconds (useful for slow dev servers), open the shutter anyway
setTimeout(() => {
setIsTransitioning((current) => {
if (current) {
@@ -69,7 +69,7 @@ export function TransitionProvider({ children }: { children: ReactNode }) {
}
return current;
});
}, 5000);
}, 60000);
}, 700);
}, [isTransitioning, router]);

View File

@@ -22,7 +22,7 @@ export default function Gallery() {
];
return (
<Section className="bg-primary-dark py-16 md:py-32">
<Section className="bg-primary-dark">
<Container>
<Heading level={2} subtitle={t('gallery.subtitle')} align="center" className="text-white mb-12 md:mb-20">
<span className="text-white">{t('gallery.title')}</span>

View File

@@ -6,12 +6,14 @@ interface AnimatedGlossyBorderProps {
borderWidth?: 1 | 2;
opacity?: number;
color?: 'white' | 'primary';
disableAnimation?: boolean;
}
export function AnimatedGlossyBorder({
className,
borderWidth = 1,
color = 'white'
color = 'white',
disableAnimation = false
}: AnimatedGlossyBorderProps) {
const isWhite = color === 'white';
const glowColor = isWhite ? 'rgba(255,255,255,0.6)' : 'rgba(17,124,97,0.6)';
@@ -35,7 +37,10 @@ export function AnimatedGlossyBorder({
}}
>
<div
className="absolute top-1/2 left-1/2 -translate-x-1/2 -translate-y-1/2 w-[250%] aspect-square animate-[spin_8s_linear_infinite]"
className={cn(
"absolute top-1/2 left-1/2 -translate-x-1/2 -translate-y-1/2 w-[250%] aspect-square",
!disableAnimation && "animate-[spin_8s_linear_infinite]"
)}
style={{
background: `conic-gradient(from 0deg, ${gradientColors})`
}}

View File

@@ -3,7 +3,7 @@ import { cn } from './utils';
export function Section({ className, children, ...props }: React.HTMLAttributes<HTMLElement>) {
return (
<section className={cn('py-12 md:py-28 lg:py-36 overflow-hidden content-visibility-auto', className)} {...props}>
<section className={cn('py-16 md:py-24 lg:py-32 overflow-hidden content-visibility-auto', className)} {...props}>
{children}
</section>
);

View File

@@ -1,7 +1,7 @@
---
title: "Kabelleitungstiefbau im Bereich Telekommunikation"
title: "Kabelleitungsnetzbau im Bereich Telekommunikation"
date: "2024-03-20"
excerpt: "Zukunftssichere Infrastruktur durch kompetenten Kabelleitungstiefbau für Glasfaser- und Telekommunikationsnetze (FTTX)."
excerpt: "Zukunftssichere Infrastruktur durch kompetenten Kabelleitungsnetzbau für Glasfaser- und Telekommunikationsnetze (FTTX)."
layout: "fullBleed"
---

View File

@@ -117,12 +117,12 @@ description: "Die E-TIB GmbH ist Ihr zuverlässiger Partner für komplexe Kabelt
ctaHref="/de/kompetenzen"
items={[
{
title: "Kabelleitungstiefbau",
description: "Kabelleitungstiefbau (Hoch-/Mittel- und Niederspannung) sowie Kabelmontagen bis 110 kV",
title: "Kabelleitungsnetzbau",
description: "Kabelleitungsnetzbau (Hoch-/Mittel- und Niederspannung) sowie Kabelmontagen bis 110 kV",
tag: "Energie",
size: "large",
href: "/de/kabelnetzbau",
image: { url: "/assets/photos/Etib_E-tib_Tiefbau_Guben_Netzanbindung_Energie-54.jpg", alt: "Kabelleitungstiefbau" }
href: "/de/kabeltiefbau",
image: { url: "/assets/photos/DSC01123.JPG", alt: "Kabelleitungsnetzbau" }
},
{
title: "Bohrtechnik",

View File

@@ -1,13 +1,13 @@
---
title: "Kabelleitungstiefbau"
title: "Kabelleitungsnetzbau"
date: "2024-03-20"
excerpt: "Professioneller Kabelleitungstiefbau (Hoch-, Mittel- und Niederspannung) und Kabelmontagen bis 110 kV. Wir garantieren höchste Sicherheitsstandards und lückenlose Dokumentation für Ihr Projekt."
excerpt: "Professioneller Kabelleitungsnetzbau (Hoch-, Mittel- und Niederspannung) und Kabelmontagen bis 110 kV. Wir garantieren höchste Sicherheitsstandards und lückenlose Dokumentation für Ihr Projekt."
layout: "fullBleed"
---
<HeroSection
badge="Kernkompetenz"
title="Kabelleitungstiefbau"
title="Kabelleitungsnetzbau"
subtitle="Komplette Infrastruktur-Lösungen für Hoch-, Mittel- und Niederspannungsnetze sowie Kabelmontagen bis 110 kV"
backgroundImage={{ url: '/assets/photos/DSC01123.JPG' }}
alignment="center"

View File

@@ -8,7 +8,7 @@ layout: "fullBleed"
<HomeHero
badge="Unsere Leistungen"
title="Alles aus einer Hand"
description="Als Full-Service-Partner im Bereich Kabelleitungstiefbau decken wir das gesamte Spektrum moderner Infrastrukturprojekte ab."
description="Als Full-Service-Partner im Bereich Kabelleitungsnetzbau decken wir das gesamte Spektrum moderner Infrastrukturprojekte ab."
videoUrl="/assets/videos/web/hero-bohrung.mp4"
linkText="Kontakt aufnehmen"
linkHref="/de/kontakt"
@@ -23,12 +23,12 @@ layout: "fullBleed"
ctaHref="/de/kontakt"
items={[
{
title: "Kabelleitungstiefbau",
title: "Kabelleitungsnetzbau",
description: "Klassischer Grabenbau, professionelle Verlegung und Kabelmontage (Hoch-, Mittel- und Niederspannung) bis 110 kV.",
tag: "Energie",
size: "large",
href: "/de/kabelnetzbau",
image: { url: "/assets/photos/DSC01123.JPG", alt: "Kabelleitungstiefbau" }
href: "/de/kabeltiefbau",
image: { url: "/assets/photos/DSC01123.JPG", alt: "Kabelleitungsnetzbau" }
},
{
title: "Bohrtechnik",
@@ -36,7 +36,7 @@ layout: "fullBleed"
tag: "Innovation",
size: "medium",
href: "/de/bohrtechnik",
image: { url: "/assets/photos/DSC08653.JPG", alt: "Bohrtechnik" }
image: { url: "/assets/photos/Etib_E-tib_Tiefbau_Guben_Netzanbindung_Energie-47.jpg", alt: "Bohrtechnik" }
},
{
title: "Glasfaser",
@@ -70,7 +70,7 @@ layout: "fullBleed"
panels={[
{
id: "energy",
title: "Kabelleitungstiefbau (Hoch-/Mittel- und Niederspannung)",
title: "Kabelleitungsnetzbau (Hoch-/Mittel- und Niederspannung)",
icon: "energy",
lists: [
[
@@ -86,7 +86,7 @@ layout: "fullBleed"
},
{
id: "fiber",
title: "Kabelleitungstiefbau (Telekommunikation)",
title: "Kabelleitungsnetzbau (Telekommunikation)",
icon: "fiber",
lists: [
[
@@ -143,7 +143,7 @@ layout: "fullBleed"
<JsonLd data={[
{
"@type": "Service",
"name": "Kabelleitungstiefbau",
"name": "Kabelleitungsnetzbau",
"description": "Klassischer Grabenbau, professionelle Verlegung und Kabelmontage (Hoch-, Mittel- und Niederspannung) bis 110 kV.",
"provider": { "@type": "Organization", "name": "E-TIB GmbH" }
},

View File

@@ -17,7 +17,7 @@ layout: "fullBleed"
<div className="container px-4 max-w-7xl mx-auto py-16">
<blockquote>
Wir bieten unseren Kunden alles aus einer Hand von der Grobplanung Ihrer Projektidee über den Kabelleitungstiefbau bis zum Bau Ihres erneuerbaren Energieprojektes.
Wir bieten unseren Kunden alles aus einer Hand von der Grobplanung Ihrer Projektidee über den Kabelleitungsnetzbau bis zum Bau Ihres erneuerbaren Energieprojektes.
</blockquote>
## Fundament für den Projekterfolg

View File

@@ -1,12 +1,12 @@
---
title: "Home - Experts for Cable Construction & Civil Engineering"
title: "Home - Experts for cable network construction & Civil Engineering"
description: "E-TIB GmbH is your reliable partner for complex cable routes, horizontal directional drilling, surveying, and telecommunications infrastructure in Germany."
---
<HomeHero
title={"The Experts for\nCable Construction"}
description="We realize complex supply lines for a modern society. Reliable, innovative and with the highest precision in cable construction and drilling technology."
title={"The Experts for\ncable network construction"}
description="We realize complex supply lines for a modern society. Reliable, innovative and with the highest precision in cable network construction and drilling technology."
videoUrl="/assets/videos/web/hero-kabelpflug.mp4"
linkText="Our Services"
linkHref="/en/competencies"
@@ -121,8 +121,8 @@ description: "E-TIB GmbH is your reliable partner for complex cable routes, hori
description: "Cable civil engineering (high, medium, low voltage) and cable assembly up to 110 kV",
tag: "Energy",
size: "large",
href: "/en/cable-civil-engineering",
image: { url: "/assets/photos/Etib_E-tib_Tiefbau_Guben_Netzanbindung_Energie-54.jpg", alt: "Cable Civil Engineering" }
href: "/en/kabeltiefbau",
image: { url: "/assets/photos/DSC01123.JPG", alt: "Cable Network Construction" }
},
{
title: "Drilling Technology",

View File

@@ -1,7 +1,7 @@
---
title: "Competencies - Cable Civil Engineering & Horizontal Directional Drilling"
date: "2024-03-20"
excerpt: "From cable construction to electrical installation: We provide holistic solutions for modern network infrastructures."
excerpt: "From cable network construction to electrical installation: We provide holistic solutions for modern network infrastructures."
layout: "fullBleed"
---
@@ -27,8 +27,8 @@ layout: "fullBleed"
description: "Classic trench construction, professional laying and cable assembly (high, medium, and low voltage) up to 110 kV.",
tag: "Energy",
size: "large",
href: "/en/cable-civil-engineering",
image: { url: "/assets/photos/DSC01123.JPG", alt: "Cable Civil Engineering" }
href: "/en/kabeltiefbau",
image: { url: "/assets/photos/DSC01123.JPG", alt: "Cable Network Construction" }
},
{
title: "Drilling Technology",
@@ -36,7 +36,7 @@ layout: "fullBleed"
tag: "Innovation",
size: "medium",
href: "/en/drilling-technology",
image: { url: "/assets/photos/DSC08653.JPG", alt: "Drilling Technology" }
image: { url: "/assets/photos/Etib_E-tib_Tiefbau_Guben_Netzanbindung_Energie-47.jpg", alt: "Drilling Technology" }
},
{
title: "Fiber Optics",

View File

@@ -1,388 +0,0 @@
/**
* Excel Products Module
*
* Provides typed access to technical product data from Excel source files.
* Reuses the parsing logic from the PDF datasheet generator.
*/
import * as fs from 'fs';
import * as path from 'path';
import * as XLSX from 'xlsx';
// Configuration
const EXCEL_SOURCE_FILES = [
path.join(process.cwd(), 'data/excel/high-voltage.xlsx'),
path.join(process.cwd(), 'data/excel/medium-voltage-KM.xlsx'),
path.join(process.cwd(), 'data/excel/low-voltage-KM.xlsx'),
path.join(process.cwd(), 'data/excel/solar-cables.xlsx'),
];
// Types
export type ExcelRow = Record<string, string | number | boolean | Date>;
export interface ExcelMatch {
rows: ExcelRow[];
units: Record<string, string>;
}
export interface TechnicalData {
configurations: string[];
attributes: Array<{
name: string;
options: string[];
}>;
}
export interface ProductLookupParams {
name?: string;
slug?: string;
sku?: string;
translationKey?: string;
}
// Cache singleton
let EXCEL_INDEX: Map<string, ExcelMatch> | null = null;
/**
* Normalize Excel key to match product identifiers
* Examples:
* - "NA2XS(FL)2Y" -> "NA2XSFL2Y"
* - "na2xsfl2y-3" -> "NA2XSFL2Y"
*/
function normalizeExcelKey(value: string): string {
return String(value || '')
.toUpperCase()
.replace(/-\d+$/g, '')
.replace(/[^A-Z0-9]+/g, '');
}
/**
* Normalize value (strip HTML, trim whitespace)
*/
function normalizeValue(value: string): string {
if (!value) return '';
return String(value)
.replace(/<[^>]*>/g, '')
.replace(/\s+/g, ' ')
.trim();
}
/**
* Check if value looks numeric
*/
function looksNumeric(value: string): boolean {
const v = normalizeValue(value).replace(/,/g, '.');
return /^-?\d+(?:\.\d+)?$/.test(v);
}
/**
* Load Excel rows from a file using xlsx library
*/
function loadExcelRows(filePath: string): ExcelRow[] {
if (!fs.existsSync(filePath)) {
console.warn(`[excel-products] File not found: ${filePath}`);
return [];
}
try {
const workbook = XLSX.readFile(filePath, {
cellDates: false,
cellNF: false,
cellText: false
});
// Get the first sheet
const sheetName = workbook.SheetNames[0];
const worksheet = workbook.Sheets[sheetName];
// Convert to JSON
const rows = XLSX.utils.sheet_to_json(worksheet, {
defval: '',
raw: false
}) as ExcelRow[];
return rows;
} catch (error) {
console.error(`[excel-products] Error reading ${filePath}:`, error);
return [];
}
}
/**
* Build the Excel index from all source files
*/
export function getExcelIndex(): Map<string, ExcelMatch> {
if (EXCEL_INDEX) return EXCEL_INDEX;
const idx = new Map<string, ExcelMatch>();
for (const file of EXCEL_SOURCE_FILES) {
const rows = loadExcelRows(file);
if (rows.length === 0) continue;
// Find units row (if present)
const unitsRow = rows.find(r => r && r['Part Number'] === 'Units') || null;
const units: Record<string, string> = {};
if (unitsRow) {
for (const [k, v] of Object.entries(unitsRow)) {
if (k === 'Part Number') continue;
const unit = normalizeValue(String(v ?? ''));
if (unit) units[k] = unit;
}
}
// Index rows by Part Number
for (const r of rows) {
const pn = r?.['Part Number'];
if (!pn || pn === 'Units') continue;
const key = normalizeExcelKey(String(pn));
if (!key) continue;
const cur = idx.get(key);
if (!cur) {
idx.set(key, { rows: [r], units });
} else {
cur.rows.push(r);
// Keep the most comprehensive units
if (Object.keys(cur.units).length < Object.keys(units).length) {
cur.units = units;
}
}
}
}
EXCEL_INDEX = idx;
return idx;
}
/**
* Find Excel match for a product using various identifiers
*/
function findExcelForProduct(params: ProductLookupParams): ExcelMatch | null {
const idx = getExcelIndex();
const candidates = [
params.name,
params.slug ? params.slug.replace(/-\d+$/g, '') : '',
params.sku,
params.translationKey,
].filter(Boolean) as string[];
for (const c of candidates) {
const key = normalizeExcelKey(c);
const match = idx.get(key);
if (match && match.rows.length) return match;
}
return null;
}
/**
* Guess column key based on patterns
*/
function guessColumnKey(row: ExcelRow, patterns: RegExp[]): string | null {
const keys = Object.keys(row || {});
for (const re of patterns) {
const k = keys.find(x => {
const key = String(x);
// Specific exclusions to prevent wrong matches
if (re.test('conductor') && /ross section conductor/i.test(key)) return false;
if (re.test('insulation thickness') && /Diameter over insulation/i.test(key)) return false;
if (re.test('conductor') && !/^conductor$/i.test(key)) return false;
if (re.test('insulation') && !/^insulation$/i.test(key)) return false;
if (re.test('sheath') && !/^sheath$/i.test(key)) return false;
if (re.test('norm') && !/^norm$/i.test(key)) return false;
return re.test(key);
});
if (k) return k;
}
return null;
}
/**
* Get unique non-empty values from an array
*/
function getUniqueNonEmpty(options: string[]): string[] {
const uniq: string[] = [];
const seen = new Set<string>();
for (const v of options.map(normalizeValue).filter(Boolean)) {
const k = v.toLowerCase();
if (seen.has(k)) continue;
seen.add(k);
uniq.push(v);
}
return uniq;
}
/**
* Get technical data for a product from Excel files
*/
export function getExcelTechnicalDataForProduct(params: ProductLookupParams): TechnicalData | null {
const match = findExcelForProduct(params);
if (!match || match.rows.length === 0) return null;
const rows = match.rows;
const sample = rows[0];
// Find cross-section column
const csKey = guessColumnKey(sample, [
/number of cores and cross-section/i,
/cross.?section/i,
/ross section conductor/i,
]);
if (!csKey) return null;
// Extract configurations
const voltageKey = guessColumnKey(sample, [/rated voltage/i, /voltage rating/i, /spannungs/i, /nennspannung/i]);
const configurations = rows
.map(r => {
const cs = normalizeValue(String(r?.[csKey] ?? ''));
const v = voltageKey ? normalizeValue(String(r?.[voltageKey] ?? '')) : '';
if (!cs) return '';
if (!v) return cs;
const vHasUnit = /\bkv\b/i.test(v);
const vText = vHasUnit ? v : `${v} kV`;
return `${cs} - ${vText}`;
})
.filter(Boolean);
if (configurations.length === 0) return null;
// Extract technical attributes
const attributes: Array<{ name: string; options: string[] }> = [];
// Key technical columns
const outerKey = guessColumnKey(sample, [/outer diameter\b/i, /outer diameter.*approx/i, /outer diameter of cable/i, /außen/i]);
const weightKey = guessColumnKey(sample, [/weight\b/i, /gewicht/i, /cable weight/i]);
const dcResKey = guessColumnKey(sample, [/dc resistance/i, /resistance conductor/i, /leiterwiderstand/i]);
const ratedVoltKey = voltageKey;
const testVoltKey = guessColumnKey(sample, [/test voltage/i, /prüfspannung/i]);
const tempRangeKey = guessColumnKey(sample, [/operating temperature range/i, /temperature range/i, /temperaturbereich/i]);
const conductorKey = guessColumnKey(sample, [/^conductor$/i]);
const insulationKey = guessColumnKey(sample, [/^insulation$/i]);
const sheathKey = guessColumnKey(sample, [/^sheath$/i]);
const normKey = guessColumnKey(sample, [/^norm$/i, /^standard$/i]);
const cprKey = guessColumnKey(sample, [/cpr class/i]);
const packagingKey = guessColumnKey(sample, [/^packaging$/i]);
const shapeKey = guessColumnKey(sample, [/shape of conductor/i]);
const flameKey = guessColumnKey(sample, [/flame retardant/i]);
const diamCondKey = guessColumnKey(sample, [/diameter conductor/i]);
const diamInsKey = guessColumnKey(sample, [/diameter over insulation/i]);
const diamScreenKey = guessColumnKey(sample, [/diameter over screen/i]);
const metalScreenKey = guessColumnKey(sample, [/metallic screen/i]);
const capacitanceKey = guessColumnKey(sample, [/capacitance/i]);
const reactanceKey = guessColumnKey(sample, [/reactance/i]);
const electricalStressKey = guessColumnKey(sample, [/electrical stress/i]);
const pullingForceKey = guessColumnKey(sample, [/max\. pulling force/i, /pulling force/i]);
const heatingTrefoilKey = guessColumnKey(sample, [/heating time constant.*trefoil/i]);
const heatingFlatKey = guessColumnKey(sample, [/heating time constant.*flat/i]);
const currentAirTrefoilKey = guessColumnKey(sample, [/current ratings in air.*trefoil/i]);
const currentAirFlatKey = guessColumnKey(sample, [/current ratings in air.*flat/i]);
const currentGroundTrefoilKey = guessColumnKey(sample, [/current ratings in ground.*trefoil/i]);
const currentGroundFlatKey = guessColumnKey(sample, [/current ratings in ground.*flat/i]);
const scCurrentCondKey = guessColumnKey(sample, [/conductor shortcircuit current/i]);
const scCurrentScreenKey = guessColumnKey(sample, [/screen shortcircuit current/i]);
const minLayKey = guessColumnKey(sample, [/minimal temperature for laying/i]);
const minStoreKey = guessColumnKey(sample, [/minimal storage temperature/i]);
const maxOpKey = guessColumnKey(sample, [/maximal operating conductor temperature/i, /max\. operating/i]);
const maxScKey = guessColumnKey(sample, [/maximal short-circuit temperature/i, /short\s*circuit\s*temperature/i]);
const insThkKey = guessColumnKey(sample, [/nominal insulation thickness/i, /insulation thickness/i]);
const sheathThkKey = guessColumnKey(sample, [/nominal sheath thickness/i, /minimum sheath thickness/i]);
const maxResKey = guessColumnKey(sample, [/maximum resistance of conductor/i]);
const bendKey = guessColumnKey(sample, [/bending radius/i, /min\. bending radius/i]);
// Helper to add attribute
const addAttr = (name: string, key: string | null, unit?: string) => {
if (!key) return;
const options = rows
.map(r => normalizeValue(String(r?.[key] ?? '')))
.map(v => (unit && v && looksNumeric(v) ? `${v} ${unit}` : v))
.filter(Boolean);
if (options.length === 0) return;
const uniqueOptions = getUniqueNonEmpty(options);
attributes.push({ name, options: uniqueOptions });
};
// Add attributes
addAttr('Outer diameter', outerKey, 'mm');
addAttr('Weight', weightKey, 'kg/km');
addAttr('DC resistance at 20 °C', dcResKey, 'Ω/km');
addAttr('Rated voltage', ratedVoltKey, '');
addAttr('Test voltage', testVoltKey, '');
addAttr('Operating temperature range', tempRangeKey, '');
addAttr('Minimal temperature for laying', minLayKey, '');
addAttr('Minimal storage temperature', minStoreKey, '');
addAttr('Maximal operating conductor temperature', maxOpKey, '');
addAttr('Maximal short-circuit temperature', maxScKey, '');
addAttr('Nominal insulation thickness', insThkKey, 'mm');
addAttr('Nominal sheath thickness', sheathThkKey, 'mm');
addAttr('Maximum resistance of conductor', maxResKey, 'Ω/km');
addAttr('Conductor', conductorKey, '');
addAttr('Insulation', insulationKey, '');
addAttr('Sheath', sheathKey, '');
addAttr('Standard', normKey, '');
addAttr('Conductor diameter', diamCondKey, 'mm');
addAttr('Insulation diameter', diamInsKey, 'mm');
addAttr('Screen diameter', diamScreenKey, 'mm');
addAttr('Metallic screen', metalScreenKey, '');
addAttr('Max. pulling force', pullingForceKey, '');
addAttr('Electrical stress conductor', electricalStressKey, '');
addAttr('Electrical stress insulation', electricalStressKey, '');
addAttr('Reactance', reactanceKey, '');
addAttr('Heating time constant trefoil', heatingTrefoilKey, 's');
addAttr('Heating time constant flat', heatingFlatKey, 's');
addAttr('Flame retardant', flameKey, '');
addAttr('CPR class', cprKey, '');
addAttr('Packaging', packagingKey, '');
addAttr('Bending radius', bendKey, 'mm');
addAttr('Shape of conductor', shapeKey, '');
addAttr('Capacitance', capacitanceKey, 'μF/km');
addAttr('Current ratings in air, trefoil', currentAirTrefoilKey, 'A');
addAttr('Current ratings in air, flat', currentAirFlatKey, 'A');
addAttr('Current ratings in ground, trefoil', currentGroundTrefoilKey, 'A');
addAttr('Current ratings in ground, flat', currentGroundFlatKey, 'A');
addAttr('Conductor shortcircuit current', scCurrentCondKey, 'kA');
addAttr('Screen shortcircuit current', scCurrentScreenKey, 'kA');
return {
configurations,
attributes,
};
}
/**
* Get raw Excel rows for a product (for detailed inspection)
*/
export function getExcelRowsForProduct(params: ProductLookupParams): ExcelRow[] {
const match = findExcelForProduct(params);
return match?.rows || [];
}
/**
* Clear the Excel index cache (useful for development)
*/
export function clearExcelCache(): void {
EXCEL_INDEX = null;
}
/**
* Preload Excel data on module initialization
* This ensures the cache is built during build time
*/
export function preloadExcelData(): void {
getExcelIndex();
}
// Preload when imported
if (require.main === module) {
preloadExcelData();
}

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

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

View File

@@ -42,7 +42,7 @@ export const getLocalBusinessSchema = () => ({
url: SITE_URL,
logo: LOGO_URL,
image: LOGO_URL,
description: 'Ihr Partner für Kabelleitungstiefbau, Horizontalspülbohrungen, Planung und Vermessung in Guben und überregional.',
description: 'Ihr Partner für Kabelleitungsnetzbau, Horizontalspülbohrungen, Planung und Vermessung in Guben und überregional.',
telephone: '+4935616857733',
email: 'info@e-tib.com',
address: {

View File

@@ -38,7 +38,7 @@ export const standorteData: StandortData[] = [
shortName: 'Guben (Hauptsitz)',
type: 'hq',
description: {
de: 'Unser Hauptsitz in Guben bildet das strategische und operative Zentrum der E-TIB Gruppe. Von hier aus steuern wir bundesweit Großprojekte im Kabelleitungstiefbau und koordinieren unseren hochmodernen Maschinenpark.',
de: 'Unser Hauptsitz in Guben bildet das strategische und operative Zentrum der E-TIB Gruppe. Von hier aus steuern wir bundesweit Großprojekte im Kabelleitungsnetzbau und koordinieren unseren hochmodernen Maschinenpark.',
en: 'Our headquarters in Guben forms the strategic and operational center of the E-TIB Group. From here, we manage large-scale civil engineering projects nationwide and coordinate our state-of-the-art machinery fleet.'
},
address: {
@@ -56,13 +56,13 @@ export const standorteData: StandortData[] = [
keyFeatures: {
de: [
'Zentrale strategische Steuerung',
'Kabelleitungstiefbau & Infrastruktur',
'Kabelleitungsnetzbau & Infrastruktur',
'Koordination Großmaschinenpark',
'Zentrales Projektmanagement'
],
en: [
'Central strategic management',
'Underground cable engineering & infrastructure',
'Cable network engineering & infrastructure',
'Coordination of heavy machinery',
'Central project management'
]

View File

@@ -20,11 +20,11 @@
"categories": {}
},
"Index": {
"title": "E-TIB GmbH Experts in Underground Cable Engineering & Infrastructure",
"description": "Your partner for underground cable engineering, horizontal drilling, and fiber optic installation in Guben and nationwide.",
"title": "E-TIB GmbH Experts in Cable network engineering & Infrastructure",
"description": "Your partner for Cable network engineering, horizontal drilling, and fiber optic installation in Guben and nationwide.",
"meta": {
"title": "E-TIB GmbH | Specialist for Underground Cable Engineering & Grid Expansion",
"description": "E-TIB GmbH from Guben is your expert for underground cable engineering, directional drilling, plowing works, and electrical installation up to 110 kV."
"title": "E-TIB GmbH | Specialist for Cable network engineering & Grid Expansion",
"description": "E-TIB GmbH from Guben is your expert for Cable network engineering, directional drilling, plowing works, and electrical installation up to 110 kV."
}
},
"Navigation": {
@@ -49,12 +49,12 @@
"career": "Career",
"contact": "Contact",
"copyright": "Copyright © {year} E-TIB GmbH. All rights reserved.",
"tagline": "Experts for underground cable engineering and the execution of electrical infrastructure projects. Quality and reliability from Guben."
"tagline": "Experts for Cable network engineering and the execution of electrical infrastructure projects. Quality and reliability from Guben."
},
"Team": {
"meta": {
"title": "About Us | The E-TIB Group",
"description": "Get to know the E-TIB Group: Specialists for underground cable engineering, drilling technology, and engineering services."
"description": "Get to know the E-TIB Group: Specialists for Cable network engineering, drilling technology, and engineering services."
},
"hero": {
"title": "Infrastructure for the Future",
@@ -71,7 +71,7 @@
"subtitle": "Specialized units for complex projects",
"etib": {
"title": "ETIB GmbH",
"desc": "Execution of electrical infrastructure projects and underground cable engineering."
"desc": "Execution of electrical infrastructure projects and Cable network engineering."
},
"bohrtechnik": {
"title": "ETIB Bohrtechnik GmbH",
@@ -90,7 +90,7 @@
"Contact": {
"meta": {
"title": "Contact | E-TIB GmbH Guben",
"description": "Get in touch with E-TIB GmbH in Guben. Your partner for underground cable engineering and grid infrastructure."
"description": "Get in touch with E-TIB GmbH in Guben. Your partner for Cable network engineering and grid infrastructure."
},
"title": "Contact Us",
"subtitle": "Do you have questions about our services or a specific project? We are here for you.",
@@ -135,7 +135,7 @@
"subtitle": "Comprehensive solutions for the expansion of modern energy and data networks.",
"badge": "Service Spectrum",
"items": {
"kabelbau": "Underground Cable Engineering",
"kabelbau": "Cable network engineering",
"pflugarbeiten": "Cable Plowing",
"bohrungen": "Horizontal Directional Drilling",
"elektromontage": "Electrical Installations up to 110 kV",
@@ -149,7 +149,7 @@
},
"Home": {
"hero": {
"title": "THE EXPERTS FOR <green>UNDERGROUND CABLE ENGINEERING</green>",
"title": "THE EXPERTS FOR <green>Cable network engineering</green>",
"subtitle": "We help expanding the energy cable networks for a green future.",
"cta": "Request now",
"searchPlaceholder": "Describe project or search for cables...",
@@ -207,7 +207,7 @@
"Blog": {
"meta": {
"title": "Blog & News",
"description": "Latest news and insights from the world of underground cable engineering and infrastructure."
"description": "Latest news and insights from the world of Cable network engineering and infrastructure."
},
"featuredPost": "Featured Post",
"readFullArticle": "Read Full Article",
@@ -226,7 +226,7 @@
"ReferencesSlider": {
"badge": "Selected Projects",
"title": "References & Successes",
"description": "A selection of our successfully completed projects in underground cable engineering, drilling technology, and grid infrastructure.",
"description": "A selection of our successfully completed projects in Cable network engineering, drilling technology, and grid infrastructure.",
"ctaLabel": "View All References"
},
"CompanyTimeline": {

View File

@@ -4,13 +4,13 @@
"private": true,
"packageManager": "pnpm@10.18.3",
"dependencies": {
"@medv/finder": "^4.0.2",
"@mintel/mail": "1.9.5",
"@mintel/next-config": "1.9.5",
"@mintel/next-feedback": "1.9.5",
"@mintel/next-utils": "1.9.5",
"@next/mdx": "^16.1.6",
"@react-email/components": "1.0.8",
"@react-pdf/renderer": "^4.3.2",
"@react-three/drei": "^10.7.7",
"@react-three/fiber": "^9.5.0",
"@sentry/nextjs": "^10.39.0",
@@ -30,7 +30,6 @@
"next-intl": "^4.8.2",
"next-mdx-remote": "^5.0.0",
"nodemailer": "^8.0.7",
"pdf-lib": "^1.17.1",
"pino": "^10.3.0",
"pino-pretty": "^13.1.3",
"react": "^19.2.4",
@@ -123,10 +122,6 @@
"check:assets": "tsx ./scripts/check-broken-assets.ts",
"check:forms": "tsx ./scripts/check-forms.ts",
"check:apis": "tsx ./scripts/check-apis.ts",
"pdf:datasheets": "tsx ./scripts/generate-pdf-datasheets.tsx",
"pdf:datasheets:legacy": "tsx ./scripts/generate-pdf-datasheets-pdf-lib.ts",
"excel:datasheets": "tsx ./scripts/generate-excel-datasheets.ts",
"brochure:generate": "tsx ./scripts/generate-brochure.ts",
"assets:push:staging": "bash ./scripts/assets-sync.sh local staging",
"assets:push:prod": "bash ./scripts/assets-sync.sh local prod",
"assets:pull:testing": "bash ./scripts/assets-sync.sh testing local",
@@ -143,7 +138,7 @@
"prepare": "husky",
"preinstall": "npx only-allow pnpm"
},
"version": "2.2.13-rc.2",
"version": "2.2.13-rc.46",
"pnpm": {
"onlyBuiltDependencies": [
"@parcel/watcher",

309
pnpm-lock.yaml generated
View File

@@ -12,6 +12,9 @@ importers:
.:
dependencies:
'@medv/finder':
specifier: ^4.0.2
version: 4.0.2
'@mintel/mail':
specifier: 1.9.5
version: 1.9.5(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
@@ -30,9 +33,6 @@ importers:
'@react-email/components':
specifier: 1.0.8
version: 1.0.8(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
'@react-pdf/renderer':
specifier: ^4.3.2
version: 4.3.2(react@19.2.4)
'@react-three/drei':
specifier: ^10.7.7
version: 10.7.7(@react-three/fiber@9.5.0(@types/react@19.2.14)(immer@11.1.4)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(three@0.183.2))(@types/react@19.2.14)(@types/three@0.183.1)(immer@11.1.4)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(three@0.183.2)
@@ -90,9 +90,6 @@ importers:
nodemailer:
specifier: ^8.0.7
version: 8.0.7
pdf-lib:
specifier: ^1.17.1
version: 1.17.1
pino:
specifier: ^10.3.0
version: 10.3.1
@@ -1894,12 +1891,6 @@ packages:
'@paulirish/trace_engine@0.0.53':
resolution: {integrity: sha512-PUl/vlfo08Oj804VI5nDPeSk9vyslnBlVzDDwFt8SUVxY8+KdGMkra/vrXjEEHe8gb7+RqVTfOIlGw0nyrEelA==}
'@pdf-lib/standard-fonts@1.0.0':
resolution: {integrity: sha512-hU30BK9IUN/su0Mn9VdlVKsWBS6GyhVfqjwl1FjZN4TxP6cCw0jP2w7V3Hf5uX7M0AZJ16vey9yE0ny7Sa59ZA==}
'@pdf-lib/upng@1.0.1':
resolution: {integrity: sha512-dQK2FUMQtowVP00mtIksrlZhdFXQZPC+taih1q4CvPZ5vqdxR/LKBaFg0oAfzd1GlHZXXSPdQfzQnt+ViGvEIQ==}
'@pinojs/redact@0.4.0':
resolution: {integrity: sha512-k2ENnmBugE/rzQfEcdWHcCY+/FM3VLzH9cYEsbdsoqrvzAKRhUZeRNhAZvB8OitQJ1TBed3yqWtdjzS6wJKBwg==}
@@ -2218,49 +2209,6 @@ packages:
react: ^18.0.0
react-dom: ^18.0.0
'@react-pdf/fns@3.1.2':
resolution: {integrity: sha512-qTKGUf0iAMGg2+OsUcp9ffKnKi41RukM/zYIWMDJ4hRVYSr89Q7e3wSDW/Koqx3ea3Uy/z3h2y3wPX6Bdfxk6g==}
'@react-pdf/font@4.0.4':
resolution: {integrity: sha512-8YtgGtL511txIEc9AjiilpZ7yjid8uCd8OGUl6jaL3LIHnrToUupSN4IzsMQpVTCMYiDLFnDNQzpZsOYtRS/Pg==}
'@react-pdf/image@3.0.4':
resolution: {integrity: sha512-z0ogVQE0bKqgXQ5smgzIU857rLV7bMgVdrYsu3UfXDDLSzI7QPvzf6MFTFllX6Dx2rcsF13E01dqKPtJEM799g==}
'@react-pdf/layout@4.4.2':
resolution: {integrity: sha512-gNu2oh8MiGR+NJZYTJ4c4q0nWCESBI6rKFiodVhE7OeVAjtzZzd6l65wsN7HXdWJqOZD3ttD97iE+tf5SOd/Yg==}
'@react-pdf/pdfkit@4.1.0':
resolution: {integrity: sha512-Wm/IOAv0h/U5Ra94c/PltFJGcpTUd/fwVMVeFD6X9tTTPCttIwg0teRG1Lqq617J8K4W7jpL/B0HTH0mjp3QpQ==}
'@react-pdf/png-js@3.0.0':
resolution: {integrity: sha512-eSJnEItZ37WPt6Qv5pncQDxLJRK15eaRwPT+gZoujP548CodenOVp49GST8XJvKMFt9YqIBzGBV/j9AgrOQzVA==}
'@react-pdf/primitives@4.1.1':
resolution: {integrity: sha512-IuhxYls1luJb7NUWy6q5avb1XrNaVj9bTNI40U9qGRuS6n7Hje/8H8Qi99Z9UKFV74bBP3DOf3L1wV2qZVgVrQ==}
'@react-pdf/reconciler@2.0.0':
resolution: {integrity: sha512-7zaPRujpbHSmCpIrZ+b9HSTJHthcVZzX0Wx7RzvQGsGBUbHP4p6s5itXrAIOuQuPvDepoHGNOvf6xUuMVvdoyw==}
peerDependencies:
react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0
'@react-pdf/render@4.3.2':
resolution: {integrity: sha512-el5KYM1sH/PKcO4tRCIm8/AIEmhtraaONbwCrBhFdehoGv6JtgnXiMxHGAvZbI5kEg051GbyP+XIU6f6YbOu6Q==}
'@react-pdf/renderer@4.3.2':
resolution: {integrity: sha512-EhPkj35gO9rXIyyx29W3j3axemvVY5RigMmlK4/6Ku0pXB8z9PEE/sz4ZBOShu2uot6V4xiCR3aG+t9IjJJlBQ==}
peerDependencies:
react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0
'@react-pdf/stylesheet@6.1.2':
resolution: {integrity: sha512-E3ftGRYUQGKiN3JOgtGsLDo0hGekA6dmkmi/MYACytmPTKxQRBSO3126MebmCq+t1rgU9uRlREIEawJ+8nzSbw==}
'@react-pdf/textkit@6.1.0':
resolution: {integrity: sha512-sFlzDC9CDFrJsnL3B/+NHrk9+Advqk7iJZIStiYQDdskbow8GF/AGYrpIk+vWSnh35YxaGbHkqXq53XOxnyrjQ==}
'@react-pdf/types@2.9.2':
resolution: {integrity: sha512-dufvpKId9OajLLbgn9q7VLUmyo1Jf+iyGk2ZHmCL8nIDtL8N1Ejh9TH7+pXXrR0tdie1nmnEb5Bz9U7g4hI4/g==}
'@react-three/drei@10.7.7':
resolution: {integrity: sha512-ff+J5iloR0k4tC++QtD/j9u3w5fzfgFAWDtAGQah9pF2B1YgOq/5JxqY0/aVoQG5r3xSZz0cv5tk2YuBob4xEQ==}
peerDependencies:
@@ -3435,9 +3383,6 @@ packages:
resolution: {integrity: sha512-6/mh1E2u2YgEsCHdY0Yx5oW+61gZU+1vXaoiHHrpKeuRNNgFvS+/jrwHiQhB5apAf5oB7UB7E19ol2R2LKH8hQ==}
engines: {node: ^14.17.0 || ^16.13.0 || >=18.0.0}
abs-svg-path@0.1.1:
resolution: {integrity: sha512-d8XPSGjfyzlXC3Xx891DJRyZfqk5JU0BJrDQcsWomFIV1/BIzPW5HDH5iDdWpqWaav0YVIEzT1RHTwWr0FFshA==}
accepts@1.3.8:
resolution: {integrity: sha512-PYAthTa2m2VKxuvSD3DPC/Gy+U+sOA1LAuT8mkmRuvw+NACSaeXEQ+NHcVF7rONl6qcaxV3Uuemwawk+7+SJLw==}
engines: {node: '>= 0.6'}
@@ -3773,9 +3718,6 @@ packages:
brotli@1.3.3:
resolution: {integrity: sha512-oTKjJdShmDuGW94SyyaoQvAjf30dZaHnjJ8uAF+u2/vGJkJbJPJAT1gDiOJP5v1Zb6f9KEyW/1HpuaWIXtGHPg==}
browserify-zlib@0.2.0:
resolution: {integrity: sha512-Z942RysHXmJrhqk88FmKBVq/v5tqmSkDz7p54G/MGyjMnCFFnC79XWNbg+Vta8W6Wb2qtSZTSxIGkJrRpCFEiA==}
browserslist@4.28.2:
resolution: {integrity: sha512-48xSriZYYg+8qXna9kwqjIVzuQxi+KYWp2+5nCYnYKPTr0LvD89Jqk2Or5ogxz0NUMfIjhh2lIUX/LyX9B4oIg==}
engines: {node: ^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7}
@@ -3970,9 +3912,6 @@ packages:
color-name@1.1.4:
resolution: {integrity: sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==}
color-string@1.9.1:
resolution: {integrity: sha512-shrVawQFojnZv6xM40anx4CkoDP+fZsw/ZerEMsW/pyzsRbElpsL/DBVW7q3ExxwusdNXI3lXpuhEZkzs8p5Eg==}
colorette@2.0.20:
resolution: {integrity: sha512-IfEDxwoWIjkeXL1eXcDiow4UbKjhLdq6/EuSVR9GMN7KVH3r9gQ83e73hsz1Nd1T3ijd5xv1wcWRYO+D6kCI2w==}
@@ -4109,9 +4048,6 @@ packages:
resolution: {integrity: sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==}
engines: {node: '>= 8'}
crypto-js@4.2.0:
resolution: {integrity: sha512-KALDyEYgpY+Rlob/iriUtjV6d5Eq+Y191A5g4UqLAi8CyGP9N1+FdVbkc1SxKc2r4YAYqG8JzO2KGL+AizD70Q==}
crypto-random-string@2.0.0:
resolution: {integrity: sha512-v1plID3y9r/lPhviJ1wrXpLeyUIGAZ2SHNYTEapm7/8A9nLPoyvVp3RK/EPFqn5kEznyWgYZNsRtYYIWbuG8KA==}
engines: {node: '>=8'}
@@ -4427,9 +4363,6 @@ packages:
electron-to-chromium@1.5.330:
resolution: {integrity: sha512-jFNydB5kFtYUobh4IkWUnXeyDbjf/r9gcUEXe1xcrcUxIGfTdzPXA+ld6zBRbwvgIGVzDll/LTIiDztEtckSnA==}
emoji-regex-xs@1.0.0:
resolution: {integrity: sha512-LRlerrMYoIDrT6jgpeZ2YYl/L8EulRTt5hQcYjy5AInh7HWXKimpqx68aknBFpGL2+/IcogTcaydJEgaTmOpDg==}
emoji-regex@10.6.0:
resolution: {integrity: sha512-toUI84YS5YmxW219erniWD0CIVOo46xGKColeNQRgOzDorgBi1v4D71/OFzgD9GO2UGKIv1C3Sp8DAn0+j5w7A==}
@@ -5167,12 +5100,6 @@ packages:
resolution: {integrity: sha512-HRcs+2mr52W0K+x8RzcLzuPPmVIKMSv97RGHy0Ea9y/mpcaK+xTrjICA04KAHi4GRzxliNqNJEFYWHghy3rSfQ==}
engines: {node: '>= 6.0.0'}
hsl-to-hex@1.0.0:
resolution: {integrity: sha512-K6GVpucS5wFf44X0h2bLVRDsycgJmf9FF2elg+CrqD8GcFU8c6vYhgXn8NjUkFCwj+xDFb70qgLbTUm6sxwPmA==}
hsl-to-rgb-for-reals@1.1.1:
resolution: {integrity: sha512-LgOWAkrN0rFaQpfdWBQlv/VhkOxb5AsBjk6NQVx4yEzWS923T07X0M1Y0VNko2H52HeSpZrZNNMJ0aFqsdVzQg==}
html-encoding-sniffer@6.0.0:
resolution: {integrity: sha512-CV9TW3Y3f8/wT0BRFc1/KAVQ3TUHiXmaAb6VW9vtiMFf7SLoMd1PdAc4W3KFOFETBJUb90KatHqlsZMWV+R9Gg==}
engines: {node: ^20.19.0 || ^22.12.0 || >=24.0.0}
@@ -5254,9 +5181,6 @@ packages:
engines: {node: '>=18'}
hasBin: true
hyphen@1.14.1:
resolution: {integrity: sha512-kvL8xYl5QMTh+LwohVN72ciOxC0OEV79IPdJSTwEXok9y9QHebXGdFgrED4sWfiax/ODx++CAMk3hMy4XPJPOw==}
i18next-fs-backend@2.6.3:
resolution: {integrity: sha512-/5aW996nbolGh/qVU9urjAJ2QiXpiyGuhG8x/ZvvZQ3bMlrK+ouUdD6whS4e+sU9CCdUIzl7x/heEjsBh/3xtw==}
@@ -5390,9 +5314,6 @@ packages:
is-arrayish@0.2.1:
resolution: {integrity: sha512-zz06S8t0ozoDXMG+ube26zeCTNXcKIPJZJi8hBrF4idCLms4CG9QtK7qBl1boi5ODzFpjswb5JPmHCbMpjaYzg==}
is-arrayish@0.3.4:
resolution: {integrity: sha512-m6UrgzFVUYawGBh1dUsWR5M2Clqic9RVXC/9f8ceNlv2IcO9j9J/z8UoCLPqtsPBFNzEpfR3xftohbfqDx8EQA==}
is-async-function@2.1.1:
resolution: {integrity: sha512-9dgM/cZBnNvjzaMYHVoxxfPj2QXt22Ev7SuuPrs+xav0ukGB0S6d4ydZdEiM48kLx5kDV+QBPrpVnFyefL8kkQ==}
engines: {node: '>= 0.4'}
@@ -5551,9 +5472,6 @@ packages:
resolution: {integrity: sha512-mE00Gnza5EEB3Ds0HfMyllZzbBrmLOX3vfWoj9A9PEnTfratQ/BcaJOuMhnkhjXvb2+FkY3VuHqtAGpTPmglFQ==}
engines: {node: '>=18'}
is-url@1.2.4:
resolution: {integrity: sha512-ITvGim8FhRiYe4IQ5uHSkj7pVaPDrCTkNd3yq3cV7iZAcJdHTUMPMEHcqSOy9xZ9qFenQCvi+2wjH9a1nXqHww==}
is-weakmap@2.0.2:
resolution: {integrity: sha512-K5pXYOm9wqY1RgjpL3YTkF39tni1XajUIkawTLUo9EZEVUFga5gSQJF8nNS7ZwJQ02y+1YCNYcMh+HIf1ZqE+w==}
engines: {node: '>= 0.4'}
@@ -5595,9 +5513,6 @@ packages:
jackspeak@3.4.3:
resolution: {integrity: sha512-OGlZQpz2yfahA/Rd1Y8Cd9SIEsqvXkLVoSw/cgwhnhFMDbsQFeZYoJJ7bIZBS9BcamUW96asq/npPWugM+RQBw==}
jay-peg@1.1.1:
resolution: {integrity: sha512-D62KEuBxz/ip2gQKOEhk/mx14o7eiFRaU+VNNSP4MOiIkwb/D6B3G1Mfas7C/Fit8EsSV2/IWjZElx/Gs6A4ww==}
jest-worker@27.5.1:
resolution: {integrity: sha512-7vuh85V5cdDofPyxn58nrPjBktZo0u9x1g8WtjQol+jZDaE+fhN+cIvTj11GndBnMnyfrUOG1sZQxCdjKh+DKg==}
engines: {node: '>= 10.13.0'}
@@ -6031,9 +5946,6 @@ packages:
mdn-data@2.27.1:
resolution: {integrity: sha512-9Yubnt3e8A0OKwxYSXyhLymGW4sCufcLG6VdiDdUGVkPhpqLxlvP5vl1983gQjJl3tqbrM731mjaZaP68AgosQ==}
media-engine@1.0.3:
resolution: {integrity: sha512-aa5tG6sDoK+k70B9iEX1NeyfT8ObCKhNDs6lJVpwF6r8vhUfuKMslIcirq6HIUYuuUYLefcEQOn9bSBOvawtwg==}
media-typer@0.3.0:
resolution: {integrity: sha512-dq+qelQ9akHpcOl/gUVRTxVIOkAJ1wR3QAvb4RsVjS8oVoFjDGTc679wJYmUmknUF5HwMLOgb5O+a3KxfWapPQ==}
engines: {node: '>= 0.6'}
@@ -6380,9 +6292,6 @@ packages:
resolution: {integrity: sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA==}
engines: {node: '>=0.10.0'}
normalize-svg-path@1.1.0:
resolution: {integrity: sha512-r9KHKG2UUeB5LoTouwDzBy2VxXlHsiM6fyLQvnJa0S5hrhzqElH/CH7TUGhT1fVvIYBIKf3OpY4YJ4CK+iaqHg==}
npm-run-path@4.0.1:
resolution: {integrity: sha512-S48WzZW777zhNIrn7gxOlISNAqi9ZC/uQFnRdbeIHhZhCA6UqpkOT8T1G7BvfdgP4Er8gF4sUbaS0i7QvIfCWw==}
engines: {node: '>=8'}
@@ -6529,9 +6438,6 @@ packages:
pako@0.2.9:
resolution: {integrity: sha512-NUcwaKxUxWrZLpDG+z/xZaCgQITkA/Dv4V/T6bw7VON6l1Xz/VnrBqrYjZQ12TamKHzITTfOEIYUj48y2KXImA==}
pako@1.0.11:
resolution: {integrity: sha512-4hLB8Py4zZce5s4yd9XzopqwVv/yGNhV1Bl8NTmCq1763HeK2+EwVTv+leGeL13Dnh2wfbqowVPXCIO0z4taYw==}
parent-module@1.0.1:
resolution: {integrity: sha512-GQ2EWRpQV8/o+Aw8YqtfZZPfNRWZYkbidE9k5rpl/hC3vtHHBfGm2Ifi6qWV+coDGkrUKZAxE3Lot5kcsRlh+g==}
engines: {node: '>=6'}
@@ -6550,9 +6456,6 @@ packages:
resolution: {integrity: sha512-ayCKvm/phCGxOkYRSCM82iDwct8/EonSEgCSxWxD7ve6jHggsFl4fZVQBPRNgQoKiuV/odhFrGzQXZwbifC8Rg==}
engines: {node: '>=8'}
parse-svg-path@0.1.2:
resolution: {integrity: sha512-JyPSBnkTJ0AI8GGJLfMXvKq42cj5c006fnLz6fXy6zfoVjJizi8BNTpu8on8ziI1cKy9d9DGNuY17Ce7wuejpQ==}
parse5-htmlparser2-tree-adapter@7.1.0:
resolution: {integrity: sha512-ruw5xyKs6lrpo9x9rCZqZZnIUntICjQAd0Wsmp396Ul9lN/h+ifgVV1x1gZHi8euej6wTfpqX8j+BFQxF0NS/g==}
@@ -6604,9 +6507,6 @@ packages:
pause-stream@0.0.11:
resolution: {integrity: sha512-e3FBlXLmN/D1S+zHzanP4E/4Z60oFAa3O051qt1pxa7DEJWKAyil6upYVXCWadEnuoqa4Pkc9oUx9zsxYeRv8A==}
pdf-lib@1.17.1:
resolution: {integrity: sha512-V/mpyJAoTsN4cnP31vc0wfNA1+p20evqqnap0KLoRUN0Yk/p3wN52DOEsL4oBFcLdb76hlpKPtzJIgo67j/XLw==}
pdf-parse@2.4.5:
resolution: {integrity: sha512-mHU89HGh7v+4u2ubfnevJ03lmPgQ5WU4CxAVmTSh/sxVTEDYd1er/dKS/A6vg77NX47KTEoihq8jZBLr8Cxuwg==}
engines: {node: '>=20.16.0 <21 || >=22.3.0'}
@@ -6823,9 +6723,6 @@ packages:
queue-microtask@1.2.3:
resolution: {integrity: sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A==}
queue@6.0.2:
resolution: {integrity: sha512-iHZWu+q3IdFZFX36ro/lKBkSvfkztY5Y7HMiPlOUjhupPcG2JMfst2KKEpu5XndviX/3UhFbRngUPNKtgvtZiA==}
quick-format-unescaped@4.0.4:
resolution: {integrity: sha512-tYC1Q1hgyRuHgloV/YXs2w15unPVh8qfu/qCTfhTYamaw7fyhumKa2yGpdSo87vY32rIclj+4fWYQXUMs9EHvg==}
@@ -7119,9 +7016,6 @@ packages:
resolution: {integrity: sha512-xAg7SOnEhrm5zI3puOOKyy1OMcMlIJZYNJY7xLBwSze0UjhPLnWfj2GF2EpT0jmzaJKIWKHLsaSSajf35bcYnA==}
engines: {node: '>=v12.22.7'}
scheduler@0.25.0-rc-603e6108-20241029:
resolution: {integrity: sha512-pFwF6H1XrSdYYNLfOcGlM28/j8CGLu8IvdrxqhjWULe2bPcKiKW4CV+OWqR/9fT52mywx65l7ysNkjLKBda7eA==}
scheduler@0.27.0:
resolution: {integrity: sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q==}
@@ -7219,9 +7113,6 @@ packages:
resolution: {integrity: sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw==}
engines: {node: '>=14'}
simple-swizzle@0.2.4:
resolution: {integrity: sha512-nAu1WFPQSMNr2Zn9PGSZK9AGn4t/y97lEm+MXTtUDwfP0ksAIX4nO+6ruD9Jwut4C49SB1Ws+fbXsm/yScWOHw==}
sirv@2.0.4:
resolution: {integrity: sha512-94Bdh3cC2PKrbgSOUqTiGPWVZeSiXfKOVZNJniWoqrWrRkB1CJzBU3NEbiTsPcYy1lDsANA/THzS+9WBiy5nfQ==}
engines: {node: '>= 10'}
@@ -7398,9 +7289,6 @@ packages:
resolution: {integrity: sha512-UXSH262CSZY1tfu3G3Secr6uGLCFVPMhIqHjlgCUtCCcgihYc/xKs9djMTMUOb2j1mVSeU8EU6NWc/iQKU6Gfg==}
engines: {node: '>= 0.4'}
string_decoder@1.3.0:
resolution: {integrity: sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA==}
stringify-entities@4.0.4:
resolution: {integrity: sha512-IwfBptatlO+QCJUo19AqvrPNqlVMpW9YEL2LIVY+Rpv2qsjCGxaDLNRgeGsQWJhfItebuJhsGSLjaBbNSQ+ieg==}
@@ -7486,9 +7374,6 @@ packages:
peerDependencies:
react: '>=17.0'
svg-arc-to-cubic-bezier@3.2.0:
resolution: {integrity: sha512-djbJ/vZKZO+gPoSDThGNpKDO+o+bAeA4XQKovvkNCqnIS2t+S4qnLAGQhyyrulhCFRl1WWzAp0wUDV8PpTVU3g==}
svg-to-pdfkit@0.1.8:
resolution: {integrity: sha512-QItiGZBy5TstGy+q8mjQTMGRlDDOARXLxH+sgVm1n/LYeo0zFcQlcCh8m4zi8QxctrxB9Kue/lStc/RD5iLadQ==}
@@ -7872,10 +7757,6 @@ packages:
victory-vendor@37.3.6:
resolution: {integrity: sha512-SbPDPdDBYp+5MJHhBCAyI7wKM3d5ivekigc2Dk2s7pgbZ9wIgIBYGVw4zGHBml/qTFbexrofXW6Gu4noGxrOwQ==}
vite-compatible-readable-stream@3.6.1:
resolution: {integrity: sha512-t20zYkrSf868+j/p31cRIGN28Phrjm3nRSLR2fyc2tiWi4cZGVdv68yNlwnIINTkMTmPoMiSlc0OadaO7DXZaQ==}
engines: {node: '>= 6'}
vite@8.0.3:
resolution: {integrity: sha512-B9ifbFudT1TFhfltfaIPgjo9Z3mDynBTJSUYxTjOQruf/zHH+ezCQKcoqO+h7a9Pw9Nm/OtlXAiGT1axBgwqrQ==}
engines: {node: ^20.19.0 || >=22.12.0}
@@ -8214,9 +8095,6 @@ packages:
resolution: {integrity: sha512-CzhO+pFNo8ajLM2d2IW/R93ipy99LWjtwblvC1RsoSUMZgyLbYFr221TnSNT7GjGdYui6P459mw9JH/g/zW2ug==}
engines: {node: '>=18'}
yoga-layout@3.2.1:
resolution: {integrity: sha512-0LPOt3AxKqMdFBZA3HBAt/t/8vIKq7VaQYbuA8WxCgung+p9TVyKRYdpvCb80HcdTN2NkbIKbhNwKUfm3tQywQ==}
zod-validation-error@4.0.2:
resolution: {integrity: sha512-Q6/nZLe6jxuU80qb/4uJ4t5v2VEZ44lzQjPDhYJNztRQ4wyWc6VF3D3Kb/fAuPetZQnhS3hnajCf9CsWesghLQ==}
engines: {node: '>=18.0.0'}
@@ -9860,14 +9738,6 @@ snapshots:
legacy-javascript: 0.0.1
third-party-web: 0.29.2
'@pdf-lib/standard-fonts@1.0.0':
dependencies:
pako: 1.0.11
'@pdf-lib/upng@1.0.1':
dependencies:
pako: 1.0.11
'@pinojs/redact@0.4.0': {}
'@pkgjs/parseargs@0.11.0':
@@ -10147,107 +10017,6 @@ snapshots:
react: 19.2.4
react-dom: 19.2.4(react@19.2.4)
'@react-pdf/fns@3.1.2': {}
'@react-pdf/font@4.0.4':
dependencies:
'@react-pdf/pdfkit': 4.1.0
'@react-pdf/types': 2.9.2
fontkit: 2.0.4
is-url: 1.2.4
'@react-pdf/image@3.0.4':
dependencies:
'@react-pdf/png-js': 3.0.0
jay-peg: 1.1.1
'@react-pdf/layout@4.4.2':
dependencies:
'@react-pdf/fns': 3.1.2
'@react-pdf/image': 3.0.4
'@react-pdf/primitives': 4.1.1
'@react-pdf/stylesheet': 6.1.2
'@react-pdf/textkit': 6.1.0
'@react-pdf/types': 2.9.2
emoji-regex-xs: 1.0.0
queue: 6.0.2
yoga-layout: 3.2.1
'@react-pdf/pdfkit@4.1.0':
dependencies:
'@babel/runtime': 7.29.2
'@react-pdf/png-js': 3.0.0
browserify-zlib: 0.2.0
crypto-js: 4.2.0
fontkit: 2.0.4
jay-peg: 1.1.1
linebreak: 1.1.0
vite-compatible-readable-stream: 3.6.1
'@react-pdf/png-js@3.0.0':
dependencies:
browserify-zlib: 0.2.0
'@react-pdf/primitives@4.1.1': {}
'@react-pdf/reconciler@2.0.0(react@19.2.4)':
dependencies:
object-assign: 4.1.1
react: 19.2.4
scheduler: 0.25.0-rc-603e6108-20241029
'@react-pdf/render@4.3.2':
dependencies:
'@babel/runtime': 7.29.2
'@react-pdf/fns': 3.1.2
'@react-pdf/primitives': 4.1.1
'@react-pdf/textkit': 6.1.0
'@react-pdf/types': 2.9.2
abs-svg-path: 0.1.1
color-string: 1.9.1
normalize-svg-path: 1.1.0
parse-svg-path: 0.1.2
svg-arc-to-cubic-bezier: 3.2.0
'@react-pdf/renderer@4.3.2(react@19.2.4)':
dependencies:
'@babel/runtime': 7.29.2
'@react-pdf/fns': 3.1.2
'@react-pdf/font': 4.0.4
'@react-pdf/layout': 4.4.2
'@react-pdf/pdfkit': 4.1.0
'@react-pdf/primitives': 4.1.1
'@react-pdf/reconciler': 2.0.0(react@19.2.4)
'@react-pdf/render': 4.3.2
'@react-pdf/types': 2.9.2
events: 3.3.0
object-assign: 4.1.1
prop-types: 15.8.1
queue: 6.0.2
react: 19.2.4
'@react-pdf/stylesheet@6.1.2':
dependencies:
'@react-pdf/fns': 3.1.2
'@react-pdf/types': 2.9.2
color-string: 1.9.1
hsl-to-hex: 1.0.0
media-engine: 1.0.3
postcss-value-parser: 4.2.0
'@react-pdf/textkit@6.1.0':
dependencies:
'@react-pdf/fns': 3.1.2
bidi-js: 1.0.3
hyphen: 1.14.1
unicode-properties: 1.4.1
'@react-pdf/types@2.9.2':
dependencies:
'@react-pdf/font': 4.0.4
'@react-pdf/primitives': 4.1.1
'@react-pdf/stylesheet': 6.1.2
'@react-three/drei@10.7.7(@react-three/fiber@9.5.0(@types/react@19.2.14)(immer@11.1.4)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(three@0.183.2))(@types/react@19.2.14)(@types/three@0.183.1)(immer@11.1.4)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(three@0.183.2)':
dependencies:
'@babel/runtime': 7.29.2
@@ -11426,8 +11195,6 @@ snapshots:
abbrev@2.0.0: {}
abs-svg-path@0.1.1: {}
accepts@1.3.8:
dependencies:
mime-types: 2.1.35
@@ -11748,10 +11515,6 @@ snapshots:
dependencies:
base64-js: 1.5.1
browserify-zlib@0.2.0:
dependencies:
pako: 1.0.11
browserslist@4.28.2:
dependencies:
baseline-browser-mapping: 2.10.13
@@ -11966,11 +11729,6 @@ snapshots:
color-name@1.1.4: {}
color-string@1.9.1:
dependencies:
color-name: 1.1.4
simple-swizzle: 0.2.4
colorette@2.0.20: {}
combined-stream@1.0.8:
@@ -12117,8 +11875,6 @@ snapshots:
shebang-command: 2.0.0
which: 2.0.2
crypto-js@4.2.0: {}
crypto-random-string@2.0.0: {}
csp_evaluator@1.1.5: {}
@@ -12450,8 +12206,6 @@ snapshots:
electron-to-chromium@1.5.330: {}
emoji-regex-xs@1.0.0: {}
emoji-regex@10.6.0: {}
emoji-regex@8.0.0: {}
@@ -13513,12 +13267,6 @@ snapshots:
hoopy@0.1.4: {}
hsl-to-hex@1.0.0:
dependencies:
hsl-to-rgb-for-reals: 1.1.1
hsl-to-rgb-for-reals@1.1.1: {}
html-encoding-sniffer@6.0.0(@noble/hashes@1.8.0):
dependencies:
'@exodus/bytes': 1.15.0(@noble/hashes@1.8.0)
@@ -13617,8 +13365,6 @@ snapshots:
husky@9.1.7: {}
hyphen@1.14.1: {}
i18next-fs-backend@2.6.3: {}
i18next@25.10.10(typescript@5.9.3):
@@ -13770,8 +13516,6 @@ snapshots:
is-arrayish@0.2.1: {}
is-arrayish@0.3.4: {}
is-async-function@2.1.1:
dependencies:
async-function: 1.0.0
@@ -13909,8 +13653,6 @@ snapshots:
is-unicode-supported@2.1.0: {}
is-url@1.2.4: {}
is-weakmap@2.0.2: {}
is-weakref@1.1.1:
@@ -13961,10 +13703,6 @@ snapshots:
optionalDependencies:
'@pkgjs/parseargs': 0.11.0
jay-peg@1.1.1:
dependencies:
restructure: 3.0.2
jest-worker@27.5.1:
dependencies:
'@types/node': 22.19.15
@@ -14513,8 +14251,6 @@ snapshots:
mdn-data@2.27.1: {}
media-engine@1.0.3: {}
media-typer@0.3.0: {}
meow@13.2.0: {}
@@ -14981,10 +14717,6 @@ snapshots:
normalize-path@3.0.0: {}
normalize-svg-path@1.1.0:
dependencies:
svg-arc-to-cubic-bezier: 3.2.0
npm-run-path@4.0.1:
dependencies:
path-key: 3.1.1
@@ -15195,8 +14927,6 @@ snapshots:
pako@0.2.9: {}
pako@1.0.11: {}
parent-module@1.0.1:
dependencies:
callsites: 3.1.0
@@ -15224,8 +14954,6 @@ snapshots:
json-parse-even-better-errors: 2.3.1
lines-and-columns: 1.2.4
parse-svg-path@0.1.2: {}
parse5-htmlparser2-tree-adapter@7.1.0:
dependencies:
domhandler: 5.0.3
@@ -15276,13 +15004,6 @@ snapshots:
dependencies:
through: 2.3.8
pdf-lib@1.17.1:
dependencies:
'@pdf-lib/standard-fonts': 1.0.0
'@pdf-lib/upng': 1.0.1
pako: 1.0.11
tslib: 1.14.1
pdf-parse@2.4.5:
dependencies:
'@napi-rs/canvas': 0.1.80
@@ -15532,10 +15253,6 @@ snapshots:
queue-microtask@1.2.3: {}
queue@6.0.2:
dependencies:
inherits: 2.0.4
quick-format-unescaped@4.0.4: {}
range-parser@1.2.1: {}
@@ -15954,8 +15671,6 @@ snapshots:
dependencies:
xmlchars: 2.2.0
scheduler@0.25.0-rc-603e6108-20241029: {}
scheduler@0.27.0: {}
schema-dts@1.1.5: {}
@@ -16108,10 +15823,6 @@ snapshots:
signal-exit@4.1.0: {}
simple-swizzle@0.2.4:
dependencies:
is-arrayish: 0.3.4
sirv@2.0.4:
dependencies:
'@polka/url': 1.0.0-next.29
@@ -16348,10 +16059,6 @@ snapshots:
define-properties: 1.2.1
es-object-atoms: 1.1.1
string_decoder@1.3.0:
dependencies:
safe-buffer: 5.2.1
stringify-entities@4.0.4:
dependencies:
character-entities-html4: 2.1.0
@@ -16422,8 +16129,6 @@ snapshots:
dependencies:
react: 19.2.4
svg-arc-to-cubic-bezier@3.2.0: {}
svg-to-pdfkit@0.1.8:
dependencies:
pdfkit: 0.18.0
@@ -16892,12 +16597,6 @@ snapshots:
d3-time: 3.1.0
d3-timer: 3.0.1
vite-compatible-readable-stream@3.6.1:
dependencies:
inherits: 2.0.4
string_decoder: 1.3.0
util-deprecate: 1.0.2
vite@8.0.3(@emnapi/core@1.9.1)(@emnapi/runtime@1.9.1)(@types/node@22.19.15)(esbuild@0.27.4)(jiti@2.6.1)(sass@1.98.0)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3):
dependencies:
lightningcss: 1.32.0
@@ -17229,8 +16928,6 @@ snapshots:
yoctocolors@2.1.2: {}
yoga-layout@3.2.1: {}
zod-validation-error@4.0.2(zod@3.25.76):
dependencies:
zod: 3.25.76

View File

@@ -1,102 +0,0 @@
#!/usr/bin/env ts-node
/**
* Master Brochure Generator
*
* Generates a multi-page PDF brochure containing all products.
* Combines Payload CMS marketing text with Excel technical data.
*/
import * as fs from 'fs';
import * as path from 'path';
import * as React from 'react';
import { renderToBuffer } from '@react-pdf/renderer';
import { getAllProducts } from '../lib/products';
import { getExcelTechnicalDataForProduct, preloadExcelData } from '../lib/excel-products';
import { PDFBrochure } from '../lib/pdf-brochure';
import { ProductData as PDFProductData } from '../lib/pdf-datasheet';
const CONFIG = {
outputDir: path.join(process.cwd(), 'public/downloads'),
locales: ['en', 'de'] as const,
} as const;
async function ensureOutputDir() {
if (!fs.existsSync(CONFIG.outputDir)) {
fs.mkdirSync(CONFIG.outputDir, { recursive: true });
}
}
async function generateBrochure(locale: 'en' | 'de') {
console.log(`\nGenerating brochure for locale: ${locale.toUpperCase()}...`);
// 1. Load data
const products = await getAllProducts(locale);
console.log(`- Loaded ${products.length} products from Payload`);
// 2. Map and Enrich
const pdfProducts: PDFProductData[] = products.map((p, index) => {
const excelData = getExcelTechnicalDataForProduct({
slug: p.slug,
sku: p.frontmatter.sku,
name: p.frontmatter.title,
});
return {
id: index + 1,
name: p.frontmatter.title,
sku: p.frontmatter.sku,
shortDescriptionHtml: p.frontmatter.description,
descriptionHtml: p.frontmatter.description,
applicationHtml: '',
featuredImage: p.frontmatter.images[0] || null,
images: p.frontmatter.images,
categories: p.frontmatter.categories.map(name => ({ name })),
attributes: excelData?.attributes || [],
};
});
if (pdfProducts.length === 0) {
console.warn(`! No products found for ${locale}. Skipping.`);
return;
}
// 3. Render
console.log(`- Rendering PDF with ${pdfProducts.length} pages...`);
const buffer = await renderToBuffer(
React.createElement(PDFBrochure, {
products: pdfProducts,
locale,
title: locale === 'de' ? 'Produktkatalog' : 'Product Catalog',
subtitle: '2026',
})
);
// 4. Save
const fileName = `KLZ_Cables_Brochure_2026_${locale.toUpperCase()}.pdf`;
const filePath = path.join(CONFIG.outputDir, fileName);
fs.writeFileSync(filePath, buffer);
const stats = fs.statSync(filePath);
console.log(`✓ Generated: ${fileName} (${(stats.size / 1024 / 1024).toFixed(2)} MB)`);
}
async function main() {
const start = Date.now();
console.log('--- KLZ Brochure Generator ---');
try {
await ensureOutputDir();
preloadExcelData();
for (const locale of CONFIG.locales) {
await generateBrochure(locale);
}
console.log(`\n✅ Brochure generation completed in ${((Date.now() - start) / 1000).toFixed(2)}s`);
} catch (error) {
console.error('\n❌ Fatal error:', error);
process.exit(1);
}
}
main().catch(console.error);

File diff suppressed because it is too large Load Diff

View File

@@ -5,6 +5,8 @@ module.exports = {
'./app/**/*.{js,ts,jsx,tsx}',
'./components/**/*.{js,ts,jsx,tsx}',
'./content/**/*.{mdx,md}',
'./node_modules/@mintel/annotator/dist/**/*.{js,ts,jsx,tsx,mjs}',
'./node_modules/.pnpm/@mintel+annotator*/node_modules/@mintel/annotator/dist/**/*.{js,ts,jsx,tsx,mjs}'
],
theme: {
extend: {

View File

@@ -7,21 +7,21 @@ describe('Task 6 Content Updates', () => {
const enHomePath = path.join(process.cwd(), 'content', 'en', 'home.mdx');
const deKompetenzenPath = path.join(process.cwd(), 'content', 'de', 'kompetenzen.mdx');
const enKompetenzenPath = path.join(process.cwd(), 'content', 'en', 'kompetenzen.mdx');
const deKabeltiefbauPath = path.join(process.cwd(), 'content', 'de', 'kabeltiefbau.mdx');
const enKabeltiefbauPath = path.join(process.cwd(), 'content', 'en', 'kabeltiefbau.mdx');
const deKabelnetzbauPath = path.join(process.cwd(), 'content', 'de', 'kabeltiefbau.mdx');
const enKabelnetzbauPath = path.join(process.cwd(), 'content', 'en', 'kabeltiefbau.mdx');
const deUeberUnsPath = path.join(process.cwd(), 'content', 'de', 'ueber-uns.mdx');
const enUeberUnsPath = path.join(process.cwd(), 'content', 'en', 'ueber-uns.mdx');
it('should verify Kabelleitungstiefbau changes in DE and EN kompetenzen.mdx', () => {
it('should verify Kabelleitungsnetzbau changes in DE and EN kompetenzen.mdx', () => {
const deContent = fs.readFileSync(deKompetenzenPath, 'utf8');
const enContent = fs.readFileSync(enKompetenzenPath, 'utf8');
// DE Kompetenzen
expect(deContent).toContain('title: "Kabelleitungstiefbau"');
expect(deContent).toContain('title: "Kabelleitungsnetzbau"');
expect(deContent).toContain('description: "Klassischer Grabenbau, professionelle Verlegung und Kabelmontage (Hoch-, Mittel- und Niederspannung) bis 110 kV."');
expect(deContent).toContain('title: "Kabelleitungstiefbau (Hoch-/Mittel- und Niederspannung)"');
expect(deContent).toContain('title: "Kabelleitungsnetzbau (Hoch-/Mittel- und Niederspannung)"');
expect(deContent).toContain('"Kabelmontagen bis 110 kV"');
expect(deContent).toContain('"name": "Kabelleitungstiefbau"');
expect(deContent).toContain('"name": "Kabelleitungsnetzbau"');
expect(deContent).toContain('"description": "Klassischer Grabenbau, professionelle Verlegung und Kabelmontage (Hoch-, Mittel- und Niederspannung) bis 110 kV."');
// EN Competencies
@@ -33,13 +33,13 @@ describe('Task 6 Content Updates', () => {
expect(enContent).toContain('"description": "Classic trench construction, professional laying and cable assembly (high, medium, and low voltage) up to 110 kV."');
});
it('should verify Kabelleitungstiefbau changes in DE and EN home.mdx', () => {
it('should verify Kabelleitungsnetzbau changes in DE and EN home.mdx', () => {
const deContent = fs.readFileSync(deHomePath, 'utf8');
const enContent = fs.readFileSync(enHomePath, 'utf8');
// DE Home
expect(deContent).toContain('title: "Kabelleitungstiefbau"');
expect(deContent).toContain('description: "Kabelleitungstiefbau (Hoch-/Mittel- und Niederspannung) sowie Kabelmontagen bis 110 kV"');
expect(deContent).toContain('title: "Kabelleitungsnetzbau"');
expect(deContent).toContain('description: "Kabelleitungsnetzbau (Hoch-/Mittel- und Niederspannung) sowie Kabelmontagen bis 110 kV"');
expect(deContent).toContain('100+');
expect(deContent).toContain('Mitarbeitende');
@@ -50,18 +50,18 @@ describe('Task 6 Content Updates', () => {
expect(enContent).toContain('Employees');
});
it('should verify Kabelleitungstiefbau changes in DE and EN kabeltiefbau.mdx', () => {
const deContent = fs.readFileSync(deKabeltiefbauPath, 'utf8');
const enContent = fs.readFileSync(enKabeltiefbauPath, 'utf8');
it('should verify Kabelleitungsnetzbau changes in DE and EN kabeltiefbau.mdx', () => {
const deContent = fs.readFileSync(deKabelnetzbauPath, 'utf8');
const enContent = fs.readFileSync(enKabelnetzbauPath, 'utf8');
// DE Kabeltiefbau
expect(deContent).toContain('title: "Kabelleitungstiefbau"');
expect(deContent).toContain('excerpt: "Professioneller Kabelleitungstiefbau');
// DE Kabelnetzbau
expect(deContent).toContain('title: "Kabelleitungsnetzbau"');
expect(deContent).toContain('excerpt: "Professioneller Kabelleitungsnetzbau');
expect(deContent).toContain('Kabelmontagen bis 110 kV');
expect(deContent).toContain('subtitle="Komplette Infrastruktur-Lösungen für Hoch-, Mittel- und Niederspannungsnetze sowie Kabelmontagen bis 110 kV"');
expect(deContent).toContain('"Kabelmontagen bis 110 kV"');
// EN Kabeltiefbau
// EN Kabelnetzbau
expect(enContent).toContain('title: "Cable Civil Engineering"');
expect(enContent).toContain('excerpt: "Professional cable civil engineering');
expect(enContent).toContain('cable assembly up to 110 kV');

View File

@@ -74,7 +74,7 @@ describe('E2E Navigation & Content (MDX Architecture)', () => {
it('should verify blog listing and post loading', async () => {
await page.goto(`${BASE_URL}/de/blog`, { waitUntil: 'networkidle2', timeout: 60000 });
let bodyText = await page.evaluate(() => document.body.innerText);
expect(bodyText).toContain('Moderne Verfahren im Kabeltiefbau');
expect(bodyText).toContain('Moderne Verfahren im Kabelnetzbau');
// Click on the first article (if found) or navigate directly
await page.goto(`${BASE_URL}/de/blog/moderne-verfahren-kabeltiefbau`, { waitUntil: 'networkidle2', timeout: 60000 });

View File

@@ -1,7 +1,7 @@
import { describe, it, expect } from 'vitest';
import fs from 'fs';
import path from 'path';
import { defaultLocations } from '../lib/map-data';
import { defaultLocations, minorLocations } from '../lib/map-data';
describe('Task 12 TDD - Bohrtechnik and Reference Links', () => {
const deHomePath = path.join(process.cwd(), 'content', 'de', 'home.mdx');
@@ -27,10 +27,11 @@ describe('Task 12 TDD - Bohrtechnik and Reference Links', () => {
});
it('should verify that all reference projects in map-data have no href links', () => {
// Check that all defaultLocations of type 'project' have href undefined or removed
const projects = defaultLocations.filter(loc => loc.type === 'project');
// Check that all minorLocations of type 'minor_node' have href undefined
// since all projects were downgraded to minor nodes without links.
const projects = minorLocations.filter(loc => loc.type === 'minor_node');
expect(projects.length).toBeGreaterThan(0);
projects.forEach(project => {
expect(project.href).toBeUndefined();
});